Skip to main content
Laravel, shipping fast.

Tests prove behavior. They don't prove structure, and structure is where inconsistency creeps back in six endpoints after everyone agreed on the pattern. That's what static analysis is for: a second opinion that runs on every commit and doesn't get tired of enforcing the same rule.

// tests/Feature/ArchTest.php
arch()->preset()->php();
arch()->preset()->security();
arch()->preset()->laravel();

it('checks that my jobs have suffix Job')
    ->expect('App\Jobs')
    ->toHaveSuffix('Job');

it('checks that my actions are final class')
    ->expect('App\Actions')
    ->toBeFinal();

it('checks that my actions extend nothing')
    ->expect('App\Actions')
    ->toExtendNothing();

Pest's arch() presets catch the categories of mistake code review misses because they're boring to check by hand: a class that should be final and isn't, a job missing its Job suffix, an action that quietly extends a base class the pattern says it shouldn't. This runs as a real test in tests/Feature on every one of the fleet's API services, api-server, api-license, and api-ai alike. It fails loudly the moment someone breaks convention, instead of waiting for a reviewer to notice on pull request forty-seven.

PHPStan, run through Larastan at level 9, is the other half. composer check runs ./vendor/bin/phpstan analyse against app/, and level 9 means no implicit mixed, no untyped array access it can't verify, no silently-swallowed nullable. It catches the bug a test would only catch if you happened to write the exact input that triggers it. Between composer test, composer check, and composer format, nothing ships that isn't behaviorally tested, statically typed, and formatted the same way as everything around it.

Keeping the suite fast enough to run

A test suite that takes twenty minutes doesn't get run before every commit. It gets run before every deploy, and by the time it fails you've already forgotten what you changed an hour ago. Speed isn't a nice-to-have here. It's the difference between a suite that catches bugs and a suite that just sits in CI making the build take longer.

<!-- api-server phpunit.xml -->
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="CACHE_STORE" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>

Every one of these lines removes a real dependency from the test run. SQLite in memory means no database server round trip. QUEUE_CONNECTION=sync means a dispatched job runs inline, in the same process, instead of waiting on a worker that isn't running in CI anyway. CACHE_STORE=array and SESSION_DRIVER=array mean nothing touches Redis or a file on disk. None of this is exotic configuration. It's the standard Laravel testing environment, applied consistently, and it's most of why a feature test that hits a real route and a real (faked) HTTP call still runs in milliseconds.

The other cost hides in retry logic. The shared SendsRequests trait's backoff sleeps for real between retries in production. Left alone, the retry test from earlier in this chapter would burn real wall-clock seconds proving that logic works:

// tests/Feature/SendsRequestsThrottleRetryTest.php
beforeEach(function () {
    config()->set('webplo.throttle_retry.max_attempts', 3);
    // Keep tests fast: clamp every backoff to zero so no real sleeping
    // happens while still exercising the retry control flow.
    config()->set('webplo.throttle_retry.base_delay_ms', 0);
    config()->set('webplo.throttle_retry.max_delay_ms', 0);
});

Clamping the delay to zero doesn't skip the retry, it just refuses to wait for it. The attempt counting and the eventual give-up still run and get asserted on. Only the sleeping gets removed. Multiply a few real seconds of backoff across a suite with hundreds of tests and a two-minute suite becomes a twenty-minute one, for zero additional confidence. Fast and thorough aren't in tension. The trick is never making the machine wait for something a test doesn't actually need to prove.