skip to content

In a Laravel CRM code review, a service calls the Cache, Http and Log facades inside its methods — do facades hide dependencies, and what would you change?

level: seniorimportance: should knowfreq 55%

answer

  1. the constructor tells half the story
  2. facade root has not been set
  3. scope creep without a growing constructor
  4. testable in feature tests, not in isolation
  5. Repository, Http Factory, LoggerInterface

basics

~10 s

Yes: facade calls keep collaborators out of the constructor, so the class looks dependency-free and its facade calls fail outside a booted app. Inject Illuminate\Contracts\Cache\Repository, Illuminate\Http\Client\Factory and Psr\Log\LoggerInterface to make them explicit.

solid answer

~40 s

Yes, in a specific sense. A facade call is a lookup in the application container from inside a method body, so the constructor no longer lists what the class needs. Reviewers lose the signal Laravel's own docs point to: a growing constructor that warns the class is taking on too much. The class also depends on a booted application; in a plain `PHPUnit\Framework\TestCase` the first `Cache::get()` throws `RuntimeException` ("A facade root has not been set."). The weak argument is "facades are untestable" — in feature tests they resolve through the container and can be replaced. For a CRM service with real logic I'd inject `Illuminate\Contracts\Cache\Repository`, `Illuminate\Http\Client\Factory` and `Psr\Log\LoggerInterface`; auto-wiring supplies the same objects the facades used. Facades in controllers and route files can stay.

code

php · 25 lines
php
<?php

namespace App\Crm;

use Illuminate\Contracts\Cache\Repository;
use Illuminate\Http\Client\Factory as Http;
use Psr\Log\LoggerInterface;

class LeadEnrichmentService
{
    public function __construct(
        private Repository $cache,
        private Http $http,
        private LoggerInterface $log,
    ) {}

    public function companyFor(string $domain): array
    {
        return $this->cache->remember("company:{$domain}", 3600, function () use ($domain) {
            $this->log->info('Enriching lead company', ['domain' => $domain]);

            return $this->http->get("https://enrich.example/api/{$domain}")->json();
        });
    }
}

go deeper

for a junior

Know that facade calls do not appear in the constructor, so reading the constructor does not tell you everything a class uses.

for a middle

Map each facade to the type-hint that resolves the same object, and explain why unit tests without a booted app fail on facade calls.

for a senior

Separate the real costs, visibility and framework coupling, from the myth of untestability, and propose a refactor that keeps behaviour identical.

for a principal

Own a per-layer convention for facades versus injected contracts and make it reviewable, so the debate is settled once rather than per pull request.

## The scenario A CRM has a `LeadEnrichmentService` that looks up a company by domain. Its methods call `Cache::remember()`, `Http::get()` and `Log::info()`. Its constructor is empty. A reviewer comments: "Facades hide dependencies — inject them." The author replies: "Facades are testable, the docs use them everywhere." Both are partly right, and a good answer separates the claims. ## What "hidden" means here A **dependency** is any collaborator a class needs to do its job. A **facade** is a class extending `Illuminate\Support\Facades\Facade` that forwards static calls to an object resolved from the service container. A facade call therefore is a service lookup performed inside the method body. Consequences that are real: - **The constructor lies by omission.** It signals "needs nothing", while the class needs a cache, an HTTP client and a logger. Only reading every method reveals that. - **Scope creep goes unnoticed.** Laravel's facade documentation names this as the primary danger: without a growing constructor there is no visual feedback that the class is accumulating responsibilities. - **It needs a booted application.** Facades reach the container through a static application reference set by the `RegisterFacades` bootstrapper. In a unit test extending `PHPUnit\Framework\TestCase` — the base class of the skeleton's `tests/Unit/ExampleTest.php` — no application exists, so `__callStatic()` throws `RuntimeException('A facade root has not been set.')`. - **Reuse outside Laravel is harder.** A class that depends on `illuminate/contracts` interfaces can live in a package; one that calls facades needs the framework running. ## What is not true - "Facades are static, so they cannot be replaced." Facades resolve an ordinary object from the container, and the framework's facade test tooling replaces that object; feature tests of facade-using code work fine. - "Facades hold global static state." The only static data is the application reference and the map of resolved roots; the cache, client and logger are ordinary container services. - "Injection changes behaviour." Auto-wiring hands the class the same singletons the facades would have resolved. - "Injected classes are slower." Both routes resolve the same objects from the same container; the constructor version resolves them once when the service is built instead of on the first facade call. ## The refactor | Facade in the method body | Constructor type-hint | Container binding it resolves | |---|---|---| | `Cache::remember(...)` | `Illuminate\Contracts\Cache\Repository` | `'cache.store'`, the default store | | `Http::get(...)` | `Illuminate\Http\Client\Factory` | the `Http` facade's own accessor | | `Log::info(...)` | `Psr\Log\LoggerInterface` | alias of `'log'`, the `LogManager` | There is no `Illuminate\Contracts` interface for the HTTP client, so the concrete `Factory` is the type-hint; that is the class the `Http` facade proxies. If the service must choose cache stores, type-hint `Illuminate\Contracts\Cache\Factory` instead of `Repository`. After the change: 1. The constructor documents three collaborators, and a fourth or fifth becomes a visible design question. 2. A unit test can construct the service with hand-written doubles and no application. 3. The service depends on interfaces and one client factory rather than on a booted framework. ## How to run the review conversation A productive review names the concrete cost instead of the slogan: 1. Point at the empty constructor and ask what a new teammate would believe the class needs. 2. Show the failing unit test, or the forced move of a pure logic test into `tests/Feature`, as the practical price. 3. Acknowledge that behaviour does not change and that feature tests of the facade version already work. 4. Propose the injected version and let the diff show that it is about the same size. That framing keeps the discussion about dependency visibility, which is a real property of the code, rather than about static calls, which in Laravel are only syntax over the container. ## Where facades remain reasonable - **Controllers, route files, service providers and Artisan closures** are framework glue that only ever runs inside the application; brevity there is a fair trade. - **Tiny classes** whose single facade call is obvious at a glance gain little from injection. A defensible team convention is: facades and helpers are acceptable in glue code; services, actions and anything in a package inject contracts. Laravel's own contracts documentation treats the choice as team preference, so the convention matters more than which side it picks — mixed styles inside one layer are what make a review like this recur.

  • Why does a facade-based service fail in tests/Unit when the same code passes in tests/Feature?
    The skeleton's unit tests extend `PHPUnit\Framework\TestCase`, which never creates a Laravel application, so no facade application is set and the first static call throws "A facade root has not been set." Feature tests extend `Tests\TestCase`, built on Laravel's testing base class, which boots the app before each test.
  • Which type-hint replaces the Http facade, given there is no HTTP-client contract in illuminate/contracts?
    `Illuminate\Http\Client\Factory`, the concrete class the `Http` facade returns from `getFacadeAccessor()`. Auto-wiring injects the same factory the facade would use, and it forwards `get`, `post`, `withToken` and the rest to a fresh pending request exactly as the static calls do.
  • Is it fine to keep facades in controllers while injecting contracts into services?
    Usually yes. Controllers are framework glue that always runs inside a booted application and rarely gets unit tested in isolation, so facade brevity costs little there. The rule to enforce is consistency per layer, so reviewers are not relitigating the style class by class.

saying these in an interview costs you the question

  • Facades cannot be replaced in tests because static methods cannot be mocked
  • Facades keep the service's data in global static state
  • Injecting the contract gives a different cache store than the facade
  • A facade-heavy service runs fine in a plain PHPUnit TestCase
  • Illuminate\Contracts has an HTTP client interface to inject instead of Http