The moment an API has a second consumer — a mobile app, a partner integration, a frontend team on a different release cycle — you lose the ability to change a response shape freely. Someone, somewhere, is parsing the field you want to rename. Versioning is how you keep moving without breaking them.
It's tempting to treat this as a problem for later, once there actually are multiple consumers. In practice, the cost of structuring resources so a version boundary is easy to introduce is tiny up front and expensive to retrofit once a dozen endpoints have grown organically with no separation between the database shape and the response shape. Setting up the resource layer correctly from the first endpoint is cheap insurance.
Resource Classes as the Seam
Laravel's JsonResource is the natural place to put a version boundary, because it already sits between your Eloquent models and the JSON you send over the wire. Changing what a resource returns doesn't touch your database schema or business logic at all.
class InvoiceResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'total' => $this->total_cents / 100,
'status' => $this->status,
'due_date' => $this->due_at->toDateString(),
];
}
}
When the next version needs to add a nested customer object and rename due_date to due_at, you don't edit this class — you add a new one.
class InvoiceResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'total' => $this->total_cents / 100,
'status' => $this->status,
'due_at' => $this->due_at->toIso8601String(),
'customer' => CustomerResource::make($this->whenLoaded('customer')),
];
}
}
Routing by Version
A URL prefix is the most honest way to version a REST API — it's visible in logs, cacheable per version, and doesn't require clients to send a custom header correctly.
Route::prefix('v1')->group(function () {
Route::apiResource('invoices', V1\InvoiceController::class);
});
Route::prefix('v2')->group(function () {
Route::apiResource('invoices', V2\InvoiceController::class);
});
Controllers in each version namespace can share the same underlying service or action class — the duplication you want to avoid is business logic, not the thin resource-shaping layer.
What a v2 Response Actually Looks Like
{
"data": {
"id": 482,
"total": 1499.00,
"status": "sent",
"due_at": "2026-11-01T00:00:00+00:00",
"customer": {
"id": 17,
"name": "Nordbridge Freight Co."
}
}
}
Notice the shape change isn't cosmetic: due_date became due_at with full ISO 8601 precision, and a previously flat response gained a nested object. A v1 client never sees this; it keeps getting exactly what it always got.
Deprecation or Sunset response header on v1 routes months before turning them off, and log which API keys are still hitting them so you know who to warn directly.Deciding When a New Version Is Actually Needed
- Additive changes — a new optional field — don't need a new version. Well-behaved clients ignore fields they don't recognize.
- Renaming or removing a field, changing a type, or altering status codes for existing cases are breaking changes and need one.
- Changing pagination or error formats almost always breaks something, even if it looks minor from the server side.
Keeping the Cost of Versions Down
The biggest risk with API versioning isn't the first version split — it's letting three or four versions pile up with nobody migrated off the old ones. Track version usage with a simple column or log line on every request, set an actual sunset date when you ship a new version, and hold yourself to it. A version that never gets retired isn't a migration path, it's permanent maintenance debt.
Documenting the Difference Between Versions
A changelog that simply says "v2 released" doesn't help the team integrating against your API decide whether they need to act. Spell out exactly which fields were renamed, removed, or restructured, and give a rough timeline for when the older version stops receiving fixes. Pairing that changelog with a working example response for each version, like the JSON snippet above, answers most integration questions before anyone has to open a support ticket.
Versioning adds a small amount of ceremony to every breaking change, which is exactly the point — it forces you to notice when you're about to break someone's integration, instead of finding out from a support ticket.