
How I Design APIs in Laravel (That Don't Break Later)
Most APIs don't break on day one. They break six months later.
On day one your API has one client: the team that wrote it. Six months in, the mobile app is on three release channels, two partners have embedded your endpoints into their workflows, and the frontend is shipping a new feature against a contract somebody wrote on a Friday afternoon and never revisited. That is the moment when a "small change" becomes an outage.
This is the fourth post in a series. I wrote about the mistakes I stopped making, the structure I land on by default, and how I optimize Laravel performance in production. Structure makes the code maintainable. Performance work keeps it fast. API design decides whether anything you build today is still standing in a year.
This is where most Laravel APIs start to break, not in code, but in contracts.
Table of Contents
The Real Problem with APIs
The mental shift that changed how I write Laravel APIs was small but absolute.
An API is not a set of endpoints. It is a contract. The moment a client depends on it, every field you return is a promise. Renaming customer_id to customerId is not a refactor anymore, it is a breaking change. Adding a new required field is not a feature, it is a deployment that will fail in production for somebody else.
Inconsistent APIs do not fail loudly. They corrode. One endpoint returns { "data": [...] }, another returns a bare array. One uses created_at, another uses createdAt. One returns 404 for a missing record, another returns 200 with { "result": null }. Every inconsistency becomes a bug report from a client who guessed wrong, and every bug report ends in a patch that locks the inconsistency in forever.
Good API design is the cheapest insurance you can buy against your future self.
What Breaks Laravel APIs in Production
The same five mistakes show up on almost every codebase I have inherited.
- No versioning. Routes mounted at
/api/userswith nov1prefix. Every breaking change is now a cross-team negotiation. - Inconsistent response shapes. Some endpoints wrap, some do not. Some paginate, some return everything. The frontend has a switch statement somewhere with comments like
// API quirk, do not touch. - Returning raw Eloquent models. Today's response is whatever the schema happens to be. Add a column tomorrow and that column is now a public field nobody decided to expose.
- Casual field changes. Renaming a column "to be cleaner" without realizing three clients are reading it.
- Inconsistent errors. Some 422s, some 400s, some 500s with a stack trace, some 200s with
{ "error": true }. Clients have no reliable way to react.
Each one is cheap to fix on day one. Each one is brutal to fix on day 200.
How I Design APIs Now
These are the rules I apply by default, because they prevent problems I don't want to debug later.
Versioning, from day one
Every API project starts at /api/v1/.... Even if I am sure there will never be a v2. The cost of the prefix is zero, the cost of adding it after the fact is a coordinated migration across every client.
// routes/api.php
Route::prefix('v1')->group(function () {
Route::apiResource('orders', V1\OrderController::class);
}); Folder structure mirrors the URL: app/Http/Controllers/Api/V1/. When v2 arrives, it gets its own folder and its own routes. No "if version" branches.
One response shape. Everywhere.
Every successful response uses the same envelope. Every error uses the same envelope. The frontend writes one parser.
{
"data": { ... },
"meta": { "page": 1, "per_page": 20, "total": 156 },
"errors": null
} Single-resource endpoints set meta to null. Collections fill it. Errors set data to null and populate errors. The shape is non-negotiable.
API Resources, not models
A controller never returns an Eloquent model. Ever. Every response goes through a JsonResource or ResourceCollection. The Resource is the contract. When the underlying schema changes, the Resource is the only file that decides whether that change is visible.
class OrderResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'status' => $this->status,
'total' => $this->total_cents,
'currency' => $this->currency,
'created_at' => $this->created_at->toIso8601String(),
];
}
}If a field is not in the Resource, it does not exist as far as clients are concerned. Adding a column to the table no longer leaks anything.
Validation through Form Requests, always
Controllers never see raw input. A FormRequest validates and authorizes at the boundary, and the controller works with validated() data only.
public function store(StoreOrderRequest $request, CreateOrder $action)
{
$order = $action->execute($request->validated(), $request->user());
return new OrderResource($order);
}This also means the OpenAPI spec for the endpoint can be generated directly from the rules. The contract documents itself.
Errors with shape and meaning
Validation failures return 422 with a structured errors object keyed by field. Domain failures (insufficient stock, payment declined) return 409 or 422 with an explicit code so clients can branch reliably. Authorization failures return 403. Authentication failures return 401. Server bugs return 500 and never leak a stack trace.
{
"data": null,
"meta": null,
"errors": {
"code": "PAYMENT_DECLINED",
"message": "Card was declined by issuer.",
"details": { "decline_reason": "insufficient_funds" }
}
} The status code tells the client what kind of problem it is. The code tells it which one.
A Real Example
A GET /api/orders/{id} endpoint I inherited returned the order Eloquent model directly. Convenient for the original developer. Three months in, it had become a load-bearing disaster.
The mobile app was reading customer.email. The frontend was reading customer.email_address because somebody had once added a computed attribute. A partner integration was reading customer_email from a top-level field that was added later. Same data, three names, all in production traffic at once.
The fix was not a clever migration. It was a rewrite of one file:
// Before: returned $order directly, schema = contract
return response()->json($order->load('customer'));
// After: explicit Resource, contract = code
return new OrderResource($order->load('customer')); The Resource exposed exactly one canonical field for the customer email. The other two were marked as deprecated, kept around for one release, then removed with a Sunset header and a changelog entry. The endpoint stayed at /v1. No version bump needed, because we had not promised the duplicates in the first place, only leaked them.
That single Resource class deleted two years of accumulated breaking-change risk.
What I Avoid Now
- Returning Eloquent models directly. The schema is not a contract. The Resource is.
- Inconsistent JSON shapes. If two endpoints return arrays differently, one of them is wrong.
- Tight coupling between frontend and backend. If a frontend change requires a backend deploy in lockstep, the API is doing the wrong job.
- Silent breaking changes. Renames, removals, and type changes get a deprecation window, a changelog, and a
Sunsetheader. Never a surprise. - Adding fields the frontend "might want." Every field is a promise. Add them when needed, not when imagined.
Key Principles I Follow
- APIs are contracts, not endpoints. Every field is a promise to somebody.
- Consistency beats convenience. A boring, predictable shape beats a clever, special-case one.
- Design for change. Version early, deprecate explicitly, never rename in place.
- Validate at the boundary. The controller never sees unvalidated input.
- The Resource is the contract. Schema changes do not leak through it by accident.
These rules are not aesthetic. They are the difference between an API that survives its second year and one that needs a rewrite.
Closing Thoughts
Good APIs do not make your system faster. They make it survivable when clients depend on it. Laravel gives you everything you need for clean REST API design out of the box. Form Requests, API Resources, route prefixes, exception handlers. The discipline is in using them by default, not after the third client has filed a bug.
Scalable Laravel APIs are not the ones with the most clever routing. They are the ones where every response looks like every other response, every change is intentional, and every contract is in code, not in someone's memory.
If you've worked with APIs in production, what was the most painful breaking change you've seen?