You've built the endpoints, wired up the controllers, and the happy path works. Then someone hammers your API with 10,000 requests in a minute, a client ships a breaking change to mobile users on v1, and a rogue token gives third-party access to everything it shouldn't. This is where most "RESTful API" tutorials stop — and where real API design begins.
This article goes beyond the basics. We'll cover Laravel Sanctum token scopes, building genuinely useful rate limiting that doesn't punish legitimate users, and a versioning strategy that scales without turning your routes file into spaghetti.
Authentication with Laravel Sanctum: Scopes and Token Abilities
Sanctum is the right tool for most Laravel APIs — it's lightweight, integrates with your existing users table, and supports both SPA cookie auth and token-based auth. But most implementations ignore token abilities entirely.
Creating Tokens with Abilities
// Issue a token with specific abilities
$token = $user->createToken('mobile-app', ['products:read', 'orders:create']);
return response()->json([
'token' => $token->plainTextToken,
]);
Checking Abilities in Controllers
public function store(Request $request)
{
if (!$request->user()->tokenCan('orders:create')) {
abort(403, 'Token lacks required ability: orders:create');
}
// proceed with order creation
}
Or use a dedicated middleware approach with a reusable ability check:
// app/Http/Middleware/RequireTokenAbility.php
public function handle(Request $request, Closure $next, string $ability): Response
{
if (!$request->user()?->tokenCan($ability)) {
return response()->json(['message' => 'Insufficient token permissions.'], 403);
}
return $next($request);
}
Register it in bootstrap/app.php (Laravel 11+):
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'token.ability' => RequireTokenAbility::class,
]);
})
Now your routes self-document their permission requirements:
Route::post('/orders', [OrderController::class, 'store'])
->middleware('token.ability:orders:create');
Granular Rate Limiting That Actually Makes Sense
The default throttle:60,1 is a blunt instrument. A read-heavy dashboard client and a bulk import tool have very different legitimacy profiles. Laravel's RateLimiter facade lets you build context-aware limits.
Defining Named Limiters
In AppServiceProvider::boot() (or a dedicated RateLimitServiceProvider):
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(20)->by($request->ip());
});
RateLimiter::for('exports', function (Request $request) {
return [
Limit::perMinute(5)->by($request->user()->id),
Limit::perDay(50)->by($request->user()->id),
];
});
RateLimiter::for('auth', function (Request $request) {
return Limit::perMinute(10)->by($request->ip())
->response(function () {
return response()->json([
'message' => 'Too many login attempts. Try again in a moment.'
], 429);
});
});
Applying Limiters to Route Groups
Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
Route::get('/products', [ProductController::class, 'index']);
Route::get('/orders', [OrderController::class, 'index']);
});
Route::middleware(['auth:sanctum', 'throttle:exports'])->group(function () {
Route::get('/orders/export', [ExportController::class, 'orders']);
});
Route::middleware('throttle:auth')->group(function () {
Route::post('/login', [AuthController::class, 'login']);
});
The X-RateLimit-Limit, X-RateLimit-Remaining, and Retry-After headers are automatically injected by Laravel's throttle middleware — make sure your API clients are reading and respecting them.
API Versioning Without the Chaos
Versioning is one of those things that feels over-engineered until you ship a breaking change and break three client apps at once. There are two dominant patterns in Laravel: URL prefix versioning and header-based versioning. URL prefixing wins on debuggability — you can see the version in logs, browser tabs, and Postman without extra config.
Folder and Namespace Structure
app/Http/Controllers/
├── Api/
│ ├── V1/
│ │ ├── ProductController.php
│ │ └── OrderController.php
│ └── V2/
│ ├── ProductController.php
│ └── OrderController.php
Route Files
// routes/api.php
use App\Http\Controllers\Api;
Route::prefix('v1')->name('api.v1.')->group(function () {
Route::apiResource('products', Api\V1\ProductController::class);
Route::apiResource('orders', Api\V1\OrderController::class);
});
Route::prefix('v2')->name('api.v2.')->group(function () {
Route::apiResource('products', Api\V2\ProductController::class);
Route::apiResource('orders', Api\V2\OrderController::class);
});
Sharing Logic Between Versions with Base Controllers
Don't duplicate code — extend a versioned base from a shared abstract:
// app/Http/Controllers/Api/BaseProductController.php
abstract class BaseProductController extends Controller
{
protected function formatProduct(Product $product): array
{
return [
'id' => $product->id,
'name' => $product->name,
'price' => $product->price,
];
}
}
// app/Http/Controllers/Api/V2/ProductController.php
class ProductController extends BaseProductController
{
public function index()
{
return ProductResource::collection(
Product::with('category')->paginate()
);
}
}
V2 adds category data and pagination; V1 keeps the original flat response. Both extend the same base — changes to shared logic propagate correctly.
API Resources: Your Contract with Consumers
Never return Eloquent models directly from an API. JsonResource classes act as a transformation and documentation layer:
// app/Http/Resources/V2/ProductResource.php
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'price' => number_format($this->price, 2),
'currency' => 'AED',
'category' => new CategoryResource($this->whenLoaded('category')),
'created_at' => $this->created_at->toIso8601String(),
];
}
The whenLoaded() helper prevents N+1 and avoids over-fetching — the field only appears if the relationship was eager-loaded.
Practical Considerations for Production
Token rotation: Set token expiry and build a /auth/refresh endpoint. Long-lived tokens are a liability.
Structured error responses: Be consistent. Pick a shape and stick to it:
{
"message": "Validation failed.",
"errors": {
"email": ["The email field is required."]
}
}
Deprecation headers: When you ship v3, start sending Deprecation: true and Sunset: Sat, 01 Jan 2026 00:00:00 GMT headers on v1 responses. Clients that read headers will catch it early.
Logging: Attach the authenticated user ID and token ID to your log context inside a middleware so every log line is traceable:
Logger::withContext([
'user_id' => $request->user()?->id,
'token_id' => $request->user()?->currentAccessToken()?->id,
]);
A colleague who works as a Dubai web developer once pointed out that the most expensive API bugs in client projects were never about logic — they were about missing authentication checks on a single route, a rate limiter that only applied to half the endpoints, and a versioning strategy that was never enforced. The infrastructure around your business logic matters as much as the logic itself.
Conclusion
Building a reliable Laravel API means thinking in layers: authentication that's granular enough to limit blast radius when a token leaks, rate limiting that's smart enough to distinguish users from bots, and versioning that lets you ship breaking changes without breaking anyone. Each of these is independently simple — the craft is in wiring them together consistently from day one rather than retrofitting them after your first incident.
Start with Sanctum abilities even if you don't need them yet. Define named rate limiters from day one. Create the V1 namespace before you have a V2. Future you will be grateful.
Top comments (0)