#
  • Development, API

Building REST APIs with Laravel

Building REST APIs with Laravel

REST APIs are the backbone of modern software. They power mobile apps, single-page applications, third-party integrations, and microservices. Laravel provides everything you need to build a production-ready API out of the box — consistent response formatting, token authentication, rate limiting, and more.

This guide walks through building a fully functional API from scratch, covering the patterns that matter most in real applications.

Structuring Your API Routes

API routes live in routes/api.php. Every route in this file is automatically prefixed with /api and wrapped in the api middleware group, which disables session state and enables stateless authentication.

// routes/api.php
use App\Http\Controllers\Api\V1\PostController;
use App\Http\Controllers\Api\V1\UserController;

Route::prefix('v1')->name('api.v1.')->group(function () {
    Route::apiResource('posts', PostController::class);
    Route::apiResource('users', UserController::class)->only(['index', 'show']);
});

Route::apiResource() registers the standard RESTful routes (index, store, show, update, destroy) without create and edit, which exist only for HTML forms.

API Versioning

Version your API from day one. It costs nothing upfront and saves enormous pain later when you need to introduce breaking changes without disrupting existing clients.

GET  /api/v1/posts
GET  /api/v1/posts/{id}
POST /api/v1/posts
PUT  /api/v1/posts/{id}
DELETE /api/v1/posts/{id}

Organize your controllers to match:

app/Http/Controllers/Api/
  V1/
    PostController.php
    UserController.php
  V2/
    PostController.php  ← breaking change lives here

API Resources

Raw Eloquent models should never be returned directly from a controller. They expose internal structure, make refactoring painful, and can leak sensitive fields. Laravel's API Resources give you a transformation layer:

php artisan make:resource PostResource
php artisan make:resource PostCollection
class PostResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'         => $this->id,
            'title'      => $this->title,
            'slug'       => $this->slug,
            'excerpt'    => $this->excerpt,
            'body'       => $this->body,
            'published'  => $this->published,
            'author'     => new UserResource($this->whenLoaded('author')),
            'tags'       => TagResource::collection($this->whenLoaded('tags')),
            'created_at' => $this->created_at->toIso8601String(),
            'updated_at' => $this->updated_at->toIso8601String(),
        ];
    }
}

whenLoaded() is critical — it only includes a relationship in the response if it was eager-loaded. This prevents accidental N+1 queries while keeping your resource reusable across endpoints that need different data shapes.

Return a resource from your controller:

public function show(Post $post): PostResource
{
    $post->load(['author', 'tags']);

    return new PostResource($post);
}

public function index(): AnonymousResourceCollection
{
    $posts = Post::with(['author', 'tags'])->latest()->paginate(15);

    return PostResource::collection($posts);
}

Laravel automatically wraps paginated collections in a data key and includes links and meta with pagination information — no extra work required.

Request Validation with Form Requests

Never validate inside controller methods. Form Requests keep validation logic separate and reusable:

php artisan make:request StorePostRequest
php artisan make:request UpdatePostRequest
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'],
            'slug'      => ['required', 'string', 'unique:posts,slug'],
            'excerpt'   => ['nullable', 'string', 'max:500'],
            'body'      => ['required', 'string'],
            'published' => ['boolean'],
            'tag_ids'   => ['array'],
            'tag_ids.*' => ['integer', 'exists:tags,id'],
        ];
    }
}

When validation fails, Laravel automatically returns a 422 Unprocessable Entity response with structured error details — no boilerplate required.

Your controller stays lean:

public function store(StorePostRequest $request): PostResource
{
    $post = Post::create($request->validated());
    $post->tags()->sync($request->validated()['tag_ids'] ?? []);

    return new PostResource($post->load(['author', 'tags']));
}

Authentication with Laravel Sanctum

Sanctum is the right choice for most APIs. It handles both SPA cookie-based auth and simple API token auth with minimal setup.

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

Add the HasApiTokens trait to your User model:

use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens, HasFactory, Notifiable;
}

Issuing Tokens

Route::post('/login', function (Request $request) {
    $request->validate([
        'email'    => 'required|email',
        'password' => 'required',
    ]);

    if (! Auth::attempt($request->only('email', 'password'))) {
        throw ValidationException::withMessages([
            'email' => ['The provided credentials are incorrect.'],
        ]);
    }

    $token = $request->user()->createToken(
        name: $request->input('device_name', 'api'),
        abilities: ['posts:read', 'posts:write'],
    );

    return ['token' => $token->plainTextToken];
});

Protecting Routes

Route::middleware('auth:sanctum')->group(function () {
    Route::apiResource('posts', PostController::class);
    Route::post('/logout', function (Request $request) {
        $request->user()->currentAccessToken()->delete();
        return response()->noContent();
    });
});

Clients send the token as a Bearer header:

Authorization: Bearer 1|abc123...

Rate Limiting

Protect your API from abuse with rate limiting. Define custom limiters in AppServiceProvider:

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

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

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

Apply to routes:

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

Route::middleware(['throttle:auth'])->group(function () {
    Route::post('/login', [AuthController::class, 'login']);
});

When a client hits the limit, Laravel returns 429 Too Many Requests with a Retry-After header automatically.

Consistent Error Responses

APIs should return consistent error shapes. Handle this globally in bootstrap/app.php:

->withExceptions(function (Exceptions $exceptions) {
    $exceptions->render(function (Throwable $e, Request $request) {
        if ($request->expectsJson()) {
            $status = match (true) {
                $e instanceof ModelNotFoundException  => 404,
                $e instanceof AuthorizationException => 403,
                $e instanceof ValidationException    => 422,
                default                              => 500,
            };

            return response()->json([
                'message' => $e->getMessage(),
                'errors'  => $e instanceof ValidationException ? $e->errors() : null,
            ], $status);
        }
    });
})

Consistency is the most important quality of an API. A client should be able to handle your error responses with a single piece of code, regardless of which endpoint triggered the error.

Testing Your API

Laravel's HTTP testing helpers make API tests a joy to write:

it('returns a paginated list of posts', function () {
    Post::factory(20)->create();

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

    $response->assertOk()
        ->assertJsonStructure([
            'data' => [['id', 'title', 'slug', 'created_at']],
            'links' => ['first', 'last', 'prev', 'next'],
            'meta'  => ['current_page', 'total', 'per_page'],
        ]);
});

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

it('creates a post for authenticated users', function () {
    $user = User::factory()->create();

    $this->actingAs($user)->postJson('/api/v1/posts', [
        'title'     => 'My First Post',
        'slug'      => 'my-first-post',
        'body'      => 'Content here...',
        'published' => true,
    ])->assertCreated()->assertJsonPath('data.title', 'My First Post');
});

Checklist Before Going to Production

  • All sensitive routes protected with auth:sanctum
  • Rate limiting applied to auth and public endpoints
  • APP_DEBUG=false in production .env
  • API responses use Resources, never raw models
  • Validation handled by Form Requests
  • Eager loading in place — run php artisan telescope:install locally to spot N+1 queries
  • Error responses are consistent JSON across all endpoints

A well-designed API is a long-term asset. The time you invest upfront in versioning, validation, and consistent error handling pays dividends every time a new client integrates with your service.

Share this post