Engineering & Performance
Designing REST APIs in PHP/Laravel That Don't Become Legacy
Most APIs become legacy within 18 months. Not because the code is bad, but because the contracts are. Here are the design patterns I use to ship PHP/Laravel APIs that stay maintainable as teams and clients multiply.
Version from day one
Even if you're sure you'll never break compatibility — you will. Prefix every route with /v1/. It costs nothing now and saves a 6-month migration project later.
Route::prefix('v1')->group(function () {
Route::get('/invoices', [InvoiceController::class, 'index']);
});Contract-first with OpenAPI
- Write the OpenAPI spec before the code. It surfaces design issues early.
- Generate request/response DTOs from the spec.
- Generate client SDKs for consumers (mobile, frontend, partners).
- Use `vyuldashev/laravel-openapi` or write specs by hand and validate in CI.
Use Laravel's API Resources
Don't return Eloquent models directly — they leak schema, expose private columns, and couple your API contract to your DB structure. API Resources give you a single place to shape the response.
class InvoiceResource extends JsonResource {
public function toArray($request): array {
return [
'id' => $this->public_id,
'amount' => $this->amount_cents,
'currency' => $this->currency,
'created_at' => $this->created_at->toIso8601String(),
];
}
}Public IDs, not auto-increment
Never expose auto-incrementing IDs in URLs — they leak business volume and enable enumeration attacks. Use ULIDs or UUIDs as public IDs (Laravel has built-in support via `HasUlids`).
Error contract
Define one error envelope and stick to it. RFC 7807 (Problem Details) is a good starting point:
{
"type": "https://api.example.com/errors/validation",
"title": "Invalid request",
"status": 422,
"errors": {
"email": ["The email field is required."]
},
"trace_id": "abc-123"
}Idempotency for write operations
Network retries are a fact of life. Accept an `Idempotency-Key` header on POST/PUT/DELETE. Store the key + response for 24 hours; return the cached response on retry. Stripe popularized this pattern for a reason.
Pagination, filtering, sorting
- Cursor pagination over offset for any list that grows (offset breaks at scale).
- Filter via `?filter[status]=paid`, not nested URL paths.
- Sort via `?sort=-created_at` (the - prefix means descending).
- Use `spatie/laravel-query-builder` to standardize this.
Rate limiting from day one
Laravel's `throttle` middleware. Different limits for anonymous vs authenticated, per-token vs per-IP. Return `429` with a `Retry-After` header.
Webhooks: signed and idempotent
- Sign every webhook with HMAC-SHA256.
- Include a timestamp in the signed payload to prevent replay.
- Send a unique event ID — consumers must dedupe.
- Retry with exponential backoff on non-2xx responses.
Document deprecations, don't delete
When something has to change, add the new endpoint, mark the old one with a `Deprecation: true` header, give consumers 6 months notice, and only then remove. Silent breaking changes destroy trust.
Need a hand?
Hiring or modernizing PHP? Let's talk.
16+ years building, scaling, and rescuing PHP applications. Direct contact, no marketplace, US time zones from LATAM.
Related reading
Scaling a Laravel Application: From 100 to 100k Users
Concrete techniques for scaling Laravel — caching, queues, database tuning, and horizontal scaling — through every order of magnitude of growth.
PHP Security in 2026: OWASP Top 10 Applied to Real PHP Code
OWASP Top 10 vulnerabilities mapped to real PHP and Laravel patterns — with concrete code examples of the bug and the fix.