[Practical, End-to-End Guide] Designing a Robust Web API with Laravel — Sanctum Auth, Versioning, OpenAPI, ETag/Caching, Rate Limiting, and Accessible Error Design
What You’ll Learn (Key Takeaways)
- API design guidelines grounded in RESTful principles (resource design, status codes, error formats)
- Simple and secure authentication/authorization with Laravel Sanctum (SPA / API token / mobile ready)
- Versioning (URL / header / route partitioning) and backward-compatibility operations
- Stable response shaping with Eloquent API Resources, plus specification via OpenAPI (Swagger)
- Bandwidth reduction & scalability with
ETag
, conditional requests, andCache-Control
- Operational must-haves for safety: rate limiting, CORS, Idempotency-Key, audit logs
- Accessibility-aware developer documentation (language, screen-reader friendliness, examples, readable error text)
Intended Audience (Who Benefits?)
- Laravel beginners–intermediates: want to correctly design production/internal/public APIs
- Tech leads / architects: want to standardize APIs as an organization-wide platform
- QA / CS / technical writers: want specs and wording that enable reproducible testing and fewer tickets
- Accessibility owners: want to establish operations that make docs and errors understandable by everyone
1. Introduction: API Design Principles & Laravel’s Strengths
An API is a “contract” maintained over the long term and used by multiple clients. Changes must be cautious, ambiguity eliminated, reproducibility essential. Laravel bundles routing, validation, auth, serialization, rate limiting, CORS, etc., making it easier to set consistent rules. This article compiles code and operational tips you can apply as production patterns.
2. Resource Design, URLs / Methods / Status Codes
2.1 Resource Naming & Collections
/api/v1/posts
(GET: list, POST: create)/api/v1/posts/{post}
(GET: retrieve, PATCH: update, DELETE: delete)- Use child resources like
/posts/{post}/comments
to express ownership - Use plural nouns, avoid snake_case; prefer kebab-case for readability and shareable URLs
2.2 Status Code Basics
- 200 OK: successful fetch/update
- 201 Created +
Location
: resource created - 204 No Content: success with no body (delete / partial updates)
- 400/422: validation errors (prefer 422)
- 401/403: authentication/authorization issues
- 404: not found or not visible
- 409: conflict/duplicate (unique constraint)
- 429: rate limit exceeded
- 5xx: server failure
2.3 Pagination, Sorting, Filtering
GET /posts?page=2&per_page=20&sort=-created_at&author_id=123
- Maintain a whitelist (forbid arbitrary
orderBy
by unknown columns) - Include page info in the response (
links
,meta
)
3. Wiring in Laravel: Routes, Policies, FormRequest
// routes/api.php
use App\Http\Controllers\Api\V1\PostController;
Route::prefix('v1')->middleware(['auth:sanctum'])->group(function () {
Route::apiResource('posts', PostController::class);
Route::get('posts/{post}/comments', [PostController::class,'comments']);
});
// app/Http/Requests/PostStoreRequest.php
class PostStoreRequest extends FormRequest {
public function rules(): array {
return [
'title' => ['required','string','max:120'],
'body' => ['required','string'],
'tags' => ['array'],
'tags.*'=> ['string','max:30'],
];
}
public function attributes(): array {
return ['title'=>'Title','body'=>'Body'];
}
}
// app/Policies/PostPolicy.php
class PostPolicy {
public function update(User $user, Post $post): bool {
return $post->user_id === $user->id || $user->tokenCan('posts:update');
}
}
apiResource
gives you a concise REST skeletonFormRequest
centralizes input validation and human-friendly field names- Policies make row-level authorization explicit
4. Authentication & Authorization: Implementing Sanctum
4.1 Why Sanctum
- Serves both SPAs (cookie-based) and mobile/server clients (personal tokens)
- Tokens can have ability scopes (Abilities)
- Lightweight setup; easier than Passport if OAuth2 is not required (prefer Sanctum)
4.2 Setup & Issuance
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate
// Issue a token (e.g., user creates a personal token)
$token = $user->createToken('cli', ['posts:read','posts:update'])->plainTextToken;
// Authorization: Bearer <token>
// Ability checks (in Policy or Controller)
if (! $request->user()->tokenCan('posts:update')) {
abort(403);
}
4.3 For SPAs
- Hit
sanctum/csrf-cookie
to receive a CSRF cookie → authenticate via cookies afterward - Configure CORS and cookie domains correctly (
config/cors.php
,SANCTUM_STATEFUL_DOMAINS
)
5. Response Shaping: Eloquent API Resources
// app/Http/Resources/PostResource.php
class PostResource extends JsonResource {
public function toArray($request): array {
return [
'id' => (string) $this->id,
'title' => $this->title,
'body' => $this->body,
'author' => [
'id' => (string) $this->author->id,
'name' => $this->author->name,
],
'tags' => $this->tags->pluck('name'),
'links' => [
'self' => route('posts.show', $this->id),
],
'created_at' => $this->created_at->toIso8601String(),
];
}
}
// Controller excerpt
public function index(Request $request) {
$posts = Post::with(['author','tags'])->latest()->paginate(20);
return PostResource::collection($posts)->additional([
'meta' => ['api_version' => '1.0']
]);
}
- Use API Resources to stabilize responses and shield clients from internal schema churn
- Standardize dates with ISO 8601
- Consolidate paging and extra info into
links/meta
6. Versioning: Agreements that Avoid Breakage
6.1 Approaches
- URL versioning:
/api/v1/...
(most transparent; easy caching/monitoring) - Header versioning:
Accept: application/vnd.example.v2+json
(limits ripple effects but heavier ops) - For breaking changes, bump major version; run old and new in parallel (document EOL clearly)
6.2 Route Partitioning
app/Http/Controllers/Api/V1/...
app/Http/Controllers/Api/V2/...
- Physically separate versioned controllers to avoid mixing change sets
- Move shared domain logic to a service layer
7. OpenAPI (Swagger): Make the Spec Machine-Readable
7.1 Why You Need It
- Prevents drift between spec and implementation; enables codegen (types, client SDKs, API tests)
- Makes spec alignment visible in large organizations with heavy async collaboration
7.2 Minimal Sample (Excerpt)
openapi: 3.0.3
info:
title: Example API
version: 1.0.0
paths:
/api/v1/posts:
get:
summary: List posts
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/PostCollection'
components:
schemas:
Post:
type: object
properties:
id: { type: string }
title: { type: string }
body: { type: string }
PostCollection:
type: object
properties:
data:
type: array
items: { $ref: '#/components/schemas/Post' }
- Use tools (e.g.,
swagger-ui
) to generate tryable docs - Laravel has libraries to generate YAML/JSON via annotations/commands
8. Error Design: Messages That Let Developers Identify Root Causes
8.1 Example of 422 (Validation)
{
"message": "Please review your input.",
"errors": {
"title": ["Title is required."],
"body": ["Body must be at least 100 characters."]
},
"request_id": "9c47e2b9-..."
}
- Use arrays per field to show multiple errors
- Attach a
request_id
for support correlation - Keep messages short and specific, indicating how to fix
8.2 Examples for 429 / 401 / 403
{ "message": "Too many requests. Try again in 30 seconds.", "retry_after": 30 }
{ "message": "Authentication required." }
{ "message": "Insufficient permissions." }
- On rate limiting, present when retry is possible
- Distinguish 401 vs 403 (or return 404 if you must hide existence)
9. Caching & Conditional Requests: Save Bandwidth
9.1 ETag / If-None-Match
public function show(Post $post, Request $request) {
$payload = new PostResource($post);
$etag = sha1(json_encode($payload));
if ($request->header('If-None-Match') === $etag) {
return response()->noContent(304);
}
return response($payload)->header('ETag', $etag)
->header('Cache-Control','public, max-age=60');
}
- Use a content hash as
ETag
, return 304 if unchanged - Even for dynamic APIs, a short max-age helps browsers/gateways cache
9.2 Last-Modified / If-Modified-Since
- Use DB
updated_at
to decide 304 - Set
Cache-Control
/Vary
properly (mind language/auth headers if responses differ)
10. Rate Limiting, Idempotency, CORS
10.1 Rate Limiting
// app/Providers/RouteServiceProvider.php
RateLimiter::for('api', function ($request) {
$key = optional($request->user())->id ?: $request->ip();
return [Limit::perMinute(60)->by($key)];
});
- Apply stricter buckets for critical operations
- Include
Retry-After
to aid client behavior
10.2 Idempotent POST (Idempotency-Key)
- Client sends an
Idempotency-Key
- Server stores/replays results per key to prevent double charges etc. (requires implementation)
10.3 CORS
- Configure
config/cors.php
with least privilege - You cannot combine credentials (cookies) with wildcard
*
- In production, enumerate allowed origins
11. Audit Logging & Observability
- Log who/when/what/from where in structured form
- Propagate a
request_id
across layers for easy tracing - For 4xx/5xx, log summary + cause separately (mask PII)
12. Internationalization & Documentation Accessibility
- Provide docs in Japanese/English at minimum
- For each endpoint, document Purpose / Parameters / Success Example / Failure Example / Notes in that heading order
- Use not only tables but also bulleted lists and examples for screen-reader clarity
- Don’t rely solely on code colorization; add comments and surrounding text
- Ensure keyboard reachability with anchor links and side navigation
13. Concrete Example: Minimal Post API
13.1 Routes / Controller
// routes/api.php
Route::prefix('v1')->middleware(['auth:sanctum','throttle:api'])->group(function () {
Route::apiResource('posts', \App\Http\Controllers\Api\V1\PostController::class);
});
// app/Http/Controllers/Api/V1/PostController.php
class PostController extends Controller
{
public function index(Request $req) {
$q = Post::with('author')->latest();
if ($kw = $req->string('q')->toString()) {
$q->where(fn($w) => $w->where('title','like',"%$kw%")->orWhere('body','like',"%$kw%"));
}
$posts = $q->paginate($req->integer('per_page') ?: 20);
return PostResource::collection($posts);
}
public function store(PostStoreRequest $req) {
$post = $req->user()->posts()->create($req->validated());
return (new PostResource($post))
->response()
->setStatusCode(201)
->header('Location', route('posts.show', $post));
}
public function show(Post $post) {
$etag = sha1($post->updated_at.$post->id);
if (request()->header('If-None-Match') === $etag) {
return response()->noContent(304);
}
return (new PostResource($post->load('author')))
->response()->header('ETag', $etag);
}
public function update(PostUpdateRequest $req, Post $post) {
$this->authorize('update', $post);
$post->update($req->validated());
return new PostResource($post);
}
public function destroy(Post $post) {
$this->authorize('delete', $post);
$post->delete();
return response()->noContent();
}
}
13.2 Sample Requests
# List
curl -H "Authorization: Bearer $TOKEN" \
"https://api.example.com/api/v1/posts?per_page=10&q=Laravel"
# Create
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"title":"My First API","body":"Body"}' \
https://api.example.com/api/v1/posts
13.3 OpenAPI Snippet (Create)
paths:
/api/v1/posts:
post:
summary: Create post
security: [{ bearerAuth: [] }]
requestBody:
required: true
content:
application/json:
schema:
required: [title, body]
type: object
properties:
title: { type: string, maxLength: 120 }
body: { type: string }
responses:
'201':
description: Created
'422':
description: Validation error
14. Testing Strategy
14.1 Feature Tests
public function test_create_post_requires_auth(): void {
$this->postJson('/api/v1/posts', ['title'=>'x','body'=>'y'])
->assertStatus(401);
}
public function test_create_post_validates_and_returns_201(): void {
Sanctum::actingAs(User::factory()->create(), ['posts:read','posts:update']);
$this->postJson('/api/v1/posts', ['title'=>'API','body'=>'Body'])
->assertCreated()
->assertHeader('Location')
->assertJsonPath('data.title','API');
}
14.2 Contract Tests (OpenAPI-Based)
- Generate & validate the schema file
- Detect breaking changes (field removal/type change) in CI
15. Operations Checklist
Contract & Compatibility
- [ ] Limit breaking changes to v2+ and announce migration window & EOL
- [ ] Manage OpenAPI as the single source of truth
Security
- [ ] Minimize Sanctum token abilities
- [ ] Integrate rate limiting / audit logs / alerts into ops
Availability
- [ ] Use ETag / Last-Modified to save bandwidth
- [ ] Prevent payload bloat with pagination and whitelist-based sorting
Accessibility (DX)
- [ ] Provide success/failure examples & messages for every endpoint
- [ ] Use bullets + prose, not just tables
- [ ] Provide both Japanese and English (honor
Accept-Language
if possible)
16. Common Pitfalls & Remedies
- Response structure changes frequently to match UI → Stabilize with API Resources; format-for-display in clients
- Passing
orderBy($request->sort)
directly → enforce a whitelist - Vague validation errors → Return 422 with per-field arrays and fix guidance
*
CORS with cookies → violates browser rules; enumerate origins- Mixed versions cause implementation confusion → directory separation + single OpenAPI source
- Double charging/duplicate submits on create → introduce Idempotency-Key
17. Conclusion
- Use Sanctum for lightweight auth and Policies for fine-grained authorization.
- Stabilize the contract with API Resources and OpenAPI, then wire it into tests and operations.
- Implement ETag/conditional requests, rate limiting, and CORS for secure and lean APIs.
- Keep docs and error texts short and specific; include success/failure examples so anyone can understand.
- Split breaking changes by version; clarify EOL for older versions to avoid confusion.
References
- Laravel (Official)
- HTTP/REST & Standards
- Documentation Specs
- Design Guides