How to Build a Multi-Tenant SaaS on Laravel — The Right Way
Introduction
Multi-tenant SaaS is the most technically demanding type of application most Laravel developers will build. Done correctly, your single codebase serves thousands of isolated customer accounts, each believing the software was built for them. Done incorrectly, one misplaced query exposes one customer's data to another — and your company never recovers from that.
This guide is not a beginner introduction to Laravel. It is a practical, architecture-first breakdown of how to build multi-tenant SaaS on Laravel the right way — the way that works at 10 customers, still works at 10,000, and doesn't require a full rewrite somewhere in between.
I have built several SaaS applications on Laravel, including products that went through the 100-customer and 1,000-customer thresholds. What follows is the architecture I use today, the reasoning behind each decision, and the mistakes I've seen (and made) along the way.
The Two Multi-Tenancy Models — Choose Consciously
Before writing a single line of code, you need to choose your tenancy model. This decision shapes everything that follows.
Model 1: Shared Database with Row-Level Isolation
How it works: All tenants share the same database and the same tables. Every tenant-scoped table has a team_id (or tenant_id) column. Application-level code ensures every query automatically filters by the current tenant.
Advantages:
- Simpler infrastructure — one database to manage, back up, and monitor
- Cheaper at early stage — no per-tenant database overhead
- Easier migrations — one migration affects all tenants simultaneously
- Faster setup — correct for most SaaS products below 10,000 tenants
Risks:
- A missing WHERE team_id = ? clause in any query exposes cross-tenant data
- Noisy neighbors — one tenant's heavy query can slow down others on the same database
- Harder to meet enterprise compliance requirements that mandate physical data separation
Right for: Early-stage SaaS, SMB-focused products, applications where tenants are small teams and not regulated enterprises.
Model 2: Database Per Tenant
How it works: Each tenant has their own database. The application maintains a central "landlord" database for routing, and switches to the correct tenant database on each request.
Advantages:
- Complete data isolation — physically impossible to leak data between tenants
- Individual tenant databases can be backed up, migrated, or scaled independently
- Enterprise and compliance requirements are easy to meet
Risks:
- Infrastructure complexity grows linearly with tenants
- Migrations must be applied to every tenant database separately
- Higher hosting cost at early stage
Right for: Healthcare SaaS, fintech, legal tech, government software, or any product targeting enterprise clients with strict data residency requirements.
The 2026 Default Recommendation
For most SaaS founders in 2026: start with shared database + row-level isolation, implemented with Laravel's global scopes. Architect it cleanly enough that database-per-tenant can be introduced later for enterprise clients if needed. This is the model I use in the rest of this guide.
The Core Architecture: Global Scopes for Automatic Tenant Isolation
The most important rule in shared-database multi-tenancy: tenant filtering must be automatic, not manual. Every query on a tenant-scoped model must filter by the current tenant — without the developer having to remember to add the filter.
The wrong way:
php
// Dangerous — developer must remember this EVERY TIME
$projects = Project::where('team_id', auth()->user()->team_id)->get();If one developer forgets this where clause on one endpoint, data leaks. And this will happen.
The right way: Laravel Global Scopes.
Implementing Tenant Isolation with Global Scopes
Create a BelongsToTeam trait applied to every tenant-scoped model:
php
// app/Traits/BelongsToTeam.php
trait BelongsToTeam
{
protected static function bootBelongsToTeam(): void
{
static::addGlobalScope('team', function (Builder $builder) {
if (auth()->check() && auth()->user()->current_team_id) {
$builder->where(
(new static)->getTable() . '.team_id',
auth()->user()->current_team_id
);
}
});
static::creating(function ($model) {
if (auth()->check() && !isset($model->team_id)) {
$model->team_id = auth()->user()->current_team_id;
}
});
}
}Apply it to every tenant-scoped model:
php
// app/Models/Project.php
class Project extends Model
{
use BelongsToTeam;
// ...
}Now every query on Project automatically includes the tenant filter. Project::all() becomes SELECT * FROM projects WHERE team_id = [current team] — automatically, every time, without any developer remembering anything.
Critical: Test the Global Scope
Write a test that verifies isolation before you ship:
php
// tests/Feature/TenantIsolationTest.php
public function test_user_cannot_access_other_teams_projects(): void
{
$teamA = Team::factory()->create();
$teamB = Team::factory()->create();
$projectA = Project::factory()->for($teamA)->create();
$userB = User::factory()->for($teamB)->create();
$this->actingAs($userB);
$this->assertEmpty(Project::all());
$this->assertDatabaseHas('projects', ['id' => $projectA->id]);
}This test should pass — the user from Team B sees no projects, even though Team A's project exists in the database. Run this test in CI on every deployment. If it ever fails, you have a data isolation breach.
Team and User Architecture
A clean team/user structure for a SaaS product in Laravel:
users - id - name - email - password - current_team_id (the active workspace) teams - id - name - slug - owner_id - subscription_status - stripe_customer_id team_user (pivot) - team_id - user_id - role (owner / admin / member / viewer)
A user can belong to multiple teams. current_team_id tracks which workspace they are currently in. The role in the pivot table controls what they can do within that workspace.
This is the foundation. Every other model in your application points to team_id, not user_id.
Subscription Billing with Stripe and Laravel Cashier
Subscription billing is where SaaS products most commonly ship with hidden bugs — because the edge cases (failed payments, plan changes mid-cycle, trials that expire) are not obvious during development and only surface when real money is involved.
Laravel Cashier handles most of the Stripe complexity. Here is how to use it correctly.
Install Cashier on the Team Model
Billing belongs to the team, not the individual user. Install Cashier on your Team model, not User:
php
// app/Models/Team.php
use Laravel\Cashier\Billable;
class Team extends Model
{
use Billable;
}This means $team->subscribe(), $team->subscription(), $team->invoices() — all billing operations are team-level.
Webhook Handling — The Part Everyone Gets Wrong
Stripe sends webhooks when billing events occur. If you don't handle them properly, your database falls out of sync with Stripe's state.
Events you must handle:
Stripe EventWhat It MeansWhat to Doinvoice.payment_succeeded | Subscription renewed | Extend access, reset usage counters
invoice.payment_failed | Card declined | Send dunning email, start grace period
customer.subscription.updated | Plan changed | Update team's plan in your database
customer.subscription.deleted | Cancelled or lapsed | Downgrade to free or disable account
customer.subscription.trial_will_end | Trial ending in 3 days | Send conversion email
Laravel Cashier has built-in webhook handling. Extend it:
php
// app/Http/Controllers/StripeWebhookController.php
class StripeWebhookController extends WebhookController
{
public function handleInvoicePaymentFailed(array $payload): Response
{
$customer = $payload['data']['object']['customer'];
$team = Team::where('stripe_customer_id', $customer)->first();
if ($team) {
$team->update(['subscription_status' => 'past_due']);
Mail::to($team->owner)->queue(new PaymentFailedEmail($team));
}
return $this->successMethod();
}
}Critical: Verify webhook signatures. Stripe signs every webhook. If you don't verify the signature, anyone can POST to your webhook endpoint and trigger billing state changes.
php
// Cashier does this automatically if STRIPE_WEBHOOK_SECRET is set in .env
Role-Based Access Control (RBAC)
Use spatie/laravel-permission — do not build your own RBAC system. The package is battle-tested, widely supported, and integrates cleanly with Laravel's built-in authorization.
For a SaaS product, structure permissions around team roles:
Roles per team: - owner → full access including billing and team deletion - admin → full access excluding billing - member → create, edit own resources - viewer → read-only access Permissions: - projects.create - projects.edit - projects.delete - billing.manage - team.settings.edit - members.invite
Check permissions in controllers using Laravel's Gate:
php
public function destroy(Project $project): RedirectResponse
{
$this->authorize('projects.delete');
$project->delete();
return redirect()->route('projects.index');
}The authorize() call checks the current user's role within the current team. If they lack permission, Laravel returns a 403 automatically.
Feature Flags and Plan-Based Access
Different subscription plans should unlock different features. The cleanest way to implement this in Laravel is with a custom middleware and a plan features configuration:
php
// config/plans.php
return [
'starter' => [
'features' => ['projects', 'api_access'],
'limits' => ['projects' => 5, 'team_members' => 2],
],
'pro' => [
'features' => ['projects', 'api_access', 'advanced_reports', 'custom_domain'],
'limits' => ['projects' => 50, 'team_members' => 10],
],
'enterprise' => [
'features' => ['*'],
'limits' => ['projects' => -1, 'team_members' => -1], // unlimited
],
];php
// app/Http/Middleware/RequireFeature.php
class RequireFeature
{
public function handle(Request $request, Closure $next, string $feature): Response
{
$team = $request->user()->currentTeam;
$plan = config("plans.{$team->plan}");
if (!in_array('*', $plan['features']) && !in_array($feature, $plan['features'])) {
return response()->json(['error' => 'Upgrade required'], 403);
}
return $next($request);
}
}Apply it to routes:
php
Route::get('/reports/advanced', AdvancedReportController::class)
->middleware('require.feature:advanced_reports');Onboarding Flow — The First 5 Minutes Matter
After a user signs up for your SaaS, the next five minutes determine whether they convert to a paying customer or churn forever. Your onboarding flow is not a "nice to have" — it is revenue.
A minimal but complete onboarding flow in Laravel:
- Registration — email, password, team name. One screen.
- Email verification — mandatory before accessing the app. Use Laravel's built-in MustVerifyEmail.
- Workspace setup — first login redirects to a setup wizard. Collect what you need to personalize the experience (industry, team size, primary goal).
- First action — guide the user to create their first project / record / item immediately. The faster a user creates something, the lower their churn rate.
- Invitation — prompt to invite a teammate. Users who invite someone in their first session have dramatically higher retention.
- Welcome email sequence — Day 0: confirmation. Day 2: "Did you set up X?". Day 5: case study. Use Laravel's Mailable and ShouldQueue for all of these.
Performance Considerations at Scale
N+1 Query Prevention
The most common performance issue in Laravel SaaS applications is the N+1 query problem. Eager load relationships:
php
// Wrong — 1 query for teams + N queries for each team's members
$teams = Team::all();
foreach ($teams as $team) {
echo $team->members->count();
}
// Right — 2 queries total
$teams = Team::with('members')->get();Use Laravel Telescope in development to watch query counts per request. Any page firing more than 10 queries warrants investigation.
Redis Caching for Frequently-Read Data
Plan features, team settings, and permission data are read on every request. Cache them:
php
$planFeatures = Cache::remember("team.{$team->id}.features", 3600, function () use ($team) {
return config("plans.{$team->plan}.features");
});Invalidate the cache when the plan changes:
php
// In your webhook handler after subscription updated:
Cache::forget("team.{$team->id}.features");What Separates a Good Laravel SaaS Developer from a Great One
You can find dozens of Laravel developers who know the framework. Building a SaaS product correctly requires knowing what goes wrong at scale — not just what works in development.
The architecture above represents decisions that need to be made before the first user signs up. A developer who proposes building tenant isolation "later when it's needed" does not understand SaaS. A developer who implements team_id filters manually on every query does not understand the risk they are introducing.
When you are evaluating a Laravel SaaS developer, ask them to walk you through their multi-tenancy approach. Ask how they handle Stripe webhook failures. Ask what happens when a team's payment lapses and they have 50,000 records in your database. The answers will tell you whether they've built SaaS before or just read about it.
If you are looking for a Laravel SaaS developer who has built multi-tenant products from scratch, handled billing edge cases in production, and can walk you through every architecture decision before writing a line of code — I'd like to talk to you.
Work with a Laravel SaaS developer →
Arun Tyagi is a senior Laravel developer based in Noida, India, specializing in SaaS product development for startups and growth-stage companies. Book a call → | View SaaS projects →
Internal Links Used:
- Laravel SaaS developer — 2x contextual
- book a call
- portfolio
Related Posts
PHP Laravel Framework Deep Dive
Explore the powerful features of Laravel framework and how to leverage them in your projects.
Laravel Developer in Dubai – Arun Tyagi Web Development Solutions
Dubai's technology sector—anchored by free zones like DIFC, Dubai Internet City, and DMCC—generates...
Leading Web Development Freelancer in NCR – Arun Tyagi
Delhi NCR hosts one of India's largest concentrations of freelance web developers, with skill levels...