Zubair Akhtar logo — Muhammad Zubair Akhtar, Software Engineer

Blog

REST API Development with Laravel: Best Practices for 2026

Practical rest api development in Laravel 13: versioned routes, Sanctum or Passport, validation, rate limits, and feature tests for production.

Muhammad Zubair Akhtar

REST API development in Laravel is a set of agreements I would rather write down once than renegotiate on every endpoint. The URL names a resource, the status code says what happened, and the JSON shape does not change between the list and the single record. I am Zubair Akhtar, a freelance full-stack developer in Lahore, and I ship Laravel, WordPress, and Next.js. This is the list I use when the product is a JSON API: resource routes, versioning, Sanctum or Passport, policies, form requests, rate limits, an OpenAPI contract, and feature tests.

If you want that API built or reviewed, start at Laravel development. The rest of this post is the method.

Versions this was written against

I wrote this against the Laravel 13.x documentation. Laravel 13 was released on 17 March 2026 and requires PHP 8.3. Routing scaffolds an API with php artisan install:api, which installs Sanctum (laravel/sanctum:^4.0 in the installer) and publishes routes/api.php. Passport is php artisan install:api --passport (laravel/passport:^13.0). I have not run these snippets in a fresh app for this article.

The current laravel/laravel 13.x skeleton renders JSON for any path under api/, even when the client forgets Accept: application/json. The closure uses shouldRenderJsonWhen, which is documented on the error handling page:

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->shouldRenderJsonWhen(
        fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
    );
});

An application you upgraded by hand may not have that closure. If a bad token still redirects to an HTML login page, this is the first place I look.

Versioned Laravel API request flow with 401, 422, and 429 branches.

Figure 1. 401, 422, and 429 leave the pipeline at a named step.

REST API development: resources, routes, and errors

Good api development is mostly those agreements. The laravel api development I do starts from the documented route file.

Scaffold the API routes

install:api registers routes/api.php on the api middleware group and prefixes every route in that file with /api. You do not add the prefix yourself. The published file is the documented user route:

Route::get('/user', function (Request $request) {
    return $request->user();
})->middleware('auth:sanctum');

Public routes in the same file omit auth:sanctum. The group is stateless: no session and no CSRF, unless you later turn on Sanctum's SPA cookie mode.

Prefer apiResource over a list of verbs

HTML forms need create and edit. A JSON client does not. The controller docs give you Route::apiResource, which registers index, store, show, update, and destroy and skips the two HTML routes:

use App\Http\Controllers\OrderController;

Route::apiResource('orders', OrderController::class);

Inside routes/api.php that is /api/orders and /api/orders/{order}. PUT and PATCH both hit update. A missing implicit binding is a 404. I return response()->noContent() from destroy (204) and the new resource with status 201 from store. The route macro does not choose those statuses.

php artisan make:controller OrderController --model=Order --api --requests stubs the controller and the form requests. --api excludes create and edit. Read the stub before you ship it. Returning the model leaks columns.

Shape JSON with an API resource

Eloquent models are a persistence format. An API resource is the contract. php artisan make:resource OrderResource writes a class extending JsonResource. toArray is the document:

public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'number' => $this->number,
        'status' => $this->status,
        'customer' => new CustomerResource($this->whenLoaded('customer')),
        'created_at' => $this->created_at,
    ];
}

whenLoaded keeps a relationship out of the payload unless the query already eager-loaded it. Returning $this->customer from the resource lazy-loads it once per row. That is the N+1, moved into the transformer where it is harder to see.

A paginated list goes through the resource collection. Pagination reads the page query parameter. A resource response keeps data, links, and meta even if you called withoutWrapping on single resources:

return OrderResource::collection(
    Order::query()->with('customer')->latest()->paginate(15)
);

paginate runs a count query so meta.total and meta.last_page exist. Use simplePaginate for next and previous only, or cursorPaginate when offset pages get expensive. Fifteen is the page size in the pagination docs' example, not a number I measured.

Laravel 13 also ships JSON:API resources. php artisan make:resource PostResource --json-api extends JsonApiResource, sets Content-Type to application/vnd.api+json, and honors ?include=. I use that when a consumer already speaks JSON:API. A first-party Next.js or mobile client stays on JsonResource.

One error document

Laravel's JSON validation response has message and errors, with status 422. message is the first error, plus the framework's "(and N more errors)" summary when there are more. errors is the field map. I keep that shape on every endpoint.

SituationStatusWho produces it
Created201Your store method
Deleted, empty body204response()->noContent()
Missing or bad token401Authentication exception, rendered as JSON
Policy or authorize refused403Authorization exception
Unknown id404Implicit route model binding
Validation failed422Form request or $request->validate()
Rate limit exceeded429throttle middleware

Clients branch on the status and read errors only for 422. Leave APP_DEBUG off in production so a 500 is not a stack trace.

Version the URL, and say when it dies

URI prefix, not a secret header

I version with a URI prefix: /api/v1/orders. It shows up in logs, in nginx, and in the OpenAPI servers entry. Header versioning works, but Laravel does not ship the middleware, so I use a header only when a client I do not control already sends one.

routes/api.php is already mounted at /api, so a prefix group is enough:

Route::prefix('v1')->group(base_path('routes/api/v1.php'));
Route::prefix('v2')->group(base_path('routes/api/v2.php'));

Each file holds that version's apiResource routes, so v2 can rename a field without a flag in every controller. A single version can instead set apiPrefix: 'api/v1' on withRouting. Keep v1 until the last client has moved.

Deprecation and Sunset are headers you set

Laravel will not mark a route deprecated for you. RFC 9745 defines Deprecation as a date. RFC 8594 defines Sunset as the date the URL may start failing. The resource docs already show response()->header() for a custom header:

return OrderResource::collection($orders)
    ->response()
    ->header('Deprecation', 'Tue, 01 Jun 2027 00:00:00 GMT')
    ->header('Sunset', 'Sat, 01 Jan 2028 00:00:00 GMT');

Those dates illustrate the header format. They are not a schedule for an API I operate. Publish the real dates in OpenAPI as well. When Sunset arrives, return 410 for that version only after the clients you named have moved. A mobile binary that cannot update itself should not discover the removal from a surprise error.

Sanctum for your own clients, Passport when you are the OAuth server

Sanctum versus Passport: tokens and first-party apps, or a full OAuth2 server.

Figure 2. OAuth2 means Passport. An SPA, a mobile app, or your own tokens means Sanctum.

When I pick Sanctum

The Sanctum docs cover SPAs, mobile apps, and token APIs. Sanctum is not an OAuth2 server. install:api without flags is this path. Add Laravel\Sanctum\HasApiTokens to the user model. Abilities are the lightweight scopes. The plain text token is shown once. Only the SHA-256 hash is stored.

return $user->createToken('mobile', ['orders:read', 'orders:create'])->plainTextToken;

On the request, tokenCan checks an ability. Alias the ability middleware in bootstrap/app.php the way the docs do, then require it on the route:

$middleware->alias([
    'abilities' => CheckAbilities::class,
    'ability' => CheckForAnyAbility::class,
]);
Route::get('/orders', [OrderController::class, 'index'])
    ->middleware(['auth:sanctum', 'abilities:orders:read']);

For a first-party SPA on the same top-level domain, do not issue that token. The docs say not to. Call $middleware->statefulApi(), request /sanctum/csrf-cookie before login, and send Accept: application/json plus Origin or Referer. tokenCan returns true for those cookie requests, so the policy still decides the row. An SPA on another registrable domain cannot use the cookie flow.

Mobile clients post email, password, and a device name, then send Authorization: Bearer. The Sanctum docs include that issuing route. Put a login limiter in front of it.

When I pick Passport

Passport is the full OAuth2 server, on top of League OAuth2. I install it when a third party must redirect a user through an authorization code, use PKCE, or call us with client credentials:

php artisan install:api --passport

The user model then uses Passport's HasApiTokens and implements Laravel\Passport\Contracts\OAuthenticatable. The api guard driver is passport, and the published route uses auth:api. Define scopes in AppServiceProvider::boot with Passport::tokensCan. Check them with CheckToken::using('orders:read') on the route. That middleware is not Sanctum's CheckAbilities. Do not mix the two traits on one model.

If the product does not need OAuth2, I do not install Passport. The tables, keys, and consent screen are real surface area. An integration directory is the case where I have used it. A mobile app that only talks to its own backend is not.

Policies still own the row

An ability says what the token may attempt. A policy says what this user may do with this record. The authorization docs generate one with php artisan make:policy OrderPolicy --model=Order. In the controller, Gate::authorize('update', $order) throws before the update runs. A form request can do the same in authorize, and false becomes a 403 before the controller method runs:

public function authorize(): bool
{
    return $this->user()->can('update', $this->route('order'));
}

A third-party token needs both the ability and the policy. A cookie-authenticated SPA still goes through the policy, because tokenCan will not refuse it.

Validate in a form request

$request->validate() is enough for two fields. A route that more than one screen calls gets a form request from php artisan make:request StoreOrderRequest. The validation docs put rules and authorize on that class. Type-hint it on store and validation runs before your method. Read $request->validated() so mass assignment cannot include a field you never checked.

On an api/ path the failure is the 422 document above, not a redirect, because the skeleton's shouldRenderJsonWhen closure matches those paths. The OpenAPI file and the feature test should read that same rules method.

Rate limit by caller, and answer 429 with Retry-After

The routing docs define limiters with RateLimiter::for inside AppServiceProvider::boot. The laravel/laravel 13.x skeleton does not call throttleApi(), and install:api does not add it. A limiter that is never attached does nothing.

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;

public function boot(): void
{
    RateLimiter::for('api', function (Request $request) {
        return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
    });
}

Sixty per minute, by user id or IP, is the docs' example, not a capacity plan. Attach it with throttleApi(), which adds throttle:api to the api group:

->withMiddleware(function (Middleware $middleware): void {
    $middleware->throttleApi();
});

throttleWithRedis() only swaps in ThrottleRequestsWithRedis when Redis is the cache. It does not register the limiter by itself.

A write route can return an array of limits. Use distinct by prefixes, such as minute: and day: plus the user id, then attach that name with throttle:orders on store. Login should be tighter and segmented by email.

Segment a personal access token on currentAccessToken() only when that object is a PersonalAccessToken. The SPA cookie flow uses a TransientToken, which has no id, so fall through to the user id.

Over the limit, the middleware returns 429 and sets Retry-After from availableIn, plus X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset. The default body is "Too Many Attempts." Use Limit::response when you want the same message and errors keys as validation. The client should honor Retry-After.

Document the contract you actually return

I keep an OpenAPI document beside the routes: paths, status codes, and resource fields. That is what I send a mobile developer instead of asking them to read toArray.

Two community packages can draft it. Scribe (knuckleswtf/scribe) generates HTML, a Postman collection, and OpenAPI from routes, form requests, and resources. Scramble (dedoc/scramble) generates OpenAPI 3.1 and a docs UI without a PHPDoc line on every field. I am not endorsing either. Scramble serves /docs/api only in local unless you change that, and a generated spec can still miss a behavior. I delete anything the code does not do.

Test the request a client will send

The HTTP tests docs lead with Pest. PHPUnit has the same assertions. Sanctum's testing helper is Sanctum::actingAs:

use App\Models\User;
use Illuminate\Testing\Fluent\AssertableJson;
use Laravel\Sanctum\Sanctum;

test('a client can list orders', function () {
    Sanctum::actingAs(User::factory()->create(), ['orders:read']);

    $this->getJson('/api/v1/orders')
        ->assertOk()
        ->assertJsonStructure([
            'data' => [
                '*' => ['id', 'number', 'status'],
            ],
            'links',
            'meta',
        ])
        ->assertJson(fn (AssertableJson $json) =>
            $json->has('data')->has('links')->has('meta')->etc()
        );
});

assertJson checks a fragment. assertExactJson is strict. assertJsonStructure checks keys. missing('password')->etc() asserts a field is absent.

Post an empty body and expect 422 via assertJsonValidationErrors. A token without orders:create should get 403. assertHeader('Deprecation') covers v1 during its retirement window.

For the limiter, call the route as one user until assertTooManyRequests() passes, then assertHeader('Retry-After'). That matches the 13.x middleware. I have not executed that loop for this post. If it never reaches 429, throttleApi() was not called.

Production checklist

Cache a response only when every caller can see the same body. An authenticated order list stays private. Public reference data can use cache.headers. Fix the query before caching a profile by user id. I am not quoting timings.

Eager-load every relationship the resource includes. whenLoaded then returns null if a later endpoint forgets the with. I check that with the query log on one index request in staging.

A client will retry a timed-out POST. Store the Idempotency-Key, unique per user, with the first response's status and body, and return that stored response on a repeat. Laravel does not ship this. Webhook duplicates are the same problem, covered in the Stripe payment integration guide.

HandleCors is already global. php artisan config:publish cors publishes the config. List real origins. Sanctum's SPA flow needs credentials and the stateful domains, or a cookie failure looks like a 401. Header meanings are in the MDN CORS documentation.

Log method, route name, status, and a request id. Do not log Authorization, the plain-text token, or the issuing password. createToken already shows the plain text once.

TLS and fastcgi_pass are the nginx setup. After the routes settle, php artisan route:cache on deploy is the routing docs' production step. A new route stays invisible until you rebuild that cache.

What this is not

A marketing site does not need this. Laravel vs WordPress is how I separate a content site from an application. A CRM that runs the business is closer to policies and resources than to a contact form, which is the fork in custom CRM vs off-the-shelf.

If you want this built

People who ask for api development services usually need this list: a versioned JSON API, Sanctum or Passport chosen on purpose, validation, a 429 the client can retry, and a test that fails when the JSON drifts. That work is Laravel development. The Laravel hiring checklist is how I want a buyer to read a repository, including mine.

Send which client will call the API, whether v1 already exists, and whether a third party must complete an OAuth consent screen. Those three answers decide Passport, the prefix, and the first limiter.

Discuss this with Muhammad

Message Muhammad Zubair Akhtar on WhatsApp or email [email protected].