Laravel Testing with Pest — Part 1 · Part 2 · Part 3 · Part 4 · Part 5
Architecture tests can keep controllers from depending on infrastructure or enforce naming rules, but they complement behavioural tests rather than replace them. Run the focused suite locally and the full suite, static analysis and formatting in CI. Parallel testing shortens feedback once isolation is sound. Treat coverage as a map of unexecuted code, not a target that proves correctness.
Put durable design rules in a dedicated test
Behavioural tests tell us whether a customer can create an order. They do not tell us that the controller stayed a transport boundary while that feature was implemented. That is the useful job for an architecture test: express a small, stable decision that would otherwise be rediscovered in every review.
In this application controllers may use requests, resources and actions. They may not query Eloquent or invoke a vendor client themselves. This is not an abstract preference for layers. It means that a later API, CLI command, or queued job can reuse the order decision without duplicating HTTP work.
<?php
declare(strict_types=1);
arch('HTTP controllers are thin')
->expect('App\\Http\\Controllers')
->toOnlyUse([
'App\\Http\\Requests',
'App\\Http\\Resources',
'App\\Actions',
'Illuminate',
]);
arch('HTTP controllers do not reach persistence')
->expect('App\\Http\\Controllers')
->not->toUse('Illuminate\\Database')
->not->toUse('App\\Models');
arch('actions have an explicit name')
->expect('App\\Actions')
->toHaveSuffix('Action')
->toBeClasses()
->not->toBeAbstract();
The exact rule is a local design choice, not a Laravel commandment. An
invokable controller can reasonably use an Order type for route model
binding. A blanket ban on models would reject legitimate framework code and
teach people to evade the suite. Move binding to a request or action where that
helps, or record a narrow exception. A rule should make an unwanted change
difficult, never make ordinary code unreadable.
Architecture checks also guard dependency direction. If App\\Domain
contains order transitions and App\\Infrastructure contains Eloquent
repositories and accounting adapters, a domain service may describe a port but
must not import an adapter.
<?php
declare(strict_types=1);
arch('domain code does not depend on infrastructure')
->expect('App\\Domain')
->not->toUse('App\\Infrastructure')
->not->toUse('Illuminate\\Http')
->not->toUse('Illuminate\\Support\\Facades');
arch('infrastructure classes reveal their role')
->expect('App\\Infrastructure')
->toHaveSuffix(['Repository', 'Client', 'Gateway'])
->toBeClasses();
Do not create those namespaces merely to write a clever assertion. A small
application with one CreateOrderAction and conventional Eloquent models may
have controller, action and model as its real boundary. Add architecture tests
after the same layering mistake happens twice, when a package boundary matters,
or when the team needs a shared rule. They are neither security checks nor type
analysis: policies and feature tests prove access, static analysis finds
impossible types, and architecture tests give fast structural feedback.
Group feedback, then make state safe for parallel processes
One huge suite is slow and ambiguous. Label the kinds of evidence without duplicating every example. A developer can run a focused feature group while changing an endpoint; the protected branch still runs all required groups. Browser tests deserve a group because their runtime is heavier and their value is a small number of expensive user journeys.
<?php
declare(strict_types=1);
use App\\Models\\User;
use Illuminate\\Foundation\\Testing\\RefreshDatabase;
uses(RefreshDatabase::class)->group('feature')->in('Feature');
test('a customer can submit a support request', function (): void {
$this->actingAs(User::factory()->create())
->post('/support/requests', [
'subject' => 'Invoice copy',
'category' => 'billing',
'message' => 'Please send the invoice for order 1001.',
])
->assertRedirect();
})->group('browser-contract');
Laravel's --parallel option improves feedback only once every process owns
its state: database, cache prefix, filesystem path and every resource it
writes. A suite that passes serially and fails in parallel has found a real
isolation defect. It has not found a reason to add retries or disable
parallelism.
<!-- phpunit.xml -->
<php>
<env name="APP_ENV" value="testing"/>
<env name="CACHE_STORE" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="FILESYSTEM_DISK" value="testing"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
</php>
php artisan test --compact
php artisan test --compact --parallel --processes=4
A hard-coded email can collide with a unique index. A test writing
reports/latest.pdf races another process. A shared
Cache::remember('current-order', ...) key can return another example's
value. Generate unique fixtures, prefix keys with an order ID, use
Storage::fake(), and create mutable state inside each example.
Parallelism is not CI sharding. Parallelism divides one command on one runner; sharding divides the suite between separate jobs. Use the first when the runner has unused CPU. Add shards only after measuring a consistently slow suite: four jobs that mostly boot Composer and services cost more without giving meaningfully faster evidence.
Let CI collect evidence in the right order
CI should fail first on cheap, deterministic checks, then run behaviour. This workflow keeps formatting and static analysis visible, tests the supported PHP versions, splits the application suite, and leaves the browser contract in its own job.
name: tests
on:
pull_request:
push:
branches: [main]
jobs:
quality:
runs-on: ubuntu-latest
strategy:
matrix:
php: ['8.4', '8.5']
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
coverage: none
- uses: ramsey/composer-install@v3
- run: vendor/bin/pint --test
- run: vendor/bin/phpstan analyse --memory-limit=1G
tests:
needs: quality
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.5'
coverage: none
- uses: ramsey/composer-install@v3
- run: php artisan test --compact --parallel --shard=${{ matrix.shard }}/4
browser-contract:
needs: quality
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.5'
- uses: ramsey/composer-install@v3
- run: npm ci
- run: php artisan test --compact --group=browser-contract
Verify the installed Pest and Laravel command syntax before copying sharding flags into production; runtime support changes. The design does not: every shard must run a disjoint, deterministic slice and branch protection must require every shard. One green shard out of four is not a green suite. Test every PHP version a package promises, but do not create a wide matrix by habit for an application deployed only on one runtime.
Treat coverage as a question
Coverage asks why a branch did not execute. It does not prove that an executed line was asserted meaningfully. Generate an HTML report when investigating a change, rather than making every pull request wait for instrumentation.
XDEBUG_MODE=coverage php artisan test --compact --coverage-html=build/coverage
An uncovered forbidden branch should prompt an HTTP test for a manager from
another account. An uncovered insufficient-stock path deserves a domain or
feature test. A test whose only purpose is calling Order::getKey() buys a
percentage, not safety. Thresholds can make sense for a mature package with a
small public API; they are a poor first target for a changing application,
migrations, generated code, or a team still learning what its tests prove.
The delivery contract is therefore layered: behaviour tests, a few browser journeys, isolated external boundaries, durable architecture rules, clean CI, and coverage-guided review. A pipeline earns trust when red is specific and reproducible, and green means the important contract really ran.
Before making any of these checks required, let the team see a few failures and agree on the repair path. A controller rule needs an obvious action to move work into; a flaky parallel test needs a reproducible fixture fix; a coverage report needs an owner who can distinguish dead code from missing risk coverage. Otherwise CI becomes a gate people resent rather than a fast, shared review of the delivery contract. Keep the rule, command and failure message close enough that the next contributor can correct the problem without guessing.