Architecture

Multi-tenancy in Laravel REST API: Architectural Approaches, Data Isolation, and Performance

Ruslan Ismailov Published 12 min read
M

Multi-tenancy is an architectural pattern where a single application instance serves multiple independent clients (tenants) while guaranteeing complete data isolation between them. For SaaS products built on Laravel, this is one of the key architectural challenges: a poorly designed system risks data leaks, performance degradation, and a scaling nightmare.

Why Multi-tenancy Is Complex

The complexity spans several dimensions at once. First, you need to enforce strict data isolation — no tenant should ever access another tenant's data under any circumstances. Second, the system must scale horizontally without linear growth in infrastructure costs. Third, you need to maintain high REST API performance as the number of tenants grows. Finally, you need to simplify deployment, new client onboarding, and schema management.

Three Architectural Approaches

1. Single Database with a tenant_id Column

The simplest option: all tenants store data in shared tables, with each record containing a tenant_id. This approach is easy to implement but demands maximum code discipline — a single missing WHERE filter can cause a data leak.

  • Pros: minimal overhead, simple deployment, unified migration schema.
  • Cons: leak risk on developer error, difficulty sharding by tenant, shared indexes.

2. PostgreSQL Schema per Tenant (Schema-per-tenant)

PostgreSQL supports schemas — logical namespaces within a single database. Each tenant gets its own schema (tenant_alice, tenant_bob), but they all live on the same database server. Laravel can dynamically switch the search_path.

  • Pros: good isolation, shared server reduces costs, tenant-specific migrations are possible.
  • Cons: more complex migration management, PostgreSQL schema limits when dealing with thousands of tenants.

3. Separate Database per Tenant (Database-per-tenant)

Each tenant gets their own database. Maximum isolation, but also maximum overhead. Best suited for the enterprise segment where clients require isolation guarantees.

  • Pros: full isolation, ability to migrate a tenant's database, independent backups.
  • Cons: high costs with many tenants, complex deployment, thousands of database connections.

Implementation in Laravel

Middleware for Tenant Resolution

The first step is identifying the tenant on every request and storing the context. Let's create the middleware:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use App\Models\Tenant;
use App\Services\TenantManager;

class ResolveTenant
{
    public function __construct(private TenantManager $manager) {}

    public function handle(Request $request, Closure $next)
    {
        // Identify via subdomain
        $host = $request->getHost();
        $subdomain = explode('.', $host)[0];

        $tenant = Tenant::where('slug', $subdomain)->firstOrFail();
        $this->manager->setTenant($tenant);

        // For the separate DB approach — connect to the right one
        config(['database.connections.tenant.database' => $tenant->database_name]);
        DB::purge('tenant');
        DB::reconnect('tenant');

        return $next($request);
    }
}

TenantManager — Context Management Service

<?php

namespace App\Services;

use App\Models\Tenant;

class TenantManager
{
    private ?Tenant $current = null;

    public function setTenant(Tenant $tenant): void
    {
        $this->current = $tenant;
    }

    public function getTenant(): ?Tenant
    {
        return $this->current;
    }

    public function getId(): ?int
    {
        return $this->current?->id;
    }
}

Global Scope for Data Isolation

With the tenant_id approach, a global scope is an essential protection tool. It automatically adds a filtering condition to every Eloquent query:

<?php

namespace App\Scopes;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
use App\Services\TenantManager;

class TenantScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $tenantId = app(TenantManager::class)->getId();

        if ($tenantId) {
            $builder->where('tenant_id', $tenantId);
        }
    }
}

We attach the scope via a trait added to all tenant-aware models:

<?php

namespace App\Traits;

use App\Scopes\TenantScope;
use App\Services\TenantManager;

trait BelongsToTenant
{
    public static function bootBelongsToTenant(): void
    {
        static::addGlobalScope(new TenantScope());

        static::creating(function ($model) {
            $model->tenant_id = app(TenantManager::class)->getId();
        });
    }
}

Switching PostgreSQL Schemas

For the schema-per-tenant approach, we dynamically change the search_path after establishing the connection:

// In middleware after tenant resolution
$schemaName = 'tenant_' . $tenant->slug;

DB::statement("SET search_path TO {$schemaName}, public");

Migrations for a new tenant are run programmatically:

Artisan::call('migrate', [
    '--database' => 'tenant',
    '--path'     => 'database/migrations/tenant',
    '--force'    => true,
]);

Data Isolation: Preventing Leaks

The global scope protects reads, but you also need to protect updates and deletes. Add Policies and make sure the global scope is active when calling findOrFail(). Never use withoutGlobalScope(TenantScope::class) in production code without an explicit audit.

An additional layer of protection — a controller-level check:

public function update(Request $request, int $id): JsonResponse
{
    // Scope already applied — returns 404 if record belongs to another tenant
    $resource = Resource::findOrFail($id);
    $resource->update($request->validated());

    return response()->json($resource);
}

Redis Caching in a Multi-tenant Environment

When using Redis, it is critical to isolate cache keys by tenant. Use prefixes with the tenant_id or tenant slug:

<?php

namespace App\Services;

use Illuminate\Support\Facades\Cache;

class TenantCache
{
    public function __construct(private TenantManager $manager) {}

    public function key(string $key): string
    {
        return 'tenant:' . $this->manager->getId() . ':' . $key;
    }

    public function remember(string $key, int $ttl, callable $callback): mixed
    {
        return Cache::remember($this->key($key), $ttl, $callback);
    }

    public function forget(string $key): void
    {
        Cache::forget($this->key($key));
    }

    public function flush(): void
    {
        // Invalidate all tenant keys via pattern
        $pattern = 'tenant:' . $this->manager->getId() . ':*';
        $keys = Redis::keys($pattern);
        if (!empty($keys)) {
            Redis::del($keys);
        }
    }
}

An alternative option is to use separate Redis databases (database index) per tenant when the number of tenants is small. For large-scale SaaS, it's better to rely on prefixes and explicit invalidation.

REST API Design for Multi-tenancy

Tenant identification in a REST API can be done in three ways:

  1. Subdomain: alice.myapp.com/api/v1/users — intuitive and convenient for browser-based clients.
  2. HTTP Header: X-Tenant-ID: alice — suitable for B2B APIs where the client is a server-side application.
  3. JWT Claim: a tenant_id field inside the token — works well with OAuth2/Passport/Sanctum.

Example of extracting the tenant from a JWT with Laravel Sanctum:

public function handle(Request $request, Closure $next)
{
    $user = $request->user();

    if (!$user || !$user->tenant_id) {
        return response()->json(['error' => 'Tenant not found'], 403);
    }

    $tenant = Tenant::findOrFail($user->tenant_id);
    app(TenantManager::class)->setTenant($tenant);

    return $next($request);
}

Testing Multi-tenant Scenarios

Testing is a critically important part. You need to verify not only that things work correctly within a tenant, but also that there are no leaks between tenants:

<?php

namespace Tests\Feature;

use App\Models\Tenant;
use App\Models\User;
use App\Models\Order;
use App\Services\TenantManager;
use Tests\TestCase;

class TenantIsolationTest extends TestCase
{
    public function test_tenant_cannot_access_other_tenant_data(): void
    {
        $tenantA = Tenant::factory()->create();
        $tenantB = Tenant::factory()->create();

        $orderA = Order::factory()->create(['tenant_id' => $tenantA->id]);

        // Set tenant B context
        app(TenantManager::class)->setTenant($tenantB);

        // A request made as tenant B should return 404
        $userB = User::factory()->create(['tenant_id' => $tenantB->id]);
        $this->actingAs($userB)
             ->getJson("/api/v1/orders/{$orderA->id}")
             ->assertStatus(404);
    }
}

Also test: record creation (verify that tenant_id is set automatically), cache invalidation when switching context, and queue isolation (jobs must carry tenant_id and restore context when executed).

Docker Deployment: Per-tenant Configuration

When deploying to Docker with the database-per-tenant approach, use environment variables and dynamic configuration. A basic docker-compose.yml contains a single application service, while tenant configurations are stored in a central database or a configuration store (Vault, AWS Secrets Manager).

# docker-compose.yml (excerpt)
services:
  app:
    build: .
    environment:
      - APP_ENV=production
      - DB_HOST=postgres
      - DB_DATABASE=saas_central  # central DB for the tenant registry
      - REDIS_HOST=redis
    depends_on:
      - postgres
      - redis

  postgres:
    image: postgres:16
    volumes:
      - pgdata:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine

When onboarding a new tenant, run the Artisan command from CI/CD or via an admin API endpoint:

php artisan tenant:create --name="Alice Corp" --slug=alice --db=tenant_alice

Performance and Scaling

A few practical performance tips for Laravel SaaS 2026:

  • Indexes: with the shared-database approach, always create composite indexes (tenant_id, id) and (tenant_id, created_at) on all large tables.
  • Connection pooling: use PgBouncer in front of PostgreSQL, especially with database-per-tenant — this reduces connection overhead significantly.
  • Queues: store tenant_id in jobs and restore the context in the handle() method. Use separate queues per tenant under high load.
  • Read replicas: route read queries to replicas using Laravel's Database Read/Write Connections.
  • Schema caching: cache tenant configuration in Redis with a TTL of 60–300 seconds to avoid querying the central database on every HTTP request.
// Cache tenant configuration
public function resolveTenant(string $slug): Tenant
{
    return Cache::remember(
        "tenant_config:{$slug}",
        300,
        fn() => Tenant::where('slug', $slug)->firstOrFail()
    );
}

Conclusion and Strategy Selection Guidelines

Choosing a multi-tenancy architecture is always a trade-off between isolation, cost, and development complexity:

  • Shared database (tenant_id): choose this for startups and products with hundreds or thousands of SMB-segment tenants. Always use global scopes and cover isolation with test cases.
  • Schema-per-tenant (PostgreSQL): optimal for products with tens or hundreds of tenants that need better isolation without an enterprise infrastructure budget.
  • Database-per-tenant: enterprise SaaS with compliance requirements (GDPR, HIPAA), willingness to pay for infrastructure, and a small number of large clients.

Regardless of the approach chosen: treat data isolation testing as priority number one, use Redis with a tenant namespace for caching, and build in the ability to migrate between strategies — the needs of a SaaS product evolve as it grows.

Technologies

Tags

Ruslan Ismailov

Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →