A deep dive into designing and shipping a production-grade multi-tenant architecture using Laravel 11, PostgreSQL row-level security, and Stripe Billing — from data isolation to zero-downtime deploys.
Multi-tenancy is one of the most important architectural decisions you make when building a SaaS product. Get it wrong and you'll spend months retrofitting isolation; get it right and you unlock the ability to onboard thousands of tenants without the operational headache of isolated deployments.
This post walks through the exact architecture I used to build ReviewX v2 — a WooCommerce reviews SaaS platform now serving 10,000+ active users across 70+ countries.
Before writing a single line of code, you need to choose your isolation strategy:
1. Separate database per tenant Maximum isolation, but expensive to operate at scale. Good for enterprise contracts with strict data residency requirements.
2. Shared database, separate schema PostgreSQL schemas give you logical separation without the per-connection overhead. Schema migrations need coordination.
3. Shared database, shared schema with tenant_id column Simplest to operate, cheapest at scale. Row-Level Security (RLS) in PostgreSQL makes this surprisingly safe.
I chose option 3 for ReviewX because it scales to millions of rows without spawning new database connections per tenant.
The secret weapon for shared-schema multi-tenancy is PostgreSQL's native RLS. Instead of scattering WHERE tenant_id = ? clauses everywhere, you define policies at the database level:
SQL-- Enable RLS on every tenant-scoped table ALTER TABLE reviews ENABLE ROW LEVEL SECURITY; -- Policy: users can only see rows belonging to their tenant CREATE POLICY tenant_isolation ON reviews USING (tenant_id = current_setting('app.current_tenant')::uuid);
In Laravel, set the tenant context at the start of each request:
PHP#6A9955">// app/Http/Middleware/SetTenantContext.php class SetTenantContext { public function handle(Request $request, Closure $next) { $tenantId = $request->user()?->tenant_id; if ($tenantId) { DB::statement("SET app.current_tenant = ?", [$tenantId]); } return $next($request); } }
This means every Eloquent query is automatically scoped — no global scopes, no fear of accidentally leaking another tenant's data.
The biggest mistake I see in Laravel SaaS codebases is putting business logic in controllers. Controllers should be thin HTTP adapters. Business logic belongs in service classes:
PHP#6A9955">// app/Services/ReviewService.php class ReviewService { public function __construct( private ReviewRepository $reviews, private NotificationService $notifications, private EventDispatcher $events, ) {} public function approve(Review $review): Review { DB::transaction(function () use ($review) { $review->update(['status' => ReviewStatus::APPROVED]); $this->notifications->notifyCustomer($review); $this->events->dispatch(new ReviewApproved($review)); }); return $review->fresh(); } }
Benefits:
For subscription management, I used Laravel Cashier (Stripe). The key insight is that billing belongs to the tenant entity, not individual users:
PHP#6A9955">// Tenant model implements Billable class Tenant extends Model { use Billable; public function isOnPlan(string $plan): bool { return $this->subscribed($plan); } public function reviewsRemaining(): int { $limit = $this->subscription()?->items->first()?->quantity ?? 100; $used = $this->reviews()->thisMonth()->count(); return max(0, $limit - $used); } }
ReviewX processes webhook payloads from WooCommerce stores — some with thousands of orders per day. Naive synchronous processing would crush response times.
The strategy: queue everything, prioritize by tenant plan:
PHP#6A9955">// Dispatch to plan-specific queues dispatch(new ProcessReviewWebhook($payload)) ->onQueue($tenant->isOnPlan('pro') ? 'high' : 'default');
This ensures Pro plan tenants get sub-second review processing while free plan webhooks are processed within minutes — without any additional infrastructure.
Schema migrations are the most dangerous operation in a multi-tenant SaaS. My rule: every migration must be safe to run while the application is live.
Techniques I use:
CONCURRENTLY (PostgreSQL)Bash# Artisan command for safe large-table backfills php artisan migrate --step php artisan backfill:new-column --chunk=1000 --sleep=50
After shipping this architecture:
Multi-tenant SaaS on Laravel is well within reach if you make the right architectural decisions early. PostgreSQL RLS handles data isolation at the database level so you never have to trust your application code to do it. Service classes keep business logic testable. And a thoughtful queue strategy makes high-volume tenants a strength rather than a bottleneck.
The full source architecture is available in ReviewX v2's case study.