Engineering & Performance

Designing REST APIs in PHP/Laravel That Don't Become Legacy

·10 min read·By Abimael Espinoza

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