Laravel

Building REST APIs with Laravel: A Practical Guide

Svet Halachev Dec 20, 2025 5 min read
Building REST APIs with Laravel: A Practical Guide

Laravel makes building APIs straightforward, but there's a difference between an API that works and one that's actually pleasant to use and maintain. Here's how I approach it.

Project Setup

Start with Laravel's API-focused structure. If you're building a pure API (no web frontend), you can skip the session and view stuff:

laravel new my-api

In bootstrap/app.php, you can simplify the middleware stack:

->withMiddleware(function (Middleware $middleware) {
    $middleware->api(prepend: [
        // Your API middleware
    ]);
})

Routing Structure

Keep your routes organized. I usually version APIs from the start—even if you think you won't need it, you probably will.

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

    // Protected routes
    Route::middleware('auth:sanctum')->group(function () {
        Route::get('user', [UserController::class, 'show']);
        Route::apiResource('posts', PostController::class);
        Route::apiResource('posts.comments', CommentController::class)->shallow();
    });
});

apiResource is your friend—it creates all the standard CRUD routes without the create and edit endpoints that only make sense for web apps.

API Resources: Shape Your Responses

Never return Eloquent models directly. Use API Resources to control exactly what gets sent:

// app/Http/Resources/PostResource.php
class PostResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'slug' => $this->slug,
            'excerpt' => $this->excerpt,
            'content' => $this->content,
            'published_at' => $this->published_at?->toIso8601String(),
            'author' => new UserResource($this->whenLoaded('author')),
            'tags' => TagResource::collection($this->whenLoaded('tags')),
            'comments_count' => $this->whenCounted('comments'),
        ];
    }
}

The whenLoaded and whenCounted methods prevent N+1 queries by only including data that's been eager loaded.

In your controller:

public function index()
{
    $posts = Post::with(['author', 'tags'])
        ->withCount('comments')
        ->published()
        ->paginate();

    return PostResource::collection($posts);
}

public function show(Post $post)
{
    $post->load(['author', 'tags', 'comments.user']);

    return new PostResource($post);
}

Form Requests for Validation

Keep validation out of your controllers. Form Requests handle authorization and validation in one place:

// 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'],
            'content' => ['required', 'string'],
            'excerpt' => ['nullable', 'string', 'max:500'],
            'tags' => ['nullable', 'array'],
            'tags.*' => ['exists:tags,id'],
            'published_at' => ['nullable', 'date'],
        ];
    }
}

Your controller stays clean:

public function store(StorePostRequest $request)
{
    $post = $request->user()->posts()->create($request->validated());

    if ($request->has('tags')) {
        $post->tags()->sync($request->tags);
    }

    return new PostResource($post->load('tags'));
}

Authentication with Sanctum

For most APIs, Laravel Sanctum is the right choice. It handles both SPA authentication (cookie-based) and API tokens (for mobile apps or third-party integrations).

// For token-based auth
public function login(Request $request)
{
    $request->validate([
        'email' => 'required|email',
        'password' => 'required',
    ]);

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

    if (! $user || ! Hash::check($request->password, $user->password)) {
        return response()->json([
            'message' => 'Invalid credentials',
        ], 401);
    }

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

    return response()->json([
        'user' => new UserResource($user),
        'token' => $token,
    ]);
}

Consistent Error Responses

APIs need predictable error formats. Laravel's exception handler can transform all errors into a consistent structure:

// bootstrap/app.php
->withExceptions(function (Exceptions $exceptions) {
    $exceptions->render(function (ValidationException $e, Request $request) {
        if ($request->expectsJson()) {
            return response()->json([
                'message' => 'Validation failed',
                'errors' => $e->errors(),
            ], 422);
        }
    });

    $exceptions->render(function (ModelNotFoundException $e, Request $request) {
        if ($request->expectsJson()) {
            return response()->json([
                'message' => 'Resource not found',
            ], 404);
        }
    });
})

Rate Limiting

Protect your API from abuse:

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

// Different limits for different endpoints
RateLimiter::for('uploads', function (Request $request) {
    return Limit::perMinute(10)->by($request->user()->id);
});

Apply in routes:

Route::middleware('throttle:uploads')->post('upload', [MediaController::class, 'store']);

Pagination

Always paginate list endpoints. Laravel makes this easy:

public function index(Request $request)
{
    $posts = Post::query()
        ->when($request->search, fn ($q, $search) => $q->where('title', 'like', "%{$search}%"))
        ->when($request->status, fn ($q, $status) => $q->where('status', $status))
        ->latest()
        ->paginate($request->per_page ?? 15);

    return PostResource::collection($posts);
}

The response automatically includes pagination metadata:

{
  "data": [...],
  "links": {
    "first": "...",
    "last": "...",
    "prev": null,
    "next": "..."
  },
  "meta": {
    "current_page": 1,
    "last_page": 10,
    "per_page": 15,
    "total": 150
  }
}

Testing Your API

Write tests. Future you will be grateful:

it('lists published posts', function () {
    $posts = Post::factory()->published()->count(3)->create();
    Post::factory()->draft()->create(); // Should not appear

    $response = $this->getJson('/api/v1/posts');

    $response->assertOk()
        ->assertJsonCount(3, 'data')
        ->assertJsonStructure([
            'data' => [
                '*' => ['id', 'title', 'slug', 'excerpt'],
            ],
            'meta' => ['current_page', 'total'],
        ]);
});

it('requires authentication to create posts', function () {
    $this->postJson('/api/v1/posts', [
        'title' => 'Test Post',
        'content' => 'Content here',
    ])->assertUnauthorized();
});

The Takeaway

A good Laravel API is really just good Laravel code applied to JSON responses. Use Resources to shape output, Form Requests for validation, and keep your controllers thin. Add rate limiting and proper error handling, and you've got an API that's ready for production.

Start simple, add complexity when you need it.

Related Articles