Multi-tenancy in Symfony and PostgreSQL: one codebase, one database, strictly isolated tenants
Our SaaS platforms LedenAdmin, ShootWise and SchoolWise have one thing in common: each platform is a single Symfony application on a single PostgreSQL database, and yet no association or school may ever see a row that belongs to another. A single forgotten WHERE is not a bug there; it is a data breach. That makes multi-tenancy both the cheapest and the most unforgiving architectural decision you make as a SaaS builder.
In this article we show how we handle it. Not as a theoretical overview, but with the patterns that run in our own platforms today, the mistakes we made along the way, and the layers we would add from day one on a new platform. It is a long and technical story; if you build a SaaS platform yourself, or have one built, you will find concrete code for Symfony, Doctrine and PostgreSQL here.
Three ways to separate tenants
A tenant is one customer organization on the platform: a dog school on LedenAdmin, a shooting club on ShootWise, a school on SchoolWise. The question that determines everything else: where do you draw the line between tenants?
| Model | Isolation | Onboarding | Migrations | Cost per tenant |
|---|---|---|---|---|
| Database per tenant | Very strong, physically separated | Create database, credentials, connection | Once per tenant | High: connections, backups and monitoring × N |
| Schema per tenant | Strong, via search_path |
Create schema and copy tables | Once per tenant | Medium: catalog grows with you, custom tooling required |
| Shared schema with tenant column | Responsibility of the application | One INSERT |
Once for everyone | Low |
Database-per-tenant is the safest choice on paper: a query cannot even reach another customer's data. But with hundreds of small tenants you pay for that in connections, backups, monitoring and, above all, in migrations that you have to run N times and that can fail halfway through. Schema-per-tenant sits in between, but Doctrine Migrations was not built to roll out one migration across hundreds of schemas; you build that tooling yourself.
For all our platforms we chose the third model: one schema, and on every table a column that points to the tenant. Onboarding a new association is a handful of inserts in one transaction, a migration runs once, and reporting across all tenants (for ourselves, never for customers) is an ordinary query. The price: isolation is entirely the responsibility of the application. The rest of this article is about how we carry that responsibility.
One exception proves the rule. Hostree, our website platform for local businesses, isolates at the infrastructure level: every customer gets their own Plesk subscription, domain name and mailboxes. There, the infrastructure is the product, so that is where the boundary belongs.
The data model: every row knows its owner
For us, the tenant is not an abstract Tenant entity but a full-fledged domain object: Club on LedenAdmin and ShootWise, School on SchoolWise. An entity that belongs to a tenant carries a ManyToOne to that object:
#[ORM\Entity]
class Member implements TenantOwned
{
#[ORM\ManyToOne(inversedBy: 'members')]
#[ORM\JoinColumn(nullable: false)]
private Club $club;
}
Nothing special so far. The important question is: which entities get such a column? Our first instinct was: only the "main entities". A membership card belongs to a member, a member belongs to a club, so the membership card inherits its tenant through the member. That is relationally correct, but it makes every isolation measure further down harder: a filter on MembershipCard then has to join to Member and only then to Club. The further an entity is from the tenant, the more special cases you pile up.
The lesson we drew from that, and apply on every new platform: denormalize the tenant column onto every table that belongs to a tenant, even when it can be derived. One extra integer column per row is nothing; what you get in return is that every filter, every index and every database policy can look at the same column without joins.
Three conventions that go with it:
- Indexes start with the tenant column. Almost every query in a multi-tenant platform filters on tenant first. An index on
(club_id, created_at)therefore serves both "all members of club X" and "the newest members of club X". - Uniqueness is per tenant. An email address may appear in two clubs, but not twice in the same club:
UNIQUE (club_id, email), notUNIQUE (email). - Relations must not cross the tenant boundary. A payment from club A must never point to a member of club B. PostgreSQL enforces that with a composite foreign key:
FOREIGN KEY (club_id, member_id) REFERENCES member (club_id, id). That requires aUNIQUE (club_id, id)on the target table, but after that a cross-tenant reference is impossible at the database level, whatever the application tries.
One more detail that is bigger than it looks: internal ids are sequential integers, but in public URLs we use UUIDs. LedenAdmin has a uuid on the club for public registration pages, SchoolWise a UUIDv7 on every entity. Not because unguessability replaces isolation (it never does), but because a sequential id in a URL invites someone to try ?id=42.
Who is speaking? Tenant resolution
For every request, the application has to answer exactly one question: which tenant am I working for right now? There are three common answers.
Through the user. In our platforms the tenant is a column on User. Whoever logs in works in the club of their account, full stop. No DNS configuration, no wildcard certificates, and the tenant is known as soon as the firewall has authenticated the user. The downside: someone who is active in two clubs has two accounts. For association software, that is an acceptable trade-off.
Through the URL. Public pages have no logged-in user, but they do belong to a tenant: the registration page for an activity, a payment link, the webhook that Mollie calls. There, the tenant sits in the path as a UUID:
#[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));
// ...
}
Note that the language of the page also comes from the tenant: a French-speaking club gets a French registration page, even though the visitor has no account.
Through the hostname. Subdomains (club.platform.be) or custom domains per tenant are the classic white-label approach. That requires a wildcard DNS record, a wildcard certificate or on-demand TLS, and a resolver that translates the hostname into a tenant. At Hostree we use a variant of that: not a tenant per host, but a module per host, directly in the 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%'
Whichever mechanism you choose, the design rule is the same: resolve the tenant once, early in the request, and put it in a service where the rest of the application asks for it. We call that service the TenantContext. It does more than hold a value: every time the tenant changes, it passes that on to the two isolation layers discussed further on.
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('');
}
/** Runs $callback in the context of $club and restores the previous context afterwards. */
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('No active tenant.');
}
public function has(): bool
{
return $this->current !== null;
}
private function propagate(string $tenantId): void
{
// Layer 1: the Doctrine filter (see "Isolation in the application layer")
$this->em->getFilters()->getFilter('tenant')->setParameter('tenantId', $tenantId);
// Layer 2: the database session (see "Row-Level Security")
$this->em->getConnection()->executeQuery(
"SELECT set_config('app.tenant_id', ?, false)",
[$tenantId],
);
}
}
That activate() is called by a listener that runs after the firewall. The Symfony firewall listens at priority 8, so anything with a lower priority sees the logged-in user:
#[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());
}
}
}
From now on, controllers, services, Twig extensions and repositories ask the context for the tenant, not the request and not $this->getUser(). That seems like a detail, but it is precisely what will later make it possible to run the same code in a Messenger handler or a cron job, where no request and no logged-in user exist. A platform administrator who switches tenants does so through that same activate(), never by disabling a filter.
Isolation in the application layer: three levels
This is where it gets interesting, because our three platforms solve this in three different ways. Not out of inconsistency, but because each platform came into being at a different time and with different insights. Ranked from least to most automatic:
Level 1: explicit per query
In ShootWise, isolation lives in every repository method and in Security Voters. The repository adds the condition itself:
public function findAllByClub(): array
{
/** @var User $user */
$user = $this->security->getUser();
return $this->findBy(['club' => $user->getClub()]);
}
And a voter guards every individual object:
private function canEdit(Member $member, User $user): bool
{
return $member->getClub() === $user->getClub()
&& $user->hasRole(UserService::ROLE_CLUB);
}
This works, and we keep the voter layer in every platform to this day. But the repository side has a structural problem: isolation is opt-in per query. Every new method is a chance to forget the condition, and a code review does not always spot the difference between findBy(['status' => 'active']) and findBy(['status' => 'active', 'club' => $club]). The most dangerous pattern we encountered is conditional scoping: "if the user is not an admin, filter on club". As soon as the tenant filter depends on a role, your data breach depends on a role too.
Level 2: automatic on the API layer
LedenAdmin builds its frontend and mobile app on API Platform, and API Platform has a hook that exists precisely for this: query extensions. One class implementing QueryCollectionExtensionInterface and QueryItemExtensionInterface adjusts every collection and item query:
final class ClubExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
/** Entities that reach the club through a relation */
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());
}
}
}
A big step forward: no API endpoint can forget the filter anymore. But look at the two escape routes. First, the extension only covers what goes through API Platform; the Twig controllers and the AJAX endpoints of data tables next to it remain manual work. Second: an entity without a club property that is also not in CLUB_VIA_RELATION is silently left unfiltered. That is isolation by omission again, just one layer higher. It is exactly the argument for the denormalization from the previous chapter: if every table has a tenant column, the exception list disappears.
Level 3: a Doctrine SQL filter
SchoolWise, the youngest of the three platforms, pushes isolation one layer deeper: into Doctrine itself. An SQLFilter adds a condition to the SQL that Doctrine generates, regardless of whether it comes from DQL, a find(), a lazy-loaded collection or a proxy:
final class TenantFilter extends SQLFilter
{
public function addFilterConstraint(ClassMetadata $targetEntity, string $targetTableAlias): string
{
if (!is_a($targetEntity->getName(), TenantOwned::class, true)) {
return '';
}
// getParameter() returns the value quoted ('42'), safe to interpolate,
// and throws an exception if no tenant has ever been set.
$tenantId = $this->getParameter('tenantId');
if ($tenantId === "''") {
throw new \LogicException('No active tenant: activate one or disable the filter explicitly.');
}
return sprintf('%s.club_id = %s', $targetTableAlias, $tenantId);
}
}
# config/packages/doctrine.yaml
doctrine:
orm:
filters:
tenant:
class: App\Doctrine\TenantFilter
enabled: true
In SchoolWise, the filter decides per entity class with a match, including a subquery for entities that are two steps away from the school (class_id IN (SELECT id FROM school_class WHERE school_id = ...)). In the example above we do it the way we would on a new platform: a marker interface TenantOwned on every entity with a tenant column, so the filter does not need to know a list of classes.
Note the enabled: true. SchoolWise only enables the filter in the request listener, and that is also what most tutorials show. We prefer the opposite: the filter is always on. As long as nobody has activated a tenant, the filter throws an exception on the first query against a tenant entity. A cron job that forgets to activate a tenant therefore crashes loudly instead of quietly processing all data of all customers. Whoever deliberately wants to work across tenants (a migration, a platform report) disables the filter explicitly with $em->getFilters()->disable('tenant'), and that is a line that immediately stands out in a code review.
Four properties of SQL filters you need to know before you rely on them:
- They apply to everything that goes through the ORM, including
find(),findBy(), lazy loading of collections and the initialization of proxies. AManyToOnepointing to a row of another tenant results in anEntityNotFoundExceptionon initialization, which is exactly the desired behavior. - They do not apply to DBAL. A
$connection->executeQuery('SELECT ...')in a reporting service bypasses the filter completely. That is the main reason for the database layer in the next chapter. - The user is the exception. The firewall loads the
Userbefore the tenant is known. TheUserentity therefore carries aclub_id, but is not covered by the filter; the email address someone logs in with is unique across the platform. - The query cache takes it into account. Doctrine includes the hash of the active filters and their parameters in the cache key of every query, so club A's SQL is never served from the cache for club B. Your own caches (Symfony Cache, HTTP cache, Redis) do not do that by themselves: put the tenant id in every cache key.
Row-Level Security: the safety net in PostgreSQL
Everything up to here lives in the application. One raw SQL query, one disable('tenant') that is not re-enabled, one bug in the filter, and the isolation is gone. If you want to cover that risk, let the database itself watch along. PostgreSQL has had a built-in mechanism for that since version 9.5: Row-Level Security (RLS).
Honestly: our existing platforms run without RLS today and rely on the layers above. But for a new platform, this is the layer we would add alongside them from day one, and it is less work than it looks.
The principle: per table you define a policy that determines which rows a session may see and write, and the tenant id comes from a session variable:
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);
Four things happen here:
ENABLE ROW LEVEL SECURITYactivates the mechanism; without a policy, an ordinary role then sees no rows at all. Fail closed, as it should be.FORCEensures that the owner of the table is subject to the policy as well. Superusers and roles withBYPASSRLSstay outside it, so the application connects with an ordinary role that has no special privileges.USINGdetermines which rows are visible forSELECT,UPDATEandDELETE;WITH CHECKguards whatINSERTandUPDATEmay write. An application bug that wants to store a row with the wrongclub_idgets an error instead of a silent write.current_setting('app.tenant_id', true)reads the session variable; thetruemakes a missing variable yieldNULLinstead of an error, andNULLIF(..., '')catches the empty string that remains after aSET LOCALor after ourclear(). In both cases the comparison isNULL, andNULLis never true: no tenant, no rows.
Setting the variable is what the TenantContext above already does with set_config('app.tenant_id', ?, false). The third parameter (false) makes the setting session-scoped, which is fine as long as every PHP request opens and closes its own database connection, as with PHP-FPM without persistent connections. If there is a connection pooler such as PgBouncer in transaction mode in between, you share connections with other requests and the setting must be transaction-scoped: set_config(..., true) or SET LOCAL, executed inside every transaction. In Doctrine, the cleanest way to do that is a DBAL middleware that wraps beginTransaction().
What does it cost? A policy is functionally an extra WHERE club_id = ... on an indexed column; EXPLAIN simply shows the condition as a filter or index condition. In practice that is negligible, especially since the application layer already added the same condition.
Two practical consequences to arrange up front:
- Migrations and platform tasks need a role that may bypass the policies (
BYPASSRLS), or they explicitly activate a tenant per iteration. We prefer the latter for everything that touches data: a cron job that loops over all clubs sets the variable per club and therefore works in the right context by definition. - The test that proves everything is surprisingly short: activate club B, count the members of club A, expect zero. And the other way around: as club B, try to insert a row with A's
club_idand expectnew row violates row-level security policy.
Together, the application filter and the database policy form classic defense in depth: the first layer gives correct results and good error messages, the second layer guarantees that a mistake in the first does not become a leak.
Outside the request: Messenger, cron and logs
The biggest misconception about multi-tenancy is that it is an HTTP problem. Half of the work in our platforms happens outside a request: membership cards that expire overnight, newsletters that leave through Messenger, exports of the shooting register, invoices that go to Billit every month. There is no firewall there, no logged-in user and therefore no tenant, unless you bring it along yourself.
We learned that the hard way. LedenAdmin has a Twig function is_allowed() that checks whether a club has a module in its subscription. To this day it contains a comment that sums up the problem: "sometimes command line tools execute these Twig functions, and they have no session", after which the function returns false. Correct, but it is a symptom of tenant context that hangs on the HTTP session.
The tenant as part of the message
Our existing handlers explicitly carry the tenant in the message and rehydrate it in the 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;
}
// everything below receives $club explicitly
}
}
That works, but it puts the responsibility on every handler again. With a TenantContext, Messenger can do it itself: a middleware that attaches the active tenant to the envelope as a stamp when sending, and restores the context before the handler runs when receiving:
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) {
// Sending side: carry the active tenant along, unless already present
if ($stamp === null && $this->tenantContext->has()) {
$envelope = $envelope->with(new TenantStamp($this->tenantContext->current()->getId()));
}
return $stack->next()->handle($envelope, $stack);
}
// Receiving side: restore the context before the handler runs
if ($stamp === null) {
throw new \LogicException(sprintf('Message %s has no tenant.', $envelope->getMessage()::class));
}
$club = $this->clubRepository->find($stamp->tenantId)
?? throw new UnrecoverableMessageHandlingException('Tenant no longer exists.');
return $this->tenantContext->run($club, fn () => $stack->next()->handle($envelope, $stack));
}
}
Because activate() sets both the Doctrine filter and the database variable, the handler automatically runs under the same two layers as an HTTP request. The exception for a message without a stamp is deliberate: a message without a tenant is a programming error, not a situation to handle silently. And a vanished tenant yields an UnrecoverableMessageHandlingException, so Messenger does not retry the message three times.
One detail you must not forget: the repository that looks up the club must of course not be blocked by the tenant filter itself. The Club entity is therefore not TenantOwned; it is the tenant.
Cron jobs: one tenant per iteration
Our scheduled tasks all have the same shape: loop over all tenants, and do the work for each tenant in its 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();
}
Two details make the difference between a script and a production-ready job. The run() method activates the context, executes the callback and restores the previous context in a finally, so that an exception at club 12 never leaves club 13 running in the wrong context. And the $em->clear() after every tenant keeps Doctrine's identity map small; without that line, the memory of a nightly job grows linearly with the number of customers. A --tenant= option on every command is the icing on the cake: a failed run for one club can then be repeated without running all the others again.
The tenant in every log line
When a customer reports that "the export did not work", you want to filter on that one club in Sentry or CloudWatch. A Monolog processor adds the tenant to every log record:
#[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;
}
}
What a tenant is allowed to do and what it configures
Multi-tenancy does not stop at data isolation: every customer also has their own settings, their own subscription and their own files. A few design decisions in that area proved valuable on every platform again.
Subscription and settings are two different things
"May this club use the course module?" and "which color does this club want for expired members?" both look like a setting, but the first is a commercial agreement and the second a preference. We keep them strictly separated:
- Subscription properties (
SubscriptionwithSubscriptionProperty): what the customer pays for.COURSE_ALLOWED,MEMBERSHIPCARD_ALLOWED,CONDUCTORS_ALLOWED_AMOUNT. Only we change those, on an upgrade or downgrade. - Club settings (
ClubProperty): what the customer configures themselves. Language, colors, whether the shooting register may use the camera, their own Mollie key.
Both are key-value tables under the hood, and key-value tables quickly become a sprawl of magic strings. What keeps it manageable is a backed enum that defines every key with a default value, and a DTO that converts the table into typed values in one go:
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);
}
}
Adding a new setting is then one enum case with a default; no migration that adds a row for every existing club. SchoolWise, with only three subscription plans, goes one step simpler: the plan is a PHP enum with methods such as maxClasses() and monthlyPriceEur(), and only the license status lives in the database.
Secrets per tenant
Some settings are secrets: the club's Mollie API key, an OAuth refresh token for the Google integration. Those do not belong in readable form in a table that ends up in every database dump. In ShootWise, the enum marks which settings are sensitive; those values are encrypted with AWS KMS before storage and only decrypted on use. A dump of the database then contains ciphertext that is worthless without the KMS key.
Email and language per tenant
An association wants emails to its members to come from them. LedenAdmin therefore lets a club configure its own SMTP server; the mailer builds the transport from those settings at the moment of sending, and falls back to the platform sender via Amazon SES if the club has configured nothing. The language follows a cascade: the user's preference, otherwise the club's language, otherwise the platform default. That cascade also applies on public pages, where there is only a club.
Files per tenant
Uploads go to S3 with the tenant id as the first path segment: {club_id}/pictures/dogs/..., {club_id}/criminal_record/.... The path comes from the tenant context, never from user input. The bucket policy makes exactly one prefix per tenant public (*/pictures/club/*, the logos); everything else is private and served through presigned URLs with a short lifetime. Long enough for a browser to fetch an image, too short to forward a link.
Onboarding, migrations and scale
Onboarding is lightning fast in the shared model, but not trivial: a new club is one transaction that creates the Club row, registers a customer with the payment provider and provides the default data (disciplines, default settings, a first user). The reverse is just as important and forgotten more often: a deprovision() that deletes or exports all data of one tenant. Because every row carries a club_id, "give me everything of club X" is a series of simple queries instead of an archaeological project. For the GDPR, that is no luxury.
Migrations run once, for all tenants at the same time. In LedenAdmin's ECS task that is literally visible: the task definition first starts a container that runs doctrine:migrations:migrate, then a container for the cache warmup, and only once those have succeeded the application container and the Messenger consumer. A failed migration thus blocks the new version instead of leaving half-finished tables behind. The flip side: a data migration that touches millions of rows blocks all tenants at once. We write such migrations in batches per tenant, and preferably as a background task rather than a schema migration.
Scale in a shared model is about the noisy-neighbor problem: one large tenant must not slow down the rest. The foundation is the index convention from earlier (tenant column first), so that the planner only touches a small part of the table for every tenant. On top of that, a rate limiter keyed by tenant, cache keys that start with the tenant, and only when a tenant really goes off the charts, table partitioning by tenant or a dedicated read replica. At our scale (associations and schools, not millions of rows per tenant), the indexes are more than enough.
Testing: the Club B test
Every isolation measure is only as reliable as the test that checks it. In LedenAdmin, every repository and API test has the same shape: build club A, build club B, create data in both, and prove that A sees nothing of B.
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());
}
The createClub() helper deliberately builds a complete tenant, including subscription and all default settings. We learned that when entity listeners started blindly reading the subscription properties while saving a member: a half-built tenant in a test crashes in places that have nothing to do with the test. In addition, every test runs in a transaction that is rolled back at the end, through the DAMA DoctrineTestBundle, so hundreds of tests can run on one database without affecting each other.
One test is still missing from our own suites and is at the top of the list for every next platform: an architecture test that walks through the Doctrine metadata of all entities and fails as soon as an entity has neither a tenant column nor a place on an explicit list of global reference data (calibers, federations, languages). With that, isolation by omission is no longer a silent mistake, but a red 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 has no tenant and is not on the list of global entities.', $metadata->getName()),
);
}
}
Eight lessons from three platforms
- Put the tenant column on every table, even when it can be derived. It is the cheapest decision with the biggest impact on everything that follows.
- Isolation must be opt-out, never opt-in. A filter that is always on and fails loudly without a tenant is right more often than a
WHEREeveryone has to remember. - Never let the tenant filter depend on a role. Whoever has to work across tenants disables the filter explicitly, visibly in the code.
- Two layers are not overkill. The application gives good error messages, the database guarantees that a mistake does not become a leak.
- Tenant context is not an HTTP concept. Messenger, cron and CLI need the same context; build them so that they get it.
- Test cross-tenant explicitly. Every test with data from club A should also contain data from club B.
- Separate what the customer pays for from what the customer configures, and encrypt the secrets.
- Think about offboarding from day one. Fully exporting or deleting a tenant must be as easy as creating one.
Building a SaaS platform yourself?
Multi-tenancy is not a feature you add afterwards; it determines the data model, the security layer, the background processing and the way you test. At Zodi Innovations we have made those choices three times and adjusted them every time. Would you like to put that experience to work for your own platform, or have an existing application audited for tenant isolation? Get in touch for a no-obligation conversation.
Recent articles
Insights, best practices and technical depth from our team.
Custom software or off-the-shelf: how do you choose?
Buy an off-the-shelf package or have software built to measure? It is one of the most important IT decisions for an SME. We offer an honest decision framework — with examples from our own practice.
Integrating AI into your existing software: a practical guide for SMEs
AI doesn't have to be science fiction. We show how SMEs can practically implement artificial intelligence in their existing applications — without rebuilding everything.