Facade contract
Every Roundly package exposes one public API in three layers — actions (the behaviour), a manager (the injectable facade root) and a final facade — so facade fans, DI users and people who want the raw action all run the same code. Three expectations and one arch preset keep that contract honest, and a fourth expectation pins that a facade over a root that takes secrets keeps them out of its stack frames:
// tests/Feature/FacadeTest.php — bound to your PackageTestCase-based TestCase
expect(Teams::class)
->toDocumentItsRoot() // docblock == root, class-string accessor, final
->toBeFakeable() // real fake(), subtype of the root, DI gets it
->toReachEveryAction(__DIR__.'/../../src/Actions') // every non-@internal action is reachable
->toRedactSensitiveArguments(methods: 3); // only when the root marks #[SensitiveParameter]
// tests/Arch/ArchTest.php
ArchPresets::modelsGoThroughTheFacade('RoundlyConsulting\Teams');use RoundlyConsulting\Testing\Arch\ArchPresets;
use RoundlyConsulting\Testing\Assert;
expect($facade)->toDocumentItsRoot(array $except = []);
expect($facade)->toBeFakeable();
expect($facade)->toReachEveryAction(string $actionsDir, array $except = [], array $via = []);
expect($facade)->toRedactSensitiveArguments(int $methods);
ArchPresets::modelsGoThroughTheFacade(string $namespace, array $ignoring = []);
// Static mirrors for plain PHPUnit
Assert::facadeDocumentsItsRoot(string $facade, array $except = []);
Assert::facadeIsFakeable(string $facade);
Assert::facadeReachesEveryAction(string $facade, string $actionsDir, array $except = [], array $via = []);
Assert::facadeRedactsSensitiveArguments(string $facade, int $methods);toDocumentItsRoot()
The facade is final, getFacadeAccessor() returns a manager or contract class-string — a string key like 'teams' fails — and its @method static lines match the root exactly: every public method documented, no phantom, every parameter count right. Constructor, magic, @internal and vendor-inherited methods (Manager::driver(), Macroable::macro()) are not demanded, and a documented name may also live on the facade itself (fake()) or on the fake (assert*()). $except names root methods deliberately left undocumented.
Parameters are counted depth-aware, so array<string, int>, array{a: int}, Closure(int, string): bool and array $x = ['a' => 1] never miscount. Each failure hands back the @method line to paste.
toBeFakeable()
The facade declares a real public static function fake(): XFake, and XFake is a proper subtype of the accessor type — otherwise every constructor-injected manager throws a TypeError under the fake, and the accessor type itself is no fake: a fake() that swaps the real manager in for itself records nothing. Calling it must build a new instance and install it as the facade root and as app(<accessor>). It calls fake() for real, so it needs the booted application: bind the test to your PackageTestCase-based TestCase. The real binding is restored afterwards.
toReachEveryAction()
Every concrete, non-@internal class under $actionsDir must be referenced from the facade surface: the root, the class the container binds it to, and every sub-accessor or handle reached through public return types — Teams::for($team)->members()->add() is two hops. Models, DTOs, events, enums, exceptions, the fake and other actions are never surface.
- Only a real @internal docblock tag exempts an action. An action that only other actions use still counts as unreachable — tag it @internal.
- $via adds a helper the manager holds but never returns as an extra root. Each entry must exist and make at least one action reachable that isn’t reachable without it, or it fails as stale.
- $except tolerates an action that really is unreachable; an entry that isn’t an unreachable host-facing action under $actionsDir fails.
toRedactSensitiveArguments()
PHP redacts a #[SensitiveParameter] argument only in the frame of the function that declares the attribute. A call through a stock facade passes Facade::__callStatic($method, $args) first, and that frame carries every argument raw — so Crypto::constantTimeEquals($known, $input) puts $known where an error tracker that collects frame arguments, print_r($e), var_dump($e) or a custom trace logger finds it. getTraceAsString() prints that frame as __callStatic('…', Array), so a test on the trace string passes on a leaking facade too. This expectation reads the frame arguments instead:
// tests/Feature/FacadeTest.php — written before `use RedactsSensitiveArguments;`, it is the
// fail-first test: a stock facade fails it.
expect(Crypto::class)
->toDocumentItsRoot()
->toRedactSensitiveArguments(methods: 15);The fix is package-toolkit-for-laravel’s Concerns\RedactsSensitiveArguments trait on the facade; real statics on the facade that carry their own attributes pass too:
use Illuminate\Support\Facades\Facade;
use RoundlyConsulting\PackageToolkit\Concerns\RedactsSensitiveArguments;
final class Crypto extends Facade
{
use RedactsSensitiveArguments;
protected static function getFacadeAccessor(): string
{
return CryptoManager::class;
}
}$methods pins how many public methods of the accessor type — statics included, magic __* excluded — take at least one #[SensitiveParameter] argument. It must be at least 1: 0 or less throws InvalidArgumentException, because a pin of 0 passes over a root that marks nothing. A facade whose root takes no secret has nothing to redact — don’t call it there. Every problem is listed in one message:
- Count — $methods must equal that number, and the failure lists the methods it found. A root that marks no parameter fails with “nothing to redact”.
- Contract drift — the facade sees only the accessor type’s attributes. When the booted app binds another class under the accessor (a contract, or a subclass), both must mark the same parameters by position, and a public method only the implementation declares must mark nothing. An installed fake() is looked through to the implementation it extends.
- A real trace — with zend.exception_ignore_args off for the check (production ini files turn it on), a probe root that throws on any call is swapped in behind the facade. Each sensitive method is called through the facade twice, positionally and with named arguments, with a unique probe value per argument. The facade’s own frame must be in the trace with its arguments, no frame may hold a secret raw — so a helper the facade forwards through is caught too — and every harmless value must still show in the facade frame.
- Nothing over-hidden — every other public root method that takes arguments is called once and must keep them all visible: a facade that hides every argument passes the leak check and fails this one.
The check is side-effect free: the facade root, the container entry for the accessor and the ini value are restored afterwards, even when it fails, and an installed fake comes back in place, still recording the real values. Run it before faking — a fake with no implementation behind it fails. A root method that Facade itself declares, such as swap() or spy(), can never be reached through the facade, so it fails by name.
ArchPresets::modelsGoThroughTheFacade()
Nothing under {ns}\Models, {ns}\Concerns or {ns}\Traits, no Eloquent model anywhere under {ns} — per-area layouts like Shops\Cart\Cart included — and no package trait such a model uses (recursively, wherever it lives) may reference {ns}\Actions. So $user->like() goes through the manager, and the facade’s fake() sees it. {ns}\Actions and {ns}\Testing are never scanned; exemptions go through the rot-checked $ignoring parameter.
No vacuous green
Together they catch facades with zero @method lines over a real manager, docblocks naming renamed methods, fakes that crash dependency injection, fakes bypassed by model traits, host-facing actions no facade can reach, and secrets a stock facade leaves raw in its __callStatic frame although the root marks them #[SensitiveParameter]. None of them can pass over nothing: an empty or missing actions directory, a docblock with no @method line, a root with nothing to document or no secret to redact and a namespace with no models all fail — and every $except, $via and $ignoring entry must still silence something.
Testing for Laravel ships no facade of its own: it is dev-only test machinery with no host-facing stateful behaviour, which is exactly the case the convention exempts.
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.