Why API Design Matters in SaaS
A well-designed REST API is the backbone of any SaaS product. In my 6+ years building Laravel applications — including a large-scale UK job portal currently serving thousands of users — I've learned that the difference between a maintainable API and a legacy nightmare comes down to a handful of key practices.
This article covers the exact patterns I use in production: authentication, versioning, rate limiting, resource transformers, error handling, and more. No fluff — just working code.
Laravel 11, PHP 8.3, MySQL 8, Laravel Sanctum, Redis (for caching & rate limiting). All patterns apply to Laravel 9+ as well.
Authentication with Laravel Sanctum
For SaaS APIs, Laravel Sanctum is the right choice over Passport for most use cases. It's lightweight, supports both SPA cookie-based auth and token-based API auth, and integrates with Laravel's built-in middleware perfectly.
class AuthController extends Controller { public function login(LoginRequest $request): JsonResponse { if (!Auth::attempt($request->only('email', 'password'))) { return response()->json([ 'message' => 'Invalid credentials' ], 401); } $user = Auth::user(); $token = $user->createToken( 'api-token', ['*'], // abilities now()->addDays(30) // expiry )->plainTextToken; return response()->json([ 'token' => $token, 'user' => new UserResource($user), ]); } public function logout(Request $request): JsonResponse { $request->user()->currentAccessToken()->delete(); return response()->json(['message' => 'Logged out']); } }
Always set token expiry. Never create tokens with createToken('name') without an expiry date — this creates permanent tokens that are a security risk if leaked.
API Versioning
API versioning is non-negotiable for any SaaS product. The moment you have paying customers, you cannot break their integrations. Here's the clean URL prefix approach I use:
// ── V1 Routes ────────────────────────────── Route::prefix('v1') ->middleware(['auth:sanctum', 'throttle:api']) ->group(base_path('routes/api/v1.php')); // ── V2 Routes ────────────────────────────── Route::prefix('v2') ->middleware(['auth:sanctum', 'throttle:api']) ->group(base_path('routes/api/v2.php')); // routes/api/v1.php Route::apiResource('posts', PostController::class); Route::apiResource('users', UserController::class); Route::apiResource('jobs', JobController::class); // GET /api/v1/posts // POST /api/v1/posts // GET /api/v1/posts/{id}
Controller Namespace per Version
Keep v1 and v2 controllers in separate namespaces so they can evolve independently:
app/Http/Controllers/ ├── Api/ │ ├── V1/ │ │ ├── PostController.php // stable │ │ └── UserController.php │ └── V2/ │ ├── PostController.php // new structure │ └── UserController.php app/Http/Resources/ ├── V1/ └── PostResource.php └── V2/ └── PostResource.php
Rate Limiting
Laravel 10+ has a powerful RateLimiter facade. Define your limiters in AppServiceProvider for clean, testable rate limiting:
use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Support\Facades\RateLimiter; public function boot(): void { // Standard API: 60 req/min per user RateLimiter::for('api', function (Request $request) { return Limit::perMinute(60) ->by($request->user()?->id ?: $request->ip()); }); // Auth: 5 req/min — prevents brute force RateLimiter::for('auth', function (Request $request) { return Limit::perMinute(5) ->by($request->ip()) ->response(function() { return response()->json([ 'message' => 'Too many attempts.', 'retry_after' => 60 ], 429); }); }); // Premium: higher limits per plan tier RateLimiter::for('premium', function (Request $request) { $limit = $request->user()->isPremium() ? 500 : 60; return Limit::perMinute($limit) ->by($request->user()->id); }); }
Resource Transformers
Never return Eloquent models directly from your API. Always use API Resources. This decouples your database schema from your API contract.
class PostResource extends JsonResource { public function toArray(Request $request): array { return [ 'id' => $this->id, 'title' => $this->title, 'slug' => $this->slug, 'excerpt' => $this->excerpt, 'status' => $this->status, 'author' => new UserResource($this->whenLoaded('user')), 'tags' => TagResource::collection($this->whenLoaded('tags')), 'created_at' => $this->created_at?->toISOString(), 'updated_at' => $this->updated_at?->toISOString(), $this->mergeWhen($request->user()?->isAdmin(), [ 'internal_notes' => $this->internal_notes, 'ip_address' => $this->ip_address, ]), ]; } }
Always use whenLoaded() for relations. This prevents N+1 queries — the relation is only included if it was eager-loaded in the controller.
Consistent Error Handling
Inconsistent error responses are one of the biggest frustrations for API consumers. Every error from your API should follow the same JSON structure:
use Illuminate\Validation\ValidationException; use Illuminate\Auth\AuthenticationException; use Illuminate\Database\Eloquent\ModelNotFoundException; ->withExceptions(function (Exceptions $exceptions) { $exceptions->render(function (ValidationException $e, Request $req) { if ($req->expectsJson()) { return response()->json([ 'message' => 'Validation failed', 'errors' => $e->errors(), ], 422); } }); $exceptions->render(function (ModelNotFoundException $e, Request $req) { if ($req->expectsJson()) { return response()->json([ 'message' => 'Resource not found', ], 404); } }); $exceptions->render(function (AuthenticationException $e, Request $req) { if ($req->expectsJson()) { return response()->json([ 'message' => 'Unauthenticated', ], 401); } }); })
Standardised Response Structure
Use a base controller trait to enforce a consistent response envelope across all your endpoints:
trait ApiResponse { protected function success( $data, string $message = 'Success', int $status = 200 ): JsonResponse { return response()->json([ 'success' => true, 'message' => $message, 'data' => $data, ], $status); } protected function error( string $message, int $status = 400, array $errors = [] ): JsonResponse { return response()->json([ 'success' => false, 'message' => $message, 'errors' => $errors, ], $status); } } // In your controller: class PostController extends Controller { use ApiResponse; public function store(StorePostRequest $request): JsonResponse { $post = Post::create($request->validated()); return $this->success(new PostResource($post), 'Post created', 201); } }
Production Checklist
Before deploying any Laravel API to production, run through this checklist:
| Item | Importance | Notes |
|---|---|---|
| Sanctum / Passport auth | Critical | Never expose unprotected endpoints |
| Rate limiting | Critical | 5/min auth, 60/min API |
| API versioning | Critical | /api/v1/ prefix from day one |
| API Resources | Critical | Never expose raw Eloquent models |
| Form Request validation | Critical | Validate all input in FormRequest |
| Consistent error responses | High | Same JSON shape for all errors |
| CORS configuration | High | Whitelist only your domains |
| Response caching (Redis) | High | Cache read-heavy endpoints |
| Eager loading (N+1) | High | Use with() on all relations |
| API documentation | Medium | Scribe or L5-Swagger |
Security first: Sanctum for auth, strict rate limiting on all endpoints especially login/register, validated FormRequests for every input.
Contract stability: Version your API from day one (/api/v1/). Use API Resources as the only output layer — never raw models. This lets your database evolve independently from your API contract.
Consistency: A single error response trait, a single success wrapper, and a single versioning convention eliminates entire categories of bugs and client confusion.