- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Open Illuminate\Support\Facades\Cache and look for a get method. There isn't one. The class body defines two methods, getFacadeAccessor() and spy(), and the first one returns the string 'cache'. Every Cache::get('user:42') in your codebase calls a method that does not exist on the class you imported.
The Laravel docs say so plainly: facades are "static proxies" to objects in the service container. The proxy is the part worth reading, because it decides what your tests can swap, what a real-time facade writes to disk, and whether your domain code knows Laravel exists. Everything below is from the 13.x branch of laravel/framework. Laravel 13 shipped on March 17th, 2026 and requires PHP 8.3 or newer.
The four hops behind Cache::get()
PHP calls __callStatic() when you invoke a static method that a class does not define. The base Facade class implements it. Here is the body, reformatted to fit:
// Facade::__callStatic($method, $args)
$instance = static::getFacadeRoot();
if (! $instance) {
throw new RuntimeException(
'A facade root has not been set.'
);
}
return $instance->$method(...$args);
getFacadeRoot() is one line: return static::resolveFacadeInstance(static::getFacadeAccessor());. For Cache, the accessor is 'cache'. Then the lookup:
// Facade::resolveFacadeInstance($name)
if (isset(static::$resolvedInstance[$name])) {
return static::$resolvedInstance[$name];
}
if (static::$app) {
if (static::$cached) {
return static::$resolvedInstance[$name]
= static::$app[$name];
}
return static::$app[$name];
}
Two details change how you think about the cost. First, static::$app[$name] is array access on the container, and Container::offsetGet() is return $this->make($offset);. Second, $cached defaults to true, so the container is asked once per accessor. After that, the facade side of every Cache:: call is a static array read; the container is not asked again.
What comes back for 'cache' is a CacheManager (the docs' facade reference table lists Illuminate\Cache\CacheManager under the cache binding). The manager has no get either. Its __call() is return $this->store()->$method(...$parameters);, and store() picks the name from config('cache.default') and memoises the resolved store in $this->stores. So Cache::get('user:42') is four hops: facade magic method, static instance array, manager magic method, default store.
Who sets static::$app? The RegisterFacades bootstrapper, during kernel boot. Its first two lines are Facade::clearResolvedInstances(); and Facade::setFacadeApplication($app);. No boot, no $app, and __callStatic throws A facade root has not been set. You have seen that error if you ever called a facade from a plain PHPUnit test.
Real-time facades write a class to disk
The real-time facades feature lets you write use Facades\App\Contracts\Publisher; and call Publisher::publish($podcast) statically, with no facade class in your repo. The trick lives in AliasLoader, which registers an autoloader.
When PHP asks it to load a class, load() checks str_starts_with($alias, static::$facadeNamespace), where $facadeNamespace is 'Facades\\'. On a match, ensureFacadeExists() looks for storage/framework/cache/facade-<sha1 of the alias>.php. If the file is missing, it fills facade.stub with three replacements (namespace, class name, and the target: everything after the Facades\ prefix), writes it atomically, and requires it.
The generated class extends Facade, and its getFacadeAccessor() returns the target class name, App\Contracts\Publisher. From there it is the same four-hop path: the container resolves that name, the instance lands in $resolvedInstance, and the call is forwarded. A real-time facade is a facade whose accessor is a class name instead of a short binding key.
How tests swap a "static" call
Since the facade only forwards to whatever sits in $resolvedInstance, swapping it is an array write. Facade::swap($instance) does exactly this:
// Facade::swap($instance)
static::$resolvedInstance[static::getFacadeAccessor()]
= $instance;
if (isset(static::$app)) {
static::$app->instance(
static::getFacadeAccessor(),
$instance
);
}
shouldReceive() builds on it: it reuses the existing mock if isMock() finds a Mockery mock in the slot, or creates a fresh one and swaps it in, then forwards your expectation. That is why the docs' example works on what reads like a static method:
Cache::shouldReceive('get')
->with('key')
->andReturn('value');
Because swap() also calls $app->instance(), anything that resolves 'cache' from the container from then on gets your mock too, not only facade callers. The slot is also static, so it would leak between tests if nothing cleared it. Laravel's test lifecycle clears it: setUpTheTestEnvironment() in InteractsWithTestCaseLifecycle calls Facade::clearResolvedInstances() before each test.
The Cache facade has no fake() method, so for cache you use shouldReceive(), spy(), or a real array store.
That is a service locator
Martin Fowler's 2004 essay on injection describes a service locator as "an object that knows how to get hold of all of the services that an application might need." His distinction from dependency injection is about who can see the dependency: with a locator, "every user of a service has a dependency to the locator."
Put the facade next to that definition. Cache::get() asks a global (Facade::$app) for a service by key ('cache') at runtime. The class that makes the call declares nothing in its constructor. You find out it needs a cache by reading its body, or when a plain unit test throws A facade root has not been set.
Laravel is honest about the trade. The facades page warns that "the primary danger of facades is class 'scope creep'", because a class can pile up facade calls with no growing constructor to warn you. The contracts page frames facades versus injected contracts as mostly a matter of team taste. For controllers, jobs and console commands, that is fair. Those classes are Laravel code anyway.
The trouble starts when the same calls move into the code that holds your business rules.
Before and after: moving the lookup to the edge
Here is a pricing rule that reaches for two facades:
namespace App\Domain\Pricing;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
final class QuoteCalculator
{
public function quote(int $cents, string $currency): int
{
$rate = Cache::remember(
"fx:$currency",
3600,
fn () => Http::get(
"https://fx.example.com/rates/$currency"
)->json('rate'),
);
return (int) round($cents * $rate);
}
}
It works, and it is testable with Cache::shouldReceive('remember'). But the test needs a booted application, the class signature says nothing about cache or HTTP, and a rule about multiplying money now imports two framework namespaces.
The fix is a port the domain owns, with no Illuminate import:
namespace App\Domain\Pricing;
interface ExchangeRates
{
public function rateFor(string $currency): float;
}
final class QuoteCalculator
{
public function __construct(
private ExchangeRates $rates,
) {}
public function quote(int $cents, string $currency): int
{
$rate = $this->rates->rateFor($currency);
return (int) round($cents * $rate);
}
}
The facades move into an adapter, where they are fine:
namespace App\Infrastructure\Pricing;
use App\Domain\Pricing\ExchangeRates;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
final class CachedHttpExchangeRates implements ExchangeRates
{
public function rateFor(string $currency): float
{
return (float) Cache::remember(
"fx:$currency",
3600,
fn () => Http::get(
"https://fx.example.com/rates/$currency"
)->json('rate'),
);
}
}
One binding in a service provider's register() method connects them:
$this->app->bind(
ExchangeRates::class,
CachedHttpExchangeRates::class,
);
Now the domain test extends PHPUnit's own TestCase, not Laravel's. No kernel boot, no facade root:
use App\Domain\Pricing\ExchangeRates;
use App\Domain\Pricing\QuoteCalculator;
use PHPUnit\Framework\TestCase;
final class FixedRates implements ExchangeRates
{
public function rateFor(string $currency): float
{
return 1.25;
}
}
final class QuoteCalculatorTest extends TestCase
{
public function test_it_applies_the_rate(): void
{
$calc = new QuoteCalculator(new FixedRates());
$this->assertSame(1250, $calc->quote(1000, 'EUR'));
}
}
The adapter could inject Illuminate\Contracts\Cache\Repository instead of calling the facade. Both are legitimate there, because the adapter is the layer whose job is to know Laravel. The line that matters is the one between App\Domain and everything else.
Where the hidden lookup earns its keep
A facade's cached lookup, with its swap slot for tests, is a good tool at the edge: controllers, listeners, jobs and adapters get short syntax, and tests can replace the service without touching a constructor.
The same mechanism is what hides dependencies when it reaches the core. The rule that keeps the framework out of your domain is short: facades may appear in any class that already depends on Laravel, and in none that shouldn't. A search for Support\Facades imports under your domain folder tells you how far the locator has spread.
If this was useful
The refactor above is the smallest version of what Decoupled PHP does across a whole application: pulling framework calls out of the code that holds your rules, defining ports the domain owns, and keeping Laravel or Symfony in adapters where swapping them stays cheap. If facades have crept into your models and services, the book walks through moving them back to the edge one boundary at a time.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)