API design best practices — REST, versioning, errors
Intermediate5 min read·eng-11-008
interview
Concept
API design best practices — building REST APIs that clients love and that scale.
Resource naming:
- Use nouns, not verbs:
/usersnot/getUsers. - Plural nouns:
/orders,/products. - Nested for relationships:
/users/{id}/orders(max 2 levels deep). - Actions that don't fit REST:
/orders/{id}/confirmis acceptable as a "sub-resource action".
HTTP methods:
GET: Read. Safe + idempotent.POST: Create. Not idempotent.PUT: Replace (full update). Idempotent.PATCH: Partial update. Idempotent.DELETE: Delete. Idempotent.
Status codes (pick the right one):
200 OK: Success, body has data.201 Created: POST success, new resource created. IncludeLocation: /resources/{id}header.204 No Content: Success, no body (DELETE, PATCH with no response).400 Bad Request: Malformed request syntax.401 Unauthorized: Not authenticated.403 Forbidden: Authenticated but not authorized.404 Not Found: Resource doesn't exist.409 Conflict: State conflict (optimistic lock failure, duplicate).422 Unprocessable Entity: Validation errors.429 Too Many Requests: Rate limited.500 Internal Server Error: Server bug.
Error response format (be consistent):
json
{"message": "Validation failed", "errors": {"email": ["Email is required"]}}Versioning: URL path (/api/v1/users) is most common — simple, explicit, easy to route. Header versioning is cleaner but harder to test. Query string versioning is messy.
Pagination: Always paginate list endpoints. Return data, meta.total, meta.per_page, meta.current_page, links.next, links.prev.
HATEOAS: Include links to related resources in responses. Not always practical, but rel=self links are good habit.
Code Example
php
<?php
// API Resource — consistent response format
class UserResource extends \Illuminate\Http\Resources\Json\JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'role' => $this->role,
'created_at' => $this->created_at->toIso8601String(),
'links' => [
'self' => route('api.users.show', $this),
'orders' => route('api.users.orders.index', $this),
],
// Conditionally include sensitive data (only for admins or self)
'email_verified_at' => $this->when(
$request->user()?->can('viewPrivate', $this->resource),
$this->email_verified_at?->toIso8601String()
),
];
}
}
// Controller — clean, thin, consistent
class UserController extends Controller
{
public function index(IndexUsersRequest $request): \Illuminate\Http\Resources\Json\AnonymousResourceCollection
{
$users = User::query()
->when($request->search, fn($q) => $q->where('name', 'like', "%{$request->search}%"))
->latest()
->paginate($request->per_page ?? 15);
return UserResource::collection($users); // includes pagination meta automatically
}
public function store(StoreUserRequest $request): UserResource
{
$user = User::create($request->validated());
return (new UserResource($user))
->response()
->setStatusCode(201)
->header('Location', route('api.users.show', $user));
}
public function destroy(User $user): \Illuminate\Http\Response
{
$this->authorize('delete', $user);
$user->delete();
return response()->noContent(); // 204
}
}
// Versioning — routes/api.php
Route::prefix('v1')->name('api.v1.')->group(function () {
Route::apiResource('users', V1\UserController::class);
});
Route::prefix('v2')->name('api.v2.')->group(function () {
Route::apiResource('users', V2\UserController::class);
});
// Consistent error responses — Handler.php
class Handler extends \Illuminate\Foundation\Exceptions\Handler
{
public function render($request, \Throwable $e): \Illuminate\Http\Response
{
if ($request->expectsJson()) {
if ($e instanceof \Illuminate\Validation\ValidationException) {
return response()->json(['message' => 'Validation failed', 'errors' => $e->errors()], 422);
}
if ($e instanceof \Illuminate\Auth\AuthenticationException) {
return response()->json(['message' => 'Unauthenticated'], 401);
}
if ($e instanceof \Illuminate\Auth\Access\AuthorizationException) {
return response()->json(['message' => 'Forbidden'], 403);
}
}
return parent::render($request, $e);
}
}