30 October 2026 · 1 min read
How to build a multi-tenant SaaS in Laravel (step by step)
TL;DR
Tenants
acme.yourapp.test, globex.yourapp.test
One database
tenant_id on every tenant-owned table.
Billing
Laravel Cashier + Stripe.
Isolation tests
Pest tests that fail on leaks.
01How a request flows
Request
acme.yourapp.test
Middleware
find tenant by subdomain
Bind tenant
app container
Global scope
where tenant_id = ?
Response
only Acme data
02Pick a tenancy model
| Single DB + tenant_id | Database per tenant | |
|---|---|---|
| Setup effort | Low | Higher (provisioning, migrations per DB) |
| Isolation | Logical (scopes + tests) | Physical |
| Cross-tenant reporting | Easy | Harder |
| Good for | Most B2B SaaS starting out | Strict compliance needs |
This guide uses single database + tenant_id. For database-per-tenant, look at a package such as stancl/tenancy.
03Prerequisites
- 1
PHP 8.2+ and Composer
Check the PHP version your Laravel release requires.
- 2
A database
MySQL or PostgreSQL.
- 3
Stripe test account
For the test-mode keys.
- 4
Local subdomains
Laravel Herd/Valet, or entries in your hosts file.
04Create the app and the tenants table
composer create-project laravel/laravel saas
cd saas
php artisan make:model Tenant -m// database/migrations/xxxx_create_tenants_table.php
Schema::create('tenants', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('subdomain')->unique();
$table->timestamps();
});
// add to the users migration (and every tenant-owned table)
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();05Resolve the tenant from the subdomain
php artisan make:middleware IdentifyTenant// app/Http/Middleware/IdentifyTenant.php
public function handle(Request $request, Closure $next): Response
{
$sub = explode('.', $request->getHost())[0];
$tenant = Tenant::where('subdomain', $sub)->firstOrFail();
app()->instance(Tenant::class, $tenant);
return $next($request);
}// routes/web.php
Route::domain('{tenant}.' . config('app.domain'))
->middleware(['web', IdentifyTenant::class])
->group(function () {
Route::get('/', DashboardController::class);
});Add 'domain' => env('APP_DOMAIN') to config/app.php and set APP_DOMAIN=yourapp.test.
06Scope every query to the tenant
// app/Models/Concerns/BelongsToTenant.php
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
static::addGlobalScope('tenant', function (Builder $query) {
if (app()->bound(Tenant::class)) {
$query->where($query->getModel()->getTable() . '.tenant_id', app(Tenant::class)->id);
}
});
static::creating(function (Model $model) {
if (app()->bound(Tenant::class) && ! $model->tenant_id) {
$model->tenant_id = app(Tenant::class)->id;
}
});
}
}Add use BelongsToTenant; to every tenant-owned model (Project, Invoice...).
DEEP DIVEQueued jobs need the tenant too
Jobs run outside the request, so the tenant isn't bound. Pass the tenant id into the job and bind it at the start of handle():
public function __construct(public int $tenantId) {}
public function handle(): void
{
app()->instance(Tenant::class, Tenant::findOrFail($this->tenantId));
// ... tenant-scoped work
}07Add Stripe billing per tenant
composer require laravel/cashier
php artisan vendor:publish --tag="cashier-migrations"
php artisan migrate// The tenant pays, not the user:
use Laravel\Cashier\Billable;
class Tenant extends Model { use Billable; }
// app/Providers/AppServiceProvider.php -> boot()
Cashier::useCustomerModel(Tenant::class);The published migrations target users; edit them to add the billing columns to tenants and point subscriptions at the tenant before migrating.
// start a subscription (Stripe Checkout)
return $tenant->newSubscription('default', 'price_XXXX')
->checkout(['success_url' => route('billing.done'), 'cancel_url' => route('billing')]);Put STRIPE_KEY, STRIPE_SECRET and STRIPE_WEBHOOK_SECRET in .env and register the webhook at /stripe/webhook.
08Prove isolation with a test
// tests/Feature/TenantIsolationTest.php (Pest)
it('never shows another tenant\'s projects', function () {
$acme = Tenant::factory()->create(['subdomain' => 'acme']);
$globex = Tenant::factory()->create(['subdomain' => 'globex']);
Project::factory()->for($globex)->create(['name' => 'Secret']);
app()->instance(Tenant::class, $acme);
expect(Project::count())->toBe(0);
});Write one of these for every tenant-owned model. It's the cheapest insurance you'll ever buy.
09Pitfalls and fixes
Raw queries skip scopes
DB::table() ignores global scopes; add where tenant_id yourself.
Unique columns
Make them unique per tenant: unique([tenant_id, email]).
Cache key collisions
Prefix cache keys with the tenant id.
Route model binding
Scoped models 404 on other tenants; test it.
10Checklist
Tenant resolved from subdomain
Unknown subdomains return 404.
Every tenant table has tenant_id
And uses BelongsToTenant.
Billing on the tenant
Webhook registered and tested.
Isolation tests in CI
They run on every push.