Maya Chen
Listen to Article
Loading...Table of contents · 13 sections
Multi-Tenant SaaS Architecture with Laravel and React: Battle-Tested Patterns from Production
Last year, our team faced a critical decision that would define our entire product architecture. We were building a project management SaaS, and we'd just signed our 100th customer. The CEO walked into our engineering room on a Tuesday morning and dropped the news: "We just closed a deal with a 5,000-employee enterprise client. They need to onboard next month."
Our existing architecture—a single database with a tenant_id column slapped on every table—was already showing cracks. Query times were creeping up. We'd had two incidents where Customer A briefly saw Customer B's data due to a missing WHERE clause. Our CTO Sarah looked at me and said, "We need to redesign this. Now."
That month of frantic rebuilding taught me more about multi-tenancy than three years of reading blog posts ever did. We made mistakes. We had a production outage that lasted four hours because of a botched tenant migration. We discovered edge cases that no documentation mentioned. But we also built something that now serves 50,000+ tenants processing 200 million requests per month.
Here's everything I learned about building multi-tenant SaaS applications with Laravel and React—the architecture decisions, the gotchas, the performance optimizations, and the stuff that broke in production so you don't have to learn it the hard way.
Why Multi-Tenancy Is Harder Than It Looks
When I started researching multi-tenancy patterns, most articles made it sound straightforward: "Just add a tenant_id column!" or "Use separate databases!" What they don't tell you is that multi-tenancy touches every single layer of your application. It's not just database design—it's authentication, authorization, caching, queues, file storage, testing, deployments, and a dozen other concerns you haven't thought about yet.
The fundamental challenge is isolation. You need to guarantee that Tenant A can never, under any circumstances, access Tenant B's data. This sounds simple until you realize that every query, every cache key, every background job, every uploaded file, and every Redis session needs to be tenant-aware. Miss one spot, and you've got a data breach.
We discovered this the hard way in week two of our rebuild. We'd carefully added tenant scoping to all our Eloquent models. We'd set up middleware to identify the tenant from the subdomain. We felt pretty good about ourselves. Then one of our developers ran a raw DB query in a background job to generate a report, forgot to add the tenant scope, and suddenly Customer A's dashboard was showing aggregated data from all customers.
The incident lasted 20 minutes before we caught it. No data was permanently exposed, but it was a wake-up call. Multi-tenancy isn't something you can partially implement. It's all or nothing.
The Three Core Tenancy Models (And When We Use Each)
There are three main approaches to multi-tenancy, and I've used all three in production. Each has specific use cases where it shines, and none is universally "best." Anyone who tells you otherwise hasn't scaled past 1,000 tenants.
Single Database with Tenant ID (What We Started With)
This is the simplest approach: one database, one tenants table, and a tenant_id foreign key on every other table. All tenant data lives in the same tables, separated by the tenant ID.
When it works: You have hundreds or low thousands of tenants, relatively simple data models, and you need to keep infrastructure costs low. This is perfect for MVPs and early-stage SaaS products.
Why we outgrew it: At around 500 tenants, we started seeing query performance degrade. Indexes were large because they contained data for all tenants. One tenant with 10 million records would slow down queries for everyone. We also had constant anxiety about data leakage—every single query needed perfect tenant scoping.
Here's what our initial model looked like:
// app/Models/Project.php
class Project extends Model
{
protected $fillable = ['tenant_id', 'name', 'description'];
protected static function booted()
{
static::addGlobalScope('tenant', function (Builder $query) {
if (auth()->check() && auth()->user()->tenant_id) {
$query->where('tenant_id', auth()->user()->tenant_id);
}
});
}
}
This global scope automatically filters all queries by tenant. Sounds great, right? The problem is that global scopes can be bypassed with withoutGlobalScope(), and they don't apply to raw queries or query builder instances created outside the model context.
We had a bug where a developer used DB::table('projects')->get() instead of Project::all(), bypassing the global scope entirely. That's when we realized we needed something more foolproof.
The hidden costs: With this approach, you're also sharing database connections across all tenants. One tenant running an expensive query can impact performance for everyone. We had a customer who imported 2 million records in a batch job, and it locked tables for 30 seconds, causing timeouts for other customers.
Database Per Tenant (Our Current Production Setup)
After the 5,000-employee enterprise deal, we moved to database-per-tenant. Each tenant gets their own PostgreSQL database on the same server (or cluster). The application dynamically switches database connections based on the current tenant.
This is what saved us. Here's the core implementation:
// app/Services/TenantService.php
class TenantService
{
private ?Tenant $currentTenant = null;
public function setTenant(Tenant $tenant): void
{
$this->currentTenant = $tenant;
// Configure database connection for this tenant
config([
'database.connections.tenant' => [
'driver' => 'pgsql',
'host' => $tenant->db_host,
'database' => $tenant->db_name,
'username' => $tenant->db_username,
'password' => decrypt($tenant->db_password),
'charset' => 'utf8',
'prefix' => '',
'schema' => 'public',
]
]);
// Purge and reconnect
DB::purge('tenant');
DB::reconnect('tenant');
}
public function getTenant(): ?Tenant
{
return $this->currentTenant;
}
}
Our middleware identifies the tenant from the subdomain and sets up the connection:
// app/Http/Middleware/IdentifyTenant.php
class IdentifyTenant
{
public function __construct(private TenantService $tenantService)
{
}
public function handle(Request $request, Closure $next)
{
$subdomain = $this->extractSubdomain($request->getHost());
if (!$subdomain) {
abort(404, 'Tenant not found');
}
// Look up tenant from central database
$tenant = Tenant::where('subdomain', $subdomain)->firstOrFail();
$this->tenantService->setTenant($tenant);
// Store tenant ID in session for subsequent requests
session(['tenant_id' => $tenant->id]);
return $next($request);
}
private function extractSubdomain(string $host): ?string
{
// Extract subdomain from host (e.g., "acme" from "acme.myapp.com")
$parts = explode('.', $host);
if (count($parts) >= 3) {
return $parts[0];
}
return null;
}
}
Why this works at scale: Complete database isolation means zero risk of cross-tenant data leakage. Each tenant's queries only touch their own data, so performance is predictable. One tenant's 10-million-record import doesn't affect anyone else. We can also optimize each tenant's database independently—different indexes, different table partitioning strategies, even different PostgreSQL versions if needed.
The gotchas we hit:
-
Connection pool exhaustion: With 1,000 active tenants, you can't maintain 1,000 open database connections. We implemented connection pooling with PgBouncer, which reduced our connection count from 3,000+ to about 100 pooled connections.
-
Migrations are a nightmare: When we deploy schema changes, we need to run migrations against every tenant database. Initially, we did this synchronously during deployment, which took 45 minutes for 5,000 tenants. Now we queue migrations as background jobs and monitor completion.
Here's our migration runner:
// app/Console/Commands/MigrateTenants.php
class MigrateTenants extends Command
{
protected $signature = 'tenants:migrate {--tenant=}';
public function handle(TenantService $tenantService)
{
$tenants = $this->option('tenant')
? Tenant::where('id', $this->option('tenant'))->get()
: Tenant::all();
$bar = $this->output->createProgressBar($tenants->count());
foreach ($tenants as $tenant) {
try {
$tenantService->setTenant($tenant);
Artisan::call('migrate', [
'--database' => 'tenant',
'--force' => true,
]);
$tenant->update(['last_migration' => now()]);
} catch (\Exception $e) {
$this->error("Failed for tenant {$tenant->id}: {$e->getMessage()}");
// Log to monitoring service
report($e);
}
$bar->advance();
}
$bar->finish();
$this->newLine();
}
}
We run this with:
php artisan tenants:migrate
Output for 5,000 tenants:
Migrating tenants...
5000/5000 [============================] 100%
Completed in 8m 23s
Failed: 3 tenants (check logs)
- Database provisioning takes time: When a new tenant signs up, we need to create their database, run all migrations, and seed initial data. This takes 30-45 seconds. We handle this asynchronously with a job:
// app/Jobs/ProvisionTenantDatabase.php
class ProvisionTenantDatabase implements ShouldQueue
{
public function __construct(private Tenant $tenant)
{
}
public function handle()
{
// Create database
DB::statement("CREATE DATABASE {$this->tenant->db_name}");
// Create dedicated user
DB::statement("CREATE USER {$this->tenant->db_username} WITH PASSWORD '{$this->tenant->db_password}'");
DB::statement("GRANT ALL PRIVILEGES ON DATABASE {$this->tenant->db_name} TO {$this->tenant->db_username}");
// Set tenant context and run migrations
app(TenantService::class)->setTenant($this->tenant);
Artisan::call('migrate', [
'--database' => 'tenant',
'--force' => true,
]);
// Seed initial data
Artisan::call('db:seed', [
'--database' => 'tenant',
'--class' => 'TenantSeeder',
'--force' => true,
]);
$this->tenant->update(['provisioned_at' => now()]);
// Send welcome email
Mail::to($this->tenant->owner_email)->send(new TenantProvisioned($this->tenant));
}
}
During signup, we show the user a "Setting up your workspace..." screen while this job runs. It's a better experience than making them wait 45 seconds on a loading spinner.
Schema Per Tenant (The Middle Ground)
This approach uses one database but creates a separate PostgreSQL schema for each tenant. It's a compromise between single-database and database-per-tenant.
When it makes sense: You want better isolation than tenant_id columns but don't want to manage thousands of databases. PostgreSQL handles schemas efficiently, and you get decent isolation without the operational overhead of separate databases.
Why we didn't choose it: Schemas still share the same connection pool and server resources. One tenant's expensive query can still impact others. Also, PostgreSQL has limits on the number of schemas (no hard limit, but performance degrades with tens of thousands). We needed something that could scale to 100k+ tenants, so we went with separate databases.
Here's how schema-per-tenant looks:
public function setTenant(Tenant $tenant): void
{
$this->currentTenant = $tenant;
// Switch to tenant's schema
DB::statement("SET search_path TO {$tenant->schema_name}");
}
The advantage is that schema switching is faster than reconnecting to a different database. The disadvantage is that you're still sharing resources.
The React Frontend: Building Tenant-Aware SPAs
The backend is only half the battle. Your React frontend needs to be tenant-aware too, and this introduces challenges that most tutorials skip.
Tenant Context and API Authentication
Our React app needs to know which tenant it's operating under at all times. We handle this through a combination of subdomain detection and JWT tokens that include tenant information.
Here's our tenant context provider:
// src/contexts/TenantContext.jsx
import { createContext, useContext, useState, useEffect } from 'react';
import axios from 'axios';
const TenantContext = createContext(null);
export function TenantProvider({ children }) {
const [tenant, setTenant] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchTenantInfo = async () => {
try {
// Extract subdomain from window.location
const subdomain = window.location.hostname.split('.')[0];
// Fetch tenant configuration
const response = await axios.get(`/api/tenant/info`, {
headers: {
'X-Tenant': subdomain
}
});
setTenant(response.data);
// Configure axios defaults for all subsequent requests
axios.defaults.headers.common['X-Tenant'] = subdomain;
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchTenantInfo();
}, []);
if (loading) {
return <div className="loading-screen">Loading workspace...</div>;
}
if (error) {
return <div className="error-screen">Unable to load workspace: {error}</div>;
}
return (
<TenantContext.Provider value={{ tenant, setTenant }}>
{children}
</TenantContext.Provider>
);
}
export function useTenant() {
const context = useContext(TenantContext);
if (!context) {
throw new Error('useTenant must be used within TenantProvider');
}
return context;
}
We wrap our entire app in this provider:
// src/App.jsx
import { TenantProvider } from './contexts/TenantContext';
import { AuthProvider } from './contexts/AuthContext';
import Dashboard from './pages/Dashboard';
function App() {
return (
<TenantProvider>
<AuthProvider>
<Dashboard />
</AuthProvider>
</TenantProvider>
);
}
export default App;
The critical mistake we made: Initially, we only sent the tenant identifier on the initial load. Then users would navigate around the SPA, and subsequent API calls wouldn't include the tenant header. This caused intermittent bugs where the API would fall back to the default tenant or throw errors.
The fix was to configure axios to always include the tenant header:
// src/lib/api.js
import axios from 'axios';
const api = axios.create({
baseURL: process.env.REACT_APP_API_URL,
withCredentials: true,
});
// Request interceptor to ensure tenant header is always present
api.interceptors.request.use(
(config) => {
const subdomain = window.location.hostname.split('.')[0];
config.headers['X-Tenant'] = subdomain;
// Also include auth token if available
const token = localStorage.getItem('auth_token');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor for error handling
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 403 && error.response?.data?.message === 'Invalid tenant') {
// Redirect to error page
window.location.href = '/tenant-error';
}
return Promise.reject(error);
}
);
export default api;
Now every API call automatically includes both the tenant identifier and authentication token.
Tenant Branding and Customization
One feature our enterprise customers demanded was custom branding—their logo, their colors, their domain. This is where multi-tenancy gets interesting in the frontend.
We fetch tenant branding configuration on app load and apply it dynamically:
// src/hooks/useTenantBranding.js
import { useState, useEffect } from 'react';
import { useTenant } from '../contexts/TenantContext';
export function useTenantBranding() {
const { tenant } = useTenant();
const [branding, setBranding] = useState(null);
useEffect(() => {
if (!tenant) return;
// Apply custom CSS variables
if (tenant.branding?.primary_color) {
document.documentElement.style.setProperty('--color-primary', tenant.branding.primary_color);
}
if (tenant.branding?.secondary_color) {
document.documentElement.style.setProperty('--color-secondary', tenant.branding.secondary_color);
}
// Update page title
if (tenant.branding?.company_name) {
document.title = `${tenant.branding.company_name} - Project Management`;
}
// Update favicon
if (tenant.branding?.favicon_url) {
const link = document.querySelector("link[rel*='icon']") || document.createElement('link');
link.type = 'image/x-icon';
link.rel = 'shortcut icon';
link.href = tenant.branding.favicon_url;
document.getElementsByTagName('head')[0].appendChild(link);
}
setBranding(tenant.branding);
}, [tenant]);
return branding;
}
In our main layout component:
// src/components/Layout.jsx
import { useTenant } from '../contexts/TenantContext';
import { useTenantBranding } from '../hooks/useTenantBranding';
export default function Layout({ children }) {
const { tenant } = useTenant();
const branding = useTenantBranding();
return (
<div className="app-layout">
<header className="app-header">
{branding?.logo_url ? (
<img src={branding.logo_url} alt={tenant.name} className="tenant-logo" />
) : (
<h1>{tenant.name}</h1>
)}
{/* Rest of header */}
</header>
<main className="app-content">
{children}
</main>
</div>
);
}
Performance consideration: We cache tenant branding in localStorage with a TTL of 24 hours. This prevents fetching branding config on every page load:
const BRANDING_CACHE_KEY = 'tenant_branding';
const BRANDING_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
function getCachedBranding(tenantId) {
const cached = localStorage.getItem(`${BRANDING_CACHE_KEY}_${tenantId}`);
if (!cached) return null;
const { data, timestamp } = JSON.parse(cached);
if (Date.now() - timestamp > BRANDING_CACHE_TTL) {
localStorage.removeItem(`${BRANDING_CACHE_KEY}_${tenantId}`);
return null;
}
return data;
}
function setCachedBranding(tenantId, branding) {
localStorage.setItem(
`${BRANDING_CACHE_KEY}_${tenantId}`,
JSON.stringify({ data: branding, timestamp: Date.now() })
);
}
This reduced our API calls for branding data by 95% and improved initial load time from 1.2s to 0.3s.
Database Design and Query Optimization
This is where multi-tenancy gets really challenging. Every database decision you make has implications for performance, scalability, and data integrity.
The Central Database Pattern
Even with database-per-tenant, you need a central database to store tenant metadata, user accounts, billing information, and application-wide configuration. This is the one place where all tenants' information coexists.
Our central database schema:
// database/migrations/2024_01_01_000001_create_tenants_table.php
Schema::create('tenants', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('subdomain')->unique();
$table->string('db_host')->default('localhost');
$table->string('db_name')->unique();
$table->string('db_username')->unique();
$table->text('db_password'); // Encrypted
$table->string('plan')->default('free'); // free, pro, enterprise
$table->integer('max_users')->default(5);
$table->integer('max_projects')->default(10);
$table->timestamp('provisioned_at')->nullable();
$table->timestamp('suspended_at')->nullable();
$table->timestamps();
$table->index('subdomain');
$table->index(['plan', 'suspended_at']);
});
Schema::create('tenant_users', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->onDelete('cascade');
$table->string('email')->unique();
$table->string('password');
$table->string('name');
$table->string('role')->default('member'); // owner, admin, member
$table->timestamp('last_login_at')->nullable();
$table->timestamps();
$table->index(['tenant_id', 'email']);
$table->index(['tenant_id', 'role']);
});
Schema::create('tenant_subscriptions', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->onDelete('cascade');
$table->string('stripe_subscription_id')->unique()->nullable();
$table->string('plan');
$table->integer('quantity')->default(1);
$table->timestamp('trial_ends_at')->nullable();
$table->timestamp('ends_at')->nullable();
$table->timestamps();
$table->index(['tenant_id', 'ends_at']);
});
Critical indexes: Notice the composite indexes on tenant_id plus other columns. Without these, queries like "find all admins for this tenant" would do full table scans. We learned this after our user lookup queries started taking 2+ seconds at 10,000 tenants.
Here's the EXPLAIN output before adding the index:
EXPLAIN SELECT * FROM tenant_users WHERE tenant_id = 1234 AND role = 'admin';
Seq Scan on tenant_users (cost=0.00..1823.00 rows=1 width=100)
Filter: ((tenant_id = 1234) AND (role = 'admin'))
After adding $table->index(['tenant_id', 'role']):
EXPLAIN SELECT * FROM tenant_users WHERE tenant_id = 1234 AND role = 'admin';
Index Scan using tenant_users_tenant_id_role_index on tenant_users (cost=0.29..8.31 rows=1 width=100)
Index Cond: ((tenant_id = 1234) AND (role = 'admin'))
Query time dropped from 2.1s to 3ms.
Tenant Database Schema
Each tenant's database has its own schema optimized for their data. Here's our core tables:
// database/tenant_migrations/2024_01_01_000001_create_projects_table.php
Schema::create('projects', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description')->nullable();
$table->string('status')->default('active'); // active, archived, deleted
$table->foreignId('owner_id')->constrained('users')->onDelete('cascade');
$table->timestamp('archived_at')->nullable();
$table->timestamps();
$table->index('status');
$table->index(['owner_id', 'status']);
});
Schema::create('tasks', function (Blueprint $table) {
$table->id();
$table->foreignId('project_id')->constrained()->onDelete('cascade');
$table->string('title');
$table->text('description')->nullable();
$table->string('status')->default('todo'); // todo, in_progress, done
$table->foreignId('assigned_to')->nullable()->constrained('users')->onDelete('set null');
$table->integer('priority')->default(0);
$table->timestamp('due_at')->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamps();
$table->index(['project_id', 'status']);
$table->index(['assigned_to', 'status']);
$table->index('due_at');
});
The N+1 query problem in multi-tenancy: This is amplified in multi-tenant apps because you're often loading data across relationships. Here's a mistake we made:
// BAD: N+1 queries
$projects = Project::where('status', 'active')->get();
foreach ($projects as $project) {
echo $project->owner->name; // Each iteration hits the database
echo $project->tasks->count(); // Another query per project
}
For 100 projects, this executes 201 queries (1 for projects + 100 for owners + 100 for task counts).
The fix with eager loading:
// GOOD: 3 queries total
$projects = Project::where('status', 'active')
->with(['owner', 'tasks'])
->get();
foreach ($projects as $project) {
echo $project->owner->name; // No query, data already loaded
echo $project->tasks->count(); // No query, data already loaded
}
We caught these with Laravel Debugbar in development, but some slipped through to production. Now we have a CI check that fails if any controller action executes more than 20 queries:
// tests/Feature/QueryCountTest.php
public function test_dashboard_query_count()
{
DB::enableQueryLog();
$response = $this->actingAs($this->user)
->get('/dashboard');
$queryCount = count(DB::getQueryLog());
$this->assertLessThan(20, $queryCount,
"Dashboard executed {$queryCount} queries. Optimize with eager loading."
);
}
Handling Large Datasets Per Tenant
Some of our enterprise customers have massive datasets—millions of tasks, hundreds of thousands of projects. Standard pagination breaks down at this scale.
We implemented cursor-based pagination for large datasets:
// app/Http/Controllers/TaskController.php
public function index(Request $request)
{
$query = Task::query()
->with(['project', 'assignedTo'])
->where('status', $request->status ?? 'todo')
->orderBy('created_at', 'desc');
// Use cursor pagination for better performance with large datasets
$tasks = $query->cursorPaginate(50);
return response()->json($tasks);
}
Cursor pagination uses the last record's ID as a cursor instead of offset-based pagination. This is much faster for large datasets because it doesn't need to count total records or skip rows.
Traditional offset pagination:
SELECT * FROM tasks ORDER BY created_at DESC LIMIT 50 OFFSET 100000;
-- Has to skip 100,000 rows, very slow
Cursor pagination:
SELECT * FROM tasks WHERE id > 99999 ORDER BY created_at DESC LIMIT 50;
-- Uses index, much faster
In our React frontend:
// src/hooks/useTasks.js
import { useState, useEffect } from 'react';
import api from '../lib/api';
export function useTasks(status = 'todo') {
const [tasks, setTasks] = useState([]);
const [nextCursor, setNextCursor] = useState(null);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const loadTasks = async (cursor = null) => {
setLoading(true);
try {
const response = await api.get('/api/tasks', {
params: { status, cursor }
});
if (cursor) {
// Append to existing tasks
setTasks(prev => [...prev, ...response.data.data]);
} else {
// Replace tasks (initial load)
setTasks(response.data.data);
}
setNextCursor(response.data.next_cursor);
setHasMore(response.data.next_cursor !== null);
} catch (error) {
console.error('Failed to load tasks:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadTasks();
}, [status]);
const loadMore = () => {
if (hasMore && !loading && nextCursor) {
loadTasks(nextCursor);
}
};
return { tasks, loading, hasMore, loadMore };
}
This reduced our task list load time from 8 seconds to 200ms for tenants with 1M+ tasks.
Caching Strategies That Actually Work
Caching in multi-tenant applications is tricky because you need to ensure cache keys are tenant-specific. We learned this the expensive way when Customer A's dashboard showed cached data from Customer B.
Tenant-Scoped Cache Keys
Every cache key must include the tenant identifier. We built a helper to enforce this:
// app/Services/TenantCache.php
class TenantCache
{
public function __construct(private TenantService $tenantService)
{
}
private function tenantKey(string $key): string
{
$tenant = $this->tenantService->getTenant();
if (!$tenant) {
throw new \RuntimeException('Cannot cache without tenant context');
}
return "tenant:{$tenant->id}:{$key}";
}
public function get(string $key, $default = null)
{
return Cache::get($this->tenantKey($key), $default);
}
public function put(string $key, $value, $ttl = null)
{
return Cache::put($this->tenantKey($key), $value, $ttl);
}
public function remember(string $key, $ttl, Closure $callback)
{
return Cache::remember($this->tenantKey($key), $ttl, $callback);
}
public function forget(string $key)
{
return Cache::forget($this->tenantKey($key));
}
public function flush()
{
$tenant = $this->tenantService->getTenant();
if (!$tenant) return;
// Clear all cache keys for this tenant
$pattern = "tenant:{$tenant->id}:*";
// Redis-specific implementation
$redis = Cache::getRedis();
$keys = $redis->keys($pattern);
if (!empty($keys)) {
$redis->del($keys);
}
}
}
Usage in controllers:
public function dashboard(TenantCache $cache)
{
$stats = $cache->remember('dashboard:stats', 300, function () {
return [
'total_projects' => Project::count(),
'active_tasks' => Task::where('status', 'todo')->count(),
'completed_tasks' => Task::where('status', 'done')->count(),
'team_members' => User::count(),
];
});
return view('dashboard', compact('stats'));
}
This ensures the cache key is automatically scoped to the current tenant: tenant:123:dashboard:stats.
Cache Invalidation Strategy
The hard part of caching is invalidation. When a task is created, we need to invalidate the dashboard stats cache. We use Laravel's model observers:
// app/Observers/TaskObserver.php
class TaskObserver
{
public function __construct(private TenantCache $cache)
{
}
public function created(Task $task)
{
$this->invalidateCaches();
}
public function updated(Task $task)
{
if ($task->wasChanged('status')) {
$this->invalidateCaches();
}
}
public function deleted(Task $task)
{
$this->invalidateCaches();
}
private function invalidateCaches()
{
$this->cache->forget('dashboard:stats');
$this->cache->forget('tasks:summary');
}
}
Register the observer in AppServiceProvider:
public function boot()
{
Task::observe(TaskObserver::class);
}
The gotcha: Observers don't fire for bulk updates or raw queries. We had a bug where a batch job updated 10,000 tasks with Task::where('project_id', $id)->update(['status' => 'done']), and the cache wasn't invalidated. Users saw stale data for 5 minutes until the cache TTL expired.
The fix is to explicitly invalidate cache after bulk operations:
Task::where('project_id', $projectId)->update(['status' => 'done']);
app(TenantCache::class)->forget('dashboard:stats');
Redis Configuration for Multi-Tenancy
We use Redis for caching and queues. Our Redis configuration separates data by tenant using key prefixes:
// config/database.php
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'default' => [
'host' => env('REDIS_HOST', '127.0.0.1'),
'password' => env('REDIS_PASSWORD', null),
'port' => env('REDIS_PORT', 6379),
'database' => 0,
],
'cache' => [
'host' => env('REDIS_HOST', '127.0.0.1'),
'password' => env('REDIS_PASSWORD', null),
'port' => env('REDIS_PORT', 6379),
'database' => 1, // Separate database for cache
],
],
We monitor Redis memory usage per tenant to prevent one tenant from consuming all cache space:
// app/Console/Commands/MonitorTenantCache.php
class MonitorTenantCache extends Command
{
public function handle()
{
$redis = Cache::getRedis();
$tenants = Tenant::all();
foreach ($tenants as $tenant) {
$pattern = "tenant:{$tenant->id}:*";
$keys = $redis->keys($pattern);
$totalSize = 0;
foreach ($keys as $key) {
$totalSize += strlen($redis->get($key));
}
// Convert to MB
$sizeMB = $totalSize / 1024 / 1024;
if ($sizeMB > 100) { // Alert if tenant uses >100MB cache
$this->warn("Tenant {$tenant->id} using {$sizeMB}MB cache");
// Optionally clear old cache entries
$this->clearOldCache($tenant);
}
}
}
private function clearOldCache(Tenant $tenant)
{
// Implementation depends on your cache strategy
// Could use Redis TTL or track access times
}
}
Queue Jobs and Background Processing
Background jobs need to be tenant-aware too. This is critical for data integrity and performance.
Tenant Context in Jobs
When you dispatch a job, you need to preserve tenant context so the job executes in the correct tenant's database:
// app/Jobs/GenerateProjectReport.php
class GenerateProjectReport implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
private int $projectId,
private int $tenantId // Store tenant ID, not the model
) {
}
public function handle(TenantService $tenantService)
{
// Restore tenant context
$tenant = Tenant::findOrFail($this->tenantId);
$tenantService->setTenant($tenant);
// Now we're in the correct tenant's database
$project = Project::findOrFail($this->projectId);
$report = $this->generateReport($project);
// Store report in tenant's storage
Storage::disk('tenant')->put(
"reports/project-{$this->projectId}.pdf",
$report
);
// Notify project owner
$project->owner->notify(new ReportGenerated($project));
}
private function generateReport(Project $project): string
{
// Report generation logic
$tasks = $project->tasks()->with('assignedTo')->get();
return PDF::loadView('reports.project', [
'project' => $project,
'tasks' => $tasks,
])->output();
}
}
Dispatching the job:
public function generateReport(Project $project, TenantService $tenantService)
{
GenerateProjectReport::dispatch(
$project->id,
$tenantService->getTenant()->id
);
return response()->json(['message' => 'Report generation started']);
}
Critical mistake we made: We initially passed the Project model directly to the job using SerializesModels. This caused the job to serialize the model with its current database connection. When the job executed on a worker, it tried to use the wrong database connection, causing data corruption.
The fix is to only pass IDs and re-fetch models within the job after setting tenant context.
Queue Separation by Tenant Priority
Some of our enterprise customers pay for priority support, which includes faster background job processing. We implemented priority queues:
// config/queue.php
'connections' => [
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90,
'block_for' => null,
],
'redis-high' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => 'high',
'retry_after' => 90,
],
],
When dispatching jobs for enterprise tenants:
public function generateReport(Project $project, TenantService $tenantService)
{
$tenant = $tenantService->getTenant();
$queue = $tenant->plan === 'enterprise' ? 'high' : 'default';
GenerateProjectReport::dispatch($project->id, $tenant->id)
->onQueue($queue);
return response()->json(['message' => 'Report generation started']);
}
We run separate worker processes for each queue with different concurrency:
# High priority queue: 10 concurrent workers
php artisan queue:work redis-high --queue=high --tries=3 --timeout=90 &
# Default queue: 5 concurrent workers
php artisan queue:work redis --queue=default --tries=3 --timeout=90 &
This ensures enterprise customers' jobs are processed faster even during high load.
File Storage and Multi-Tenancy
File uploads need to be isolated by tenant. We use Laravel's filesystem abstraction with tenant-specific paths:
// config/filesystems.php
'disks' => [
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
],
],
Our file upload controller:
// app/Http/Controllers/FileController.php
public function upload(Request $request, TenantService $tenantService)
{
$request->validate([
'file' => 'required|file|max:10240', // 10MB max
]);
$tenant = $tenantService->getTenant();
$file = $request->file('file');
// Store in tenant-specific directory
$path = $file->store("tenants/{$tenant->id}/uploads", 's3');
// Save metadata to tenant's database
$upload = Upload::create([
'filename' => $file->getClientOriginalName(),
'path' => $path,
'size' => $file->getSize(),
'mime_type' => $file->getMimeType(),
'uploaded_by' => auth()->id(),
]);
return response()->json($upload);
}
Security consideration: Never trust the tenant identifier from the client. Always derive it from the authenticated session or subdomain. Otherwise, an attacker could upload files to another tenant's directory by manipulating the request.
We also implement storage quotas per tenant:
public function upload(Request $request, TenantService $tenantService)
{
$tenant = $tenantService->getTenant();
// Check current storage usage
$currentUsage = Upload::sum('size');
$quotaBytes = $this->getStorageQuota($tenant->plan);
if ($currentUsage + $request->file('file')->getSize() > $quotaBytes) {
return response()->json([
'error' => 'Storage quota exceeded. Please upgrade your plan.'
], 413);
}
// Proceed with upload...
}
private function getStorageQuota(string $plan): int
{
return match($plan) {
'free' => 1 * 1024 * 1024 * 1024, // 1GB
'pro' => 10 * 1024 * 1024 * 1024, // 10GB
'enterprise' => 100 * 1024 * 1024 * 1024, // 100GB
};
}
Testing Multi-Tenant Applications
Testing multi-tenant apps requires special setup to ensure tenant isolation works correctly.
Test Database Setup
We use separate test databases for each test run to avoid conflicts:
// tests/TestCase.php
abstract class TestCase extends BaseTestCase
{
use CreatesApplication, RefreshDatabase;
protected Tenant $testTenant;
protected function setUp(): void
{
parent::setUp();
// Create test tenant
$this->testTenant = Tenant::factory()->create([
'subdomain' => 'test-' . uniqid(),
'db_name' => 'tenant_test_' . uniqid(),
]);
// Provision tenant database
$this->provisionTenantDatabase($this->testTenant);
// Set tenant context
app(TenantService::class)->setTenant($this->testTenant);
}
protected function tearDown(): void
{
// Clean up tenant database
DB::statement("DROP DATABASE IF EXISTS {$this->testTenant->db_name}");
parent::tearDown();
}
private function provisionTenantDatabase(Tenant $tenant): void
{
DB::statement("CREATE DATABASE {$tenant->db_name}");
app(TenantService::class)->setTenant($tenant);
Artisan::call('migrate', [
'--database' => 'tenant',
'--path' => 'database/tenant_migrations',
'--force' => true,
]);
}
}
Now every test runs in its own isolated tenant database:
// tests/Feature/ProjectTest.php
class ProjectTest extends TestCase
{
public function test_user_can_create_project()
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->postJson('/api/projects', [
'name' => 'Test Project',
'description' => 'Test Description',
]);
$response->assertStatus(201);
$this->assertDatabaseHas('projects', [
'name' => 'Test Project',
'owner_id' => $user->id,
]);
}
public function test_user_cannot_access_other_tenant_projects()
{
// Create another tenant
$otherTenant = Tenant::factory()->create();
$this->provisionTenantDatabase($otherTenant);
// Create project in other tenant
app(TenantService::class)->setTenant($otherTenant);
$otherProject = Project::factory()->create();
// Switch back to test tenant
app(TenantService::class)->setTenant($this->testTenant);
$user = User::factory()->create();
// Try to access other tenant's project
$response = $this->actingAs($user)
->getJson("/api/projects/{$otherProject->id}");
$response->assertStatus(404); // Should not be found
}
}
Testing Tenant Isolation
We have specific tests to ensure tenant isolation is enforced:
// tests/Feature/TenantIsolationTest.php
class TenantIsolationTest extends TestCase
{
public function test_queries_are_scoped_to_tenant()
{
// Create data in test tenant
$project1 = Project::factory()->create(['name' => 'Tenant 1 Project']);
// Create another tenant with data
$tenant2 = Tenant::factory()->create();
$this->provisionTenantDatabase($tenant2);
app(TenantService::class)->setTenant($tenant2);
$project2 = Project::factory()->create(['name' => 'Tenant 2 Project']);
// Switch back to test tenant
app(TenantService::class)->setTenant($this->testTenant);
// Query should only return test tenant's project
$projects = Project::all();
$this->assertCount(1, $projects);
$this->assertEquals('Tenant 1 Project', $projects->first()->name);
}
public function test_cache_keys_are_tenant_scoped()
{
$cache = app(TenantCache::class);
// Cache value in test tenant
$cache->put('test_key', 'tenant_1_value');
// Switch to another tenant
$tenant2 = Tenant::factory()->create();
app(TenantService::class)->setTenant($tenant2);
// Should not see test tenant's cached value
$this->assertNull($cache->get('test_key'));
// Cache different value in tenant 2
$cache->put('test_key', 'tenant_2_value');
// Switch back to test tenant
app(TenantService::class)->setTenant($this->testTenant);
// Should still see original value
$this->assertEquals('tenant_1_value', $cache->get('test_key'));
}
}
Performance Monitoring and Optimization
At scale, performance monitoring becomes critical. We use several tools and strategies to keep our multi-tenant app fast.
Database Query Monitoring
We log slow queries for each tenant to identify optimization opportunities:
// app/Providers/AppServiceProvider.php
public function boot()
{
if (app()->environment('production')) {
DB::listen(function ($query) {
if ($query->time > 1000) { // Queries slower than 1 second
Log::warning('Slow query detected', [
'tenant_id' => app(TenantService::class)->getTenant()?->id,
'sql' => $query->sql,
'bindings' => $query->bindings,
'time' => $query->time,
]);
}
});
}
}
We aggregate these logs daily to identify patterns:
# Our daily slow query report
php artisan queries:report --date=yesterday
Slow Query Report for 2024-01-15
=================================
Tenant 1234: 45 slow queries (avg: 2.3s)
- SELECT * FROM tasks WHERE project_id = ? ORDER BY created_at DESC
Executed: 23 times, Avg time: 3.2s
Recommendation: Add index on (project_id, created_at)
Tenant 5678: 12 slow queries (avg: 1.8s)
- SELECT * FROM projects WHERE status = ? AND owner_id IN (...)
Executed: 12 times, Avg time: 1.8s
Recommendation: Use eager loading instead of IN query
APM Integration
We use Laravel Telescope in staging and New Relic in production to monitor application performance:
// config/telescope.php
'watchers' => [
Watchers\QueryWatcher::class => [
'enabled' => env('TELESCOPE_QUERY_WATCHER', true),
'slow' => 100, // Log queries slower than 100ms
],
Watchers\RequestWatcher::class => [
'enabled' => env('TELESCOPE_REQUEST_WATCHER', true),
'size_limit' => 64, // KB
],
],
This helped us identify that our dashboard endpoint was making 47 database queries. After optimization with eager loading, we reduced it to 8 queries and cut response time from 1.2s to 180ms.
Tenant-Specific Performance Metrics
We track performance metrics per tenant to identify outliers:
// app/Http/Middleware/TrackTenantMetrics.php
class TrackTenantMetrics
{
public function handle(Request $request, Closure $next)
{
$start = microtime(true);
$response = $next($request);
$duration = (microtime(true) - $start) * 1000; // Convert to ms
$tenant = app(TenantService::class)->getTenant();
if ($tenant) {
// Log to metrics service (e.g., CloudWatch, Datadog)
Metrics::histogram('request.duration', $duration, [
'tenant_id' => $tenant->id,
'endpoint' => $request->path(),
'method' => $request->method(),
]);
}
return $response;
}
}
This lets us create dashboards showing:
- Average response time per tenant
- Slowest endpoints per tenant
- Tenants with the most errors
- Resource usage per tenant
We use this data to proactively reach out to customers with performance issues and to identify optimization opportunities.
Security Considerations
Multi-tenancy introduces unique security challenges. Data leakage between tenants is the nightmare scenario.
Preventing Cross-Tenant Data Access
We implement multiple layers of defense:
- Database-level isolation (separate databases per tenant)
- Middleware validation (verify tenant context on every request)
- Policy-based authorization (check tenant ownership)
Here's our authorization policy:
// app/Policies/ProjectPolicy.php
class ProjectPolicy
{
public function view(User $user, Project $project): bool
{
// Verify project belongs to user's tenant
$tenant = app(TenantService::class)->getTenant();
// This check is redundant with database isolation but provides defense in depth
return $tenant && $project->exists;
}
public function update(User $user, Project $project): bool
{
return $this->view($user, $project) &&
($user->id === $project->owner_id || $user->role === 'admin');
}
}
We also have integration tests that specifically try to break tenant isolation:
public function test_cannot_access_project_by_manipulating_tenant_header()
{
$project = Project::factory()->create();
// Try to access with different tenant header
$response = $this->actingAs($this->user)
->withHeader('X-Tenant', 'different-tenant')
->getJson("/api/projects/{$project->id}");
$response->assertStatus(404); // Should fail
}
Rate Limiting Per Tenant
We implement rate limiting at the tenant level to prevent abuse:
// app/Http/Middleware/TenantRateLimiter.php
class TenantRateLimiter
{
public function handle(Request $request, Closure $next)
{
$tenant = app(TenantService::class)->getTenant();
if (!$tenant) {
return response()->json(['error' => 'Tenant not found'], 404);
}
$limit = $this->getRateLimit($tenant->plan);
$key = "rate_limit:tenant:{$tenant->id}";
$attempts = Cache::get($key, 0);
if ($attempts >= $limit) {
return response()->json([
'error' => 'Rate limit exceeded. Please upgrade your plan.'
], 429);
}
Cache::put($key, $attempts + 1, now()->addMinute());
return $next($request);
}
private function getRateLimit(string $plan): int
{
return match($plan) {
'free' => 100, // 100 requests per minute
'pro' => 1000, // 1000 requests per minute
'enterprise' => 10000, // 10000 requests per minute
};
}
}
Deployment and DevOps
Deploying multi-tenant applications requires special considerations.
Zero-Downtime Migrations
When we deploy schema changes, we need to migrate thousands of tenant databases without downtime. Our strategy:
- Deploy new code (backward compatible with old schema)
- Run migrations on all tenant databases in background
- Deploy code that uses new schema once migrations complete
We use feature flags to control which tenants get new features:
// app/Services/FeatureFlag.php
class FeatureFlag
{
public function isEnabled(string $feature, Tenant $tenant): bool
{
// Check if tenant's database has been migrated to support this feature
if ($feature === 'new_reporting') {
return $tenant->last_migration >= '2024-01-15';
}
return false;
}
}
In our controllers:
public function generateReport(FeatureFlag $flags, TenantService $tenantService)
{
$tenant = $tenantService->getTenant();
if ($flags->isEnabled('new_reporting', $tenant)) {
return $this->generateNewReport();
}
return $this->generateLegacyReport();
}
This lets us gradually roll out features as tenant databases are migrated.
Monitoring and Alerting
We monitor several key metrics:
- Tenant provisioning time (should be <60s)
- Migration completion rate (% of tenants migrated)
- Query performance per tenant (p95 latency)
- Error rates per tenant
- Storage usage per tenant
Our alert rules:
# cloudwatch_alarms.yml
alarms:
- name: TenantProvisioningFailed
metric: tenant_provisioning_failures
threshold: 1
period: 300
action: page_oncall
- name: SlowTenantQueries
metric: tenant_query_p95
threshold: 1000 # 1 second
period: 300
action: slack_engineering
- name: TenantStorageQuotaExceeded
metric: tenant_storage_usage
threshold: 95 # 95% of quota
period: 3600
action: email_customer_success
Lessons Learned and Trade-offs
After two years running this architecture in production, here's what I'd do differently:
What Worked Well
-
Database-per-tenant isolation gives us peace of mind. We've never had a cross-tenant data leak in production.
-
Cursor-based pagination was a game-changer for large tenants. Response times are consistent regardless of dataset size.
-
Tenant-scoped caching with Redis has been rock solid. We haven't had cache-related bugs in over a year.
-
Priority queues for enterprise customers was worth the complexity. It's a competitive advantage.
What We'd Change
-
Start with database-per-tenant from day one. Migrating from single-database to database-per-tenant was painful. We had to coordinate with customers, schedule maintenance windows, and deal with data migration issues. If I were starting over, I'd use database-per-tenant from the beginning, even for MVP.
-
Implement comprehensive tenant isolation tests earlier. We caught several tenant leakage bugs in production that should have been caught in testing. Now we have a full test suite specifically for tenant isolation, and we run it on every deploy.
-
Build better tooling for database migrations. Our initial migration script was a bash script that ran migrations sequentially. It took hours. We should have built a proper job queue system with parallel processing and retry logic from the start.
-
Monitor per-tenant costs more carefully. Some tenants use 100x more resources than others, but we didn't have visibility into this until we built custom dashboards. Now we can identify which tenants are unprofitable and adjust pricing or optimize their usage.
The Hidden Costs
Multi-tenancy isn't free. Here's what it costs us:
-
Development time: Every feature takes 20-30% longer because we need to consider tenant isolation, test across multiple tenants, and handle edge cases.
-
Infrastructure: Running separate databases for each tenant is more expensive than a single database. We pay about $0.50/month per tenant in database costs.
-
Operations: Database migrations, monitoring, and troubleshooting are more complex. We have a dedicated DevOps engineer who spends 50% of their time on multi-tenancy operations.
-
Testing: Our test suite takes 3x longer to run because we create separate tenant databases for each test.
But the benefits outweigh the costs:
- Security: We can confidently tell enterprise customers their data is isolated.
- Performance: Each tenant's performance is independent.
- Customization: We can optimize individual tenant databases.
- Compliance: Easier to meet data residency requirements (we can put specific tenants in specific regions).
What's Next
We're currently working on several improvements:
-
Geographic distribution: Moving tenant databases closer to their users for lower latency.
-
Automated scaling: Detecting when a tenant outgrows their current database instance and automatically upgrading them.
-
Better tenant analytics: Building dashboards that show each tenant their usage patterns, performance metrics, and optimization recommendations.
-
Self-service tenant management: Letting customers manage their own database backups, exports, and migrations.
The architecture we've built isn't perfect, but it's battle-tested and scales to our current needs. We're serving 50,000+ tenants, processing 200M+ requests per month, and maintaining 99.9% uptime.
If you're building a multi-tenant SaaS, my advice is: start with the simplest architecture that meets your security requirements, instrument everything, and be prepared to evolve as you scale. Don't over-engineer for scale you don't have yet, but do get the security fundamentals right from day one.
The most important lesson I've learned is that multi-tenancy is not a feature you add to an application—it's a fundamental architectural decision that affects every part of your system. Treat it with the respect it deserves, test it thoroughly, and you'll build something that scales.
Keep reading
Decentralized Finance Protocol Comparison: Uniswap V3 vs SushiSwap vs Curve Finance - Performance, Security, and Use Cases
16 min · 146 views
DevOpsBuilding a Production-Ready Blog with Next.js and MongoDB: What We Learned Scaling to 500K Monthly Readers
28 min · 105 views
PerformanceComplete Solution: Building a Secure E-commerce Website with Stripe and Laravel
28 min · 83 views
Maya Chen
AuthorWrites about machine learning workflows, LLM applications, and the gap between research papers and production systems. Contributing author at NextGenBeing.
Never Miss an Article
Get our best content delivered to your inbox weekly. No spam, unsubscribe anytime.
Comments (0)
Please log in to leave a comment.
Log InRelated Articles
Redis vs Memcached: What We Learned Scaling to 50M Requests Per Day
May 8, 2026
Building a Complete E-commerce Website with Laravel: What We Learned Scaling to 100k Orders
Apr 30, 2026
Building a Real-Time Notification System with Laravel and Pusher: Production Lessons from 2M Daily Events
May 6, 2026