APIs

Building REST APIs in PHP: Modern Best Practices (2026)

Build production-ready REST APIs in PHP with proper routing, validation, authentication, error handling, and versioning using 2026 best practices.

REST API architecture diagram with PHP code examples

Every PHP developer builds APIs eventually. The difference between an API that works in development and one that survives production traffic comes down to decisions about structure, error handling, authentication, and versioning. Most of these decisions are not hard—they just need to be made deliberately rather than by accident.

The foundation: consistent response structure

Before writing any endpoint, decide on a response envelope. Every response from your API should follow the same shape, regardless of whether it is a success or error.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Success response
{
"data": {
"id": 42,
"name": "PHP Everyday",
"created_at": "2026-03-15T18:24:00Z"
}
}

// Collection response
{
"data": [
{"id": 1, "name": "Item 1"},
{"id": 2, "name": "Item 2"}
],
"meta": {
"current_page": 1,
"total": 50,
"per_page": 25
}
}

// Error response
{
"error": "not_found",
"message": "The requested resource does not exist.",
"details": null
}

This consistency means API consumers can always check for data on success and error on failure without guessing the response shape per endpoint.

Laravel API Resources

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class ArticleResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'published_at' => $this->published_at?->toIso8601String(),
'author' => new AuthorResource($this->whenLoaded('author')),
'tags' => TagResource::collection($this->whenLoaded('tags')),
];
}
}

whenLoaded prevents N+1 queries by only including relationships that were eagerly loaded. This is a detail that matters at scale.

Routing and URL design

Resource-oriented routes

1
2
3
4
5
6
7
8
9
10
11
12
// Good: resource-oriented
Route::apiResource('articles', ArticleController::class);
// Generates:
// GET /api/articles → index
// POST /api/articles → store
// GET /api/articles/{id} → show
// PUT /api/articles/{id} → update
// DELETE /api/articles/{id} → destroy

// Bad: action-oriented
Route::post('/api/createArticle', ...);
Route::post('/api/deleteArticle', ...);

Nested resources

1
2
3
4
5
6
7
Route::apiResource('articles.comments', CommentController::class)
->shallow();
// GET /api/articles/{article}/comments → index
// POST /api/articles/{article}/comments → store
// GET /api/comments/{comment} → show (shallow)
// PUT /api/comments/{comment} → update (shallow)
// DELETE /api/comments/{comment} → destroy (shallow)

Shallow nesting avoids deeply nested URLs like /api/articles/5/comments/12/replies/3. Once a resource has its own ID, it does not need the parent in the URL.

Validation

Validate every incoming request at the controller boundary. Never trust client data.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class StoreArticleRequest extends FormRequest
{
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'body' => ['required', 'string', 'min:50'],
'category_id' => ['required', 'exists:categories,id'],
'tags' => ['sometimes', 'array', 'max:10'],
'tags.*' => ['string', 'max:50'],
'published_at' => ['nullable', 'date', 'after:now'],
];
}
}

Custom validation messages for API consumers

1
2
3
4
5
6
7
8
public function messages(): array
{
return [
'body.min' => 'Article body must be at least 50 characters.',
'category_id.exists' => 'The selected category does not exist.',
'published_at.after' => 'Publish date must be in the future.',
];
}

Authentication

Token-based authentication with Laravel Sanctum

For most PHP APIs, Sanctum provides the right balance of simplicity and security:

1
2
3
4
5
6
7
8
// Issue a token
$token = $user->createToken(
name: 'api-client',
abilities: ['articles:read', 'articles:write'],
expiresAt: now()->addDays(30),
);

return ['token' => $token->plainTextToken];
1
2
3
4
5
6
7
8
9
10
11
12
13
// Protect routes
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('articles', ArticleController::class);
});

// Check abilities
public function store(StoreArticleRequest $request): JsonResponse
{
if (!$request->user()->tokenCan('articles:write')) {
abort(403, 'Token does not have write access.');
}
// ...
}

Rate limiting

1
2
3
4
5
6
7
8
// bootstrap/app.php or RouteServiceProvider
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});

RateLimiter::for('auth', function (Request $request) {
return Limit::perMinute(5)->by($request->ip());
});

Rate limit authentication endpoints aggressively (5/minute) and general API endpoints moderately (60/minute). Return proper 429 Too Many Requests responses with Retry-After headers.

Error handling

Centralized exception handling

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// bootstrap/app.php
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (NotFoundHttpException $e, Request $request) {
if ($request->expectsJson()) {
return response()->json([
'error' => 'not_found',
'message' => 'The requested resource does not exist.',
], 404);
}
});

$exceptions->render(function (ValidationException $e, Request $request) {
if ($request->expectsJson()) {
return response()->json([
'error' => 'validation_failed',
'message' => 'The given data was invalid.',
'details' => $e->errors(),
], 422);
}
});
})

Status code reference

Code When to use
200 Successful GET, PUT, PATCH
201 Successful POST (resource created)
204 Successful DELETE (no content)
400 Malformed request syntax
401 Missing or invalid authentication
403 Authenticated but not authorized
404 Resource not found
409 Conflict (duplicate, state mismatch)
422 Validation failure
429 Rate limit exceeded
500 Server error (log it, alert on it)

API versioning

URI versioning (most common)

1
2
3
4
5
6
7
Route::prefix('v1')->group(function () {
Route::apiResource('articles', V1\ArticleController::class);
});

Route::prefix('v2')->group(function () {
Route::apiResource('articles', V2\ArticleController::class);
});

Header versioning (cleaner but less discoverable)

1
2
3
Route::middleware('api.version:2')->group(function () {
Route::apiResource('articles', ArticleController::class);
});

URI versioning is easier for API consumers to understand and test. Use it unless you have a specific reason for header versioning.

Common mistakes

Not paginating list endpoints: Returning 10,000 records in a single response will eventually crash something. Always paginate.

Exposing internal IDs: Use UUIDs or hashed IDs in public-facing APIs. Auto-increment integers leak information about your data volume.

Ignoring CORS: If your API serves a frontend on a different domain, configure CORS properly instead of using Access-Control-Allow-Origin: * in production.

Missing content type headers: Always set Content-Type: application/json on responses. Some HTTP clients behave differently without it.

Inconsistent date formats: Pick ISO 8601 (2026-03-15T18:24:00Z) and use it everywhere. Never mix Unix timestamps with formatted date strings.

Production tradeoffs

Framework API vs. microframework: Laravel’s API scaffolding saves time but adds overhead. For tiny microservices, Slim or Mezzio might be more appropriate. For anything with authentication, authorization, or database access, the framework overhead is worth it.

REST vs. GraphQL: REST is simpler to cache, easier to debug, and better supported by CDNs. Choose GraphQL only if your clients genuinely need flexible queries across related resources. See the related guide on GraphQL for PHP developers for a deeper comparison.

JSON:API spec vs. custom envelope: JSON:API provides a standard response format but adds complexity. Custom envelopes are simpler when you control both the API and its consumers.

FAQ

Should I use PUT or PATCH for updates?

Use PUT for full replacement and PATCH for partial updates. If you only support one, PATCH is more practical since clients rarely send every field.

How do I handle file uploads in a REST API?

Use multipart/form-data for the upload endpoint. Return the file’s URL or ID in the JSON response. Do not try to base64-encode files in JSON—it triples the payload size.

What about HATEOAS?

In theory, API responses should include links to related actions. In practice, very few PHP APIs implement full HATEOAS. Include pagination links and leave it at that unless your API is genuinely consumed by automated agents.

Next steps

Start with a single resource endpoint. Get the response structure, validation, and error handling right on that one endpoint before building out the rest. The patterns you establish early become the template for everything that follows.

For database interaction patterns behind your API, the PDO tutorial covers the fundamentals. The Laravel 13 upgrade guide covers framework-level improvements that affect API development. For understanding PHP’s new language features that improve API code, see the PHP 8.5/8.6 guide.