All Articles
25 May 2026 8 min read 58 views
Laravel

Laravel 13 REST API Best Practices (2026) — Complete Guide

Production-tested Laravel 13 REST API patterns — versioning, API Resources, Sanctum, rate limiting, N+1 fixes, UUID v7, contract testing, and PHP 8.3 attributes. With code.

Tushar Modi.
Tushar Modi.
May 25, 2026 · Jaipur, India
8 min 58
Category Laravel
Published May 25, 2026
Read 8 min
Views 58
Updated Jun 6, 2026
Laravel 13 REST API Best Practices (2026) — Complete Guide

Laravel 13 REST API Best Practices (2026) — Complete Guide

I inherited a Laravel API once where the previous developer mixed response formats across endpoints, skipped versioning entirely, and hardcoded auth logic into controllers.

We spent three weeks just standardizing responses before we could add a single new feature. Three weeks of real money and real frustration that was completely avoidable.

This guide covers the exact Laravel 13 REST API patterns I now use in production across every project — from structure and authentication to performance and testing.

1. Version Your API from Day One

Skipping versioning is one of the easiest ways to trap yourself later. Even if your first release is tiny, your API is still a contract. The moment a mobile app, a frontend, or another team depends on it, breaking changes get expensive.

Starting with /api/v1/ costs nothing upfront. Changing /api/products to /api/v2/products later breaks every client that already depends on it.

php

// routes/api.php
Route::prefix('v1')->group(function () {
    Route::apiResource('posts', PostController::class);
    Route::apiResource('users', UserController::class);
});

File structure to match:

app/Http/Controllers/
  Api/
    V1/
      PostController.php
      UserController.php
    V2/
      PostController.php

When to create V2: Only when you need breaking changes — changed response shape, removed fields, changed auth requirements. Adding new fields to an existing response is non-breaking and does not require a new version.

2. Always Use API Resources — Never Return Raw Models

php

// Wrong — exposes internal fields, passwords, timestamps
return response()->json($user);

// Right — full control over what the client receives
return new UserResource($user);

php

// app/Http/Resources/UserResource.php
class UserResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'         => $this->id,
            'name'       => $this->name,
            'email'      => $this->email,
            'avatar_url' => $this->avatar_url,
            'role'       => $this->role->name,
            'created_at' => $this->created_at->toISOString(),

            // Conditionally include sensitive data
            'api_key' => $this->when(
                $request->user()->isAdmin(),
                $this->api_key
            ),

            // Include relationships only when loaded
            'posts' => PostResource::collection(
                $this->whenLoaded('posts')
            ),
        ];
    }
}

For collections with pagination:

php

return UserResource::collection(User::paginate(20));
// Returns data array + pagination links automatically

3. Sanctum vs Passport — Choose the Right Tool

Use Sanctum when:

  • Building auth for your own SPA or mobile app
  • Issuing simple API tokens to users
  • You do not need third-party OAuth2 flows
  • This covers 90% of projects

Use Passport when:

  • External developers need to integrate against your API
  • You need multiple OAuth2 grant types
  • Building a public API platform

php

// config/auth.php
'guards' => [
    'api' => [
        'driver'   => 'sanctum',
        'provider' => 'users',
    ],
],

// Protect routes
Route::middleware('auth:sanctum')->group(function () {
    Route::apiResource('posts', PostController::class);
});

// Issue token on login
$token = $user->createToken('mobile-app', ['posts:read', 'posts:write'])
               ->plainTextToken;

// Fine-grained permissions
Route::middleware(['auth:sanctum', 'abilities:posts:write'])->group(function () {
    Route::post('/posts', [PostController::class, 'store']);
});

4. Form Requests for All Validation

php

// Wrong — validation in controller
public function store(Request $request): JsonResponse
{
    $request->validate([
        'title'  => 'required|string|max:255',
        'body'   => 'required|string',
        'status' => 'required|in:draft,published',
    ]);
    // ...
}

// Right — dedicated Form Request
public function store(StorePostRequest $request): JsonResponse
{
    $post = Post::create($request->validated());
    return new PostResource($post);
}

php

// app/Http/Requests/StorePostRequest.php
class StorePostRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', Post::class);
    }

    public function rules(): array
    {
        return [
            'title'   => ['required', 'string', 'max:255'],
            'body'    => ['required', 'string'],
            'status'  => ['required', Rule::in(['draft', 'published'])],
            'tags'    => ['array'],
            'tags.*'  => ['exists:tags,id'],
        ];
    }
}

5. UUID v7 for Public-Facing IDs

Auto-incrementing IDs expose your record count and creation order. UUIDv7 is time-ordered, index-friendly, and does not leak internal data.

php

// Migration
Schema::create('posts', function (Blueprint $table) {
    $table->uuid('id')->primary()->default(DB::raw('uuidv7()'));
    $table->string('title');
    $table->timestamps();
});

// Model
use Illuminate\Database\Eloquent\Concerns\HasUuids;

class Post extends Model
{
    use HasUuids;
}

6. Consistent Error Responses

php

// Standard error shape — use everywhere
{
  "message": "The given data was invalid.",
  "errors": {
    "title": ["The title field is required."]
  },
  "status": 422
}

// Standard success shape
{
  "data": { ... },
  "message": "Post created successfully."
}

php

// app/Exceptions/Handler.php
public function register(): void
{
    $this->renderable(function (ModelNotFoundException $e) {
        return response()->json([
            'message' => 'Resource not found.',
            'status'  => 404,
        ], 404);
    });

    $this->renderable(function (AuthorizationException $e) {
        return response()->json([
            'message' => 'You are not authorized to perform this action.',
            'status'  => 403,
        ], 403);
    });
}

Important: Set APP_DEBUG=false in production. Stack traces expose your file paths, database structure, and dependency versions.

7. Rate Limiting on Everything Public

php

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

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

php

// routes/api.php
Route::middleware(['throttle:api-login'])->group(function () {
    Route::post('/login', [AuthController::class, 'login']);
    Route::post('/register', [AuthController::class, 'register']);
});

Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
    Route::apiResource('posts', PostController::class);
});

8. Correct HTTP Status Codes

CodeWhen to useExample200Successful GET, PUT, PATCHReturn updated resource201Successful POSTNew post created204Successful DELETEPost deleted, no body401UnauthenticatedMissing or invalid token403UnauthorizedNot owner of resource404Resource not foundPost ID does not exist422Validation failedRequired field missing429Too many requestsRate limit exceeded500Server errorUnexpected exception

php

public function store(StorePostRequest $request): JsonResponse
{
    $post = Post::create($request->validated());
    return (new PostResource($post))
        ->response()
        ->setStatusCode(201); // 201 Created — not 200
}

public function destroy(Post $post): JsonResponse
{
    $this->authorize('delete', $post);
    $post->delete();
    return response()->json(null, 204); // 204 No Content
}

9. Eliminate N+1 Queries

php

// Wrong — N+1 problem
$posts = Post::paginate(20);
// Each post fires separate query for author and tags
// = 1 + 20 + 20 = 41 queries

// Right — eager loading
$posts = Post::with(['author', 'tags', 'category'])
    ->withCount('comments')
    ->paginate(20);
// = 4 queries total. Always.

Add this to AppServiceProvider to catch N+1 in development:

php

Model::preventLazyLoading(!app()->isProduction());

10. Test the Contract — Not the Implementation

php

// tests/Feature/Api/V1/PostTest.php
test('guests cannot create posts', function () {
    $this->postJson('/api/v1/posts', [
        'title' => 'Hello world',
        'body'  => 'Post content',
    ])->assertUnauthorized(); // 401
});

test('authenticated users can create posts', function () {
    $user = User::factory()->create();

    $this->actingAs($user, 'sanctum')
        ->postJson('/api/v1/posts', [
            'title'  => 'Hello world',
            'body'   => 'Post content.',
            'status' => 'published',
        ])
        ->assertCreated() // 201
        ->assertJsonStructure([
            'data' => ['id', 'title', 'body', 'status', 'created_at'],
        ]);
});

test('users cannot delete posts they do not own', function () {
    $owner = User::factory()->create();
    $other = User::factory()->create();
    $post  = Post::factory()->for($owner)->create();

    $this->actingAs($other, 'sanctum')
        ->deleteJson("/api/v1/posts/{$post->id}")
        ->assertForbidden(); // 403
});

Assert against literal URLs — not named routes. Your clients depend on the actual URL.

11. Laravel 13 — PHP Attributes and Typed Config

php

// Before — Laravel 12
class Post extends Model
{
    protected $fillable = ['title', 'body', 'status', 'user_id'];
    protected $hidden   = ['internal_notes'];
    protected $casts    = ['published_at' => 'datetime'];
}

// After — Laravel 13 PHP Attributes
#[Fillable(['title', 'body', 'status', 'user_id'])]
#[Hidden(['internal_notes'])]
#[Cast(['published_at' => 'datetime'])]
class Post extends Model {}

php

// Typed config — catch env mistakes at boot
$limit  = config()->integer('api.rate_limit');
$debug  = config()->boolean('app.debug');
$prefix = config()->string('api.prefix');
// Wrong type throws ConfigTypeMismatchException at boot

12. Auto Documentation

bash

composer require dedoc/scramble
# Generates OpenAPI docs automatically at /docs/api

Complete Checklist

Structure:

  • /api/v1/ versioning from day one
  • All responses through API Resources
  • All validation through Form Requests
  • Business logic in Service classes

Auth:

  • Sanctum for SPA/mobile
  • Passport only for third-party OAuth2
  • Token abilities for permissions

Security:

  • Rate limiting on all public endpoints
  • APP_DEBUG=false in production
  • UUID v7 for public IDs
  • Policies for authorization

Performance:

  • Eager loading on all list endpoints
  • Pagination on all collections
  • Indexes on filter columns

Testing:

  • Feature test for every endpoint
  • Assert against literal URLs
  • Test unauthorized and forbidden cases

Laravel 13:

  • PHP Attributes on models
  • Typed config — config()->string(), config()->integer()
  • JSON:API resources for third-party consumers

Wrapping Up

Good Laravel APIs are not just about returning JSON from a controller. They are about giving clients a contract they can trust — stable URLs, predictable payloads, useful status codes, clear authentication, and changes that do not break clients six months later.

The patterns above are production-tested. Apply them from the start and your API will survive growth far better than one held together by "we'll clean it up later."

Tushar Modi — Full Stack Developer, Jaipur tusharmodi.in