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.

ℹ️ Stack Used

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.

AuthController.php
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']);
    }
}
⚠️ Token Expiry

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:

routes/api.php
// ── 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:

Directory Structure
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:

AppServiceProvider.php
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.

app/Http/Resources/V1/PostResource.php
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,
            ]),
        ];
    }
}
✅ Pro Tip: whenLoaded()

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:

bootstrap/app.php · Laravel 11
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:

app/Traits/ApiResponse.php
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:

ItemImportanceNotes
Sanctum / Passport authCriticalNever expose unprotected endpoints
Rate limitingCritical5/min auth, 60/min API
API versioningCritical/api/v1/ prefix from day one
API ResourcesCriticalNever expose raw Eloquent models
Form Request validationCriticalValidate all input in FormRequest
Consistent error responsesHighSame JSON shape for all errors
CORS configurationHighWhitelist only your domains
Response caching (Redis)HighCache read-heavy endpoints
Eager loading (N+1)HighUse with() on all relations
API documentationMediumScribe or L5-Swagger
🏁 Key Takeaways

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.

👨‍💻
Sonu Sahani
Full Stack Developer · CodeCraft Systems

6+ years building production Laravel APIs, Vue.js SPAs & cloud SaaS products. Currently working with Lifelancer (UK) — a large-scale job portal serving thousands of users. Writing about real patterns I use every day.