Pest Architecture Tests: Enforcing Clean Boundaries in Laravel Without a CI Nanny
#pest #laravel #testing #clean-architecture #php

Pest Architecture Tests: Enforcing Clean Boundaries in Laravel Without a CI Nanny

3 min read Mohamed Said Mohamed Said

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(), and toHaveMethod() for fine-grained control.
  • Keep arch tests in a separate file and exclude them from mutation runs.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Do Pest architecture tests require any extra packages?
No. The `arch()` API ships with Pest itself. As long as you have Pest installed in your Laravel project, you can start writing architecture tests immediately with no additional dependencies.
Q02 Will architecture tests slow down my CI pipeline?
Barely. Pest resolves architecture rules through static analysis of your class files — no database, no HTTP, no bootstrapping. A full set of arch assertions typically adds under a second to a suite.
Q03 What's the difference between the built-in Laravel preset and custom arch rules?
The preset covers generic Laravel conventions (model base class, job finality, etc.). Custom rules encode your project's specific layer contracts — things only your team has agreed on. Use both together.

Continue reading

More Articles

View all