Multi-tenancy is one of those features that sounds simple until you try to implement it properly. Separate the data per organization, isolate access, make sure tenant A never sees tenant B’s data. Easy to say. The devil is in the details.
After building multi-tenant SaaS products in Symfony and making most of the mistakes you can make, here is the approach that actually holds up in production.
The three strategies — and why two of them fall short
Before writing code, you need to pick a strategy. There are three:
Separate databases per tenant. Each organization gets its own database. Perfect isolation, easy to back up per tenant, clean migrations. The cost: operational complexity scales with your customer count. Running ten customers means ten database connections, ten migration runs, ten backups to manage. At 100 customers, this becomes a full-time job.
Separate schemas, shared database. A middle ground. One PostgreSQL instance, one schema per tenant. Better than separate databases, but you still run migrations per schema, and connection pooling gets complicated.
Shared schema with a tenant column. Every table that belongs to a tenant has an organization_id column. One database, one schema, standard migrations. The tradeoff: every query must include a WHERE organization_id = ? filter, and if you forget one, tenant data leaks.
For most early-stage SaaS products, shared schema is the right call. The operational simplicity is worth it, and the data leakage risk is manageable with the right architecture.
This article covers the shared schema approach.
The Organization entity
Start with the anchor of your multi-tenancy model: the Organization entity.
#[ORM\Entity]
class Organization
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $name;
#[ORM\Column(length: 255, unique: true)]
private string $slug;
#[ORM\OneToMany(targetEntity: Member::class, mappedBy: 'organization')]
private Collection $members;
// ...
}
The slug is useful for subdomain-based routing (acme.yourapp.com) or URL prefixes (/org/acme/dashboard). Pick one approach early and stick to it.
Resolving the current tenant
The core challenge with shared schema is knowing which organization owns the current request. You need a service that resolves this reliably from anywhere in your codebase.
interface TenantResolverInterface
{
public function resolve(Request $request): ?Organization;
}
The implementation depends on your routing strategy. For subdomain-based tenancy:
class SubdomainTenantResolver implements TenantResolverInterface
{
public function __construct(
private OrganizationRepository $organizations,
private string $rootDomain,
) {}
public function resolve(Request $request): ?Organization
{
$host = $request->getHost();
$subdomain = str_replace('.' . $this->rootDomain, '', $host);
if ($subdomain === $host) {
// No subdomain, not a tenant request
return null;
}
return $this->organizations->findOneBySlug($subdomain);
}
}
Now you need to store the resolved tenant somewhere accessible during the request lifecycle. A simple service that acts as a context holder:
class TenantContext
{
private ?Organization $currentOrganization = null;
public function set(Organization $organization): void
{
$this->currentOrganization = $organization;
}
public function get(): ?Organization
{
return $this->currentOrganization;
}
public function require(): Organization
{
if ($this->currentOrganization === null) {
throw new \LogicException('No tenant in context. Is this a tenant request?');
}
return $this->currentOrganization;
}
}
Wire everything together in an event listener:
class TenantListener
{
public function __construct(
private TenantResolverInterface $resolver,
private TenantContext $context,
) {}
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$organization = $this->resolver->resolve($event->getRequest());
if ($organization === null) {
return;
}
$this->context->set($organization);
}
}
# services.yaml
App\EventListener\TenantListener:
tags:
- { name: kernel.event_listener, event: kernel.request, priority: 20 }
Priority 20 ensures the tenant is resolved before your security listeners run, which matters when access control depends on membership.
Automatic query filtering with Doctrine Filters
The most dangerous part of shared schema multi-tenancy is a missing WHERE clause. Add one, and data leaks. Doctrine has a built-in solution for exactly this: Filters.
A Doctrine Filter adds a SQL condition automatically to every query on configured entities. You register it once, enable it at the right moment, and stop worrying about individual queries.
First, mark which entities are tenant-scoped using an interface:
interface TenantAwareInterface
{
public function getOrganization(): Organization;
}
Then create the filter:
class TenantFilter extends SQLFilter
{
public function addFilterConstraint(ClassMetadata $targetEntity, string $targetTableAlias): string
{
if (!in_array(TenantAwareInterface::class, class_implements($targetEntity->name) ?: [])) {
return '';
}
return $targetTableAlias . '.organization_id = ' . $this->getParameter('organization_id');
}
}
Register it in Doctrine configuration:
# doctrine.yaml
doctrine:
orm:
filters:
tenant:
class: App\Doctrine\Filter\TenantFilter
enabled: false
It starts disabled. Enable it in the event listener once the tenant is resolved:
class TenantListener
{
public function __construct(
private TenantResolverInterface $resolver,
private TenantContext $context,
private EntityManagerInterface $entityManager,
) {}
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$organization = $this->resolver->resolve($event->getRequest());
if ($organization === null) {
return;
}
$this->context->set($organization);
$filter = $this->entityManager->getFilters()->enable('tenant');
$filter->setParameter('organization_id', $organization->getId());
}
}
From this point forward, every Doctrine query on a TenantAwareInterface entity automatically includes WHERE organization_id = ?. You cannot forget it.
The Member entity — linking users to organizations
A user can belong to multiple organizations. You need a join entity that captures the relationship and the role within each organization.
#[ORM\Entity]
class Member
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'memberships')]
private User $user;
#[ORM\ManyToOne(targetEntity: Organization::class, inversedBy: 'members')]
private Organization $organization;
#[ORM\Column(length: 50)]
private string $role = 'member'; // 'owner', 'admin', 'member'
#[ORM\Column]
private \DateTimeImmutable $joinedAt;
}
To check access within a tenant request:
class MembershipChecker
{
public function __construct(
private MemberRepository $members,
private TenantContext $context,
) {}
public function getCurrentMember(User $user): Member
{
$organization = $this->context->require();
$member = $this->members->findOneBy([
'user' => $user,
'organization' => $organization,
]);
if ($member === null) {
throw new AccessDeniedException('User is not a member of this organization.');
}
return $member;
}
}
The invitation flow
New members join through an invitation. The flow is: owner sends invite → email with token → recipient clicks link → account created or linked → member record created.
Keep the invitation token in a separate entity with an expiry:
#[ORM\Entity]
class Invitation
{
#[ORM\Column(length: 64, unique: true)]
private string $token;
#[ORM\Column(length: 255)]
private string $email;
#[ORM\ManyToOne(targetEntity: Organization::class)]
private Organization $organization;
#[ORM\Column]
private \DateTimeImmutable $expiresAt;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $acceptedAt = null;
}
Generate tokens using random_bytes:
$token = bin2hex(random_bytes(32));
Validate on acceptance:
if ($invitation->getExpiresAt() < new \DateTimeImmutable()) {
throw new \Exception('This invitation has expired.');
}
if ($invitation->getAcceptedAt() !== null) {
throw new \Exception('This invitation has already been used.');
}
What to watch out for
Background jobs. When a command or message handler runs outside an HTTP request, the tenant context is empty and the Doctrine Filter is disabled. You must resolve and set the tenant explicitly before any query. A TenantAwareMessage interface that carries the organizationId is a clean pattern.
Superadmin access. Your admin panel needs to bypass the tenant filter to see all data. Disable the filter for admin requests or use a separate EntityManager instance without the filter enabled.
Cascade deletes. When an organization is deleted, all related data must be cleaned up. Use ON DELETE CASCADE at the database level or orphanRemoval: true in Doctrine. Relying on application-level cleanup for this is a mistake.
Unique constraints. A UNIQUE constraint on email in a users table is fine. A UNIQUE on slug in a projects table must be scoped: UNIQUE (slug, organization_id).
The architecture in practice
The pattern above gives you:
- A single source of truth for the current tenant (
TenantContext) - Automatic query isolation at the database level (Doctrine Filter)
- Explicit tenant resolution at the edge of the system (event listener)
- No risk of cross-tenant data leakage in normal application code
It is not magic. You still need to be deliberate about background jobs, raw SQL queries, and admin tooling. But for the 95% of your application that runs in a standard HTTP request, it works reliably and scales cleanly.
This is the approach used in the Horme Symfony SaaS Boilerplate. If you want to see the full implementation — filter, context, listeners, invitation flow — the source code is all there.