Why Architecture Tests Belong in Your Suite
Code reviews catch style drift. Architecture tests catch it automatically, every push. Pest ships a first-class arch() API that lets you assert structural rules — no external tools, no separate linting step. If a junior dev imports an Eloquent model directly into a Controller that's supposed to go through a Service, the test suite tells them immediately.
This article focuses on practical, opinionated rules for a Laravel project that follows a loose clean-architecture split: App\Models, App\Services, App\Http\Controllers, App\Actions, and App\Repositories.
Setting Up the Arch Preset
Pest ships a Laravel preset that covers the most common rules out of the box:
// tests/ArchTest.php
arch()->preset()->laravel();
This single line enforces things like: controllers are not invokable by default, models extend Illuminate\Database\Eloquent\Model, jobs are final, etc. Run it and fix the noise first — then layer your own rules on top.
Encoding Your Own Layer Rules
Controllers Must Not Touch Models Directly
arch('controllers do not import models')
->expect('App\Http\Controllers')
->not->toUse('App\Models');
This forces all Eloquent access through Services or Repositories. The moment a controller calls User::find() directly, the suite fails.
Services Must Not Know About HTTP
arch('services are HTTP-agnostic')
->expect('App\Services')
->not->toUse([
'Illuminate\Http\Request',
'Illuminate\Http\Response',
]);
Services that accept Request objects are a common smell. This rule makes it structural policy.
Actions Are Final and Have a Single Public Method
arch('actions are final')
->expect('App\Actions')
->toBeFinal();
arch('actions expose only handle')
->expect('App\Actions')
->toHaveMethod('handle');
Combining toBeFinal() with a method-name convention keeps single-responsibility honest.
Ignoring Specific Classes
Sometimes a rule has one legitimate exception. Use ignoring() rather than weakening the rule:
arch('controllers do not import models')
->expect('App\Http\Controllers')
->not->toUse('App\Models')
->ignoring('App\Http\Controllers\Webhooks\StripeController');
The exception is explicit and reviewable in git history.
Testing That Interfaces Are Properly Implemented
arch('repositories implement contract')
->expect('App\Repositories')
->toImplement('App\Contracts\RepositoryInterface');
If someone adds a repository and forgets the interface, the test fails before the PR is opened.
Combining With Mutation Testing
Arch tests are cheap — they run in milliseconds and require zero database setup. Put them in a dedicated ArchTest.php file and exclude them from mutation testing (they have no logic to mutate):
./vendor/bin/pest --mutate --exclude-testsuite=Architecture
Keep your mutation score meaningful by not letting Pest try to mutate structural assertions.
Takeaways
arch()->preset()->laravel()gives you sensible defaults for free — start there.- Encode layer boundaries as tests, not wiki pages nobody reads.
- Use
ignoring()for exceptions; never weaken the rule itself. - Arch tests are millisecond-fast — there's no excuse not to run them on every push.
- Combine with Pest's
toBeReadonly(),toBeFinal(), andtoHaveMethod()for fine-grained control. - Keep arch tests in a separate file and exclude them from mutation runs.