Multi-tenant SaaS in Symfony: one database, Doctrine filters, zero forgotten WHERE clauses
The single-database tenancy model that scales from side project to real B2B: organizations, memberships, and an SQL filter that scopes every query automatically — with the pitfalls that bite in production.
Every B2B SaaS eventually gets the email: "Can I invite my colleague?"
If your schema attached everything to a User, that email starts your
most painful migration. If you modeled tenancy on day one, it's a feature
you flip on.
This is the tenancy design I ship in ShipAnvil: light enough to not
slow down a solo project, structural enough that teams, billing and data
isolation have an obvious home. And its core trick — a Doctrine SQL filter
— removes the scariest bug class in multi-tenant apps: the forgotten
WHERE organization_id = ?.
The model: two entities, one rule
- Organization — the tenant. Everything that "belongs to the account" belongs here: data, and crucially the subscription. Bill the tenant, never the user.
- Membership — joins a User to an Organization with a role
(
owner>admin>member). Compare roles with a singleisAtLeast()method instead of scattering=== 'admin'strings.
The rule that makes this painless for solo users: every user gets a personal organization at registration. Sign-up provisions User + Organization + owner Membership in one transaction. No "personal vs team account" special cases anywhere downstream — a personal account is simply an organization with one member.
Why single database
Schema-per-tenant and database-per-tenant exist for compliance-heavy or
massive-scale situations. For the other 95 % of B2B SaaS, one database
with an organization_id column on tenant-owned tables is simpler to
migrate, simpler to back up, simpler to query across tenants (your admin
dashboard will want that), and fast enough for years. The real risk of the
single-database model is human: one missing WHERE clause leaks customer
A's data to customer B. So we make the database layer add it for us.
The Doctrine filter
Doctrine's SQL filters append SQL to every query for the entities they target. First, mark what's tenant-owned with an interface:
interface OrganizationOwnedInterface
{
public function getOrganization(): Organization;
}
Then the filter — note it activates only for entities implementing the interface:
final class OrganizationFilter extends SQLFilter
{
public const string NAME = 'organization';
public const string PARAMETER = 'organization_id';
public function addFilterConstraint(ClassMetadata $targetEntity, string $targetTableAlias): string
{
if (!$targetEntity->getReflectionClass()->implementsInterface(OrganizationOwnedInterface::class)) {
return '';
}
return \sprintf('%s.organization_id = %s', $targetTableAlias, $this->getParameter(self::PARAMETER));
}
}
Finally, a request subscriber enables it as soon as an authenticated user with a current organization is resolved:
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$organization = $this->organizationContext->getOrganization();
if (null === $organization) {
return;
}
$this->entityManager
->getFilters()
->enable(OrganizationFilter::NAME)
->setParameter(OrganizationFilter::PARAMETER, (string) $organization->getId());
}
Register the subscriber to run just after the firewall (the firewall listens at priority 8; subscribe at 7) so the user is authenticated when you ask for their organization.
From here on, $invoiceRepository->findAll() returns this tenant's
invoices. A query you write at 1 a.m. six months from now is scoped too.
That's the point: isolation stops depending on every future contributor
remembering.
The pitfalls — this is the part to bookmark
1. Console commands and workers run unfiltered. The filter is enabled per HTTP request. Cron commands and Messenger workers have no request, so they see everything — which is what you want for "iterate over all tenants" jobs, and a data leak if a handler assumes scoping. Convention: anything async receives an explicit organization id in the message and scopes its queries explicitly.
2. Your admin panel needs the filter OFF. The back office legitimately
works across tenants (metrics, support, CRUDs). Skip enabling the filter
for /admin paths — and rely on the firewall restricting /admin to
admins. Write a functional test that proves an admin sees data from two
different organizations on the same page; that test documents the
exception forever.
3. The filter parameter is a string. SQLFilter::setParameter()
stores strings — cast your UUID explicitly and don't pass objects.
4. New entities silently bypass nothing — but test it anyway. The
interface check means a new tenant-owned entity is covered the moment it
implements OrganizationOwnedInterface. The failure mode is forgetting
to implement it. Cheap insurance: one functional test that logs in as
tenant A, requests every listing page, and asserts nothing from tenant B
appears. Isolation is the one feature a demo can't show — only tests can.
5. Doctrine caches filter state per EntityManager. If you enable the
filter and then resetManager() (after a DBAL exception, say), the new
manager doesn't inherit it. Re-enable after any reset.
What this unlocks later
Because the subscription hangs off the Organization, "invite your
colleague" doesn't touch billing. Because roles live on the Membership,
"only owners can manage billing" is one isAtLeast() check. Because every
tenant-owned table already has organization_id, the eventual
"export all my data" GDPR request is a loop, not a project.
The whole model — entities, provisioning, the filter, the subscriber and the cross-tenant isolation tests — ships wired-up in ShipAnvil, alongside billing that attaches where it should. You can poke a live multi-tenant setup in the demo.