Why Architecture Tests Belong in Your CI Pipeline
Code reviews catch style drift. Architecture tests catch structural rot. When a junior dev imports an Eloquent model directly into a domain action, no linter fires — but your architecture test will. Pest's arch() API turns architectural decisions into first-class, version-controlled assertions that run on every push.
This article focuses on practical, opinionated rules for a Laravel codebase that follows a modular or DDD-lite structure.
Setting Up the Architecture Plugin
Pest ships the architecture API out of the box from v2.x onward. No extra package is needed.
composer require pestphp/pest --dev
Create a dedicated file so the rules stay separate from feature tests:
tests/
Architecture/
DomainTest.php
InfrastructureTest.php
Enforcing Domain Purity
The core rule: domain classes must never depend on the framework's infrastructure layer.
// tests/Architecture/DomainTest.php
arch('domain actions do not depend on Eloquent')
->expect('App\Domain')
->not->toUse('Illuminate\Database\Eloquent');
arch('domain value objects are readonly')
->expect('App\Domain\ValueObjects')
->toBeReadonly();
arch('domain DTOs are final')
->expect('App\Domain\DataTransferObjects')
->toBeFinal();
These three rules alone prevent the most common leakage patterns: an action that reaches for User::find(), a value object mutated after construction, and a DTO subclassed into something unrecognisable.
Naming Convention Rules
Consistency in naming is load-bearing in large teams. Pest can assert it:
arch('actions are suffixed correctly')
->expect('App\Domain\Actions')
->toHaveSuffix('Action');
arch('jobs live in the right namespace')
->expect('App\Jobs')
->toImplement(\Illuminate\Contracts\Queue\ShouldQueue::class);
arch('repositories extend the base repository')
->expect('App\Infrastructure\Repositories')
->toExtend('App\Infrastructure\Repositories\BaseRepository');
The toHaveSuffix / toHavePrefix matchers are simple but eliminate entire categories of "where does this class live?" confusion.
Preventing Upward Dependencies
In a layered architecture, infrastructure must not bleed into the domain, and the domain must not know about HTTP concerns.
arch('domain layer is unaware of HTTP')
->expect('App\Domain')
->not->toUse([
'Illuminate\Http\Request',
'Illuminate\Http\Response',
'Illuminate\Routing\Controller',
]);
arch('infrastructure does not import application services directly')
->expect('App\Infrastructure')
->not->toUse('App\Application\Services');
Combining with arch Presets
Pest ships a handful of opinionated presets that cover common Laravel conventions:
arch()->preset()->laravel();
arch()->preset()->strict();
strict() enforces no dd(), no var_dump(), and strict types across all files. laravel() validates that controllers, models, and jobs follow framework conventions. Layer your own rules on top rather than replacing these.
Ignoring Specific Classes
Sometimes a rule has a legitimate exception. Use ignoring() rather than deleting the rule:
arch('domain actions do not depend on Eloquent')
->expect('App\Domain')
->not->toUse('Illuminate\Database\Eloquent')
->ignoring('App\Domain\Actions\SeedDemoDataAction');
The exception is explicit, documented, and reviewable in git history.
Key Takeaways
- Architecture tests are executable documentation — they fail loudly when structure drifts.
arch()rules run in milliseconds; add them to the samepestcommand as unit tests.- Start with three rules: no Eloquent in the domain, readonly value objects, final DTOs.
- Use
ignoring()for deliberate exceptions rather than weakening the rule. - Combine Pest presets (
laravel(),strict()) with project-specific rules for layered coverage. - Commit architecture tests alongside the architectural decision record (ADR) that motivated them.