Multi-tenant Laravel applicaties voor SaaS platforms
Terug naar blog

Multi-tenant Laravel applicaties voor SaaS platforms

AuthorRuthger Idema
8 april 202611 min leestijd

Database per tenant of een gedeelde database met een tenant_id kolom? Het antwoord bepaalt je schaalbaarheid, beveiliging en maandelijkse hostingkosten. Hier is hoe wij het aanpakken.

Multi-tenant Laravel applicaties voor SaaS platforms

1 database voor alle klanten of 1 database per klant. Die keuze bepaalt je architectuur, beveiliging, schaalbaarheid en hosting kosten voor de volgende vijf jaar. De meeste teams maken hem te laat, als migreren al pijn doet.

Multi-tenancy is het fundament van SaaS. Elke klant die jouw platform gebruikt is een tenant. Hoe je hun data scheidt, isoleert en beheert verschilt fundamenteel per aanpak. Dit artikel legt het uit aan de hand van concrete Laravel implementaties.

Wat je leert in dit artikel

  • De drie multi-tenant architecturen en hun trade-offs
  • Database per tenant implementeren met Spatie Multitenancy
  • Shared database aanpak met tenant_id scoping
  • Automatisch tenant routing via subdomain of domein
  • Data isolatie afdwingen zodat tenant A nooit data van tenant B ziet

De drie architecturen

Er zijn drie fundamentele patronen voor multi-tenancy.

Single database, shared tables — Alle tenants staan in dezelfde tabellen. Elke rij heeft een tenant_id kolom. Simpel te implementeren, risico op data lekkage als je een query vergeet te scoperen. Single database, separate schemas — Elke tenant krijgt zijn eigen PostgreSQL schema. Meer isolatie, complexer migratiebeheer. Werkt goed op PostgreSQL, minder op MySQL. Database per tenant — Elke tenant heeft zijn eigen database. Maximale isolatie, eenvoudig te backuppen per klant, complexer in beheer bij grote aantallen tenants.
AanpakIsolatieBeheerslastSchaalbaarheidKosten
Shared tablesLaagLaagHoogLaag
Separate schemasMiddelMiddelMiddelMiddel
Database per tenantHoogHoogLaag bij >1000 tenantsHoog

De juiste keuze hangt af van twee factoren: hoeveel tenants verwacht je, en hoe streng zijn de data-isolatie eisen van je klanten.

Spatie Laravel Multitenancy

Het meest gebruikte package voor multi-tenancy in Laravel is spatie/laravel-multitenancy. Het ondersteunt zowel database per tenant als shared database aanpakken.

Installatie en configuratie

bash
composer require spatie/laravel-multitenancy
php artisan vendor:publish --provider="Spatie\Multitenancy\MultitenancyServiceProvider" --tag="multitenancy-config"
php artisan vendor:publish --provider="Spatie\Multitenancy\MultitenancyServiceProvider" --tag="multitenancy-migrations"
php artisan migrate

Het package maakt een tenants tabel aan in je landlord database (de centrale database die tenants beheert).

php
// config/multitenancy.php
return [
    'tenant_model' => \App\Models\Tenant::class,

    // Hoe wordt een tenant gevonden bij een request?
    'tenant_finder' => \Spatie\Multitenancy\TenantFinder\DomainTenantFinder::class,

    // Wat gebeurt er als een tenant actief wordt gemaakt?
    'switch_tenant_tasks' => [
        \Spatie\Multitenancy\Tasks\SwitchTenantDatabase::class,
        \App\Multitenancy\Tasks\SwitchTenantCache::class,
    ],
];

Tenant model

php
// app/Models/Tenant.php
namespace App\Models;

use Spatie\Multitenancy\Models\Tenant as BaseTenant;

class Tenant extends BaseTenant
{
    protected $fillable = [
        'name',
        'domain',
        'database',
        'plan',
        'settings',
    ];

    protected $casts = [
        'settings' => 'array',
    ];

    // Tenant aanmaken inclusief database
    public static function createWithDatabase(array $attributes): self
    {
        $tenant = self::create($attributes);

        // Database aanmaken
        \DB::statement("CREATE DATABASE `{$tenant->database}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");

        // Migraties uitvoeren op de nieuwe tenant database
        $tenant->execute(function () {
            \Artisan::call('migrate', [
                '--path' => 'database/migrations/tenant',
                '--force' => true,
            ]);
        });

        return $tenant;
    }
}

Automatisch tenant wisselen per request

Het package detecteert de tenant op basis van het domein en schakelt automatisch naar de juiste database.

php
// app/Http/Middleware/EnsureValidTenantSession.php
namespace App\Http\Middleware;

use Closure;
use Spatie\Multitenancy\Models\Tenant;

class EnsureValidTenantSession
{
    public function handle($request, Closure $next)
    {
        if (! app()->bound('currentTenant')) {
            abort(404, 'Tenant niet gevonden.');
        }

        return $next($request);
    }
}
php
// routes/tenant.php — routes die alleen beschikbaar zijn voor tenants
Route::middleware(['web', \Spatie\Multitenancy\Http\Middleware\NeedsTenant::class])->group(function () {
    Route::get('/dashboard', [DashboardController::class, 'index']);
    Route::resource('/orders', OrderController::class);
    Route::resource('/products', ProductController::class);
});

Shared database met tenant scoping

Voor platforms met honderden kleine tenants is shared database vaak de betere keuze. Je scope alle queries automatisch op de actieve tenant.

php
// app/Traits/BelongsToTenant.php
namespace App\Traits;

use Illuminate\Database\Eloquent\Builder;
use Spatie\Multitenancy\Models\Concerns\UsesTenantModel;

trait BelongsToTenant
{
    use UsesTenantModel;

    protected static function bootBelongsToTenant(): void
    {
        // Automatisch tenant_id toevoegen bij aanmaken
        static::creating(function ($model) {
            if (! $model->tenant_id && app()->bound('currentTenant')) {
                $model->tenant_id = app('currentTenant')->id;
            }
        });

        // Automatisch filteren op tenant_id bij alle queries
        static::addGlobalScope('tenant', function (Builder $query) {
            if (app()->bound('currentTenant')) {
                $query->where('tenant_id', app('currentTenant')->id);
            }
        });
    }
}
php
// app/Models/Order.php
class Order extends Model
{
    use BelongsToTenant;

    // Order::all() geeft automatisch alleen orders van de huidige tenant
    // Order::create([...]) vult tenant_id automatisch in
}

Data isolatie testen

Een multi-tenant applicatie zonder tests op data isolatie is een tikkende tijdbom.

php
// tests/Feature/TenantIsolationTest.php
class TenantIsolationTest extends TestCase
{
    public function test_tenant_cannot_see_orders_of_other_tenant(): void
    {
        $tenantA = Tenant::factory()->create();
        $tenantB = Tenant::factory()->create();

        $orderA = Order::factory()->create(['tenant_id' => $tenantA->id]);
        $orderB = Order::factory()->create(['tenant_id' => $tenantB->id]);

        // Activeer tenant A
        $tenantA->makeCurrent();

        $visibleOrders = Order::all();

        // Tenant A mag alleen zijn eigen order zien
        $this->assertCount(1, $visibleOrders);
        $this->assertEquals($orderA->id, $visibleOrders->first()->id);
        $this->assertFalse($visibleOrders->contains($orderB));
    }
}

Subdomain routing

SaaS platforms gebruiken vaak subdomains per tenant: klant-a.jouweapp.nl, klant-b.jouweapp.nl.

php
// app/Multitenancy/TenantFinder/SubdomainTenantFinder.php
namespace App\Multitenancy\TenantFinder;

use Illuminate\Http\Request;
use Spatie\Multitenancy\Contracts\FindsTenant;
use Spatie\Multitenancy\Models\Tenant;

class SubdomainTenantFinder implements FindsTenant
{
    public function findForRequest(Request $request): ?Tenant
    {
        $host = $request->getHost();
        $baseDomain = config('app.base_domain'); // bijv. 'jouweapp.nl'

        // Verwijder base domain om subdomain te isoleren
        $subdomain = str_replace(".{$baseDomain}", '', $host);

        if ($subdomain === $baseDomain || empty($subdomain)) {
            return null; // Landlord/marketing domein
        }

        return Tenant::where('subdomain', $subdomain)->first();
    }
}
php
// config/multitenancy.php
'tenant_finder' => \App\Multitenancy\TenantFinder\SubdomainTenantFinder::class,

Migraties beheren voor meerdere databases

Bij database per tenant voer je migraties uit op elke tenant database afzonderlijk. Dit vereist een andere aanpak dan de standaard php artisan migrate.

php
// app/Console/Commands/MigrateTenants.php
class MigrateTenants extends Command
{
    protected $signature = 'tenants:migrate {--fresh} {--seed}';

    public function handle(): void
    {
        Tenant::all()->each(function (Tenant $tenant) {
            $this->info("Migrating tenant: {$tenant->name}");

            $tenant->execute(function () {
                Artisan::call('migrate', [
                    '--path' => 'database/migrations/tenant',
                    '--force' => true,
                ]);
            });
        });

        $this->info('Alle tenant databases bijgewerkt.');
    }
}

Zet migraties voor de landlord database in database/migrations/landlord/ en tenant migraties in database/migrations/tenant/. Zo blijft het overzichtelijk.

Veelgemaakte fouten

  1. Geen isolatietests schrijven — De global scope werkt totdat iemand een raw query schrijft of de scope uitschakelt. Test isolatie expliciet.
  2. Tenant context in queued jobs vergeten — Jobs draaien buiten de HTTP request cycle. Sla de tenant_id op in de job en zet de tenant actief in handle().
  3. Cache niet per tenant scheiden — Als je Redis cache deelt tussen tenants zonder prefix, kan tenant A gecachte data van tenant B zien. Gebruik SwitchTenantCache task.
  4. Te vroeg kiezen voor database per tenant — Bij 50 tenants is het beheerbaar. Bij 5000 tenants heb je tooling nodig voor migraties, monitoring en backups. Plan dit vooraf.
php
// app/Jobs/ProcessTenantOrder.php — correct tenant context in jobs
class ProcessTenantOrder implements ShouldQueue
{
    public function __construct(
        private int $tenantId,
        private int $orderId,
    ) {}

    public function handle(): void
    {
        $tenant = Tenant::find($this->tenantId);

        $tenant->execute(function () {
            $order = Order::findOrFail($this->orderId);
            // Verwerk order in de context van de juiste tenant
        });
    }
}

Conclusie

Multi-tenancy is geen feature die je later toevoegt. Het is een architectuurkeuze die je bij de start maakt. Shared database is eenvoudiger en goedkoper voor de meeste SaaS platforms. Database per tenant geeft maximale isolatie maar vraagt meer van je operationele processen.

Begin met de juiste keuze, bouw isolatietests vanaf dag één en gebruik Spatie's package om het zware werk te doen.

De Spatie Laravel Multitenancy package is de meest gebruikte oplossing in het Laravel-ecosysteem. Zie ook de Laravel documentatie over database connections voor de onderliggende configuratie.

Gerelateerde artikelen:

Bekijk onze Laravel-diensten of neem contact op voor een architectuurgesprek over jouw SaaS platform.

Ruthger Idema

Geschreven door Ruthger Idema

15+ jaar ervaring in e-commerce development. Gespecialiseerd in Magento, Shopify en Laravel maatwerk.

Meer over ons team →
Deel dit artikel:

Wil je jouw e-commerce naar het volgende niveau?

Plan een vrijblijvend gesprek met onze experts over Magento, Shopify of Laravel maatwerk.

Plan een Tech Check