Laravel

How to Build a Multi-Tenant SaaS on Laravel — The Right Way

Arun Tyagi
June 18, 2026
21 views

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:

  1. Registration — email, password, team name. One screen.
  2. Email verification — mandatory before accessing the app. Use Laravel's built-in MustVerifyEmail.
  3. Workspace setup — first login redirects to a setup wizard. Collect what you need to personalize the experience (industry, team size, primary goal).
  4. First action — guide the user to create their first project / record / item immediately. The faster a user creates something, the lower their churn rate.
  5. Invitation — prompt to invite a teammate. Users who invite someone in their first session have dramatically higher retention.
  6. 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: