Building a Multi-Tenant SaaS Platform with Laravel 11
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.
The Three Multi-Tenancy Models
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.
PostgreSQL Row-Level Security
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.
Laravel Architecture: Service Layer Pattern
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:
- Testable without HTTP context
- Reusable across API, CLI commands, and queue jobs
- Clear separation of concerns
Stripe Billing with Tenant Plans
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); } }
Queue Strategy for High-Volume Tenants
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.
Zero-Downtime Migrations
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:
- Add nullable columns first, backfill data, then add NOT NULL constraint
- Create new indexes
CONCURRENTLY(PostgreSQL) - Never rename columns — add new, migrate data, deprecate old
- Use feature flags to gate new column reads until migration is complete
Bash# Artisan command for safe large-table backfills php artisan migrate --step php artisan backfill:new-column --chunk=1000 --sleep=50
Results
After shipping this architecture:
- 62% reduction in average API response time
- Zero data leakage incidents across 18+ months in production
- Onboarded from 3,000 to 10,000+ tenants without architectural changes
- Deployment time cut from 40 minutes to 4 minutes with GitHub Actions
Conclusion
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.

