php elephant sticker
Photo by RealToughCandy.com on Pexels.com
Table of Contents

[Hands-On Field Guide] Building a Robust API Platform with Laravel

REST / JSON:API / GraphQL, OpenAPI, Versioning, ETag / Conditional Requests, Rate Limiting, Idempotency, Error Design, and Accessible Documentation

What you’ll learn in this article

  • How to choose between REST, JSON:API, and GraphQL, and implementation guidelines in Laravel
  • Spec-driven development with OpenAPI (OAS), and validation of requests/responses
  • Versioning strategies (URI / header / subdomain) and migration for breaking changes
  • Using ETag / If-None-Match and If-Modified-Since for caching and bandwidth reduction
  • Practical reliability techniques like rate limiting, idempotency, and signed webhooks
  • Error representation (RFC 7807 problem+json), multilingual messages, and trace IDs
  • How to make API docs, samples, and SDKs accessible and easy to understand for everyone
  • Testing (Feature / Contract), monitoring, and compatibility checks in operations

Intended readers

  • Beginner to intermediate Laravel backend engineers: want to safely expose APIs and avoid breaking changes
  • Tech leads / PMs: want to run spec-driven development and release management centered on OpenAPI
  • QA / documentation owners: want to deliver API specs, tutorials, and samples in an accessible, readable form
  • Developers building integrations: want standard patterns for webhooks, signature verification, and idempotency

Accessibility level: ★★★★★

We’ll concretely define a policy not only for API availability itself, but also for making docs, tutorials, and sample code understandable for everyone. This includes color-independent representations, support for screen readers and contrast, keyboard-only navigation, easy copying of samples, alt text for diagrams, and more.


1. Introduction: An API Is Both a “Contract” and a “Public Good”

An API is not just a collection of endpoints. It’s a contract to be honored with clients over the long term. Breaking changes and ambiguous semantics will come back as outages and support load.

Laravel has pretty much everything you need to build APIs—authentication, authorization, routing, validation, caching, rate limiting—but without clear design principles, those tools won’t shine.

In this article, we’ll build a robust and readable API platform step by step, with spec-driven development and compatibility as the main axes.


2. Choosing a Style: REST / JSON:API / GraphQL

2.1 Quick comparison

  • REST (generic JSON)

    • Pros: Low learning cost, stays close to HTTP semantics. Easy to leverage CDN/caching.
    • Cons: Very flexible; representation can vary a lot between implementations.
  • JSON:API (spec-compliant REST)

    • Pros: Clear conventions for docs, errors, related resources, pagination. Easier client implementations.
    • Cons: Because the spec is strict, there’s an initial learning curve.
  • GraphQL

    • Pros: Solves over/under-fetching. Great for complex screens.
    • Cons: HTTP caching is harder. N+1 issues and schema maintenance can be tricky.

For small to standard business APIs, REST (or JSON:API) is usually enough. When you’re struggling with complex dashboard aggregations or mobile screen optimization, GraphQL is worth considering.

Avoid mixing too many styles—it makes operations harder. Start with REST as your base, and standardize errors, auth, pagination first.


3. Spec-Driven Development: OpenAPI at the Center

3.1 Treat OAS as the single source of truth

Use the OpenAPI Specification (OAS) as your single source of truth, and derive/validate server, client, tests, and docs from it.

Benefits:

  • Less ambiguity in requirements
  • Easier detection of breaking changes
  • Safer clients through generated SDKs/types

3.2 Example directory structure

api/
├─ openapi.yaml            # Spec (managed by humans + validated in CI)
├─ examples/               # Concrete request/response examples
└─ schemas/                # Reusable JSON Schemas
app/
└─ Http/
   ├─ Controllers/Api/
   ├─ Middleware/
   └─ Requests/Api/
tests/
└─ Contract/               # Contract tests against OAS

3.3 Type safety and validation in Laravel

  • Write the same constraints as in OAS in each FormRequest
  • Additionally, use league/openapi-psr7-validator or similar in contract tests to verify that real responses conform to the spec
  • Use DTOs via spatie/laravel-data or similar, and enforce response shapes by types

4. Versioning and Compatibility Guarantees

4.1 Comparison of approaches

  • URI prefix: /api/v1/... is the easiest to understand; good for routing and caching
  • Header (Accept: application/vnd.example.v2+json): elegant but higher learning cost
  • Subdomain: v2.api.example.com works nicely with API gateways

Recommended starting point: /api/v1.

When you introduce incompatible changes, expose them as v2 and run v1 and v2 side-by-side for some time.

4.2 Change categories and operations

  • Non-breaking

    • Add fields
    • Relax defaults
    • Increase upper bounds (e.g., max page size)
  • Soft-breaking (need announcement, but may not break all clients)

    • Change default values
    • Change sort order
  • Breaking

    • Removing fields
    • Changing types
    • Changing semantics
    • Removing endpoints

The default rule: avoid breaking changes and ask “Can this be expressed as an additive change?”

If a breaking change is truly necessary, release a new version, provide a detailed migration guide and a linter/compat checker.


5. Authentication, Authorization, and Scopes

5.1 Auth methods

  • Session + CSRF: for same-origin SPA
  • Sanctum personal access tokens: for external clients / APIs
  • OAuth 2.1 / OIDC (external IdP): for partner integrations or SaaS SSO use cases

5.2 Scopes (abilities)

// issuing
$token = $user->createToken('cli', ['orders:read', 'orders:write'])->plainTextToken;

// checking
abort_unless($request->user()->tokenCan('orders:write'), 403);

Finer-grained scopes improve security but complicate operations.

Start simple with a read / write split, and refine as needs become clear.


6. Routing, Naming, Pagination, and Sorting

6.1 Naming

  • Use plural resource names:
    • GET /orders, GET /orders/{id}, POST /orders
  • Represent actions as subresources:
    • POST /orders/{id}/cancel so intent is explicit
  • Consistency beats cleverness: short and concrete wins

6.2 Pagination, sorting, filtering

GET /orders?page=2&per_page=50&sort=-created_at&status=shipped

Guidelines:

  • Set a maximum for per_page (e.g., 100)
  • Allow sorting only by a whitelist of fields; reject arbitrary columns
  • Include pagination info in the response
{
  "data": [ ... ],
  "meta": { "total": 1234, "page": 2, "per_page": 50 },
  "links": { "next": "...", "prev": "..." }
}

7. Conditional Requests and HTTP Caching

7.1 ETag / If-None-Match

Add a hash (ETag) to each resource representation. When the client sends If-None-Match, return 304 Not Modified if there’s no change.

public function show(Order $order) {
    $etag = sha1($order->updated_at . $order->id);

    if (request()->header('If-None-Match') === $etag) {
        return response()->noContent(304)->header('ETag', $etag);
    }

    return response()
        ->json($order)
        ->header('ETag', $etag);
}

7.2 Last-Modified / If-Modified-Since

Time-based conditional requests are also useful. Reducing needless re-fetches saves both cost and latency.


8. Rate Limiting, Idempotency, and Retries

8.1 Rate limiting

// routes/api.php
Route::middleware('throttle:60,1')->group(function () {
    Route::get('/orders', [OrderController::class, 'index']);
});

Guidelines:

  • Use API keys or user IDs as the key (“by”) per use case to enforce fairness
  • Return 429 Too Many Requests with Retry-After when limiting

8.2 Idempotency (avoiding duplicate side effects)

For payments and create-type operations, use idempotency keys to avoid duplicate execution.

// Middleware outline
$key = request()->header('Idempotency-Key');
abort_unless($key, 400);

$lock = Cache::lock("idem:$key", 60);
abort_unless($lock->get(), 409); // already in progress

try {
    // If we already have a response for this key, replay it
    if ($cached = Cache::get("idem:resp:$key")) {
        return response()->json($cached['body'], $cached['status']);
    }

    $resp = $next($request);

    Cache::put(
        "idem:resp:$key",
        [
          'status' => $resp->status(),
          'body'   => json_decode($resp->getContent(), true),
        ],
        3600
    );

    return $resp;
} finally {
    optional($lock)->release();
}

8.3 Retry strategies

Clients should use exponential backoff for retries (e.g., 100ms, 200ms, 400ms, …).

On the server side, design endpoints for safe re-execution.


9. Error Design: Standardizing on RFC 7807 (problem+json)

9.1 Format

{
  "type": "https://docs.example.com/problems/validation",
  "title": "Validation Failed",
  "status": 422,
  "detail": "The email field is required.",
  "instance": "/api/v1/users",
  "errors": {
    "email": ["This field is required."]
  },
  "trace_id": "req-8a2c..."
}

Conventions:

  • type: URL to a fixed doc page, with reproduced steps and recommended fixes
  • Always include a trace_id, so support and operators can quickly locate logs

9.2 Implementing in Laravel

Catch Throwable in the exception handler and map them to problem+json responses depending on status codes. Unify FormRequest validation errors into the same format.

// app/Exceptions/Handler.php (excerpt)
public function render($request, Throwable $e)
{
    if ($request->expectsJson()) {
        $traceId = (string) Str::uuid();
        Log::error('api.error', ['trace_id' => $traceId, 'ex' => $e]);

        $status = $this->statusOf($e); // map exception to HTTP code
        $payload = [
            'type'     => $this->problemType($e),
            'title'    => Response::$statusTexts[$status] ?? 'Error',
            'status'   => $status,
            'detail'   => $this->detailOf($e),
            'instance' => $request->path(),
            'trace_id' => $traceId,
        ];

        if ($e instanceof ValidationException) {
            $payload['type']   = 'https://docs.example.com/problems/validation';
            $payload['errors'] = $e->errors();
        }

        return response()
            ->json($payload, $status)
            ->header('Content-Type', 'application/problem+json');
    }

    return parent::render($request, $e);
}

9.3 Multilingual messages

Localize detail according to Accept-Language.

Do not change machine-readable type and status. Providing a way to access an English detail can also help support teams.


10. Securing and Sizing JSON Payloads

  • Use application/json and disable JSONP
  • Return only minimal necessary fields. Do not expose internal info for observability (stack traces, PII, etc.)
  • Always paginate lists. Use per_page limits to control bandwidth
  • For huge arrays, consider streaming or asynchronous exports

11. Webhooks: Signature Verification and Replay Protection

11.1 As the sender (you send webhooks)

  • Include a signature and timestamp in headers
  • Include an idempotency key (event ID)
$payload = json_encode($event, JSON_UNESCAPED_UNICODE);
$ts      = time();

$sig = hash_hmac('sha256', $ts . '.' . $payload, config('services.webhook.secret'));

Http::withHeaders([
    'X-Webhook-Timestamp' => $ts,
    'X-Webhook-Signature' => $sig,
    'Idempotency-Key'     => $event['id'],
])->post($url, $event);

11.2 As the receiver (you receive external webhooks)

  • First verify the signature and timestamp (with a tolerance, e.g., ±5 minutes)
  • After verification, hand off processing to a job. Ignore duplicate event IDs.
$payload = $request->getContent();
$ts      = $request->header('X-Webhook-Timestamp');
$sig     = $request->header('X-Webhook-Signature');

abort_if(abs(time() - (int) $ts) > 300, 401); // timestamp too old
$calc = hash_hmac('sha256', $ts . '.' . $payload, config('services.webhook.secret'));
abort_unless(hash_equals($calc, $sig), 401);

$event = json_decode($payload, true);

if (Cache::add('evt:' . $event['id'], true, 3600)) {
    dispatch(new HandleWebhook($event));
}

12. Documentation Accessibility: Easy to Read, Easy to Find, Easy to Try

12.1 Information architecture

  • Provide a table of contents in a left sidebar, navigable via keyboard
  • Keep one topic per page. At the top, show: “What you can do”, “Who this is for”, and “Shortest path to success”
  • Do not rely on background or badge colors alone. Use text labels and icons for states
  • Provide alt text and longer descriptions for diagrams and sequence charts

12.2 Crafting samples

  • Provide at least these four flavors:
    • cURL
    • JavaScript (fetch/axios)
    • PHP (HTTP client)
    • Python (requests)
  • Ensure the copy button is keyboard-accessible
  • Long code should be collapsible and always have headings and explanations
  • Include failure examples (401 / 403 / 422 / 429 / 500) and short guidance on how to fix each case

12.3 Error catalog

Publish a page per type with a fixed URL.

Describe:

  • Cause
  • How to fix
  • Conditions for reproduction
  • Whether retrying is OK
  • Support contact

Use color only as a supplement; text must be primary.


13. Implementation Sample: Orders API (Excerpt)

13.1 Routes

Route::prefix('api/v1')
    ->middleware(['auth:sanctum', 'throttle:120,1'])
    ->group(function () {
        Route::get('/orders', [OrderController::class, 'index']);
        Route::post('/orders', [OrderController::class, 'store'])
            ->middleware('idem.key'); // idempotency
        Route::get('/orders/{order}', [OrderController::class, 'show']);
        Route::post('/orders/{order}/cancel', [OrderController::class, 'cancel']);
    });

13.2 Validation

class StoreOrderRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'items'          => ['required', 'array', 'min:1'],
            'items.*.sku'    => ['required', 'string'],
            'items.*.qty'    => ['required', 'integer', 'min:1', 'max:100'],
            'note'           => ['nullable', 'string', 'max:500'],
        ];
    }
}

13.3 Controller (ETag support in show)

public function show(Order $order)
{
    $this->authorize('view', $order);

    $etag = sha1($order->updated_at . $order->id);

    if (request()->header('If-None-Match') === $etag) {
        return response()
            ->noContent(304)
            ->header('ETag', $etag);
    }

    return response()
        ->json([
            'data' => [
                'id'     => $order->id,
                'status' => $order->status,
                'total'  => $order->total,
                'items'  => $order->items()->get(['sku', 'name', 'qty', 'price']),
            ],
            'meta' => ['currency' => 'JPY'],
        ], 200)
        ->header('ETag', $etag);
}

13.4 Error (422 as problem+json)

throw ValidationException::withMessages([
    'items.0.sku' => ['This SKU does not exist.'],
]);

14. Contract Tests and Compatibility Checks

14.1 Contract tests

  • Load the OAS, and for each endpoint, verify that actual responses conform to the spec
  • Detect backward-incompatible schema changes (type changes, fields becoming required, etc.) in CI

14.2 Preventing regressions

  • Run E2E smoke tests for representative clients (internal SDKs) on every deploy
  • Use trace_id to significantly shorten investigation when incidents happen

15. Monitoring and SLOs

Track:

  • p50 / p95 / p99 latency, error rates (5xx / 4xx), rate-limit hit rate, cache hit rate
  • Queue delays in the backend, database slow queries

Define SLOs for critical endpoints (e.g., p95 < 300ms, 5xx < 0.1%), and set up alerts when they are violated.


16. Key Points for Security Hardening

  • Use HSTS, CSP, X-Content-Type-Options: nosniff, Referrer-Policy
  • Input validation (FormRequest), and protection against SQL / template / command injection
  • For file uploads, enforce MIME checks, size limits, and virus scanning
  • Audit logs: store trace_id, user_id, ip, path, status, and latency as structured logs
  • Mask sensitive data (tokens, card details, personal identifiers)

17. Accessible Sample App and Developer Portal

  • The portal’s navigation must be fully usable by keyboard, with visible focus rings
  • Code blocks should have language labels and a copy button, with color schemes that work for color-blind users
  • Tables and parameter definitions must have clear headings and descriptions; don’t rely on icons alone
  • For sample requests, show the same content via curl and multiple languages, and provide nicely formatted responses
  • Support dark mode and ensure sufficient contrast (WCAG AA or better)

18. Common Pitfalls and How to Avoid Them

  • Passing arbitrary sort/filter parameters straight into SQL

    • ➜ Allow only whitelisted fields and validate values
  • “Fake” version numbers without actual compatibility policy

    • ➜ Pair versioning with a rule to avoid breaking changes plus migration guides
  • Returning full, unpaginated lists

    • ➜ Require pagination and use ETag/conditional requests to reduce re-fetch costs
  • Inconsistent error formats

    • ➜ Standardize on RFC 7807 and link to an error catalog
  • Create endpoints without idempotency

    • ➜ Implement Idempotency-Key and server-side locking/caching
  • Webhooks without protection against spoofing

    • ➜ Verify signatures, enforce timestamp windows, ignore duplicate event IDs
  • Docs that rely solely on color

    • ➜ Make text primary, use alt text, and ensure flows can be completed via keyboard only
  • Untraceable incidents

    • ➜ Return trace_id on every response and tie it to logs

19. Checklist (for distribution)

Design

  • [ ] Chosen API style (REST / JSON:API / GraphQL) and documented rules for deviations
  • [ ] OpenAPI is the single source of truth; contract tests are wired into CI
  • [ ] Versioning with /api/v{n} and written policy for breaking changes and migration

Functionality

  • [ ] Auth (Sanctum/OAuth) with least-privilege scopes
  • [ ] Pagination / sorting / filtering through whitelisted parameters and sensible limits
  • [ ] ETag / conditional requests enable bandwidth reduction
  • [ ] Rate limiting, idempotency, and retry policy defined

Errors

  • [ ] RFC 7807 standardized, trace_id always included
  • [ ] Multilingual detail and URLs for error catalog pages

Security / Operations

  • [ ] Signed webhooks with replay protection
  • [ ] Structured audit logs with PII masked
  • [ ] SLOs and metrics dashboards in place

Docs / Accessibility

  • [ ] Table of contents, keyboard navigation, contrast, and alt text ensured
  • [ ] cURL + multiple language samples with copy button
  • [ ] Error examples and how to fix them documented

20. Conclusion

APIs are long-lived public infrastructure.

Center your design around OpenAPI, preserve compatibility, unify error representations, and use caching and rate limiting to keep the platform healthy. Make create-type endpoints robust against duplicates with idempotency. For webhooks, combine signature verification with replay protection.

Documentation should be easy to read and try for everyone—don’t rely only on colors or images; convey meaning through text and structure.

Use the designs and samples in this article as a starting point to establish your team’s API standards. If you nurture the platform carefully, it can become friendly to both users and developers.


References

By greeden

Leave a Reply

Your email address will not be published. Required fields are marked *

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)