Skip to main content
Laravel, shipping fast.
Chapter 10 ยท Developer Experience

Documentation That Survives The Code Changing

Julian Beaujardin

Here's the brutal truth about hand-written docs: they're accurate on the day you write them and wrong on some day after that, and nobody can tell you which day.

Most teams solve this by scheduling documentation reviews. That doesn't work, the review happens on a calendar and the code changes on a deploy pipeline, and the two schedules never line up. The fix isn't discipline. It's making documentation something you regenerate instead of something you remember to update.

# artisan
$ php artisan boost:update

AGENTS.md isn't hand-maintained prose, it's produced by Laravel Boost from the packages and skills actually installed, declared in boost.json. When a dependency changes, you rerun the command instead of hunting for a paragraph to fix. It can't drift the way a wiki page drifts, because it isn't describing the codebase from memory, it's reading it.

ArchTest.php does the same job in a different form. A comment saying "jobs must be suffixed Job" goes stale the moment someone stops reading comments. An arch() assertion that says the same thing fails the build the moment someone violates it. The test is the documentation.

The README's own contributor notes make the strategy explicit: "when changing endpoint behavior, update feature tests in tests/Feature and this README together", a habit tied to the same pull request as the change, the only place a doc update reliably survives.

Errors That Tell You What To Do Next

Here's a controversial opinion: an error message that just states the problem has done half its job. The other half is telling the developer what happens next.

// api-infrastructure/src/Exceptions/ApiExceptionRenderer.php
if ($exception instanceof ThrottleRequestsException) {
    $status = HttpResponse::HTTP_TOO_MANY_REQUESTS;
    $message = 'Too many requests. Please try again later.';
} elseif ($exception instanceof ModelNotFoundException) {
    $status = HttpResponse::HTTP_NOT_FOUND;
    $message = 'The requested resource was not found.';
} elseif ($exception instanceof ValidationException) {
    $status = HttpResponse::HTTP_UNPROCESSABLE_ENTITY;
    $message = json_encode($exception->errors()) ?: '{}';
}

Every branch answers two questions at once: what went wrong, and what the status code tells the caller to do. A 429 says slow down and retry. A 404 says stop asking, the thing isn't there. A 422 hands back the exact fields that failed, from BaseFormRequest::failedValidation(), so the caller doesn't have to guess which field was the problem.

api-license goes further for its own upstream dependency. When the Statamic driver times out, that's a ConnectionException, and the app maps it deliberately:

// bootstrap/app.php
$exceptions->render(function (ConnectionException $exception) {
    return response()->json(
        data: ['errors' => [$exception->getMessage()]],
        status: HttpResponse::HTTP_GATEWAY_TIMEOUT,
    );
});

A 504 instead of a generic 500 tells the caller something specific: this wasn't your fault, the upstream was slow, retrying is reasonable. A bare 500 Internal Server Error tells the caller nothing except to file a support ticket. One response ends the conversation. The other lets the caller act.