Terug naar overzicht
Backend Development

Multi-tenancy in Symfony en PostgreSQL: één codebase, één database, strikt gescheiden klanten

Jens Gepubliceerd op 14 Sep 2026 18 min leestijd
Multi-tenancy in Symfony en PostgreSQL: één codebase, één database, strikt gescheiden klanten

Onze SaaS-platformen LedenAdmin, ShootWise en SchoolWise hebben één ding gemeen: elk platform is één Symfony-applicatie op één PostgreSQL-database, en toch mag geen enkele vereniging of school ooit een rij van een andere te zien krijgen. Eén vergeten WHERE is daar geen bug, maar een datalek. Dat maakt multi-tenancy tegelijk de goedkoopste en de meest onvergeeflijke architectuurkeuze die u als SaaS-bouwer maakt.

In dit artikel leggen we open hoe we dat aanpakken. Niet als theoretisch overzicht, maar met de patronen die vandaag in onze eigen platformen draaien, de fouten die we onderweg maakten en de lagen die we bij een nieuw platform vanaf dag één zouden toevoegen. Het is een lang en technisch verhaal; wie zelf een SaaS-platform bouwt of laat bouwen, vindt hier concrete code voor Symfony, Doctrine en PostgreSQL.

Drie manieren om tenants te scheiden

Een tenant is één klantorganisatie op het platform: een hondenschool bij LedenAdmin, een schietclub bij ShootWise, een school bij SchoolWise. De vraag die alles bepaalt: waar legt u de grens tussen tenants?

Model Isolatie Onboarding Migraties Kost per tenant
Database per tenant Zeer sterk, fysiek gescheiden Database aanmaken, credentials, connectie Eén keer per tenant Hoog: connecties, backups en monitoring × N
Schema per tenant Sterk, via search_path Schema aanmaken en tabellen kopiëren Eén keer per tenant Gemiddeld: catalogus groeit mee, eigen tooling nodig
Gedeeld schema met tenantkolom Verantwoordelijkheid van de applicatie Eén INSERT Eén keer voor iedereen Laag

Database-per-tenant is de veiligste keuze op papier: een query kán niet eens bij de data van een andere klant. Maar met honderden kleine tenants betaalt u dat in connecties, backups, monitoring en vooral in migraties die u N keer moet draaien en die halverwege kunnen falen. Schema-per-tenant zit daartussen, maar Doctrine Migrations is niet gebouwd om één migratie over honderden schema's uit te rollen; die tooling bouwt u zelf.

Wij kozen voor al onze platformen het derde model: één schema, en op elke tabel een kolom die naar de tenant verwijst. Onboarding van een nieuwe vereniging is een handvol inserts in één transactie, een migratie draait één keer, en rapportage over alle tenants heen (voor onszelf, nooit voor klanten) is een gewone query. De prijs: de isolatie is volledig de verantwoordelijkheid van de applicatie. De rest van dit artikel gaat over hoe we die verantwoordelijkheid dragen.

Eén uitzondering bevestigt de regel. Hostree, ons websiteplatform voor lokale ondernemers, isoleert op infrastructuurniveau: elke klant krijgt een eigen Plesk-abonnement, domeinnaam en mailboxen. Daar ís de infrastructuur het product, dus daar hoort de grens.

Het datamodel: elke rij kent zijn eigenaar

De tenant is bij ons geen abstracte Tenant-entiteit, maar een volwaardig domeinobject: Club bij LedenAdmin en ShootWise, School bij SchoolWise. Een entiteit die van een tenant is, draagt een ManyToOne naar dat object:

#[ORM\Entity]
class Member implements TenantOwned
{
    #[ORM\ManyToOne(inversedBy: 'members')]
    #[ORM\JoinColumn(nullable: false)]
    private Club $club;
}

Tot zover niets bijzonders. De belangrijke vraag is: welke entiteiten krijgen zo'n kolom? Ons eerste instinct was: enkel de "hoofdentiteiten". Een lidkaart hoort bij een lid, een lid hoort bij een club, dus de lidkaart erft haar tenant via het lid. Dat klopt relationeel, maar het maakt elke isolatiemaatregel verderop moeilijker: een filter op MembershipCard moet dan via een join naar Member en pas dan naar Club. Hoe verder een entiteit van de tenant staat, hoe meer speciale gevallen u opstapelt.

De les die we daaruit trokken, en die we bij elk nieuw platform toepassen: denormaliseer de tenantkolom op elke tabel die van een tenant is, ook als ze afleidbaar is. Eén integerkolom extra per rij is niets; wat u ervoor terugkrijgt is dat elke filter, elke index en elke databankpolicy naar dezelfde kolom kan kijken zonder joins.

Drie afspraken die daarbij horen:

  • Indexen beginnen met de tenantkolom. Vrijwel elke query in een multi-tenant platform filtert eerst op tenant. Een index op (club_id, created_at) bedient dus zowel "alle leden van club X" als "de nieuwste leden van club X".
  • Uniciteit is per tenant. Een e-mailadres mag in twee clubs voorkomen, maar niet twee keer in dezelfde club: UNIQUE (club_id, email), niet UNIQUE (email).
  • Relaties mogen de tenantgrens niet oversteken. Een betaling van club A mag nooit naar een lid van club B verwijzen. PostgreSQL dwingt dat af met een samengestelde foreign key: FOREIGN KEY (club_id, member_id) REFERENCES member (club_id, id). Dat vereist een UNIQUE (club_id, id) op de doeltabel, maar daarna is een cross-tenant referentie op databankniveau onmogelijk, wat de applicatie ook probeert.

Nog een detail dat groter is dan het lijkt: interne id's zijn oplopende integers, maar in publieke URL's gebruiken we UUID's. LedenAdmin heeft een uuid op de club voor publieke inschrijvingspagina's, SchoolWise een UUIDv7 op elke entiteit. Niet omdat onraadbaarheid isolatie vervangt (dat doet het nooit), maar omdat een oplopend id in een URL uitnodigt om ?id=42 eens te proberen.

Wie is er aan het woord? Tenantresolutie

Voor elke request moet de applicatie exact één vraag beantwoorden: voor welke tenant werk ik nu? Er zijn drie gangbare antwoorden.

Via de gebruiker. In onze platformen is de tenant een kolom op User. Wie inlogt, werkt in de club van zijn account, punt. Geen DNS-configuratie, geen wildcardcertificaten, en de tenant is bekend zodra de firewall de gebruiker heeft geauthenticeerd. Het nadeel: iemand die in twee clubs actief is, heeft twee accounts. Voor verenigingssoftware is dat een aanvaardbare ruil.

Via de URL. Publieke pagina's hebben geen ingelogde gebruiker, maar horen wél bij een tenant: de inschrijvingspagina voor een activiteit, een betaallink, de webhook die Mollie aanroept. Daar zit de tenant als UUID in het pad:

#[Route('/public/activity/{clubUuid}/{activityUuid}', name: 'app_public_activity_registration')]
public function register(string $clubUuid, string $activityUuid, Request $request): Response
{
    $club = $this->clubRepository->findOneBy(['uuid' => $clubUuid]);

    if (!$club instanceof Club) {
        throw $this->createNotFoundException();
    }

    $request->setLocale($this->localeService->resolveClubLocale($club));
    // ...
}

Merk op dat ook de taal van de pagina uit de tenant komt: een Franstalige club krijgt een Franstalige inschrijvingspagina, ook al heeft de bezoeker geen account.

Via de hostnaam. Subdomeinen (club.platform.be) of eigen domeinen per tenant zijn de klassieke white-label-aanpak. Dat vraagt een wildcard-DNS-record, een wildcardcertificaat of on-demand TLS, en een resolver die de hostnaam naar een tenant vertaalt. Bij Hostree gebruiken we een variant daarvan: geen tenant per host, maar een module per host, rechtstreeks in de routing:

# config/routes.yaml
preview:
    resource: ../src/Preview/Controller/
    type: attribute
    host: '%app.preview_host%'

website:
    resource: ../src/Website/Controller/
    type: attribute
    host: '%app.website_host%'

Welk mechanisme u ook kiest, de ontwerpregel is dezelfde: los de tenant één keer op, vroeg in de request, en stop hem in een dienst waar de rest van de applicatie hem opvraagt. Wij noemen die dienst de TenantContext. Ze doet meer dan een waarde bijhouden: elke keer dat de tenant verandert, geeft ze dat door aan de twee isolatielagen die verderop aan bod komen.

final class TenantContext
{
    private ?Club $current = null;

    public function __construct(
        private readonly EntityManagerInterface $em,
    ) {
    }

    public function activate(Club $club): void
    {
        $this->current = $club;
        $this->propagate((string) $club->getId());
    }

    public function clear(): void
    {
        $this->current = null;
        $this->propagate('');
    }

    /** Voert $callback uit in de context van $club en herstelt daarna de vorige context. */
    public function run(Club $club, callable $callback): mixed
    {
        $previous = $this->current;
        $this->activate($club);

        try {
            return $callback();
        } finally {
            $previous === null ? $this->clear() : $this->activate($previous);
        }
    }

    public function current(): Club
    {
        return $this->current ?? throw new \LogicException('Geen actieve tenant.');
    }

    public function has(): bool
    {
        return $this->current !== null;
    }

    private function propagate(string $tenantId): void
    {
        // Laag 1: de Doctrine-filter (zie "Isolatie in de applicatielaag")
        $this->em->getFilters()->getFilter('tenant')->setParameter('tenantId', $tenantId);

        // Laag 2: de databanksessie (zie "Row-Level Security")
        $this->em->getConnection()->executeQuery(
            "SELECT set_config('app.tenant_id', ?, false)",
            [$tenantId],
        );
    }
}

Die activate() wordt aangeroepen door een listener die na de firewall draait. De Symfony-firewall luistert op prioriteit 8, dus alles met een lagere prioriteit ziet de ingelogde gebruiker:

#[AsEventListener(event: RequestEvent::class, priority: 5)]
final class TenantRequestListener
{
    public function __construct(
        private readonly Security $security,
        private readonly TenantContext $tenantContext,
    ) {
    }

    public function __invoke(RequestEvent $event): void
    {
        if (!$event->isMainRequest()) {
            return;
        }

        $user = $this->security->getUser();

        if ($user instanceof User) {
            $this->tenantContext->activate($user->getClub());
        }
    }
}

Controllers, services, Twig-extensies en repositories vragen de tenant voortaan aan de context, niet aan de request en niet aan $this->getUser(). Dat lijkt een detail, maar het is precies wat het straks mogelijk maakt om dezelfde code in een Messenger-handler of een cronjob te draaien, waar geen request en geen ingelogde gebruiker bestaat. Ook een platformbeheerder die van tenant wisselt, doet dat via diezelfde activate(), nooit door een filter uit te schakelen.

Isolatie in de applicatielaag: drie niveaus

Hier wordt het interessant, want onze drie platformen lossen dit op drie verschillende manieren op. Niet uit inconsistentie, maar omdat elk platform op een ander moment en met andere inzichten ontstond. Gerangschikt van minst naar meest automatisch:

Niveau 1: expliciet per query

In ShootWise zit de isolatie in elke repository-methode en in Security Voters. De repository voegt de voorwaarde zelf toe:

public function findAllByClub(): array
{
    /** @var User $user */
    $user = $this->security->getUser();

    return $this->findBy(['club' => $user->getClub()]);
}

En een voter bewaakt elk individueel object:

private function canEdit(Member $member, User $user): bool
{
    return $member->getClub() === $user->getClub()
        && $user->hasRole(UserService::ROLE_CLUB);
}

Dit werkt, en de voter-laag houden we tot vandaag in elk platform aan. Maar de repository-kant heeft een structureel probleem: de isolatie is opt-in per query. Elke nieuwe methode is een kans om de voorwaarde te vergeten, en een code review ziet het verschil tussen findBy(['status' => 'active']) en findBy(['status' => 'active', 'club' => $club]) niet altijd. Het gevaarlijkste patroon dat we tegenkwamen is de voorwaardelijke scoping: "als de gebruiker geen admin is, filter dan op club". Zodra de tenantfilter afhangt van een rol, hangt uw datalek ook af van een rol.

Niveau 2: automatisch op de API-laag

LedenAdmin bouwt zijn frontend en mobiele app op API Platform, en API Platform heeft een haak die precies hiervoor bestaat: query extensions. Eén klasse die QueryCollectionExtensionInterface en QueryItemExtensionInterface implementeert, past élke collectie- en item-query aan:

final class ClubExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
    /** Entiteiten die de club via een relatie bereiken */
    private const array CLUB_VIA_RELATION = [
        LessonSubscription::class => 'lesson.club',
        MembershipCard::class => 'conductor.club',
    ];

    public function applyToCollection(QueryBuilder $qb, QueryNameGeneratorInterface $generator, string $resourceClass, ?Operation $operation = null, array $context = []): void
    {
        $this->addWhere($qb, $resourceClass);
    }

    public function applyToItem(QueryBuilder $qb, QueryNameGeneratorInterface $generator, string $resourceClass, array $identifiers, ?Operation $operation = null, array $context = []): void
    {
        $this->addWhere($qb, $resourceClass);
    }

    private function addWhere(QueryBuilder $qb, string $resourceClass): void
    {
        $user = $this->security->getUser();

        if (!$user instanceof User) {
            return;
        }

        $rootAlias = $qb->getRootAliases()[0];

        if (isset(self::CLUB_VIA_RELATION[$resourceClass])) {
            $this->addWhereViaRelation($qb, $rootAlias, self::CLUB_VIA_RELATION[$resourceClass], $user);

            return;
        }

        if (property_exists($resourceClass, 'club')) {
            $qb->andWhere(sprintf('%s.club = :current_club', $rootAlias))
                ->setParameter('current_club', $user->getClub());
        }
    }
}

Een grote stap vooruit: geen enkel API-endpoint kan de filter nog vergeten. Maar kijk naar de twee ontsnappingsroutes. Ten eerste dekt de extensie enkel wat via API Platform loopt; de Twig-controllers en de AJAX-endpoints van datatabellen ernaast blijven handwerk. Ten tweede: een entiteit zonder club-property die ook niet in CLUB_VIA_RELATION staat, wordt stilzwijgend niet gefilterd. Dat is opnieuw isolatie door weglating, alleen een laag hoger. Het is precies het argument voor de denormalisatie uit het vorige hoofdstuk: als elke tabel een tenantkolom heeft, verdwijnt de uitzonderingslijst.

Niveau 3: een Doctrine SQL-filter

SchoolWise, het jongste van de drie platformen, legt de isolatie nog een laag dieper: in Doctrine zelf. Een SQLFilter voegt een voorwaarde toe aan de SQL die Doctrine genereert, ongeacht of die uit DQL, een find(), een lazy-loaded collectie of een proxy komt:

final class TenantFilter extends SQLFilter
{
    public function addFilterConstraint(ClassMetadata $targetEntity, string $targetTableAlias): string
    {
        if (!is_a($targetEntity->getName(), TenantOwned::class, true)) {
            return '';
        }

        // getParameter() geeft de waarde gequote terug ('42'), veilig om te interpoleren,
        // en gooit een exception als er nog nooit een tenant werd gezet.
        $tenantId = $this->getParameter('tenantId');

        if ($tenantId === "''") {
            throw new \LogicException('Geen actieve tenant: activeer er een of schakel de filter expliciet uit.');
        }

        return sprintf('%s.club_id = %s', $targetTableAlias, $tenantId);
    }
}
# config/packages/doctrine.yaml
doctrine:
    orm:
        filters:
            tenant:
                class: App\Doctrine\TenantFilter
                enabled: true

In SchoolWise beslist de filter per entiteitsklasse met een match, inclusief een subquery voor entiteiten die twee stappen van de school verwijderd staan (class_id IN (SELECT id FROM school_class WHERE school_id = ...)). In het voorbeeld hierboven doen we het zoals we het bij een nieuw platform zouden doen: een markerinterface TenantOwned op elke entiteit met een tenantkolom, zodat de filter geen lijst van klassen hoeft te kennen.

Let op de enabled: true. SchoolWise zet de filter pas aan in de request-listener, en dat is ook wat de meeste tutorials tonen. Wij verkiezen het omgekeerde: de filter staat altijd aan. Zolang niemand een tenant heeft geactiveerd, gooit de filter een exception bij de eerste query op een tenant-entiteit. Een cronjob die vergeet een tenant te activeren, crasht dus luid in plaats van stilletjes alle data van alle klanten te verwerken. Wie bewust over tenants heen wil werken (een migratie, een platformrapport) schakelt de filter expliciet uit met $em->getFilters()->disable('tenant'), en dat is een regel die in een code review meteen opvalt.

Vier eigenschappen van SQL-filters die u moet kennen voor u erop vertrouwt:

  • Ze gelden voor alles wat via de ORM loopt, inclusief find(), findBy(), lazy loading van collecties en het initialiseren van proxies. Een ManyToOne naar een rij van een andere tenant leidt bij het initialiseren tot een EntityNotFoundException, wat exact het gewenste gedrag is.
  • Ze gelden niet voor DBAL. Een $connection->executeQuery('SELECT ...') in een rapportageservice omzeilt de filter volledig. Dat is de belangrijkste reden voor de databanklaag in het volgende hoofdstuk.
  • De gebruiker is de uitzondering. De firewall laadt de User vóór de tenant bekend is. De User-entiteit draagt dus wel een club_id, maar valt niet onder de filter; het e-mailadres waarmee iemand inlogt, is platformbreed uniek.
  • De query cache houdt er rekening mee. Doctrine neemt de hash van de actieve filters en hun parameters op in de cache-sleutel van elke query, zodat de SQL van club A nooit uit de cache voor club B wordt geserveerd. Uw eigen caches (Symfony Cache, HTTP-cache, Redis) doen dat niet vanzelf: zet de tenant-id in elke cache-sleutel.

Row-Level Security: het vangnet in PostgreSQL

Alles tot hier zit in de applicatie. Eén ruwe SQL-query, één disable('tenant') die niet opnieuw wordt ingeschakeld, één bug in de filter, en de isolatie is weg. Wie dat risico wil afdekken, laat de databank zélf meekijken. PostgreSQL heeft daar sinds versie 9.5 een ingebouwd mechanisme voor: Row-Level Security (RLS).

Eerlijk: onze bestaande platformen draaien vandaag zonder RLS en vertrouwen op de lagen hierboven. Maar voor een nieuw platform is dit de laag die we er vanaf dag één naast zouden leggen, en het is minder werk dan het lijkt.

Het principe: per tabel definieert u een policy die bepaalt welke rijen een sessie mag zien en schrijven, en de tenant-id komt uit een sessievariabele:

ALTER TABLE member ENABLE ROW LEVEL SECURITY;
ALTER TABLE member FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON member
    USING (club_id = NULLIF(current_setting('app.tenant_id', true), '')::int)
    WITH CHECK (club_id = NULLIF(current_setting('app.tenant_id', true), '')::int);

Vier dingen gebeuren hier:

  1. ENABLE ROW LEVEL SECURITY activeert het mechanisme; zonder policy ziet een gewone rol dan geen enkele rij meer. Fail closed, zoals het hoort.
  2. FORCE zorgt dat ook de eigenaar van de tabel onder de policy valt. Superusers en rollen met BYPASSRLS blijven erbuiten, dus de applicatie verbindt met een gewone rol die niets bijzonders mag.
  3. USING bepaalt welke rijen zichtbaar zijn voor SELECT, UPDATE en DELETE; WITH CHECK bewaakt wat INSERT en UPDATE mogen wegschrijven. Een applicatiebug die een rij met het verkeerde club_id wil opslaan, krijgt een foutmelding in plaats van een stille schrijfactie.
  4. current_setting('app.tenant_id', true) leest de sessievariabele; de true maakt dat een ontbrekende variabele NULL oplevert in plaats van een fout, en NULLIF(..., '') vangt de lege string op die overblijft na een SET LOCAL of na onze clear(). In beide gevallen is de vergelijking NULL, en NULL is nooit waar: geen tenant, geen rijen.

De variabele zetten is wat de TenantContext hierboven al doet met set_config('app.tenant_id', ?, false). De derde parameter (false) maakt de instelling sessiegebonden, wat prima is zolang elke PHP-request een eigen databankconnectie opent en sluit, zoals bij PHP-FPM zonder persistent connections. Zit er een connection pooler zoals PgBouncer in transaction mode tussen, dan deelt u connecties met andere requests en moet de instelling transactiegebonden zijn: set_config(..., true) of SET LOCAL, uitgevoerd binnen elke transactie. In Doctrine doet u dat het netst met een DBAL-middleware die beginTransaction() omhult.

Wat kost het? Een policy is functioneel een extra WHERE club_id = ... op een geïndexeerde kolom; EXPLAIN toont de voorwaarde gewoon als filter of index condition. In de praktijk is dat verwaarloosbaar, zeker omdat de applicatielaag dezelfde voorwaarde al toevoegde.

Twee praktische gevolgen om vooraf te regelen:

  • Migraties en platformtaken hebben een rol nodig die de policies mag omzeilen (BYPASSRLS), of ze activeren expliciet een tenant per iteratie. Wij verkiezen het tweede voor alles wat data aanraakt: een cronjob die over alle clubs loopt, zet per club de variabele en werkt dus per definitie in de juiste context.
  • De test die alles bewijst is verrassend kort: activeer club B, tel de leden van club A, verwacht nul. En omgekeerd: probeer als club B een rij met het club_id van A te inserten en verwacht new row violates row-level security policy.

Samen vormen de applicatiefilter en de databankpolicy een klassieke defense in depth: de eerste laag geeft correcte resultaten en goede foutmeldingen, de tweede laag garandeert dat een fout in de eerste geen lek wordt.

Buiten de request: Messenger, cron en logs

Het grootste misverstand over multi-tenancy is dat het een HTTP-probleem is. De helft van het werk in onze platformen gebeurt buiten een request: lidkaarten die 's nachts vervallen, nieuwsbrieven die via Messenger vertrekken, exports van het schietregister, facturen die maandelijks naar Billit gaan. Daar is geen firewall, geen ingelogde gebruiker en dus geen tenant, tenzij u hem zelf meeneemt.

We leerden dat op de harde manier. In LedenAdmin bestaat een Twig-functie is_allowed() die controleert of een club een module in haar abonnement heeft. Ze bevat tot vandaag een commentaar die het probleem samenvat: "soms voeren commandline-tools deze Twig-functies uit, en die hebben geen sessie", waarna de functie false teruggeeft. Correct, maar het is een symptoom van tenantcontext die aan de HTTP-sessie hangt.

De tenant als deel van het bericht

Onze bestaande handlers nemen de tenant expliciet mee in het bericht en hydrateren hem opnieuw in de handler:

final class ShootingRegisterExportMessage
{
    public function __construct(
        public readonly int $clubId,
    ) {
    }
}

#[AsMessageHandler]
final class ShootingRegisterExportMessageHandler
{
    public function __invoke(ShootingRegisterExportMessage $message): void
    {
        $club = $this->clubRepository->find($message->clubId);

        if (!$club instanceof Club) {
            return;
        }

        // alles hieronder krijgt $club expliciet mee
    }
}

Dat werkt, maar het legt de verantwoordelijkheid opnieuw bij elke handler. Met een TenantContext kan Messenger het zelf doen: een middleware die bij het versturen de actieve tenant als stamp aan de envelop hangt, en bij het ontvangen de context herstelt vóór de handler draait:

final class TenantStamp implements StampInterface
{
    public function __construct(public readonly int $tenantId)
    {
    }
}

final class TenantMiddleware implements MiddlewareInterface
{
    public function __construct(
        private readonly TenantContext $tenantContext,
        private readonly ClubRepository $clubRepository,
    ) {
    }

    public function handle(Envelope $envelope, StackInterface $stack): Envelope
    {
        $stamp = $envelope->last(TenantStamp::class);

        if ($envelope->last(ReceivedStamp::class) === null) {
            // Verzendkant: neem de actieve tenant mee, tenzij al aanwezig
            if ($stamp === null && $this->tenantContext->has()) {
                $envelope = $envelope->with(new TenantStamp($this->tenantContext->current()->getId()));
            }

            return $stack->next()->handle($envelope, $stack);
        }

        // Ontvangstkant: herstel de context vóór de handler draait
        if ($stamp === null) {
            throw new \LogicException(sprintf('Bericht %s heeft geen tenant.', $envelope->getMessage()::class));
        }

        $club = $this->clubRepository->find($stamp->tenantId)
            ?? throw new UnrecoverableMessageHandlingException('Tenant bestaat niet meer.');

        return $this->tenantContext->run($club, fn () => $stack->next()->handle($envelope, $stack));
    }
}

Omdat activate() zowel de Doctrine-filter als de databankvariabele zet, draait de handler automatisch onder dezelfde twee lagen als een HTTP-request. De exception voor een bericht zonder stamp is bewust: een bericht zonder tenant is een programmeerfout, geen situatie om stilzwijgend af te handelen. En een verdwenen tenant levert een UnrecoverableMessageHandlingException op, zodat Messenger het bericht niet drie keer opnieuw probeert.

Eén detail dat u niet mag vergeten: de repository die de club opzoekt, mag natuurlijk niet zelf door de tenantfilter geblokkeerd worden. De Club-entiteit is dan ook géén TenantOwned; ze ís de tenant.

Cronjobs: één tenant per iteratie

Onze geplande taken hebben allemaal dezelfde vorm: loop over alle tenants, en doe per tenant het werk in diens context.

foreach ($this->clubRepository->findAll() as $club) {
    $this->tenantContext->run($club, function () use ($club): void {
        if ($this->clubSettings->isEnabled($club, ClubSettingName::MEMBERSHIP_CARD_AUTO_DEACTIVATE)) {
            $this->membershipCardService->deactivateExpiredCards();
        }
    });

    $this->em->clear();
}

Twee details maken het verschil tussen een script en een productieklare job. De run()-methode activeert de context, voert de callback uit en herstelt de vorige context in een finally, zodat een exception bij club 12 nooit club 13 in de verkeerde context laat draaien. En de $em->clear() na elke tenant houdt de identity map van Doctrine klein; zonder die regel groeit het geheugen van een nachtelijke job lineair met het aantal klanten. Een --tenant=-optie op elk commando is de kers op de taart: een mislukte run voor één club herhaalt u dan zonder alle andere opnieuw te draaien.

De tenant in elke logregel

Wanneer een klant meldt dat "de export niet werkte", wilt u in Sentry of CloudWatch filteren op die ene club. Een Monolog-processor voegt de tenant toe aan elk logrecord:

#[AsMonologProcessor]
final class TenantProcessor
{
    public function __construct(
        private readonly TenantContext $tenantContext,
    ) {
    }

    public function __invoke(LogRecord $record): LogRecord
    {
        if ($this->tenantContext->has()) {
            $record->extra['tenant_id'] = $this->tenantContext->current()->getId();
        }

        return $record;
    }
}

Wat een tenant mag en wat hij instelt

Multi-tenancy stopt niet bij data-isolatie: elke klant heeft ook eigen instellingen, een eigen abonnement en eigen bestanden. Een paar ontwerpbeslissingen daarin bleken bij elk platform opnieuw waardevol.

Abonnement en instellingen zijn twee verschillende dingen

"Mag deze club de cursusmodule gebruiken?" en "welke kleur wil deze club voor vervallen leden?" lijken beide een instelling, maar de eerste is een commerciële afspraak en de tweede een voorkeur. Wij houden ze strikt gescheiden:

  • Abonnementseigenschappen (Subscription met SubscriptionProperty): wat de klant betaalt. COURSE_ALLOWED, MEMBERSHIPCARD_ALLOWED, CONDUCTORS_ALLOWED_AMOUNT. Enkel wij wijzigen die, bij een upgrade of downgrade.
  • Clubinstellingen (ClubProperty): wat de klant zelf configureert. Taal, kleuren, of het schietregister de camera mag gebruiken, de eigen Mollie-sleutel.

Beide zijn onder de motorkap key-value-tabellen, en key-value-tabellen worden snel een wildgroei van magische strings. Wat het beheersbaar houdt, is een backed enum die elke sleutel definieert mét een standaardwaarde, en een DTO die de tabel in één keer omzet naar getypte waarden:

final readonly class ClubSettings
{
    public static function fromClubProperties(Collection $properties): self
    {
        $map = [];

        foreach ($properties as $property) {
            $map[$property->getName()] = $property->getValue();
        }

        foreach (ClubSetting::cases() as $setting) {
            $map[$setting->value] ??= $setting->default();
        }

        return new self($map);
    }
}

Een nieuwe instelling toevoegen is dan één enum-case met een default; geen migratie die bij elke bestaande club een rij toevoegt. SchoolWise, met slechts drie abonnementsformules, gaat nog een stap eenvoudiger: het plan is een PHP-enum met methodes als maxClasses() en monthlyPriceEur(), en enkel de licentiestatus staat in de databank.

Geheimen per tenant

Sommige instellingen zijn geheimen: de Mollie-API-sleutel van de club, een OAuth-refreshtoken voor de Google-koppeling. Die horen niet in leesbare vorm in een tabel die in elke databasedump belandt. In ShootWise markeert de enum welke instellingen gevoelig zijn; die waarden worden vóór opslag versleuteld met AWS KMS en pas bij gebruik ontsleuteld. Een dump van de databank bevat dan cijfertekst die zonder de KMS-sleutel waardeloos is.

E-mail en taal per tenant

Een vereniging wil dat mails aan haar leden van háár komen. LedenAdmin laat een club daarom een eigen SMTP-server configureren; de mailer bouwt het transport op het moment van verzenden op uit die instellingen, en valt terug op de platformafzender via Amazon SES als de club niets configureerde. De taal volgt een cascade: voorkeur van de gebruiker, anders de taal van de club, anders de standaardtaal van het platform. Die cascade geldt ook op publieke pagina's, waar er alleen een club is.

Bestanden per tenant

Uploads gaan naar S3 met de tenant-id als eerste padsegment: {club_id}/pictures/dogs/..., {club_id}/criminal_record/.... Het pad komt uit de tenantcontext, nooit uit gebruikersinvoer. De bucketpolicy maakt precies één prefix per tenant publiek (*/pictures/club/*, de logo's); al de rest is privé en wordt uitgeleverd via presigned URL's met een korte geldigheidsduur. Lang genoeg voor een browser om een afbeelding op te halen, te kort om een link door te sturen.

Onboarding, migraties en schaal

Onboarding is in het gedeelde model razendsnel, maar niet triviaal: een nieuwe club is één transactie die de Club-rij aanmaakt, een klant registreert bij de betaalprovider en de standaarddata voorziet (disciplines, standaardinstellingen, een eerste gebruiker). Het omgekeerde is even belangrijk en wordt vaker vergeten: een deprovision() die alle data van één tenant verwijdert of exporteert. Omdat elke rij een club_id draagt, is "geef mij alles van club X" een reeks eenvoudige queries in plaats van een archeologisch project. Voor de GDPR is dat geen luxe.

Migraties draaien één keer, voor alle tenants tegelijk. In de ECS-taak van LedenAdmin is dat letterlijk zichtbaar: de taakdefinitie start eerst een container die doctrine:migrations:migrate uitvoert, daarna een container voor de cache-warmup, en pas als die geslaagd zijn de applicatiecontainer en de Messenger-consumer. Een mislukte migratie houdt zo de nieuwe versie tegen in plaats van halve tabellen achter te laten. De keerzijde: een datamigratie die miljoenen rijen aanraakt, blokkeert alle tenants tegelijk. Zulke migraties schrijven we in batches per tenant, en liefst als achtergrondtaak in plaats van als schemamigratie.

Schaal in een gedeeld model draait om het noisy-neighbour-probleem: één grote tenant mag de rest niet vertragen. De basis is de indexafspraak van eerder (tenantkolom voorop), zodat de planner voor elke tenant maar een klein deel van de tabel raakt. Daarbovenop een rate limiter met de tenant als sleutel, cache-sleutels die met de tenant beginnen, en pas als een tenant écht uit de band springt, tabelpartitionering op tenant of een eigen read replica. Voor onze schaal (verenigingen en scholen, geen miljoenen rijen per tenant) volstaan de indexen ruimschoots.

Testen: de Club B-test

Elke isolatiemaatregel is zo betrouwbaar als de test die ze controleert. In LedenAdmin heeft elke repository- en API-test dezelfde vorm: bouw club A, bouw club B, maak data in beide, en bewijs dat A niets van B ziet.

public function testMembershipCardsOfOtherClubAreNeverReturned(): void
{
    $clubA = $this->createClub('Club A');
    $clubB = $this->createClub('Club B');

    $this->createMembershipCard($this->createConductor($clubA));
    $this->createMembershipCard($this->createConductor($clubB));

    $this->loginAs($this->createClubAdmin($clubA));

    $cards = $this->repository->findAllForCurrentClub();

    self::assertCount(1, $cards);
    self::assertSame($clubA, $cards[0]->getConductor()->getClub());
}

De createClub()-helper bouwt bewust een complete tenant, inclusief abonnement en alle standaardinstellingen. Dat leerden we toen entity listeners bij het opslaan van een lid blind de abonnementseigenschappen begonnen te lezen: een halve tenant in een test crasht op plaatsen die niets met de test te maken hebben. Elke test draait bovendien in een transactie die aan het einde wordt teruggedraaid, via de DAMA DoctrineTestBundle, zodat honderden tests op één databank kunnen draaien zonder elkaar te beïnvloeden.

Eén test ontbreekt nog in onze eigen suites en staat bovenaan de lijst voor elk volgend platform: een architectuurtest die de Doctrine-metadata van álle entiteiten doorloopt en faalt zodra een entiteit noch een tenantkolom heeft, noch op een expliciete lijst van globale referentiedata staat (kalibers, federaties, talen). Daarmee is isolatie door weglating geen stille fout meer, maar een rode build:

public function testEveryEntityIsTenantOwnedOrExplicitlyGlobal(): void
{
    $global = [Club::class, User::class, Caliber::class, Federation::class, Language::class];

    foreach ($this->em->getMetadataFactory()->getAllMetadata() as $metadata) {
        if (in_array($metadata->getName(), $global, true)) {
            continue;
        }

        self::assertTrue(
            is_a($metadata->getName(), TenantOwned::class, true),
            sprintf('%s heeft geen tenant en staat niet op de lijst van globale entiteiten.', $metadata->getName()),
        );
    }
}

Acht lessen uit drie platformen

  1. Zet de tenantkolom op elke tabel, ook als ze afleidbaar is. Het is de goedkoopste beslissing met de grootste impact op alles wat volgt.
  2. Isolatie moet opt-out zijn, nooit opt-in. Een filter die altijd aanstaat en luid faalt zonder tenant, klopt vaker dan een WHERE die iedereen moet onthouden.
  3. Laat de tenantfilter nooit van een rol afhangen. Wie over tenants heen moet werken, schakelt de filter expliciet uit, zichtbaar in de code.
  4. Twee lagen zijn geen overkill. De applicatie geeft goede foutmeldingen, de databank garandeert dat een fout geen lek wordt.
  5. Tenantcontext is geen HTTP-concept. Messenger, cron en CLI hebben dezelfde context nodig; bouw ze zo dat ze die krijgen.
  6. Test cross-tenant expliciet. Elke test met data van club A hoort ook data van club B te bevatten.
  7. Scheid wat de klant betaalt van wat de klant instelt, en versleutel de geheimen.
  8. Denk vanaf dag één aan offboarding. Een tenant volledig exporteren of verwijderen moet even eenvoudig zijn als hem aanmaken.

Bouwt u zelf een SaaS-platform?

Multi-tenancy is geen feature die u achteraf toevoegt; het bepaalt het datamodel, de securitylaag, de achtergrondverwerking en de manier van testen. Bij Zodi Innovations hebben we die keuzes drie keer gemaakt en telkens bijgestuurd. Wilt u die ervaring inzetten voor uw eigen platform, of wilt u een bestaande applicatie laten doorlichten op tenant-isolatie? Neem contact op voor een vrijblijvend gesprek.

Symfony PostgreSQL Doctrine SaaS Multi-tenancy Architectuur