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 single isAtLeast() 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.