← All writing

Contract-First API Changes: How to Evolve Laravel APIs Without Breaking Mobile Apps

Mobile clients do not update when a backend deploys. This is the compatibility model I use to evolve Laravel API responses without making old app versions fail in the field.


An API change can look harmless from the Laravel side and still break a mobile release that has been in the App Store for six months.

Rename scheduled_at to starts_at. Turn a string into an object. Remove a field the new web client no longer needs. The request test passes, the controller returns a neat resource, and production starts receiving errors from an app binary you cannot update with a deployment.

The backend deploys in minutes. Mobile clients update when people notice the update button, have enough storage, and decide it is worth tapping. That gap is why I treat an API response as a contract, not an implementation detail.

The contract includes field names, types, nullability, pagination, error shapes, and behaviour. A PHP property can change freely. A field consumed by a released application cannot.

Start with the consumer, not the controller

The usual failure begins in a controller. A product request arrives, the model has better names now, and the resource gets cleaned up to match.

{
  "id": "a9b0",
  "scheduled_at": "2026-07-10T09:00:00Z"
}

Someone changes it to this:

{
  "id": "a9b0",
  "schedule": {
    "starts_at": "2026-07-10T09:00:00Z",
    "timezone": "Europe/Stockholm"
  }
}

The new shape may be better. It is still breaking. An old client looking for scheduled_at gets null, and the failure might surface as a blank screen rather than a useful server error.

The first question should be: “What released clients read this field today?” If the answer is not known, the change is not ready to ship.

Prefer additive changes

The lowest-risk API change adds a new capability without removing the old one.

{
  "id": "a9b0",
  "scheduled_at": "2026-07-10T09:00:00Z",
  "schedule": {
    "starts_at": "2026-07-10T09:00:00Z",
    "timezone": "Europe/Stockholm"
  }
}

Now the mobile team can adopt schedule.starts_at in a new release while the existing version continues to work. The temporary duplication is not beautiful, but it is explicit. It buys time for the consumer release cycle.

Laravel API resources are a good boundary for this work. Keep the model free to change internally, then make the public shape intentional in one place:

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

final class AppointmentResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => (string) $this->id,
            'scheduled_at' => $this->scheduled_at->toIso8601String(),
            'schedule' => [
                'starts_at' => $this->scheduled_at->toIso8601String(),
                'timezone' => $this->timezone,
            ],
        ];
    }
}

The Laravel API resource documentation is useful for formatting and conditional fields. The design decision sits above the framework: the resource should represent a promise to consumers, not whatever happens to be convenient in the current model.

Make the compatibility window explicit

Every change needs an owner and an expiry date. Otherwise the temporary field becomes permanent because nobody can prove it is safe to remove.

For the example above, a release note could say:

2026-07-10  Added schedule.starts_at and schedule.timezone.
2026-07-10  scheduled_at remains supported for existing clients.
2026-09-30  Earliest date scheduled_at may be removed.

The removal date is not a threat. It is a coordination point. The mobile release needs time to reach an agreed adoption level, and the backend needs a real signal that it happened. Depending on the product, that signal may be minimum supported app version, active-client telemetry, enterprise customer confirmation, or a planned major API version.

Do not remove a field because the pull request is old. Remove it because the contract owner can show that it is no longer needed.

Version only when coexistence stops working

I do not create /v2 for every new field. Additive changes, optional fields, and extra response metadata usually fit inside the current contract.

Create a new API version when the old and new meanings cannot reasonably coexist. Typical examples are:

  • A field changes from one unit to another, such as cents to decimal currency.
  • An identifier changes format in a way that affects storage or validation.
  • A paginated collection changes its navigation contract.
  • Authentication, authorization, or error semantics must change together.

At that point, put the boundary in routing and documentation instead of asking each client to guess from a response:

use App\Http\Controllers\Api\V1\AppointmentController as V1AppointmentController;
use App\Http\Controllers\Api\V2\AppointmentController as V2AppointmentController;
use Illuminate\Support\Facades\Route;

Route::prefix('v1')->group(function (): void {
    Route::get('/appointments/{appointment}', V1AppointmentController::class);
});

Route::prefix('v2')->group(function (): void {
    Route::get('/appointments/{appointment}', V2AppointmentController::class);
});

Versioning has a maintenance cost. Two versions mean two test surfaces, two documentation paths, and a deprecation plan. That cost is worth paying when it makes the incompatibility clear. It is unnecessary ceremony when a new optional field would solve the problem.

Test the response as a contract

Controller tests that only assert 200 OK do not protect consumers. A contract test checks the names, shapes, and important values a released app relies on.

$this->getJson("/api/v1/appointments/{$appointment->id}")
    ->assertOk()
    ->assertJsonPath('data.id', (string) $appointment->id)
    ->assertJsonPath('data.scheduled_at', $appointment->scheduled_at->toIso8601String())
    ->assertJsonStructure([
        'data' => [
            'id',
            'scheduled_at',
            'schedule' => ['starts_at', 'timezone'],
        ],
    ]);

This is not testing Laravel’s serializer. It is protecting the public shape. If a future refactor removes scheduled_at, the test should fail until the compatibility window has ended and the versioned consumer contract has been updated deliberately.

For higher-risk APIs, I also keep small fixture responses shared with mobile teams. They help catch a different class of mistake: a response that technically has the correct field but uses a value, null, enum case, or date format the client does not understand.

Treat errors with the same care

Teams often version successful responses and forget errors. Mobile clients still need predictable validation messages, authorization failures, rate limits, and retryable server problems.

Your existing response envelope is part of the contract too. If a client expects:

{
  "success": false,
  "message": "Validation failed",
  "data": null,
  "errors": {
    "scheduled_at": ["The scheduled at field is required."]
  }
}

do not replace it with an unrelated error shape on one new endpoint. Consistency lets the client handle normal failure paths once instead of shipping endpoint-specific defensive code.

A release checklist for API changes

Before I approve an API change that reaches a mobile client, I ask:

[ ] What released clients consume the existing field or error shape?
[ ] Is the change additive, or is a new version genuinely required?
[ ] What is the compatibility window and who owns its end date?
[ ] Are date formats, nullability, enum values, and units documented?
[ ] Does a feature test assert the contract rather than only the status code?
[ ] Does the client have a fixture or integration check for the new response?
[ ] Is there a measured condition for safely removing the old path?

Good API design is less about a perfect first response and more about making the next change survivable. The contract gives backend and mobile teams a shared timetable. Without it, every tidy refactor is a gamble against code that is already in somebody else’s pocket.