Building a Production REST API with Laravel
Prerequisites: PHP 8.2+, Composer, Laravel 12.x, Redis (for production rate limiting and queues), basic familiarity with Eloquent and routing.
Laravel 12 (released March 2025) is a maintenance release on top of the structural changes introduced in Laravel 11. If you are starting a new API project today, the setup steps are meaningfully different from what you may remember from Laravel 9 or 10. This article walks through the decisions and implementation steps that matter most when shipping a REST API to production — authentication strategy, resource transformation, rate limiting, CORS, versioning, and testing — without rehashing the basics.
Step 1: Scaffold the API Layer
In Laravel 11 and 12, routes/api.php is not present in a fresh installation. Running php artisan install:api creates the file, installs Sanctum, registers the throttle:api middleware on the api route group, and adds the EnsureFrontendRequestsAreStateful middleware.
php artisan install:api
If you genuinely need a full OAuth2 server — authorization code flows, client credentials for machine-to-machine, or token introspection for third-party developers — swap in Passport:
php artisan install:api --passport
For the vast majority of projects (own SPA, own mobile app, internal microservice), Sanctum is the correct choice. Passport adds migrations, encryption key management, and client administration overhead that you do not need for first-party consumers.
Security note: If you are on Passport 13.0.0–13.7.0, upgrade to 13.7.1+ immediately. CVE-2026-39976 (CVSS 7.1) allows a
client_credentialsJWT token to authenticate as a real user when the client ID integer matches a user's ID. See the official advisory.
Step 2: Version Your Routes from Day One
Retrofitting versioning after launch means coordinating client updates and maintaining dual routing indefinitely. The URL prefix strategy (/api/v1/, /api/v2/) is the most debuggable and cache-friendly approach.
// routes/api.php
use Illuminate\Support\Facades\Route;
Route::prefix('v1')->group(base_path('routes/api_v1.php'));
Route::prefix('v2')->group(base_path('routes/api_v2.php'));
Keep separate controller namespaces:
App\Http\Controllers\Api\V1\PostControllerApp\Http\Controllers\Api\V2\PostController
And separate Resource classes per version so V1 and V2 response shapes evolve independently. Sharing a Resource class across versions is a false economy — when V2 requires a renamed field, you will end up with conditionals that are harder to maintain than two clean files.
Step 3: Protect Routes with Sanctum
// routes/api_v1.php
use App\Http\Controllers\Api\V1\PostController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('posts', PostController::class);
Route::get('/user', fn (Request $r) => $r->user());
});
For browser-based SPAs, use Sanctum's stateful (HttpOnly cookie) authentication — tokens stored in localStorage are vulnerable to XSS exfiltration. For mobile apps and CLI clients, use Sanctum API tokens stored in the device's secure keychain.
Step 4: Transform Responses with API Resources
Returning raw Eloquent models from controllers leaks internal field names, exposes timestamps in inconsistent formats, and makes it painful to add computed fields later. API Resources are the standard transformation layer.
php artisan make:resource PostResource
// app/Http/Resources/V1/PostResource.php
use Illuminate\Http\Resources\Json\JsonResource;
class PostResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'author' => new UserResource($this->whenLoaded('author')),
'comments_count' => $this->whenCounted('comments'),
'created_at' => $this->created_at->toISOString(),
];
}
}
Two methods worth understanding deeply:
-
whenLoaded('author')— only includes the relationship data if it was already eager-loaded. Without this, accessing$this->authorinsidetoArray()fires a lazy-load query per resource item (N+1). -
whenCounted('comments')— only includes the count ifwithCount('comments')was called on the query. Neither method triggers additional queries.
In the controller, eager-load everything the Resource needs:
// app/Http/Controllers/Api/V1/PostController.php
public function index(): AnonymousResourceCollection
{
$posts = Post::with(['author', 'tags'])
->withCount('comments')
->paginate(20);
return PostResource::collection($posts);
}
paginate() works well for small-to-medium datasets. For tables with millions of rows, switch to cursorPaginate() — it uses keyset pagination on an indexed column and does not degrade with OFFSET like paginate() does.
Step 5: Validate Inputs Safely
Always use a FormRequest and pass $request->validated() to model methods — never $request->all().
// app/Http/Requests/V1/StorePostRequest.php
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Exceptions\HttpResponseException;
class StorePostRequest extends FormRequest
{
public function authorize(): bool { return true; }
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'content' => ['required', 'string'],
];
}
protected function failedValidation(Validator $validator)
{
throw new HttpResponseException(
response()->json([
'success' => false,
'errors' => $validator->errors(),
], 422)
);
}
}
The failedValidation() override ensures API clients receive a consistent JSON envelope instead of Laravel's default 422 response format, which varies depending on the Accept header and exception handler configuration.
In the controller:
public function store(StorePostRequest $request): PostResource
{
$post = Post::create($request->validated());
return new PostResource($post->load('author'));
}
Never define $guarded = [] or call forceFill() on user-controlled input. Mass assignment attacks are entirely preventable — define $fillable explicitly on every model.
Step 6: Rate Limiting with Redis
In Laravel 11 and 12, rate limiters are configured in bootstrap/app.php (the Kernel.php approach from Laravel 10 is gone).
// bootstrap/app.php
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());
});
This applies automatically to all api.php routes via the throttle:api middleware registered during php artisan install:api.
Critical: in production, set CACHE_DRIVER=redis. Using the file or database cache driver for rate limiting creates race conditions under concurrent load and introduces disk I/O bottlenecks. The file driver also does not share state across multiple application servers.
Step 7: Configure CORS Correctly
Laravel handles CORS natively via Illuminate\Http\Middleware\HandleCors. Edit config/cors.php:
return [
'paths' => ['api/*'],
'allowed_methods' => ['*'],
'allowed_origins' => [env('FRONTEND_URL', 'https://app.example.com')],
'allowed_headers' => ['Content-Type', 'Authorization', 'X-Requested-With'],
'supports_credentials' => true,
'max_age' => 86400,
];
Do not set allowed_origins to ['*'] with supports_credentials => true. Browsers reject credentialed requests to wildcard origins (CORS spec requirement) and this configuration is a security misconfiguration regardless.
Step 8: Prevent N+1 Queries
Add this to AppServiceProvider::boot() to throw an exception on lazy-loaded relationships in non-production environments:
// app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::preventLazyLoading(! app()->isProduction());
}
This catches N+1 issues during development and staging before they reach production. Pair it with withCount() and with() calls in every controller method that returns a collection.
Step 9: Write Feature Tests
Test the HTTP contract, not implementation details. Laravel's actingAs() with the 'sanctum' guard makes authenticated API tests straightforward:
// tests/Feature/Api/V1/PostTest.php
use Illuminate\Foundation\Testing\RefreshDatabase;
test('authenticated user can create a post', function () {
$user = User::factory()->create();
$response = $this->actingAs($user, 'sanctum')
->postJson('/api/v1/posts', [
'title' => 'Hello World',
'content' => 'Body text',
]);
$response->assertStatus(201)
->assertJsonStructure(['data' => ['id', 'title']]);
});
test('unauthenticated request returns 401', function () {
$this->postJson('/api/v1/posts', ['title' => 'x'])
->assertUnauthorized();
});
test('validation rejects missing content', function () {
$user = User::factory()->create();
$this->actingAs($user, 'sanctum')
->postJson('/api/v1/posts', ['title' => 'No content'])
->assertStatus(422)
->assertJsonPath('success', false)
->assertJsonStructure(['errors' => ['content']]);
});
Use RefreshDatabase to reset state between tests. Test the 422 envelope shape explicitly — your failedValidation() override is part of the API contract.
Common Mistakes Checklist
| Mistake | Fix |
|---|---|
Skipping php artisan install:api
|
No routes/api.php exists in L11/L12 fresh installs |
$request->all() in Model::create()
|
Use $request->validated() always |
APP_DEBUG=true in production |
Full stack traces leak in JSON error responses |
allowed_origins: ['*'] with credentials |
Browsers reject it; enumerate origins explicitly |
| File/database cache for rate limiting | Use Redis; file driver has race conditions under load |
Missing whenLoaded() in Resources |
Every resource item triggers a lazy-load query (N+1) |
| No versioning from day one | Retrofitting /api/v2/ after launch is painful |
Passport 13.0.0–13.7.0 with client_credentials
|
CVE-2026-39976 — upgrade to 13.7.1+ |
Production Deployment Checklist
-
APP_DEBUG=falseandAPP_ENV=productionin.env -
CACHE_DRIVER=redis,QUEUE_CONNECTION=redis,SESSION_DRIVER=redis - Rotate
APP_KEYbefore first deploy (invalidates sessions and signed URLs) - Run
composer auditto check installed packages against the PHP Security Advisories Database - Configure Horizon with named queues (
critical,default,emails) and--max-jobs+--max-timeflags on workers to cap memory drift - Set
secureflag totrueon cookies and enforce HTTPS with HSTS headers
For a broader look at authentication strategies, pagination patterns, and Octane configuration for high-throughput APIs, see Building Production-Ready APIs with Laravel.
If you need Laravel development in Mumbai, Mumbai Web Designer builds production-grade Laravel applications.
Top comments (0)