Skip to content
Mehedi Hasan Sagor

// blog

Versioning, Rate Limiting, and Security: A Laravel API Playbook for Startups

Mehedi Hasan Sagor Nov 7, 2026 6 min read
Versioning, Rate Limiting, and Security: A Laravel API Playbook for Startups

Building a public API for a startup is a balancing act. You need to ship features quickly, but you also have to protect the contract you expose to partners, mobile apps, and third‑party developers. A sloppy API can become a maintenance nightmare as soon as you need a breaking change, or when a malicious client starts hammering your endpoints.

This post walks through three pillars that keep a Laravel API production‑ready: versioning, rate limiting, and security. Each section includes concrete Laravel code, configuration snippets, and a short checklist you can apply today.

1. Define a clear versioning strategy

Path‑based versioning (the simplest and most visible)

// routes/api.php
Route::prefix('v1')->group(function () {
    Route::apiResource('orders', App\Http\Controllers\Api\V1\OrderController::class);
});

Route::prefix('v2')->group(function () {
    Route::apiResource('orders', App\Http\Controllers\Api\V2\OrderController::class);
});

Pros: URL shows the version, no extra headers needed, easy to test with tools like Postman. Cons: You duplicate route files when you have many versions.

Header‑based versioning (cleaner URLs, more flexible)

// app/Http/Middleware/ApiVersion.php
namespace App\Http\Middleware;

use Closure;

class ApiVersion {
    public function handle($request, Closure $next) {
        $version = $request->header('Accept-Version', '1');
        $request->attributes->set('api_version', $version);
        return $next($request);
    }
}

Register the middleware on the api group, then resolve the version inside a controller:

public function __construct() {
    $this->version = request()->attributes->get('api_version');
}

public function index() {
    $service = $this->version === '2'
        ? new \App\Services\OrderServiceV2()
        : new \App\Services\OrderServiceV1();
    return response()->json($service->list());
}

Tip: Keep the version number in a single place (middleware) so you can deprecate old versions with a simple route‑group removal.

2. Harden authentication with Laravel Sanctum

Sanctum gives you token‑based authentication that works for SPAs, mobile apps, and third‑party services without the overhead of OAuth.

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

Add the middleware to api routes:

// app/Http/Kernel.php
'api' => [
    \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
    'throttle:api',
    \Illuminate\Routing\Middleware\SubstituteBindings::class,
],

Generate a token for a service account:

$token = $user->createToken('service-client', ['order:read', 'order:write'])->plainTextToken;

When a request arrives, Sanctum automatically scopes the token, so you can protect routes with abilities:

Route::middleware(['auth:sanctum', 'ability:order:read'])->get('orders', ...);

Why Sanctum over Passport?

  • Simpler setup for token‑only use cases.
  • No need for an OAuth server unless you need delegated access.
  • Works out‑of‑the‑box with Laravel’s rate‑limiting middleware.

3. Validate and sanitize inputs early

Never trust client data. Laravel’s FormRequest isolates validation logic and keeps controllers thin.

// app/Http/Requests/StoreOrderRequest.php
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreOrderRequest extends FormRequest {
    public function authorize() { return true; }

    public function rules() {
        return [
            'product_id' => 'required|integer|exists:products,id',
            'quantity'   => 'required|integer|min:1',
            'address'    => 'required|string|max:255',
        ];
    }
}

In the controller:

public function store(StoreOrderRequest $request) {
    $data = $request->validated();
    // Business logic here
    return response()->json(['message' => 'order created'], 201);
}

Sanitization (e.g., trimming strings, normalising phone numbers) can be added as a custom rule or a mutator on the model.

4. Implement rate limiting per client

Laravel’s ThrottleRequests middleware is powerful enough for most SaaS APIs.

// app/Http/Kernel.php
'api' => [
    'throttle:60,1', // 60 requests per minute per IP
    // other middleware …
],

For token‑based limits, define a custom limiter:

// app/Providers/RouteServiceProvider.php
use Illuminate\Routing\RateLimiter;
use Illuminate\Support\Facades\RateLimiter as Facade;

public function boot() {
    RateLimiter::for('api', function ($request) {
        $user = $request->user();
        $key = $user ? $user->id : $request->ip();
        return limit(200)->by($key); // 200 req/min per token or IP
    });
}

Apply it to a route group:

Route::middleware('throttle:api')->group(function () {
    Route::apiResource('orders', OrderController::class);
});

Best practice: Return a Retry-After header when throttling occurs. Laravel does this automatically, but you can customise the response if you need a JSON payload.

5. Consistent response envelope and error handling

Clients appreciate a predictable shape. Wrap every payload in a standard envelope and let Laravel’s exception handler translate errors.

// app/Http/Responses/ApiResponse.php
namespace App\Http\Responses;

class ApiResponse {
    public static function success($data, $meta = [], $status = 200) {
        return response()->json([
            'status' => 'success',
            'data'   => $data,
            'meta'   => $meta,
        ], $status);
    }

    public static function error($message, $code = 400, $errors = null) {
        return response()->json([
            'status'  => 'error',
            'message' => $message,
            'errors'  => $errors,
        ], $code);
    }
}

Use it in a controller:

return ApiResponse::success($order);

For validation failures, Laravel already returns a JSON payload with a 422 status. To unify format, register a renderable in App\Exceptions\Handler:

public function register() {
    $this->renderable(function (ValidationException $e, $request) {
        return ApiResponse::error('Validation failed', 422, $e->errors());
    });
}

6. Automated contract testing

A contract test guarantees that the API response matches the agreed schema. pestphp/pest combined with spatie/laravel-json-api-paginate works well.

composer require pestphp/pest --dev
composer require spatie/laravel-json-api-paginate --dev

Create a test:

// tests/Feature/Api/OrderApiTest.php
it('returns a paginated list of orders', function () {
    $user = User::factory()->create();
    $token = $user->createToken('test')->plainTextToken;

    $response = $this->withHeaders([
        'Authorization' => "Bearer $token",
        'Accept' => 'application/json',
    ])->getJson('/api/v1/orders');

    $response->assertStatus(200)
        ->assertJsonStructure([
            'status',
            'data' => [
                '*' => ['id', 'product_id', 'quantity', 'created_at']
            ],
            'meta' => ['current_page', 'last_page', 'per_page']
        ]);
});

Run the suite in CI on every pull request. If a contract breaks, the pipeline fails before code reaches production.

7. Deploy‑time considerations

  • Cache config and routes – php artisan config:cache && php artisan route:cache reduces bootstrap time.
  • Queue failed jobs – API actions that trigger emails or webhooks should be dispatched to a queue. Use php artisan queue:work --daemon under a process supervisor like Supervisor or systemd.
  • HTTPS enforcement – In App\Http\Middleware\ForceHttps, redirect any non‑TLS request. Laravel’s App\Providers\AppServiceProvider can also set URL::forceScheme('https') for generated URLs.
  • Monitoring – Export request latency and error counts to a metrics system (e.g., Prometheus) via the spatie/laravel-health package.

Takeaway checklist

  • Choose path versioning for simplicity or header versioning for clean URLs.
  • Protect every endpoint with Sanctum tokens and appropriate abilities.
  • Validate all inputs with FormRequest objects; never rely on client‑side checks.
  • Apply RateLimiter::for to enforce per‑token or per‑IP throttling.
  • Return data inside a standard JSON envelope; centralise error formatting.
  • Add contract tests to CI so breaking changes are caught early.
  • Optimize production boot time with config/route caching and enforce HTTPS.

By following these steps you’ll ship an API that scales, stays secure, and remains easy to evolve as your product grows.

  • Laravel
  • Api
  • Security
  • Versioning
  • Performance