Skip to main content
Laravel, shipping fast.

Chapter 11

API Evolution

Julian Beaujardin

Your API has consumers who don't ship on your schedule. A partner's integration was written by someone who left the company two years ago. A merchant's automation script hasn't been touched since it was written, and nobody remembers it exists until it breaks. You will change your API forever, and every change is a negotiation with code you cannot see and cannot deploy.

This chapter covers the mechanics of that negotiation: telling a breaking change from a safe one, resolving a version at the edge instead of scattering if statements through controllers, running two versions off one codebase, setting a real deprecation date instead of a vague promise, telling consumers before they find out the hard way, writing a migration guide someone can follow, and retiring a version once its time is up.

A version is a promise with an expiration date, not a permanent fork. Everything below exists to keep that promise without freezing your codebase in amber.

Changes that break and changes that do not

Most API "versioning" conversations start too late, at the point someone already broke something and is arguing about what to call the fix. Start earlier: know which changes need a version bump before you write the code.

Take LicenseResource, the shape every license endpoint returns:

// app/Http/Resources/LicenseResource.php
final class LicenseResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'key' => $this->resource->key,
            'name' => $this->resource->name,
            'domains' => $this->resource->domains,
            'created_at' => $this->resource->created_at,
        ];
    }
}

Additive changes are not breaking. Adding a new key to that array, say a status field nobody asked for yet, doesn't touch what's already there. A consumer parsing key and domains never notices.

Renaming a field is breaking. Rename domains to verified_domains and every consumer reading $data['domains'] gets null at best, a fatal error at worst.

Changing a field's type is breaking. created_at going from a formatted string to a Unix timestamp passes every test you wrote and fails every test your consumer wrote.

Removing a field is breaking, even a field you're sure nobody uses. You don't have visibility into every consumer's code. Assume they read everything you send.

Adding a required request parameter is breaking. A new optional field on CreateLicenseRequest costs nothing. A new required one turns every existing integration's create call into a 422.

Here's the rule: if an existing, unmodified client would parse your new response or make your new request differently than it does today, it's a breaking change. Everything else ships on Tuesday without a version bump.