Build1 publisher3 min readPublished
Thirty lines of Doctrine filter, and the query paths where it is simply not there
A dev.to writeup of single-database multi-tenancy in Symfony spends less effort on the SQLFilter than on enumerating where it never runs. That enumeration is the deliverable.
The Engineer · Build desk
Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction
What happened
- A dev.to article describes single-database multi-tenancy in Symfony implemented with a Doctrine SQLFilter of about thirty lines (headline: a 31-line Doctrine filter).
- The article states the interesting part is not the filter but the map of the places where it is simply not there, because that map is what you actually have to defend.
- The design rests on the promise that no developer on the team will ever have to remember to write WHERE organization_id = ?, because forgetting it once leaks another customer's data.
- Single-database multi-tenancy as described is one schema, one connection, and an organization_id column on every tenant-owned table.
- OrganizationFilter::addFilterConstraint returns an empty string if the target entity's reflection class does not implement OrganizationOwnedInterface, and otherwise returns sprintf('%s.organization_id = %s', $targetTableAlias, $this->getParameter(self::PARAMETER)).
Compiled by The EngineerSomething wrong?How this is made
Why it matters
A dev.to writeup on single-database multi-tenancy in Symfony puts the entire isolation mechanism into a Doctrine SQLFilter of about thirty lines, then spends the rest of its length mapping the places that filter is not present [1][2]. The map matters more than the filter, because the design's whole promise is that nobody on the team ever has to remember to write `WHERE organization_id = ?`, and forgetting once leaks another customer's data [3].
The cheap shape: one schema, one connection, an `organization_id` column on every tenant-owned table [4]. The filter returns an empty string for any entity that does not implement `OrganizationOwnedInterface`, and otherwise appends `alias.organization_id = <parameter>` [5]. That interface is a marker with a single method, `getOrganization()`, and opting in is the entire public API: no attribute to remember, no base class, no trait whose absence is invisible in a diff [6]. The author states the code was read against Doctrine ORM 3.6.7 with a suite running on every commit [7].
The filter ships `enabled: false` in `doctrine.yaml`, deliberately, because a filter on by default in the container is also on in fixtures, migrations and data-repair scripts [8]. It gets switched on by the layer that knows who is asking [9]. That layer is a `kernel.request` listener at priority 7, chosen because Symfony's Firewall listener subscribes at priority 8 and 7 is the first slot where `Security::getUser()` is populated [10]. Set it higher and you get no user, therefore no filter, and the page still renders [11]. There is exactly one priority step of slack between the firewall and the thing that depends on it [12].
Now the part worth printing and pinning above the desk. First, CLI: there is no `kernel.request` in a console process, so commands, cron jobs and Messenger consumers run with the filter off and see every tenant's rows [13]. The author argues this is the behaviour you want, since a nightly billing command has to iterate over all organizations [14]. The consequence is that "tenant data is invisible by default" is a guarantee about HTTP, not about the application, and every command touching tenant-owned entities has to scope itself with no compiler to remind it [15].
Second, the admin panel is exempted by path prefix, because filtering it would make the metrics wrong and the CRUD useless [16]. That only holds because the same prefix is locked to `ROLE_ADMIN` in `access_control`; the writeup's instruction is to copy both halves and treat the exemption list as security-critical code rather than configuration [17].
Third, the identity map. `EntityManager::find()` resolves through `$unitOfWork->tryGetById()` before any SQL is generated, so if an entity belonging to another organization was loaded earlier in the same request, by a fixture, a cascade, or an initialized `getReference()`, `find()` hands it straight back and no filter is consulted because no query happens [18]. Fourth, `getReference()` itself builds a proxy [19].
The listener's own body adds the boundary conditions: it exits early on sub-requests, on the admin prefix, and when the organization context is null, so coverage is precisely main HTTP requests with a resolved tenant outside `/admin` [20]. The published piece advertises five such gaps; the text available here documents four before it breaks off mid-sentence on `getReference()` [21].
Watch whether the path prefix and the `access_control` rule stay in sync under review, since only the pair is load-bearing [17]. Watch every new console command that touches a tenant-owned entity [15]. And watch fixtures that warm the identity map before the assertion runs [18].