0

Facades vs dependency injection — when to use which

Intermediate5 min read·lv-04-004
interviewsolid

Concept

The choice between Facades and dependency injection affects testability, clarity, and coupling. Both work, but they have different tradeoffs.

Facades:

  • Convenient static-style syntax available anywhere.
  • No constructor parameters needed — just use the Facade.
  • IDE support is limited without barryvdh/laravel-ide-helper.
  • Work with Laravel's fake/mock system for testing.
  • Tightly couple code to Laravel's container infrastructure — harder to use the class outside Laravel.

Dependency injection (DI):

  • Explicit dependencies — from the constructor signature you know exactly what the class needs.
  • IDE-friendly — full type inference, autocomplete.
  • Framework-agnostic — a class with injected dependencies works in any PHP context.
  • Easier to test with standard PHPUnit mocks (no Facade faking needed).
  • More verbose — constructor grows as dependencies are added.

When to use Facades:

  • In route files, Blade templates, scripts.
  • Quick, fire-and-forget calls in controllers (where DI would add clutter for a one-liner).
  • Config access (Config::get()) — rarely changes and doesn't need mocking.

When to use DI:

  • Services, repositories, domain classes — anything with business logic.
  • Classes that need unit testing (DI allows mock injection without Facades).
  • Code intended to be reusable outside Laravel.

Modern Laravel best practice: Inject dependencies in services/repositories. Use Facades in controllers and routes where the overhead of injection isn't worth the clarity cost.

Code Example

php
<?php
// Facade approach — convenient but coupled to Laravel
namespace App\Http\Controllers;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;

class ProductController extends Controller
{
    public function index(): JsonResponse
    {
        $products = Cache::remember('products:all', 3600, fn() =>
            Product::with('category')->get()
        );
        Log::info('Products listed', ['count' => $products->count()]);
        return response()->json($products);
    }
}

// DI approach — explicit, testable, IDE-friendly
namespace App\Http\Controllers;

use Psr\Log\LoggerInterface;
use Psr\SimpleCache\CacheInterface;
use App\Repositories\ProductRepository;

class ProductController extends Controller
{
    public function __construct(
        private readonly ProductRepository $products,
        private readonly CacheInterface $cache,
        private readonly LoggerInterface $logger,
    ) {}

    public function index(): JsonResponse
    {
        $products = $this->cache->get('products:all')
            ?? tap($this->products->all(), fn($p) => $this->cache->set('products:all', $p, 3600));
        $this->logger->info('Products listed', ['count' => count($products)]);
        return response()->json($products);
    }
}

// Testing DI — use PHPUnit mocks directly (no Facade::fake() needed)
class ProductControllerTest extends TestCase
{
    public function test_lists_products(): void
    {
        $mockProducts = [['id' => 1, 'name' => 'Widget']];
        $mockRepo = $this->createMock(ProductRepository::class);
        $mockRepo->method('all')->willReturn($mockProducts);

        $mockCache = $this->createMock(CacheInterface::class);
        $mockCache->method('get')->willReturn(null);

        $controller = new ProductController($mockRepo, $mockCache, $this->createMock(LoggerInterface::class));
        $response = $controller->index();
        $this->assertEquals($mockProducts, json_decode($response->content(), true));
    }
}