Enforcing Laravel architecture rules with Pest's arch() helper

3 min read

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.

pest
laravel
testing
php
Steven Richardson

Steven is a software engineer with a passion for building scalable web applications. He enjoys sharing his knowledge through articles and tutorials.