Complete Guide to Architecting a Scalable SaaS Platform

Avatar
M

Maksudur Rahman

Software Engineer•
5Views
5mRead
0Reactions

Overview

This tutorial walks you through a production‑ready architecture for a multi‑tenant SaaS product built with Laravel, Next.js, and PostgreSQL. We cover data isolation, type‑safe request handling, domain‑driven services, asynchronous AI‑powered jobs, and automated CI/CD pipelines.


System Architecture

flowchart TD
    A([Client Request]) --> B[Next.js Edge API]
    B --> C[Laravel API Gateway]
    C --> D[FormRequest Validation]
    D --> E[Thin Controller]
    E --> F[Typed Service / Action]
    F --> G[(DB Transaction – PostgreSQL Schema per Tenant)]
    G --> H[Domain Event Dispatch]
    H --> I[Queue Worker (AI Content Generation)]
    I --> J[Cache / Search Index]
    J --> K[Standard ApiResponse JSON]

The diagram mirrors the flow described in our internal guide on microservices with Laravel and Next.js: /articles/building-scalable-microservices-with-laravel-and-nextjs-2026-guide.


1. Multi‑Tenant Data Isolation

We isolate each tenant in its own PostgreSQL schema. Laravel’s Schema facade lets us switch schemas at runtime.

// File: app/Providers/TenantServiceProvider.php
<?php
namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\DB;

class TenantServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        // Resolve tenant identifier from sub‑domain or JWT claim
        $tenantId = request()->header('X‑Tenant‑Id');
        if ($tenantId) {
            DB::statement("SET search_path TO tenant_{$tenantId}, public");
        }
    }
}

Migration Example

// File: database/migrations/2026_01_01_000000_create_tenant_schema.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

return new class extends Migration {
    public function up(): void
    {
        $tenantId = env('DEFAULT_TENANT_ID', 1);
        DB::statement("CREATE SCHEMA IF NOT EXISTS tenant_{$tenantId}");
        DB::statement("SET search_path TO tenant_{$tenantId}, public");
        // Run the core migrations inside the tenant schema
        $this->call('migrate', ['--path' => '/database/migrations/tenant']);
    }

    public function down(): void
    {
        $tenantId = env('DEFAULT_TENANT_ID', 1);
        DB::statement("DROP SCHEMA IF EXISTS tenant_{$tenantId} CASCADE");
    }
};

Running the migration creates an isolated schema tenant_1 that holds all tenant‑specific tables.


2. Request Validation & Type Safety

Laravel FormRequests enforce validation and authorization before the controller is invoked.

// File: app/Http/Requests/ProjectCreateRequest.php
<?php
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class ProjectCreateRequest extends FormRequest
{
    public function authorize(): bool
    {
        // Example: only allow users with the "manage‑projects" permission
        return $this->user()->can('manage-projects');
    }

    public function rules(): array
    {
        return [
            'title'       => 'required|string|max:150',
            'description' => 'nullable|string',
            'status'      => 'required|in:active,draft,archived',
        ];
    }
}

The controller receives a validated payload via $request->validated() guaranteeing type safety.


3. Domain Service & Event Dispatch

Business logic lives in a service class. After a successful mutation we fire a domain event that downstream listeners (e.g., AI content generator) can react to.

// File: app/Services/ProjectService.php
<?php
namespace App\Services;

use App\Events\ProjectCreated;
use App\Models\Project;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

class ProjectService
{
    public function create(array $data): Project
    {
        return DB::transaction(function () use ($data) {
            $project = Project::create($data);
            Log::info('Project created', ['id' => $project->id]);
            ProjectCreated::dispatch($project);
            return $project;
        });
    }
}
// File: app/Events/ProjectCreated.php
<?php
namespace App\Events;

use App\Models\Project;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class ProjectCreated
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public Project $project;

    public function __construct(Project $project)
    {
        $this->project = $project;
    }
}

4. Asynchronous AI‑Enhanced Content Generation

A queued job consumes the ProjectCreated event, calls an external AI service, and stores the generated copy.

// File: app/Jobs/GenerateProjectCopy.php
<?php
namespace App\Jobs;

use App\Models\Project;
use GuzzleHttp\Client;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class GenerateProjectCopy implements ShouldQueue
{
    use Queueable, InteractsWithQueue, SerializesModels;

    public function __construct(public Project $project) {}

    public function handle(): void
    {
        $client = new Client(['base_uri' => 'https://api.example.com']);
        $response = $client->post('/generate', [
            'json' => ['prompt' => $this->project->description]
        ]);
        $copy = json_decode($response->getBody()->getContents(), true)['text'];
        $this->project->update(['generated_copy' => $copy]);
    }
}

The job is automatically queued when the ProjectCreated event is broadcasted:

// File: app/Listeners/QueueProjectCopy.php
<?php
namespace App\Listeners;

use App\Events\ProjectCreated;
use App\Jobs\GenerateProjectCopy;

class QueueProjectCopy
{
    public function handle(ProjectCreated $event): void
    {
        GenerateProjectCopy::dispatch($event->project);
    }
}

5. Thin Controller & Standardized JSON Response

We use the Maksudur/ApiResponse package to keep responses consistent.

// File: app/Http/Controllers/Api/ProjectController.php
<?php
namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Requests\ProjectCreateRequest;
use App\Services\ProjectService;
use Illuminate\Http\JsonResponse;
use Maksudur\ApiResponse\ApiResponse;

class ProjectController extends Controller
{
    public function __construct(protected ProjectService $service) {}

    public function store(ProjectCreateRequest $request): JsonResponse
    {
        $project = $this->service->create($request->validated());
        return ApiResponse::success($project, 'Project created successfully.');
    }
}

Expected output (JSON):

{
  "status": "success",
  "message": "Project created successfully.",
  "data": {
    "id": 42,
    "title": "New SaaS Feature",
    "status": "draft",
    "created_at": "2026-11-05T12:34:56Z"
  }
}

6. CI/CD Automation (GitHub Actions)

A minimal pipeline builds the Laravel container, runs tests, and deploys to a Kubernetes namespace.

# File: .github/workflows/ci-cd.yml
name: CI/CD Pipeline
on:
  push:
    branches: [ main ]
jobs:
  build-test-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Set up PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.2'
          extensions: pgsql, intl, zip
      - name: Install Composer dependencies
        run: composer install --no-interaction --prefer-dist --optimize-autoloader
      - name: Run PHPUnit
        run: vendor/bin/phpunit --coverage-text
      - name: Build Docker image
        run: |
          docker build -t ghcr.io/yourorg/saas-platform:${{ github.sha }} .
      - name: Push to registry
        uses: docker/login-action@v2
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - name: Deploy to Kubernetes
        uses: azure/k8s-deploy@v4
        with:
          manifests: |
            k8s/deployment.yaml
            k8s/service.yaml
          images: |
            ghcr.io/yourorg/saas-platform:${{ github.sha }}

The workflow mirrors best practices described in our article on fixing Laravel queue workers after deploy: /articles/laravel-queue-workers-stuck-after-deploy.


7. Performance & Security Best Practices

  • Query Caching – Use Cache::remember() for read‑heavy endpoints.

  • Strict Types – Add declare(strict_types=1); at the top of every PHP file.

  • Eager Loading – Prevent N+1 queries with $model->load(['relationA', 'relationB']).

  • Database Row‑Level Security – Leverage PostgreSQL ROW SECURITY policies per tenant schema.

  • Rate Limiting – Apply Laravel’s ThrottleRequests middleware on public API routes.


  • Frequently Asked Questions

    How does the service layer differ from a repository pattern?

    The service layer orchestrates use‑cases and transaction boundaries, while repositories encapsulate raw data access. Combining both yields clear separation of concerns.

    What is the recommended way to switch tenant schemas in a request?

    Inject a middleware that reads the tenant identifier (sub‑domain, JWT claim, or header) and executes DB::statement('SET search_path TO tenant_{id}, public') before the request reaches the controller.

    How can I ensure CI/CD does not break queued workers after a deploy?

    Include a step that gracefully restarts Laravel Horizon or the queue supervisor after the new container is live, as outlined in the "Fix laravel queue workers stuck after deploy" article.


    Related Resources

    • Building Scalable Microservices with Laravel and Next.js: 2026 Guide – Deep dive into service boundaries and API gateway patterns.

    • Complete Guide to Service Repository Pattern Vs Direct Eloquent – Compare architectural trade‑offs.

    • Composer detected issues in your platform – Ensure PHP version compatibility.

    • OpenAI GPT‑5 Rumors – Stay ahead with upcoming multimodal AI capabilities.


    Continue building robust, multi‑tenant SaaS platforms with confidence.

    Recommended Resources & Courses

    Partner Links

    React to this article