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
usethe 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));
}
}