CCelestiallines.com
← Back to blog

How to Build a REST API in Laravel

By Pawan Singh · Jun 2026

A Laravel REST API is more than Route::apiResource and a controller — response shaping, validation, error handling, and authentication are what separate a demo from something a real client can build against. This covers the full path.

Set Up Versioned API Routes

Keep API routes separate from web routes and version them from day one in routes/api.php — retrofitting versioning after a client is already depending on unversioned routes is painful:

Route::prefix('v1')->group(function () {
    Route::apiResource('posts', PostController::class);
});

apiResource registers exactly the five RESTful routes an API needs (index, store, show, update, destroy) — unlike Route::resource, it deliberately skips create and edit, since those exist only to render HTML forms, which an API has no use for.

Generate a Resource Controller

php artisan make:controller Api/PostController --api --model=Post

The --api flag scaffolds method stubs for exactly the five routes above with the right type-hints, and --model=Post pre-fills route-model-binding parameters so show(Post $post) already resolves the model from the route's {post} segment — no manual Post::findOrFail() needed.

Shape Responses with API Resources

Never return Eloquent models directly from a controller — it leaks every column (including things like internal timestamps or soft-delete markers) and couples your API's shape to your database schema. Wrap responses in a Resource instead:

php artisan make:resource PostResource
class PostResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'excerpt' => str($this->body)->limit(150),
            'author' => new UserResource($this->whenLoaded('author')),
            'published_at' => $this->published_at?->toIso8601String(),
        ];
    }
}

whenLoaded() is worth knowing specifically: it only includes the relationship in the response if it was actually eager-loaded on the query, instead of triggering a lazy-loaded query per row (see the N+1 note below). For list endpoints, use a ResourceCollection (php artisan make:resource PostCollection) rather than manually wrapping an array — it gives you a place to add top-level pagination metadata (links, meta.current_page, etc.) alongside the data array.

Validate Input with Form Requests

Move validation out of the controller into a dedicated Form Request class:

php artisan make:request StorePostRequest
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'],
        ];
    }
}

Type-hint it in the controller method (store(StorePostRequest $request)) and Laravel validates automatically before the method body even runs, returning a 422 Unprocessable Entity with a structured error body on failure — you never write the validation-check-then-fail logic yourself. authorize() is where permission checks belong, kept separate from the "is this data shaped correctly" concern in rules().

Consistent Error Responses

Laravel already converts common exceptions to sensible JSON automatically when the request expects JSON (ValidationException → 422, ModelNotFoundException → 404, AuthorizationException → 403), but it's worth confirming the shape matches what your API consumers expect, and customizing it in bootstrap/app.php's exception handling if not — e.g. wrapping every error under a consistent {"error": {"message": ..., "code": ...}} envelope rather than Laravel's default shape.

Authenticate with Sanctum

For a mobile app or a separate SPA frontend (like a decoupled React or Next.js app calling this API), Sanctum issues lightweight personal-access tokens without the overhead of a full OAuth2 flow:

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

Issue a token after login and hand it to the client:

$token = $user->createToken('mobile-app')->plainTextToken;

The client sends it back as Authorization: Bearer {token} on every request; protect routes with the auth:sanctum middleware:

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

Revoke a token when a user logs out or a device is deauthorized with $user->currentAccessToken()->delete(), or wipe every token for that user with $user->tokens()->delete().

Sanctum vs Passport: reach for Sanctum for first-party SPAs and mobile apps you control. Reach for Passport instead only if you need full OAuth2 — specifically, if a third party you don't control needs to obtain scoped, revocable access to a user's data on their behalf (the "log in with X" pattern from the provider side). Most projects that think they need Passport actually just need Sanctum.

Rate Limiting

Apply Laravel's built-in throttle middleware to protect the API from abuse:

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

This limits a client to 60 requests per minute; define named, more granular limiters (different limits for authenticated vs anonymous, or per-endpoint) in RouteServiceProvider or bootstrap/app.php via RateLimiter::for() rather than hardcoding numbers inline everywhere.

Avoiding N+1 Queries in Responses

The single most common Laravel API performance bug: looping resources that lazy-load a relationship per row.

// Bad: 1 query for posts + N queries, one per post's author
return PostResource::collection(Post::all());

// Good: eager load up front, 2 queries total
return PostResource::collection(Post::with('author')->get());

Enable Model::preventLazyLoading() in AppServiceProvider during local development so a missing with() throws immediately in your test environment instead of silently degrading response times in production.

Testing the API

Laravel's HTTP testing helpers make API endpoints straightforward to cover without a real HTTP call:

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

    $response = $this->actingAs($user, 'sanctum')
        ->postJson('/api/v1/posts', ['title' => 'Hello', 'body' => 'World']);

    $response->assertCreated()
        ->assertJsonPath('data.title', 'Hello');
}

Documenting the API

Once the API is more than a couple of endpoints, hand-written documentation drifts out of date fast. Tools like Scribe generate documentation directly from your route definitions, Form Requests, and response resources, keeping docs and code from silently diverging.

Common Issues

  • CORS errors from a separate frontend — configure allowed origins in config/cors.php; a missing entry here is the most common reason a working Postman request fails only from the browser.
  • 422 responses with no useful detail, or an HTML page instead of JSON — the request isn't sending Accept: application/json, so Laravel falls back to redirect-based web behavior instead of returning a JSON error body.
  • Sanctum "CSRF token mismatch" for an SPA — if you're using Sanctum's cookie-based SPA authentication (not bearer tokens), the frontend domain must be listed in SANCTUM_STATEFUL_DOMAINS and the SPA must hit /sanctum/csrf-cookie before its first stateful request.
  • Mass assignment exceptions — add the field to the model's $fillable array; Laravel blocks unguarded mass assignment by default as a safety measure against unexpected input.
  • Slow list endpoints under real data volume — check for the N+1 pattern above before assuming it's a database or infrastructure problem.

Need a Laravel API designed and built for a frontend you already have? Get in touch — I work as a freelance Laravel developer.

Comments