Pest Architecture Testing: Enforcing Domain Boundaries in a Laravel Codebase
#pest #laravel #testing #architecture #ddd

Pest Architecture Testing: Enforcing Domain Boundaries in a Laravel Codebase

3 min read Mohamed Said Mohamed Said

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 same pest command 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.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Do architecture tests slow down the test suite significantly?
No. Pest's arch() assertions perform static analysis on class metadata rather than executing application code, so a full set of architecture rules typically adds under a second to the suite.
Q02 Can I run architecture tests separately from unit and feature tests?
Yes. Place them in a dedicated directory (e.g. tests/Architecture) and use Pest's --filter or a separate phpunit group to run them independently in CI if needed.
Q03 What happens when a rule is violated — does it show which class caused the failure?
Pest reports the exact fully-qualified class name that violated the rule, making it straightforward to locate and fix the offending dependency.

Continue reading

More Articles

View all