Enforcing Laravel architecture rules with Pest's arch() helper
I've caught more real bugs with arch tests than I have with some feature tests. Not because feature tests aren't valuable — they are — but because architectural violations are quiet. They don't throw exceptions. They just slowly make your codebase harder to work with until one day a service has six Eloquent calls buried in it and nobody can explain how they got there.
Pest's arch() helper gives you a way to codify the conventions your team agrees on and enforce them automatically at CI time.
Why architecture testing in Laravel?
The problem isn't that developers ignore conventions. It's that nothing enforces them. You agree in a PR review that controllers shouldn't touch Eloquent directly, write it in the contributing guide, and three months later someone adds a User::find() call to a controller because it was the fastest path and the tests still passed.
Pest architecture testing runs as part of your standard test suite. Violations fail the build. There's no "we need to fix this at some point" — it just doesn't merge.
The arch() helper in Pest
arch() is a chainable function that scans your namespaces and evaluates rules against them. Pest v4 ships with it out of the box alongside the Laravel plugin:
composer require pestphp/pest pestphp/pest-plugin-laravel --dev
Create a dedicated file for your rules:
mkdir -p tests/Arch && touch tests/Arch/AppTest.php
The simplest rule you can write:
<?php
arch('no debug functions in production code')
->expect('App')
->not->toUse(['dd', 'dump', 'var_dump', 'die', 'ray']);
That single line has saved me from pushing debug output to production more than once.
Practical Pest architecture testing rules for Laravel apps
Here's the file I drop into every new project. Each rule targets a real failure mode I've seen in live codebases.
<?php
// Models must extend Eloquent and live in App\Models
arch('models extend Eloquent')
->expect('App\Models')
->toBeClasses()
->toExtend('Illuminate\Database\Eloquent\Model');
// Services must not import HTTP layer concerns — keeps business logic portable
arch('services do not depend on HTTP layer')
->expect('App\Services')
->not->toUse([
'Illuminate\Http\Request',
'App\Http\Controllers',
'App\Http\Requests',
]);
// Jobs must implement ShouldQueue — enforce async by default
arch('jobs implement ShouldQueue')
->expect('App\Jobs')
->toImplement('Illuminate\Contracts\Queue\ShouldQueue');
// No debug functions anywhere in the application
arch('no debug functions in production code')
->expect('App')
->not->toUse(['dd', 'dump', 'var_dump', 'die', 'ray']);
For the "controllers must not query Eloquent directly" rule, I scope it based on whether the project uses a service/repository pattern. If it does:
// Controllers route through services, not models directly
arch('controllers use services not models')
->expect('App\Http\Controllers')
->not->toUse('App\Models');
Leave this out for resource-controller-heavy apps where calling Eloquent from a controller is intentional.
You can also use Pest's built-in presets instead of writing everything from scratch. The laravel preset enforces naming conventions (controller suffix, method names), php bans die and deprecated functions, and security blocks eval, md5, and other unsafe calls:
arch()->preset()->laravel();
arch()->preset()->php();
arch()->preset()->security();
If a preset rule is too strict for your project, exclude specific namespaces:
arch()->preset()->security()->ignoring('App\Legacy');
Running arch tests in GitHub Actions
Arch tests run with your normal suite — ./vendor/bin/pest picks them up automatically. I prefer a separate job so failures appear with a clear label in CI:
name: Architecture Tests
on: [push, pull_request]
jobs:
arch:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up PHP 8.4
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
coverage: none
- name: Install dependencies
run: composer install --no-progress --prefer-dist --no-interaction
- name: Run architecture tests
run: ./vendor/bin/pest tests/Arch --colors=always
Running tests/Arch directly means a failing architectural rule surfaces under "Architecture Tests" in GitHub's checks panel, not buried inside a general test failure.
One gotcha worth knowing: if a rule references a namespace that doesn't yet exist in your codebase, Pest passes it silently. Add a comment on that rule so it's obvious when the namespace eventually gets populated.
Steven is a software engineer with a passion for building scalable web applications. He enjoys sharing his knowledge through articles and tutorials.