Building Multi-Tenant SaaS with Row-Level Security in Laravel



This content originally appeared on DEV Community and was authored by Avinash Zala

“One machine can do the work of fifty ordinary men. No machine can do the work of one extraordinary man.” — Elbert Hubbard

Key Takeaways

  • Global Scopes are Essential: They provide automatic tenant filtering at the model level, preventing data leakage even if developers forget manual filtering.
  • Multiple Resolution Methods: Support subdomain, custom domain, and path-based tenant resolution for flexibility.
  • Defense in Depth: Implement multiple security layers: model scopes, middleware validation, authorization policies, and database constraints.
  • Performance Matters: Use proper indexing and tenant-aware caching strategies to handle scale effectively.
  • Test Thoroughly: Comprehensive testing ensures that tenant isolation works correctly across all scenarios.

Index

  1. Overview
  2. Multi-Tenancy Patterns
  3. Implementation
  4. Security Best Practices
  5. Stats
  6. Interesting Facts
  7. FAQs
  8. Conclusion

1. Overview

Multi-tenancy allows multiple customers (tenants) to share the same application while maintaining complete data isolation. Row-level security ensures each tenant accesses only their data at the database level, making it ideal for SaaS applications serving hundreds or thousands of customers.

2. Multi-Tenancy Patterns

1. Single Database, Shared Schema (Row-Level Security)

  • Best for: Large number of small tenants
  • Pros: Cost-effective, easy maintenance
  • Cons: Complex security, potential data leakage

2. Single Database, Separate Schemas

  • Best for: Medium number of medium-sized tenants
  • Pros: Better isolation, easier backups
  • Cons: Migration complexity

3. Separate Databases

  • Best for: Small number of large tenants
  • Pros: Complete isolation, compliance-friendly
  • Cons: Higher costs, maintenance overhead

“Have the courage to follow your heart and intuition.” — Steve Jobs

3. Implementation

Database Schema with the Tenant ID

CREATE TABLE tenants (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(255) NOT NULL,
    slug VARCHAR(255) UNIQUE NOT NULL,
    domain VARCHAR(255) UNIQUE
);

CREATE TABLE users (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    tenant_id BIGINT UNSIGNED NOT NULL,
    email VARCHAR(255) NOT NULL,
    name VARCHAR(255) NOT NULL,
    FOREIGN KEY (tenant_id) REFERENCES tenants(id),
    UNIQUE KEY unique_email_per_tenant (tenant_id, email)
);

Base Tenant-Aware Model

<?php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use App\Scopes\TenantScope;

abstract class TenantAwareModel extends Model
{
    protected static function booted()
    {
        static::addGlobalScope(new TenantScope);

        static::creating(function ($model) {
            if (!$model->tenant_id) {
                $model->tenant_id = auth()->user()?->tenant_id ?? app('current_tenant')?->id;
            }
        });
    }

    public function tenant()
    {
        return $this->belongsTo(Tenant::class);
    }
}

Global Tenant Scope

<?php
namespace App\Scopes;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;

class TenantScope implements Scope
{
    public function apply(Builder $builder, Model $model)
    {
        $tenantId = auth()->user()?->tenant_id ?? app('current_tenant')?->id;

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

Tenant Resolution Middleware

<?php
namespace App\Http\Middleware;

use Closure;
use App\Models\Tenant;

class ResolveTenant
{
    public function handle($request, Closure $next)
    {
        $tenant = $this->resolveTenant($request);

        if (!$tenant) {
            abort(404, 'Tenant not found');
        }

        app()->instance('current_tenant', $tenant);
        return $next($request);
    }

    protected function resolveTenant($request): ?Tenant
    {
        // Subdomain: tenant.yourapp.com
        if ($subdomain = $this->getSubdomain($request)) {
            return Tenant::where('slug', $subdomain)->first();
        }

        // Custom domain: custom.domain.com
        if ($domain = $request->getHost()) {
            return Tenant::where('domain', $domain)->first();
        }

        return null;
    }

    protected function getSubdomain($request): ?string
    {
        $parts = explode('.', $request->getHost());
        return count($parts) > 2 ? $parts[0] : null;
    }
}

Controller Example

<?php
namespace App\Http\Controllers;

use App\Models\Project;
use Illuminate\Http\Request;

class ProjectController extends Controller
{
    public function index()
    {
        // Automatically filtered by tenant scope
        $projects = Project::paginate(15);
        return view('projects.index', compact('projects'));
    }

    public function store(Request $request)
    {
        $validated = $request->validate([
            'name' => 'required|string|max:255',
            'description' => 'nullable|string',
        ]);

        // tenant_id automatically set
        $project = Project::create($validated);
        return redirect()->route('projects.show', $project);
    }
}

Testing Tenant Isolation

public function test_users_can_only_see_their_tenant_projects()
{
    $tenant1 = Tenant::factory()->create();
    $tenant2 = Tenant::factory()->create();

    $user1 = User::factory()->create(['tenant_id' => $tenant1->id]);
    $project1 = Project::factory()->create(['tenant_id' => $tenant1->id]);
    $project2 = Project::factory()->create(['tenant_id' => $tenant2->id]);

    app()->instance('current_tenant', $tenant1);
    $this->actingAs($user1);

    $response = $this->get('/projects');
    $response->assertSee($project1->name);
    $response->assertDontSee($project2->name);
}

4. Security Best Practices

1. Always Use Global Scopes
Never rely on manual tenant filtering in controllers. Global scopes provide automatic, fail-safe protection against data leakage.

2. Double-Check Sensitive Operations

public function delete(Project $project)
{
    if ($project->tenant_id !== auth()->user()->tenant_id) {
        abort(403);
    }
    $project->delete();
}

3. Validate Tenant Context

class TenantValidationMiddleware
{
    public function handle($request, Closure $next)
    {
        if (auth()->check()) {
            $userTenant = auth()->user()->tenant_id;
            $currentTenant = app('current_tenant')?->id;

            if ($userTenant !== $currentTenant) {
                auth()->logout();
                abort(403, 'Tenant mismatch');
            }
        }
        return $next($request);
    }
}

4. Proper Database Indexing

-- Essential for performance
CREATE INDEX idx_projects_tenant_created ON projects(tenant_id, created_at);
CREATE INDEX idx_users_tenant_email ON users(tenant_id, email);

5. Tenant-Aware Caching

class TenantCacheManager

{
    public static function remember($key, $ttl, $callback)
    {
        $tenantId = app('current_tenant')?->id;
        $cacheKey = "tenant:{$tenantId}:{$key}";
        return cache()->remember($cacheKey, $ttl, $callback);
    }
}

“Science without religion is lame, religion without science is blind.” — Albert Einstein

5. Stats

  • Market Growth: The global SaaS market is projected to reach $716.52 billion by 2028 (Source: Fortune Business Insights)
  • Multi-Tenancy Adoption: 73% of organizations plan to move most applications to SaaS by 2025 (Source: Gartner)
  • Cost Efficiency: Multi-tenant architectures can reduce infrastructure costs by 30–50% compared to single-tenant deployments (Source: AWS Architecture Center)
  • Laravel Ecosystem: Laravel powers over 1.5 million websites globally, making it a popular choice for SaaS development (Source: BuiltWith)

6. Interesting Facts

  • Salesforce Pioneer: Salesforce popularized the multi-tenant SaaS model in 1999, serving multiple customers from a single application instance.
  • Netflix Scale: Netflix uses a multi-tenant microservices architecture serving over 230 million subscribers across 190+ countries.
  • Database Efficiency: Row-level security can handle 1000+ tenants per database instance efficiently with proper indexing.
  • Laravel Performance: With optimized queries and caching, Laravel multi-tenant applications can serve 10,000+ concurrent users per server.
  • Security Record: Properly implemented row-level security has a 99.9% success rate in preventing cross-tenant data access.

7. FAQs

Q: When should I choose row-level security over separate databases?
A: Choose row-level security when you have 100+ small to medium tenants. It’s cost-effective and easier to maintain. Use separate databases for large enterprise clients requiring strict compliance.

Q: How do I handle tenant-specific customizations?
A: Store configuration data in tenant-specific tables, use feature flags, or implement a plugin system that respects tenant boundaries.

Q: What about database performance with many tenants?
A: Implement proper composite indexing on (tenant_id, frequently_queried_columns), use database query optimization, and consider read replicas for heavy workloads.

Q: How do I migrate existing single-tenant applications?
A: Add tenant_id columns gradually, implement global scopes, update authentication logic, and migrate data in batches with thorough testing.

Q: How do I handle tenant onboarding and provisioning?
A: Create automated provisioning services that set up tenant records, default users, sample data, and configure tenant-specific settings atomically.

8. Conclusion

Building secure multi-tenant SaaS applications in Laravel requires careful planning and implementation of robust security measures. The row-level security pattern with global scopes provides an excellent balance of cost-effectiveness, maintainability, and security for most use cases.

Key success factors include implementing automatic tenant filtering through global scopes, using multiple tenant resolution methods for flexibility, maintaining defense-in-depth security practices, and ensuring comprehensive testing coverage.

With proper implementation, this architecture can scale to serve thousands of tenants efficiently while maintaining strict data isolation and security. The Laravel ecosystem provides excellent tools and patterns to build production-ready multi-tenant applications that can grow with your business needs.

Remember that security is paramount in multi-tenant systems. Always test tenant isolation thoroughly, implement multiple layers of protection, and stay updated with security best practices as your application evolves.

About the Author: Avinash is a web developer since 2008. Currently working at AddWebSolution, where he’s passionate about clean code, modern technologies, and building tools that make the web better.


This content originally appeared on DEV Community and was authored by Avinash Zala