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:
// 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
// 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 = []);
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 = []);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.
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, and host-facing actions no facade can reach. 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 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.