Laravel REST API Best Practices in 2026: The Guide Senior Developers Actually Follow

Versioning, authentication with Sanctum, consistent error responses, rate limiting, resource transformation, pagination that doesn’t break clients, and the API contract decisions that your frontend team will either thank you for or complain about for the next two years.


Most “API best practices” posts cover what to do. This one covers what happens when you don’t — the specific bugs, the specific frontend complaints, and the specific production incidents that come from skipping each practice. Every recommendation here has a “why you’ll regret not doing it” attached, because the practices that get skipped are always the ones where the cost isn’t immediately visible.


Versioning — The Decision You Have to Make Before the First Request

API versioning is the decision that feels unnecessary until it is. The first version of your API works. The frontend works against it. Then a breaking change is needed — a field renamed, a response shape changed, an endpoint behaviour modified. Without versioning, you have two choices: break existing clients or never change anything. Neither is acceptable.

The versioning debate is URL path vs header. Both work. URL path wins in practice:

# URL path versioning — visible, cacheable, bookmarkable
GET /api/v1/projects
GET /api/v2/projects

# Header versioning — cleaner URLs, harder to test, harder to cache
GET /api/projects
Accept: application/vnd.myapi.v2+json

URL path versioning has three practical advantages: it’s visible in logs (you can filter by version), it’s easy to test in a browser or curl, and CDN caching works correctly (different URLs, different cache entries). Header versioning breaks CDN caching by default and makes debugging substantially harder.

// routes/api.php
Route::prefix('v1')->name('v1.')->group(function () {
    Route::apiResource('projects', V1\ProjectController::class);
    Route::apiResource('tasks', V1\TaskController::class);
});

Route::prefix('v2')->name('v2.')->group(function () {
    Route::apiResource('projects', V2\ProjectController::class);
    // v2 adds new endpoints and changes some response shapes
    Route::apiResource('tasks', V2\TaskController::class);
    Route::apiResource('users', V2\UserController::class); // new in v2
});

The controller structure:

app/Http/Controllers/Api/
├── V1/
│   ├── ProjectController.php
│   └── TaskController.php
└── V2/
    ├── ProjectController.php  ← extends V1 or is fully separate
    ├── TaskController.php
    └── UserController.php

The pragmatic approach: V2 controllers extend V1 and override only the changed methods:

namespace App\Http\Controllers\Api\V2;

use App\Http\Controllers\Api\V1\ProjectController as V1ProjectController;
use App\Http\Resources\V2\ProjectResource;

class ProjectController extends V1ProjectController
{
    // Only override what changed between v1 and v2
    public function show(Project $project): JsonResponse
    {
        return new ProjectResource($project->load(['owner', 'tags', 'tasks']));
        // V2 includes tasks in the show response; V1 didn't
    }
}

When to version: any change that removes a field, renames a field, changes a field’s type, changes an endpoint’s URL, or changes the meaning of a status code. Changes that add new optional fields or new endpoints are backward-compatible and don’t require a new version.


Authentication With Sanctum — The Right Configuration

Sanctum handles two distinct scenarios: SPA authentication (cookie-based, same-domain) and API token authentication (token-based, cross-domain). Most applications need one or both.

composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate

SPA authentication (same-domain frontend):

// config/sanctum.php
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', implode(',', [
    'localhost',
    'localhost:3000',
    env('APP_URL') ? parse_url(env('APP_URL'), PHP_URL_HOST) : null,
]))),
// routes/api.php
Route::middleware('auth:sanctum')->group(function () {
    Route::get('/user', fn (Request $request) => $request->user());
    Route::apiResource('projects', ProjectController::class);
});

SPA flow:

1. GET /sanctum/csrf-cookie → sets XSRF-TOKEN cookie
2. POST /login → establishes session
3. All subsequent requests include cookies automatically
4. Sanctum validates session via stateful middleware

Token authentication (mobile apps, third-party integrations):

class ApiTokenController extends Controller
{
    public function create(Request $request): JsonResponse
    {
        $request->validate([
            'email'       => ['required', 'email'],
            'password'    => ['required'],
            'device_name' => ['required', 'string', 'max:255'],
        ]);

        $user = User::where('email', $request->email)->first();

        if (!$user || !Hash::check($request->password, $user->password)) {
            return response()->json([
                'message' => 'The provided credentials are incorrect.',
                'errors'  => ['email' => ['These credentials do not match our records.']],
            ], 401);
        }

        // Revoke all existing tokens for this device before creating new one
        $user->tokens()->where('name', $request->device_name)->delete();

        $token = $user->createToken($request->device_name, ['*'], now()->addDays(90));

        return response()->json([
            'token'      => $token->plainTextToken,
            'expires_at' => $token->accessToken->expires_at->toIso8601String(),
        ]);
    }

    public function revoke(Request $request): JsonResponse
    {
        $request->user()->currentAccessToken()->delete();
        return response()->json(['message' => 'Token revoked.']);
    }
}

Token abilities (scoped permissions):

// Create a token with specific abilities
$token = $user->createToken('mobile-app', ['projects:read', 'tasks:write']);

// Check ability in middleware or policy
if (!$request->user()->tokenCan('projects:read')) {
    abort(403, 'Insufficient token permissions.');
}

// Or in a policy
public function view(User $user, Project $project): bool
{
    return $user->tokenCan('projects:read')
        && $project->tenant_id === $user->current_tenant_id;
}

Consistent Error Responses — The Contract Your Frontend Depends On

The most damaging inconsistency in API design is inconsistent error responses. If validation errors return {"errors": {"field": ["message"]}} and authentication errors return {"message": "Unauthenticated."} and server errors return {"error": "Something went wrong"}, the frontend has to handle three different error shapes. Every new error shape is a bug waiting to happen.

The standard: every error response has the same top-level shape.

{
    "message": "Human-readable summary",
    "errors": {
        "field_name": ["Specific error message"]
    },
    "code": "MACHINE_READABLE_CODE",
    "trace_id": "abc-123-def"
}
  • message: always present, human-readable, suitable for display
  • errors: present only for validation errors, maps field names to arrays of messages
  • code: machine-readable error code for programmatic handling
  • trace_id: request identifier for support debugging (correlates with logs)

Implementation in the exception handler:

// app/Exceptions/Handler.php
use Illuminate\Auth\AuthenticationException;
use Illuminate\Validation\ValidationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;

public function register(): void
{
    $this->renderable(function (AuthenticationException $e, Request $request) {
        if ($request->expectsJson()) {
            return response()->json([
                'message'  => 'Unauthenticated. Please log in.',
                'code'     => 'UNAUTHENTICATED',
                'trace_id' => request()->header('X-Request-Id', str()->uuid()),
            ], 401);
        }
    });

    $this->renderable(function (ValidationException $e, Request $request) {
        if ($request->expectsJson()) {
            return response()->json([
                'message'  => 'The given data was invalid.',
                'code'     => 'VALIDATION_ERROR',
                'errors'   => $e->errors(),
                'trace_id' => request()->header('X-Request-Id', str()->uuid()),
            ], 422);
        }
    });

    $this->renderable(function (ModelNotFoundException $e, Request $request) {
        if ($request->expectsJson()) {
            $model = class_basename($e->getModel());
            return response()->json([
                'message'  => "{$model} not found.",
                'code'     => 'NOT_FOUND',
                'trace_id' => request()->header('X-Request-Id', str()->uuid()),
            ], 404);
        }
    });

    $this->renderable(function (\Throwable $e, Request $request) {
        if ($request->expectsJson() && !config('app.debug')) {
            return response()->json([
                'message'  => 'An unexpected error occurred.',
                'code'     => 'SERVER_ERROR',
                'trace_id' => request()->header('X-Request-Id', str()->uuid()),
            ], 500);
        }
    });
}

The trace_id from the request header (or generated if absent) is the link between the error the client receives and the log entry on the server. When support receives “error abc-123-def”, the developer can grep the logs for that ID and find the exact request, the stack trace, and the request context.

Machine-readable error codes:

// A typed enum of all API error codes
enum ApiErrorCode: string
{
    case Unauthenticated       = 'UNAUTHENTICATED';
    case Forbidden             = 'FORBIDDEN';
    case NotFound              = 'NOT_FOUND';
    case ValidationError       = 'VALIDATION_ERROR';
    case RateLimitExceeded     = 'RATE_LIMIT_EXCEEDED';
    case SubscriptionRequired  = 'SUBSCRIPTION_REQUIRED';
    case FeatureNotAvailable   = 'FEATURE_NOT_AVAILABLE';
    case ServerError           = 'SERVER_ERROR';
    case ServiceUnavailable    = 'SERVICE_UNAVAILABLE';
}

The frontend can switch on error.code rather than parsing error messages, which allows error messages to change without breaking client error handling.


API Resources — Transform Before Responding

Never return Eloquent models directly. The model’s database representation is not your API contract. When you add a column, change a column name, or change a relationship, your API response changes — and breaks clients.

// ❌ Returns the database column directly — any column change breaks the API
return response()->json($project);

// ✅ Resource transforms — API contract is explicit and separate from the model
return new ProjectResource($project);
// app/Http/Resources/V2/ProjectResource.php
class ProjectResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            // Explicit field mapping — renaming a DB column doesn't break the API
            'id'          => $this->id,
            'name'        => $this->name,
            'description' => $this->description,
            'status'      => $this->status,

            // Computed fields — don't exist as columns
            'is_overdue'  => $this->due_date?->isPast() && $this->status !== 'completed',
            'task_count'  => $this->tasks_count ?? $this->tasks()->count(),

            // Dates formatted consistently — ISO 8601 always
            'created_at'  => $this->created_at->toIso8601String(),
            'updated_at'  => $this->updated_at->toIso8601String(),
            'due_date'    => $this->due_date?->toIso8601String(),

            // Conditional relationships — only included when loaded
            'owner'       => new UserResource($this->whenLoaded('owner')),
            'tags'        => TagResource::collection($this->whenLoaded('tags')),
            'tasks'       => TaskResource::collection($this->whenLoaded('tasks')),

            // Conditional fields based on auth
            'billing_info' => $this->when(
                $request->user()?->can('view-billing', $this->resource),
                fn () => $this->billing_summary
            ),
        ];
    }
}

The whenLoaded() method is essential for controlling N+1 queries and response payload size. If the controller doesn’t eager load owner, whenLoaded('owner') returns null without triggering a query. If it does eager load, the owner data is included. The client gets predictable behavior: load what you need in the controller, the resource includes it if available.

Resource collections with metadata:

class ProjectCollection extends ResourceCollection
{
    public $collects = ProjectResource::class;

    public function toArray(Request $request): array
    {
        return [
            'data' => $this->collection,
            'meta' => [
                'version'     => 'v2',
                'generated_at' => now()->toIso8601String(),
            ],
        ];
    }
}

Pagination — The Response Shape That Can’t Change

Pagination is the API response that’s hardest to change after launch. Clients build their pagination logic against your specific shape. Changing from meta.current_page to page.current after clients are deployed requires a coordinated frontend + backend release.

The standard shape that works:

{
    "data": [...],
    "links": {
        "first": "https://api.example.com/projects?page=1",
        "last":  "https://api.example.com/projects?page=12",
        "prev":  null,
        "next":  "https://api.example.com/projects?page=2"
    },
    "meta": {
        "current_page": 1,
        "from":         1,
        "last_page":    12,
        "per_page":     15,
        "to":           15,
        "total":        180,
        "path":         "https://api.example.com/projects"
    }
}

Laravel’s paginate() + API resource produces this shape automatically:

public function index(Request $request): AnonymousResourceCollection
{
    $projects = Project::query()
        ->with(['owner:id,name,avatar_url', 'tags:id,name,color'])
        ->withCount('tasks')
        ->filter($request->only(['status', 'owner_id', 'search']))
        ->sort($request->input('sort', '-created_at'))
        ->paginate($request->integer('per_page', 15));

    return ProjectResource::collection($projects);
}

ResourceCollection::collection() on a LengthAwarePaginator automatically wraps the response in the data/links/meta structure.

The per_page guard:

// Never let clients request unlimited results
$perPage = min($request->integer('per_page', 15), 100);
// Max 100 per page regardless of what the client requests

Cursor-based pagination for large datasets:

// For tables with millions of rows, cursor pagination is more efficient
// No COUNT(*) query, O(1) instead of O(n) for the count
$projects = Project::query()
    ->with(['owner:id,name'])
    ->latest()
    ->cursorPaginate(15);

Cursor pagination doesn’t provide total or last_page — it only provides prev and next links. This is a client-facing change. Document which endpoints use offset pagination (with totals) and which use cursor pagination (without totals) before the first client builds against them.


Rate Limiting — The Right Limits for the Right Endpoints

Rate limiting was covered in the dedicated post, but the API-specific considerations deserve emphasis.

The request-based rate limit is wrong for AI and expensive endpoints. The endpoint-based tiered approach:

// AppServiceProvider::boot()
RateLimiter::for('api', function (Request $request) {
    return $request->user()
        ? Limit::perMinute(120)->by($request->user()->id)
        : Limit::perMinute(20)->by($request->ip());
});

RateLimiter::for('search', function (Request $request) {
    return Limit::perMinute(30)->by($request->user()?->id ?? $request->ip());
});

RateLimiter::for('mutations', function (Request $request) {
    // Write operations — slightly more restricted
    return Limit::perMinute(60)->by($request->user()?->id ?? $request->ip());
});

The response headers that enable client-side rate limit handling:

HTTP/1.1 200 OK
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 87
Retry-After: 47        (only on 429)
X-RateLimit-Reset: 1720000847

A client that reads X-RateLimit-Remaining can slow down its requests before hitting the limit. A client that reads Retry-After on a 429 knows exactly when to retry. Without these headers, clients retry immediately (making the problem worse) or wait an arbitrary duration.


Request Validation — Form Requests, Not Inline

// ❌ Inline validation — hard to test, hard to reuse, pollutes the controller
public function store(Request $request): JsonResponse
{
    $data = $request->validate([
        'name'        => 'required|string|min:3|max:255',
        'description' => 'nullable|string|max:10000',
        'due_date'    => 'nullable|date|after_or_equal:today',
        'owner_id'    => 'required|exists:users,id',
    ]);
    // ...
}

// ✅ Form Request — testable independently, reusable, clean controller
class StoreProjectRequest extends FormRequest
{
    public function authorize(): bool
    {
        // Authorization lives here, not in the controller
        return $this->user()->can('create-projects', $this->user()->currentTenant);
    }

    public function rules(): array
    {
        return [
            'name'        => ['required', 'string', 'min:3', 'max:255'],
            'description' => ['nullable', 'string', 'max:10000'],
            'due_date'    => ['nullable', 'date', 'after_or_equal:today'],
            'owner_id'    => [
                'required',
                Rule::exists('users', 'id')
                    ->where('tenant_id', $this->user()->current_tenant_id),
                // Scoped exists — owner must belong to the current tenant
            ],
            'tags'        => ['nullable', 'array', 'max:10'],
            'tags.*'      => [
                'integer',
                Rule::exists('tags', 'id')
                    ->where('tenant_id', $this->user()->current_tenant_id),
            ],
        ];
    }

    public function messages(): array
    {
        return [
            'owner_id.exists' => 'The selected owner does not exist in your workspace.',
            'due_date.after_or_equal' => 'The due date must be today or in the future.',
        ];
    }
}

The Rule::exists()->where('tenant_id', ...) pattern is the most important validation rule in a multi-tenant API. Without it, a tenant could pass another tenant’s owner_id and the existence check would pass — the validation says the user exists, which is true, but not that they belong to this tenant.


API Response Consistency — The Headers and Patterns That Matter

Always return JSON:

// api.php routes — ensure JSON responses even for non-JSON requests
Route::middleware(['api', 'accept-json'])->group(function () {
    // ...
});

// Middleware: force Accept: application/json
class ForceJsonAcceptHeader
{
    public function handle(Request $request, Closure $next): Response
    {
        $request->headers->set('Accept', 'application/json');
        return $next($request);
    }
}

Without this, an unauthenticated request to an API endpoint returns an HTML redirect to /login instead of a 401 JSON response. Every Laravel application that serves both web and API routes needs this middleware on API routes.

Request ID tracing:

// Middleware: assign a request ID to every request
class AssignRequestId
{
    public function handle(Request $request, Closure $next): Response
    {
        $requestId = $request->header('X-Request-Id', (string) Str::uuid());

        // Add to log context for every log entry in this request
        Log::withContext(['request_id' => $requestId]);

        $response = $next($request);

        // Return the ID in the response so clients can reference it
        $response->headers->set('X-Request-Id', $requestId);

        return $response;
    }
}

HTTP method and status code conventions:

GET     → 200 OK (collection or single resource)
POST    → 201 Created (new resource)
PUT     → 200 OK (full replacement)
PATCH   → 200 OK (partial update)
DELETE  → 204 No Content (no response body)

200 → success with body
201 → created (include Location header pointing to new resource)
204 → success with no body
400 → bad request (malformed request, not validation)
401 → unauthenticated (not logged in)
403 → unauthorized (logged in but not allowed)
404 → not found
409 → conflict (duplicate, version mismatch)
422 → validation error
429 → rate limit exceeded
500 → server error

The 401 vs 403 distinction is the one most APIs get wrong. 401 means “you need to authenticate.” 403 means “you’re authenticated but not allowed.” Returning 401 for a permission check failure causes clients to re-authenticate unnecessarily.


API Documentation — OpenAPI From Your Code

composer require dedoc/scramble
php artisan vendor:publish --provider="Dedoc\Scramble\ScrambleServiceProvider"

Scramble generates OpenAPI documentation automatically from your routes, controllers, Form Requests, and API Resources. The documentation stays in sync with the code because it’s generated from the code.

// config/scramble.php
return [
    'api_path'    => 'api',
    'api_domain'  => null,
    'theme'       => 'default',

    // Only document v2 endpoints
    'middleware'  => ['api'],
    'info'        => [
        'version' => '2.0',
        'title'   => 'My App API',
    ],
];

The documentation is available at /docs/api and the OpenAPI spec at /docs/api.json. Client teams can import the spec into Insomnia, Postman, or use it to generate typed API clients.


The API Contract — What the Frontend Will Actually Thank You For

After shipping APIs for several years, the patterns that frontend developers consistently identify as the difference between “this API is pleasant to work with” and “this API is a constant source of bugs”:

Consistent date format (ISO 8601, always):

"created_at": "2026-03-15T09:23:41+05:30"
// Not: "March 15, 2026", "2026-03-15", "1710484421"

Nulls for absent optional fields, never missing keys:

// ✅ Key present, value null
{"due_date": null, "description": null}

// ❌ Key absent
{"name": "Project"}
// → frontend: if (project.due_date) vs if ('due_date' in project)
// → TypeScript: project.due_date is string | undefined (unexpected)

Monetary values as integers (cents), never floats:

// ✅ Integer cents — no floating point precision issues
{"price": 2999, "currency": "USD"}  // $29.99

// ❌ Float — $29.99 in float becomes 29.990000000000002
{"price": 29.99}

Consistent casing (camelCase for JSON):

// ✅ camelCase — matches JavaScript conventions
{"firstName": "Sadique", "createdAt": "2026-03-15T..."}

// ❌ snake_case in JSON — mismatches JS conventions, requires mapping
{"first_name": "Sadique", "created_at": "2026-03-15T..."}

Transform snake_case from Laravel’s Eloquent to camelCase in your API resources:

// Base API Resource that transforms keys to camelCase
abstract class ApiResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return $this->transformKeys(parent::toArray($request));
    }

    private function transformKeys(array $data): array
    {
        $result = [];
        foreach ($data as $key => $value) {
            $camelKey = Str::camel($key);
            $result[$camelKey] = is_array($value) ? $this->transformKeys($value) : $value;
        }
        return $result;
    }
}

Or use explicit key mapping in each resource (more control, more verbose):

public function toArray(Request $request): array
{
    return [
        'id'          => $this->id,
        'firstName'   => $this->first_name,  // explicit camelCase
        'lastName'    => $this->last_name,
        'createdAt'   => $this->created_at->toIso8601String(),
    ];
}

Envelope consistency:

Every list response has data array. Every single-resource response has data object. Never return a bare array. Never return a bare object for list endpoints.

// ✅ List response
{"data": [{...}, {...}], "meta": {...}, "links": {...}}

// ✅ Single resource
{"data": {...}}

// ❌ Bare array
[{...}, {...}]

// ❌ Different shapes at different endpoints
// /projects → [...], /users → {"users": [...]}

The Testing Patterns That Catch API Regressions

// Feature test for the API contract
class ProjectApiTest extends TestCase
{
    public function test_project_index_returns_expected_shape(): void
    {
        $tenant  = Tenant::factory()->create();
        $owner   = User::factory()->for($tenant)->create();
        $project = Project::factory()->for($tenant)->for($owner)->create();

        $response = $this->actingAs($owner, 'sanctum')
            ->getJson('/api/v2/projects');

        $response->assertOk();

        // Assert the shape, not just the status
        $response->assertJsonStructure([
            'data' => [
                '*' => [
                    'id', 'name', 'status', 'isOverdue',
                    'taskCount', 'createdAt', 'updatedAt',
                ]
            ],
            'meta' => ['currentPage', 'perPage', 'total'],
            'links' => ['first', 'last', 'prev', 'next'],
        ]);

        // Assert date format
        $response->assertJsonPath('data.0.createdAt',
            fn ($value) => (bool) \DateTime::createFromFormat(\DateTime::ATOM, $value)
        );

        // Assert camelCase keys
        $response->assertJsonMissing(['created_at']);  // no snake_case
        $response->assertJsonMissing(['task_count']);
    }

    public function test_api_returns_consistent_error_shape_on_validation(): void
    {
        $user = User::factory()->create();

        $response = $this->actingAs($user, 'sanctum')
            ->postJson('/api/v2/projects', ['name' => '']);

        $response->assertStatus(422)
            ->assertJsonStructure([
                'message', 'code', 'errors' => ['name'], 'trace_id'
            ])
            ->assertJsonPath('code', 'VALIDATION_ERROR');
    }

    public function test_api_returns_consistent_error_shape_on_not_found(): void
    {
        $user = User::factory()->create();

        $response = $this->actingAs($user, 'sanctum')
            ->getJson('/api/v2/projects/99999');

        $response->assertStatus(404)
            ->assertJsonStructure(['message', 'code', 'trace_id'])
            ->assertJsonPath('code', 'NOT_FOUND');
    }
}

The contract tests — asserting the response shape, the date format, the casing, the error structure — are the tests that prevent regressions from reaching the frontend team. An API refactor that changes created_at to createdAt, or changes a 404 to return a message field instead of error, fails these tests before the change ships.

Leave a Reply

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