Skip to main content
Back to Blog

PestPHP Arch Testing for Laravel: 10 Tests That Keep Your Codebase in Shape

PestPHP Arch Testing for Laravel: 10 Tests That Keep Your Codebase in Shape

TLDR;

PestPHP arch tests turn Laravel architecture rules into CI checks, so layer leaks, debug helpers, and naming drift fail before they reach production.

PestPHP arch testing lets you turn architecture decisions into tests. Not a wiki page. Not a diagram nobody opens after the sprint review. Actual checks that fail when a Laravel controller reaches into the wrong layer, a model starts doing too much, or somebody commits dd() at 18:07 on a Friday.

That is why we like it for Laravel teams.

Architecture rarely breaks in one dramatic commit. It drifts. One shortcut in a controller. One service that imports Illuminate\Http\Request. One helper that starts talking to Eloquent because “it was just faster.” Six months later, every change needs a tour guide.

PestPHP arch tests give you a quiet guardrail against that drift.

What is PestPHP arch testing?

PestPHP arch testing checks whether your PHP code follows the structural rules you expect: namespaces, dependencies, class types, traits, inheritance, naming, strict types, and forbidden functions. In Laravel, that usually means keeping controllers thin, protecting service and domain layers, blocking debug helpers, and making architectural rules visible in CI.

The important bit: arch tests are not unit tests. They do not prove that a checkout flow calculates VAT correctly. They prove that the checkout flow has not pulled your HTTP layer into your domain logic.

Different kind of safety.

A unit test says: “does this behavior work?”

A PestPHP arch test says: “is this code still allowed to be shaped like this?”

For older Laravel applications, that second question can save a lot of pain.

Before you add arch tests to Laravel

You need Pest installed, and your project should already have a working test suite. If Pest itself is new to the codebase, start there first. We have a separate guide on PHP unit testing with Codeception, but the principle is the same: get the team used to running tests locally before you add more rules in CI.

For a Laravel app using Composer, install Pest with the Laravel plugin:

1 2 composer require pestphp/pest pestphp/pest-plugin-laravel --dev --with-all-dependencies php artisan pest:install

If you are not using the Laravel plugin, use Pest's generic setup instead:

1 ./vendor/bin/pest --init

Then create an architecture test file. I tend to keep it obvious:

1 tests/Architecture/AppArchitectureTest.php

You can use arch() directly in Pest:

1 2 3 4 5 <?php arch('application code uses strict types') ->expect('App') ->toUseStrictTypes();

Run it with:

1 ./vendor/bin/pest tests/Architecture

Small warning: do not begin by testing your dream architecture. Test the architecture you can defend today. If the first run gives you 400 failures, the team will mute the test or delete it. Start with rules that catch real mistakes without blocking every open branch.

10 PestPHP arch tests worth adding to a Laravel app

The best PestPHP arch tests are boring. They catch the same annoying mistakes again and again. Here are the ten we would start with on a Laravel project.

1. Require strict types in application code

Strict types are not glamorous. They just remove a whole category of “why did PHP accept that?” moments.

1 2 3 4 5 <?php arch('app code uses strict types') ->expect('App') ->toUseStrictTypes();

If you are adding this to a legacy Laravel codebase, do not point it at all of App on day one. Aim it at new code first:

1 2 3 arch('new modules use strict types') ->expect('App\Features\Billing') ->toUseStrictTypes();

Then expand the namespace when the old files are cleaned up.

2. Ban debug helpers from committed code

Every PHP team has done it once. You add dd($payload), fix the issue, commit the real change, and forget the dump.

PestPHP arch testing can catch that before the pull request lands.

1 2 3 4 5 6 7 8 9 arch('debug helpers are not committed') ->expect('App') ->not->toUse([ 'dd', 'dump', 'ray', 'var_dump', 'die', ]);

This is usually the easiest first win because nobody wants these calls in production code. No architecture debate needed.

3. Keep controllers out of business logic

Controllers should translate HTTP into application calls. Once controllers start calculating discounts, opening transactions, or deciding which domain event should fire, the app gets harder to test.

A simple rule is to stop controllers from using infrastructure directly:

1 2 3 4 5 6 7 arch('controllers do not use repositories or models directly') ->expect('App\Http\Controllers') ->not->toUse([ 'App\Models', 'App\Repositories', 'Illuminate\Support\Facades\DB', ]);

Will every Laravel app want this exact rule? No. Some teams are fine with controllers using Eloquent for simple CRUD screens.

That is the point. The test forces the decision into the open. If controllers may use models, say so. If not, make the rule fail loudly.

4. Stop service classes from depending on HTTP

This one catches a common Laravel smell: passing Request deeper and deeper because it is convenient.

1 2 3 4 5 6 7 arch('services do not depend on the HTTP layer') ->expect('App\Services') ->not->toUse([ 'App\Http\Controllers', 'App\Http\Requests', 'Illuminate\Http\Request', ]);

A service should receive the data it needs, not the entire web request. That makes the same service easier to call from jobs, commands, tests, and future APIs.

If this rule fails, the fix is normally small: create a DTO or pass named values into the service.

1 2 3 4 $service->registerCustomer( email: $request->string('email')->toString(), companyName: $request->string('company_name')->toString(), );

Much easier to test. Much less Laravel magic leaking everywhere.

5. Protect your domain layer from Laravel infrastructure

If your app has a domain layer, protect it early. Once domain code imports facades, jobs, controllers, or Eloquent models, it stops being a domain layer and becomes “miscellaneous PHP we hope not to touch.”

1 2 3 4 5 6 7 8 9 10 arch('domain code stays framework-light') ->expect('App\Domain') ->not->toUse([ 'App\Http', 'App\Console', 'App\Jobs', 'App\Models', 'Illuminate\Support\Facades', 'Illuminate\Database\Eloquent', ]);

You may still use value objects, enums, events, and interfaces here. The goal is not purity for its own sake. The goal is keeping business rules portable and testable.

For a PHP development team working on a long-lived Laravel system, this is often where arch tests earn their keep.

6. Make models stay models

Eloquent models attract code. Scopes, accessors, relationships, business rules, API formatting, permissions, background side effects. It all starts to feel “near the data,” so it lands in the model.

Set a few limits.

1 2 3 4 5 6 7 8 9 arch('models remain Eloquent models') ->expect('App\Models') ->toBeClasses() ->toExtend('Illuminate\Database\Eloquent\Model') ->not->toUse([ 'App\Http\Controllers', 'Illuminate\Http\Request', 'Illuminate\Support\Facades\Http', ]);

You can also add a line-count rule if your team agrees on a threshold:

1 2 3 4 5 6 arch('models do not become junk drawers') ->expect('App\Models') ->toHaveLineCountLessThan(300) ->ignoring([ 'App\Models\LegacyReport', ]);

The ignoring() call matters. Without it, legacy exceptions turn a useful rule into a political argument.

7. Check naming for commands, jobs, events, and requests

Naming rules feel small until you onboard a new developer. Then they matter.

1 2 3 4 5 6 7 8 9 10 11 arch('commands use the Command suffix') ->expect('App\Console\Commands') ->toHaveSuffix('Command'); arch('jobs use the Job suffix') ->expect('App\Jobs') ->toHaveSuffix('Job'); arch('form requests use the Request suffix') ->expect('App\Http\Requests') ->toHaveSuffix('Request');

This is not about pedantry. It keeps search, auto-complete, and review conversations simple. When a class is called ApproveInvoiceJob, you already know where to look and how it is meant to run.

8. Keep traits in trait namespaces

Pest supports wildcards for architecture expectations in Pest 3.8 and newer. That makes it easier to enforce patterns across nested namespaces.

1 2 3 arch('trait folders contain traits') ->expect('App\*\Traits') ->toBeTraits();

If your project uses deeper nesting, Pest also supports broader wildcard patterns:

1 2 3 arch('nested trait folders contain traits') ->expect('App\*\*\Traits') ->toBeTraits();

If this fails in an older project, check your Pest version before rewriting half the tree. Wildcard support is newer than the first arch-testing examples people copied around the internet.

9. Restrict which layers may use external clients

HTTP clients, payment SDKs, search clients, and vendor APIs should not appear randomly across the app. Put them behind one layer.

1 2 3 4 5 6 7 arch('external HTTP calls stay in infrastructure') ->expect('App') ->not->toUse('Illuminate\Support\Facades\Http') ->ignoring([ 'App\Infrastructure', 'App\Services\Integrations', ]);

This helps when a provider changes its API or when you need to add retries, logging, timeouts, or circuit breakers. You know where the integration code lives.

It also avoids a nasty testing problem: suddenly every feature test needs to fake three different outbound calls because external clients are scattered through controllers, jobs, and listeners.

10. Use Pest presets for broad safety checks

Pest includes presets for common rule sets. They are useful as a baseline, especially when you want security or PHP hygiene checks without writing every expectation yourself.

1 2 3 4 5 6 7 8 9 10 arch('php preset') ->preset() ->php(); arch('security preset') ->preset() ->security() ->ignoring([ 'md5', // only if you have a documented legacy reason ]);

Do not ignore things casually. Add a short comment when you do. A future developer should understand whether the exception is temporary, legacy-only, or genuinely allowed.

How to run PestPHP arch tests in CI

Once the local tests pass, add a CI job. Keep it separate from the full test suite at first so failures are easy to spot.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 # .github/workflows/architecture-tests.yml name: Architecture Tests on: pull_request: push: branches: - main jobs: architecture-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: '8.3' coverage: none - name: Install Composer dependencies run: composer install --no-interaction --prefer-dist --no-progress - name: Run Pest architecture tests run: ./vendor/bin/pest tests/Architecture

If your suite is already grouped, you can mark architecture tests and run the group:

1 2 3 <?php uses()->group('architecture');

Then:

1 ./vendor/bin/pest --group=architecture

For bigger Laravel applications, run these tests on every pull request. They are usually fast because they inspect code structure rather than booting the whole application for every case.

Common mistakes when adding PestPHP arch tests

Starting with too many rules

The first architecture test suite should not be a constitution. Pick five to ten rules that stop real bugs or real review noise.

Good first rules:

  • no debug helpers
  • strict types for new namespaces
  • services do not use HTTP requests
  • domain does not use controllers or facades
  • jobs, commands, and requests follow naming rules

Bad first rules:

  • every class must be final
  • every namespace must match a brand-new architecture diagram
  • all legacy code must pass tomorrow

That last one is how you make everyone hate the tool.

Testing rules the team never agreed on

An arch test is an agreement. If the team has not agreed that repositories are allowed, forbidden, or required, Pest cannot solve that for you.

Write the decision down in plain language first:

Application services may use repositories and DTOs. They may not use controllers, form requests, or facades.

Then turn it into a PestPHP arch test.

Forgetting exceptions

Every older Laravel codebase has a few strange corners. Name them.

1 2 3 4 5 6 7 arch('legacy import code is isolated') ->expect('App\Legacy\Import') ->toOnlyUse([ 'App\Legacy', 'App\Shared', 'Illuminate\Support', ]);

This is better than pretending legacy code does not exist. It gives the mess a fence.

Making architecture tests replace review

PestPHP arch testing catches structural drift. It does not know whether a boundary makes sense for your business, whether a use case should be split, or whether a team can live with a rule.

Use it to remove repeat comments from code review. Not to remove thinking.

Where PestPHP arch tests fit in a Laravel architecture

For a small Laravel app, arch tests might only block debug helpers and enforce strict types. That is fine.

For a larger app, the tests become more useful when they match the way the code is meant to be changed:

  • controllers call application services
  • services coordinate use cases
  • domain code holds business rules
  • infrastructure code talks to vendors, queues, storage, and APIs
  • models handle persistence, not every decision in the system

That structure also matters when you compare monolith and microservices architecture in PHP. A well-kept monolith is often easier to change than a distributed system with weak boundaries. PestPHP arch tests help keep those boundaries visible while the Laravel app is still one deployable codebase.

And if your team is growing, they help new developers move faster. Less “ask Johan where this belongs.” More “the test tells me this layer is not allowed to depend on that one.”

When to call in help

If your Laravel app is already fighting you, do not start by writing 40 PestPHP arch tests. Start by mapping the boundaries that matter:

  • where HTTP ends
  • where application decisions live
  • where domain rules live
  • where external systems are allowed
  • what legacy code should be fenced off

Then write tests around those decisions.

That is the work we usually do before scaling a PHP team or modernising a Laravel codebase. If you need senior PHP developers who can improve the codebase while keeping delivery moving, see our PHP team augmentation service or talk to us about PHP development.

PestPHP arch testing FAQ

Are PestPHP arch tests worth it for small Laravel apps?

Yes, but keep the rules small. Ban debug helpers, require strict types in new code, and add one or two dependency rules. A five-rule suite that the team respects is better than a 50-rule suite everyone works around.

Do arch tests replace PHPStan or Psalm?

No. PHPStan and Psalm catch type and static-analysis issues. PestPHP arch tests catch structural choices: which namespaces may depend on which layers, whether classes follow naming rules, and whether forbidden functions are used.

Should Laravel controllers be allowed to use Eloquent models?

It depends on the project. For simple CRUD, direct Eloquent use may be acceptable. For larger applications, we prefer controllers calling services or actions, with database decisions behind a clearer boundary. Choose the rule your team will actually maintain.

Can PestPHP arch testing help with legacy Laravel code?

Yes, if you use it to fence legacy code instead of pretending it is clean. Add rules for new namespaces first, document exceptions with ignoring(), and tighten the boundary as old code is touched.

How often should architecture tests run?

Run them on every pull request. They are fast, and architecture drift is cheapest to fix while the change is still fresh.

PHP

Laravel

PestPHP

Testing

Architecture

Code Quality