Build1 distinct publisher3 min readUpdated
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
Compiled by The EngineerSomething wrong?How this is made
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].
Follow any of these and your For You feed starts watching them — no settings page required.
Ranked by verification strength, evidence, and original report placement.
The listener subscribes to KernelEvents::REQUEST at priority 7; Symfony's Firewall listener subscribes to kernel.request at priority 8, so 7 is the first slot where Security::getUser() is populated.
Subscribing at a higher priority yields no user and therefore no filter at all, which the author calls the worst possible failure mode because the page still renders.
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.
Evidence-backed comparisons of source perspectives and observed adoption signals. Read the methodology
Which Builder, Operator, and Investor concerns the observed source mix emphasized—not a truth score.
Evidence, demonstrated adoption, hype gap, incentives, and confidence are assessed independently, each on its own current evidence. How these are measured.
Code-level and version-pinned, but single-source and self-reported
The claims are unusually checkable for a blog post: full class and listener listings, a named ORM version (doctrine/orm 3.6.7), quoted internals from EntityManager::find() and SqlWalker::generateFilterConditionSQL(), an explicit priority relationship to Symfony's Firewall listener, and a stated basis in a per-commit test suite. What holds the score down is that everything comes from one publisher and one author, none of the quoted source lines or the priority-8 firewall value is independently corroborated in the cluster, the test suite is asserted rather than shown, and the final section is truncated before its fix.
No adoption signal in the cluster
The cluster contains no release, deployment, benchmark, incident, pricing or usage disclosure. The article references a Doctrine ORM version it read and a private test suite, neither of which measures how widely this pattern or these gaps occur in the field. Adoption cannot be scored without inventing facts.
Slightly understated: caveat-first, no product claim
The framing runs against the usual direction of inflation. The headline's only quantitative promise (a 31-line filter, five places it never runs) is met by the body, the mechanism is deliberately described as a safety net rather than a guarantee, and the piece volunteers its own limits: HTTP-only coverage, unfiltered workers, path-prefix fragility, identity-map and native-SQL bypasses. Slightly negative rather than zero because the material consequence of the CLI and native-SQL gaps for multi-tenant SaaS is larger than the modest 'useful part' presentation suggests; it is not more negative because none of it is corroborated or measured beyond one author's codebase.
Low commercial stake; reputational upside only
The article sells nothing: no product, vendor, sponsor, pricing, licensing or hiring pitch appears, and the recommendations point users toward framework primitives and their own authorization voters rather than toward a paid dependency. The residual incentive is the ordinary developer-platform one, where a specific, contrarian 'here is where the popular pattern fails' headline earns attention on dev.to, which favours emphatic framing of gaps.
Moderate: internally verifiable, externally unconfirmed
Confidence is limited by structure rather than by quality. The technical assertions are precise and version-pinned, and the derived claims follow directly from the shown code, so the internal reading is reliable. But the cluster is single-publisher, single-item, has no adoption evidence, no independent confirmation of the framework internals, and one section truncated mid-argument, so any judgement about how the pattern behaves outside this author's codebase stays provisional.
build
PHP-FPM's dynamic pool is a one-second idle-worker loop, not a capacity plan1 distinct publisher
build
The guard that worked in tests and still wrote 2,684 live records1 distinct publisher
build
Block themes move who controls layout, not just how templates are written1 distinct publisher
build
Force the tool call, then hand Lightsail a long-lived key1 distinct publisher
Distinct publishers with included, body-backed reporting in this cluster.
dev.to
1 article · August 19, 2026