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.
Architecture testing pairs well with the other automated quality gates in your pipeline. Running Laravel Pint automatically with Git pre-commit hooks enforces code style before a commit lands; arch tests enforce structural rules at CI time — together they cover the two most common categories of "should have caught this earlier" violations. Auditing PHP dependencies adds the security dimension to the same pipeline. All three are part of The Complete Laravel Developer Toolchain for 2026.
FAQ#
What happens if an arch rule references a namespace that doesn't exist yet?
Pest silently passes the rule. This is intentional — you might want to define a rule for a service layer your app doesn't have yet, expecting developers to follow it once they create the namespace. The gotcha is that the rule won't catch violations until the namespace exists. Add a comment on such rules so your team knows it's intentional: // Enforces once App\Repositories namespace is created.
Can I exclude specific files or directories from an arch rule?
Yes, use the ignoring() method. For example, if you have a App\Legacy namespace where you're migrating old code, you can exempt it from strict architecture rules: arch()->preset()->security()->ignoring('App\Legacy'). You can also ignore multiple namespaces by passing an array: ignoring(['App\Legacy', 'App\Temporary']).
Should I run arch tests separately from my main test suite or together?
Both approaches work. Running them together (./vendor/bin/pest) means fewer CI jobs, but a single arch violation buries the failure in a general test report. Running them separately in GitHub Actions (a dedicated "Architecture Tests" job) makes violations more visible — they surface under a distinct check in the PR. For larger projects, the separate job approach is clearer.
What's the difference between arch() rules and PHPStan's architectural checking?
PHPStan focuses on type safety and static analysis — it catches type mismatches and undefined variables. Arch rules focus on dependency direction and layer boundaries — they ensure controllers don't import models, services don't touch the HTTP layer, and debug functions stay out of production code. They're complementary; use both. PHPStan catches the "what", arch tests catch the "where and why".