Multi-Tenant SaaS with Laravel
Building a SaaS application where users can belong to multiple organizations or workspaces is a common requirement. Think Slack, GitHub, or any platform where you switch between teams. In this guide, we will build a multi-tenant Laravel application using Spatie laravel-multitenancy package with a single database and subdomain-based tenant identification.
The key challenge: users need to access multiple tenants securely. Let us dive into the implementation.
Quick Setup
Install the package and publish its configuration:
composer require spatie/laravel-multitenancy
php artisan vendor:publish --tag="multitenancy-config"
Add the base domain to your .env file:
TENANT_BASE_DOMAIN=yourapp.test
Since users will switch between tenant subdomains, configure your session to work across all subdomains by adding a leading dot to the session domain:
SESSION_DOMAIN=.yourapp.test
This allows the authentication session to persist when users navigate between tenant-a.yourapp.test and tenant-b.yourapp.test, preventing them from having to log in again when switching tenants.
Database Migrations
Create the tenants table and pivot table for the many-to-many relationship between users and tenants:
Schema::create('tenants', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->string('domain')->nullable();
$table->json('data')->nullable();
$table->timestamps();
});
Schema::create('tenant_user', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->timestamps();
});
// Add tenant_id to users table for tracking last accessed tenant
Schema::table('users', function (Blueprint $table) {
$table->foreignId('last_tenant_id')->nullable()->constrained('tenants');
});
For any model that needs tenant isolation, add a tenant_id column:
Schema::create('projects', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('status')->default('active');
$table->timestamps();
});
Configuration
Here is the key configuration in config/multitenancy.php:
return [
// Identifies tenant from subdomain during request bootstrap
'tenant_finder' => DomainOrSubdomainTenantFinder::class,
// Tasks executed when tenant becomes current
'switch_tenant_tasks' => [
// Prefix cache keys by tenant to prevent collisions
PrefixCacheTask::class,
// Set permissions context (if using spatie/laravel-permission)
SwitchPermissionsTeamTask::class,
],
// Your Tenant model
'tenant_model' => Tenant::class,
// Automatically make queued jobs tenant-aware
'queues_are_tenant_aware_by_default' => true,
// Single database setup - both null
'tenant_database_connection_name' => null,
'landlord_database_connection_name' => null,
// Base domain for subdomains
'tenant_subdomain_base' => env('TENANT_BASE_DOMAIN', ''),
];
Note: We are NOT using SwitchTenantDatabaseTask (single database) or Spatie session middleware (does not work for multi-tenant users).
The Tenant Model
Your Tenant model extends Spatie base and defines relationships:
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Spatie\Multitenancy\Models\Tenant as MultiTenantModel;
class Tenant extends MultiTenantModel
{
protected $fillable = ['name', 'slug', 'domain', 'data'];
/**
* Users who have access to this tenant (many-to-many)
*/
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class)->withTimestamps();
}
/**
* Tenant-scoped resources (one-to-many)
*/
public function projects(): HasMany
{
return $this->hasMany(Project::class);
}
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
}
BelongsToTenant Trait
This trait handles automatic tenant scoping. Add it to any model that should be isolated by tenant:
namespace App\Models\Concerns;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
// Global scope: automatically filter all queries by current tenant
static::addGlobalScope('tenant', function (Builder $builder): void {
if (($tenant = Tenant::current()) instanceof Tenant) {
$builder->where(
$builder->qualifyColumn($builder->getModel()->getTenantForeignKey()),
$tenant->getKey()
);
}
});
// Auto-set tenant_id when creating models
static::creating(function (Model $model): void {
if (($tenant = Tenant::current()) instanceof Tenant) {
$model->setAttribute($model->getTenantForeignKey(), $tenant->getKey());
}
});
}
public function getTenantForeignKey(): string
{
return 'tenant_id';
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, $this->getTenantForeignKey());
}
/**
* Query without tenant filtering (admin operations)
*/
public function scopeWithoutTenantScope(Builder $builder): Builder
{
return $builder->withoutGlobalScope('tenant');
}
/**
* Query a specific tenant data
*/
public function scopeForTenant(Builder $builder, Tenant $tenant): Builder
{
return $builder->withoutGlobalScope('tenant')
->where($builder->qualifyColumn($this->getTenantForeignKey()), $tenant->getKey());
}
}
Usage Example
Add the trait to any model that needs tenant isolation:
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
class Project extends Model
{
use BelongsToTenant;
protected $fillable = ['tenant_id', 'name', 'status', 'description'];
}
Now all queries are automatically scoped:
// Only returns projects for current tenant
$projects = Project::all();
// Automatically sets tenant_id
$project = Project::create(['name' => 'New Project']);
// Cross-tenant query when needed (admin)
$allProjects = Project::withoutTenantScope()->get();
Security Middleware
This is the critical piece. Spatie's EnsureValidTenantSession middleware won't work for us because it's designed for users who belong to a single tenant. When a user can access multiple tenants, we need database-level authorization on every request.
Here is our custom middleware:
namespace App\Http\Middleware;
use App\Contracts\TenantUrlGenerator;
use App\Models\Tenant;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class EnsureUserBelongsToTenant
{
public function __construct(protected TenantUrlGenerator $urlService) {}
public function handle(Request $request, Closure $next): Response
{
$user = Auth::user();
$currentTenant = Tenant::current();
if (! $currentTenant instanceof Tenant) {
return redirect()->to('/');
}
if (! $user) {
return $next($request);
}
// THE SECURITY CHECK: Does user belong to this tenant?
if (! $user->tenants()->where('tenants.id', $currentTenant->id)->exists()) {
$defaultTenant = $user->getDefaultTenant();
if (! $defaultTenant) {
Auth::logout();
return redirect()->to('/')
->with('error', 'You do not have access to any workspaces.');
}
$redirectUrl = $this->urlService->tenantUrl($defaultTenant, $request->getPathInfo());
return redirect()->to($redirectUrl)
->with('info', 'You were redirected to your workspace.');
}
if ($user->last_tenant_id !== $currentTenant->id) {
$user->updateLastTenant($currentTenant);
}
return $next($request);
}
}
Key security points:
Database query on every request - no session trust
Subdomain determines tenant - user cannot manipulate
Automatic redirect to user default tenant if unauthorized
Route Configuration
In bootstrap/app.php, register the middleware:
$middleware->group('tenant', [
\Spatie\Multitenancy\Http\Middleware\NeedsTenant::class,
]);
$middleware->alias([
'ensure_user_belongs_to_tenant' => \App\Http\Middleware\EnsureUserBelongsToTenant::class,
]);
In routes/tenant.php:
// Protected tenant routes
Route::middleware(['tenant', 'ensure_user_belongs_to_tenant', 'auth', 'verified'])
->group(function () {
Route::get('dashboard', [DashboardController::class, 'index']);
Route::resource('projects', ProjectController::class);
});
Usage in Practice
With the trait and middleware in place, tenant isolation happens automatically:
// In your controllers - queries are automatically scoped
public function index()
{
$projects = Project::with('tasks')->get();
return view('projects.index', compact('projects'));
}
public function store(StoreProjectRequest $request)
{
// tenant_id is automatically set
$project = Project::create($request->validated());
return redirect()->route('projects.show', $project);
}
Get the current tenant when needed:
use App\Models\Tenant;
$tenant = Tenant::current();
$tenantName = $tenant->name;
$users = $tenant->users;
Beyond the Basics
This implementation provides a solid foundation for multi-tenant applications. As your needs grow, there are several directions you can take it.
The middleware performs a database query on every request to verify tenant membership. For high-traffic applications, you will want to cache this relationship using Laravel Cache or Redis. Remember to invalidate the cache when user-tenant relationships change.
If you are using Inertia or another SPA framework, note that cross-subdomain navigation requires special handling. Regular redirects will not work - you will need to use Inertia::location() or similar methods to force a full page reload when switching between tenant subdomains.
You may also want to consider storing tenant membership in the session after initial verification, implementing more sophisticated error handling, adding audit logging for tenant switches, or creating custom switch tasks for tenant-specific configurations like timezone or locale settings.
The patterns shown here give you a working multi-tenant system. From this foundation, you can add the specific features and optimizations your application needs.
Conclusion
This single-database, subdomain-based approach works well for SaaS applications where users need access to multiple organizations. The architecture is straightforward: subdomain determines the tenant, database queries verify authorization, and global scopes handle data isolation automatically.
The key benefits of this approach include simplified infrastructure (no database-per-tenant complexity), easy tenant switching for users, and Laravel-native patterns that feel natural. The BelongsToTenant trait eliminates the need to manually scope queries, reducing bugs and keeping your codebase clean.
As your application grows, you can optimize the middleware authorization check with caching, add more sophisticated switch tasks, or implement tenant-specific customizations. The foundation provided here scales well from small startups to enterprise applications.
Resources
Spatie Multitenancy docs – https://spatie.be/docs/laravel-multitenancy/v4/introduction
Laravel Global Scopes – https://laravel.com/docs/eloquent#global-scopes
Isaac Earl
// Lead DeveloperRelated Posts
-
Development Stateless Social Authentication with SocialiteLearn how to use Laravel Socialite for stateless social authentication in APIs, ideal for SPAs or mobile apps that don’t rely on sessions.
Read More