Skip to main content
Laravel, shipping fast.

I've watched a leaf service change shape without telling anyone. Not maliciously, not even carelessly, just a field that used to always be present started arriving as null, or missing outright, because some upstream migration decided an empty list wasn't worth serializing. Nothing in the contract said it couldn't happen. Nothing in most test suites checks for it either, because writing the happy-path test is easy and writing the "what if the field just isn't there" test requires imagining the failure before it happens.

Here's the one that would have caught it, and did, because someone imagined it before Statamic ever actually did it:

// tests/Feature/LicenseControllerTest.php
test('defaults domains to an empty array when Statamic omits it', function () {
    Http::swap(new Factory);
    Http::fake(fn () => Http::response(['data' => [
        ['key' => 'k1', 'name' => 'No Domains License', 'created_at' => '2026-01-28T10:30:00Z'],
    ]], 200));

    $response = getJson('/api/v1/licenses', [
        'Authorization' => "Bearer {$this->token->token}",
    ]);

    $response->assertStatus(Response::HTTP_OK);
    expect($response->json('items.0.domains'))->toBe([]);
});

This test doesn't fake the domain list. It removes it entirely from the fake response and asserts the API doesn't crash, doesn't leak a null into a field a consumer expects to always be an array. It's paired with the mapper that made the decision:

// app/Http/Mappers/LicenseDTOMapper.php
// A license with no domains is valid; tolerate an absent/non-array field.
/** @var array<string> $domains */
$domains = is_array($license['domains'] ?? null)
    ? array_values($license['domains'])
    : [];

That one-line ternary is the entire fix for a class of bug that would otherwise show up as a TypeError in production the day a leaf service changes shape. The test is what makes it a decision instead of an accident, written down once, re-verified on every future change to the mapper. That's the whole argument for testing failure explicitly: not that it catches today's bug, but that it catches the version of today's bug that hasn't happened yet.

Chapter 7 Checklist

Before you write the test:

  • [X] Know what the consumer sees: status code, response shape, side effect
  • [X] Decide whether this is feature-test or unit-test territory (it's almost always feature)

While you write it:

  • [X] Fake every external call, never let a test depend on a leaf service's uptime
  • [X] Use factory states (expired(), not hand-rolled attributes) to name intent
  • [X] Write the failure case next to the success case, not "someday"
  • [X] Clamp anything that sleeps in production down to zero in tests

Before you ship it:

  • [X] composer test passes
  • [X] composer check (PHPStan level 9) is clean
  • [X] arch() presets and your own architecture rules still hold

Tests don't slow you down. A suite you're afraid to run does. Write the ones that would catch the bug before it ships, keep them fast enough that nobody skips them, and the confidence compounds the same way the architecture does.