DEV Community

Marcc Atayde
Marcc Atayde

Posted on

Building Production-Ready RESTful APIs with Laravel: Authentication, Rate Limiting, and Versioning

Building Production-Ready RESTful APIs with Laravel: Authentication, Rate Limiting, and Versioning

Most Laravel API tutorials stop at Route::get('/users', ...) and call it a day. But shipping an API to production is a different animal entirely. You need robust authentication, protection against abuse, and a versioning strategy that won't break your clients every time you iterate. This guide walks through all three — with real code, not hand-waving.

Authentication with Laravel Sanctum

For most APIs — especially SPAs and mobile apps — Laravel Sanctum is the right tool. It's lighter than Passport and covers 90% of real-world use cases.

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

Add the HasApiTokens trait to your User model:

use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens, HasFactory, Notifiable;
}
Enter fullscreen mode Exit fullscreen mode

Issuing Tokens on Login

// app/Http/Controllers/Api/AuthController.php

public function login(Request $request): JsonResponse
{
    $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);
    }

    // Revoke old tokens for this device if needed
    $user->tokens()->where('name', $request->device_name)->delete();

    $token = $user->createToken($request->device_name, ['*'], now()->addDays(30));

    return response()->json([
        'token'      => $token->plainTextToken,
        'expires_at' => $token->accessToken->expires_at,
    ]);
}
Enter fullscreen mode Exit fullscreen mode

Notice the token expiry — never issue non-expiring tokens in production. Also notice we delete any existing token for that device name before issuing a new one. This prevents token accumulation.

Protecting Routes

// routes/api.php

Route::middleware('auth:sanctum')->group(function () {
    Route::get('/user', [UserController::class, 'show']);
    Route::post('/logout', [AuthController::class, 'logout']);
});
Enter fullscreen mode Exit fullscreen mode

For token abilities (scopes), check them in controllers:

if ($request->user()->tokenCan('orders:write')) {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Rate Limiting

Without rate limiting, your API is one angry bot away from a bad day. Laravel's RateLimiter facade gives you precise control.

Defining Custom Limiters

In App\Providers\RouteServiceProvider (or a dedicated AppServiceProvider in Laravel 11+), define your limiters in the boot method:

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

public function boot(): void
{
    // Public endpoints — limit by IP
    RateLimiter::for('public-api', function (Request $request) {
        return Limit::perMinute(30)->by($request->ip());
    });

    // Authenticated endpoints — limit by user
    RateLimiter::for('authenticated-api', function (Request $request) {
        return $request->user()
            ? Limit::perMinute(120)->by($request->user()->id)
            : Limit::perMinute(20)->by($request->ip());
    });

    // Expensive operations (e.g., AI endpoints)
    RateLimiter::for('heavy-operations', function (Request $request) {
        return [
            Limit::perMinute(10)->by($request->user()?->id ?? $request->ip()),
            Limit::perDay(500)->by($request->user()?->id ?? $request->ip()),
        ];
    });
}
Enter fullscreen mode Exit fullscreen mode

Apply them to routes:

Route::middleware(['auth:sanctum', 'throttle:authenticated-api'])->group(function () {
    Route::get('/products', [ProductController::class, 'index']);
});

Route::middleware(['auth:sanctum', 'throttle:heavy-operations'])->group(function () {
    Route::post('/ai/generate', [AiController::class, 'generate']);
});
Enter fullscreen mode Exit fullscreen mode

Returning Useful Rate Limit Headers

Laravel automatically adds X-RateLimit-Limit and X-RateLimit-Remaining headers. When the limit is hit, it returns a 429 Too Many Requests with a Retry-After header. Make sure your API clients handle this gracefully.

If you want to expose remaining limits proactively, you can add them manually in a middleware or global response macro — but for most APIs, the defaults are sufficient.

API Versioning

This is where teams argue forever. URI versioning (/api/v1/) vs. header versioning (Accept: application/vnd.api+json;version=1). For most teams, URI versioning wins on practicality — it's visible, cacheable, and easy to test in a browser.

Route Structure

routes/
  api/
    v1.php
    v2.php
app/Http/Controllers/
  Api/
    V1/
      UserController.php
      ProductController.php
    V2/
      UserController.php
Enter fullscreen mode Exit fullscreen mode

In routes/api.php:

Route::prefix('v1')->name('api.v1.')->group(base_path('routes/api/v1.php'));
Route::prefix('v2')->name('api.v2.')->group(base_path('routes/api/v2.php'));
Enter fullscreen mode Exit fullscreen mode

Sharing Logic Between Versions

Don't duplicate business logic. Put it in services or actions, and call the same layer from both versioned controllers:

// app/Actions/GetUserProfileAction.php

class GetUserProfileAction
{
    public function execute(User $user): array
    {
        return [
            'id'    => $user->id,
            'name'  => $user->name,
            'email' => $user->email,
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

V1 and V2 controllers both inject and use GetUserProfileAction. The V2 controller might transform the output differently, but the core query and business rules live in one place.

Versioned API Resources

Laravel's API Resources are ideal here:

// app/Http/Resources/V1/UserResource.php
class UserResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'   => $this->id,
            'name' => $this->name,
        ];
    }
}

// app/Http/Resources/V2/UserResource.php
class UserResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'        => $this->id,
            'full_name' => $this->name,   // renamed field in v2
            'avatar'    => $this->avatar_url,
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

This keeps your transformations clean and isolated per version.

Consistent Error Responses

Nothing frustrates API consumers more than inconsistent error shapes. Standardise using Laravel's exception handler:

// app/Exceptions/Handler.php (or bootstrap/app.php in L11)

$exceptions->render(function (\Illuminate\Validation\ValidationException $e, Request $request) {
    if ($request->expectsJson()) {
        return response()->json([
            'message' => 'Validation failed',
            'errors'  => $e->errors(),
        ], 422);
    }
});

$exceptions->render(function (\Illuminate\Auth\AuthenticationException $e, Request $request) {
    if ($request->expectsJson()) {
        return response()->json(['message' => 'Unauthenticated'], 401);
    }
});
Enter fullscreen mode Exit fullscreen mode

Every error, every time, returns the same JSON shape. Clients can rely on it.

A Note on Real-World API Projects

The patterns above aren't theoretical — they're pulled directly from production projects. When building APIs for clients in the region (the kind of multi-tenant SaaS and e-commerce platforms common in the UAE market), the combination of Sanctum for auth, granular rate limiters, and clean URI versioning consistently proves reliable and maintainable. If you're curious about how these patterns fit into larger Laravel architecture decisions, you can visit website to see how we approach these builds at HanzWeb.

Conclusion

Building a Laravel API that's actually production-ready means thinking beyond the happy path. Sanctum handles authentication cleanly without the overhead of OAuth when you don't need it. Custom rate limiters let you protect different endpoints differently — a blunt global throttle is rarely the right answer. And URI versioning, backed by shared action classes and versioned API Resources, gives you a clear upgrade path without breaking existing clients.

The three pillars — authentication, rate limiting, versioning — aren't optional extras. They're the floor, not the ceiling, of a professional API. Nail these, and you've got a solid foundation to build anything on top of.

Top comments (0)