Single-database multi-tenancy in Symfony: a 31-line Doctrine filter, and the five places it never runs
Single-database multi-tenancy means several organizations share one database and usually one schema. Tenant-owned tables contain an organization id value, which marks each row's owner. This saves infrastructure and keeps deployment simpler. The design is cheap, but isolation depends on every access path applying the right restriction. In a Symfony application using Doctrine, an organization might create a project. The project row stores that organization's identifier. A Doctrine SQLFilter can automatically add a matching condition to generated ORM SQL, so ordinary queries return only that organization's rows. Developers do not need to repeat the condition manually in every repository method. The article highlights the central promise: nobody will forget to write the restriction. That promise is also the danger. Raw SQL, direct database access, or other paths may sit outside the filter. As the application grows, teams must audit every path and test tenant isolation, not merely trust the convenient ORM behavior.
What is single-database multi-tenancy in a Symfony application?
Single-database multi-tenancy means several organizations share one database and usually one schema. Tenant-owned tables contain an organization_id value, which marks each row's owner. This saves infrastructure and keeps deployment simpler. The design is cheap, but isolation depends on every access path applying the right restriction.
In a Symfony application using Doctrine, an organization might create a project. The project row stores that organization's identifier. A Doctrine SQLFilter can automatically add a matching condition to generated ORM SQL, so ordinary queries return only that organization's rows. Developers do not need to repeat the condition manually in every repository method.
The article highlights the central promise: nobody will forget to write the restriction. That promise is also the danger. Raw SQL, direct database access, or other paths may sit outside the filter. As the application grows, teams must audit every path and test tenant isolation, not merely trust the convenient ORM behavior.
How does a Doctrine SQLFilter automatically add an organization_id restriction to database queries?
A Doctrine SQLFilter is a hook that modifies SQL generated for mapped ORM entities. When the filter is enabled, it contributes a condition that limits rows to the current organization_id. Doctrine then includes that condition in applicable queries, rather than asking each developer to remember it manually.
For example, a repository may request all invoices through Doctrine. If the current organization is 42, the generated SQL can include a predicate equivalent to organization_id = 42. The application sets the filter's organization value for the request, and the filter adds the restriction while Doctrine builds SQL. The article says this tool has existed for years and can be about thirty lines.
The filter is a strong convenience and an important safety layer, but it is not a universal database firewall. It mainly protects SQL generated through the relevant Doctrine ORM path. Native SQL, direct connections, or incorrectly configured execution can bypass it. Secure systems therefore combine the filter with careful access review and tests.
How many schemas, database connections, and organization_id columns does this tenancy design use?
This tenancy model deliberately minimizes physical separation. It uses one database schema and one connection for the application, rather than provisioning infrastructure for each organization. Tenant-owned tables share those structures, so rows from many organizations live side by side.
The third piece is an organization_id column on every tenant-owned table. A customer record, invoice, or project carries the identifier of its organization. Queries must use that value to select the correct rows. Doctrine's SQLFilter can help add the restriction automatically for ORM-generated queries, making the shared layout practical.
The source calls single-database multi-tenancy the cheapest kind. That economy comes with a strict operational requirement: isolation is logical, not physical. One missed restriction can expose another customer's data. Scaling the number of organizations does not require new schemas or connections, but it increases the importance of consistent filters, review, and testing.
What happens if a query bypasses the Doctrine filter and returns records belonging to another organization?
If a query bypasses the Doctrine filter and lacks its own organization_id restriction, it can read rows belonging to multiple organizations. If the application returns those rows to the requesting user, one customer sees another customer's data. That is a cross-tenant isolation failure, not merely a display bug.
Imagine an unfiltered invoice query that selects every invoice in a shared table. The database has no separate schema to stop the query. Unless another permission layer intervenes, invoices for organization 17 can appear in organization 42's response. The SQLFilter normally supplies the missing restriction only when the query travels through its supported Doctrine path.
The article frames this as the design's core promise: developers should not have to remember the condition, because forgetting once leaks data. Therefore, a bypass needs immediate investigation, containment, and tests for regression. Forward-looking designs should treat filters as helpful enforcement, while reviewing raw SQL and other paths as security-critical.
Which kinds of database operations or execution paths can cause a Doctrine SQLFilter not to run?
A Doctrine SQLFilter operates during supported Doctrine ORM SQL generation. It does not automatically rewrite every statement sent to the database. Consequently, native SQL, direct DBAL or connection calls, and database commands issued outside the filtered ORM context can avoid the filter.
For example, a report might use a raw SQL string against the invoices table. Unless that SQL explicitly includes organization_id, it can return every organization's invoices. Bulk updates, maintenance scripts, imports, migrations, background workers, and administrative tools also need review. Some may use Doctrine ORM correctly; others may use lower-level paths and therefore require their own tenant restriction.
The exact risk depends on how each path is implemented and configured. The safe assumption is not that every query is filtered. Teams should inventory ORM, raw SQL, jobs, commands, and integrations. They should add explicit restrictions where needed and test that each path preserves tenant isolation.
Why must developers treat every database access path—not only ordinary ORM queries—as part of the security boundary?
Tenant isolation is a security boundary because the same physical tables hold multiple customers' data. An ORM filter is one enforcement mechanism inside that boundary. If another path reads or writes the database without the filter, the shared storage itself does not know which organization the caller represents.
Consider a web request that uses filtered Doctrine and a background export that uses raw SQL. The web request may be safe, while the export silently selects every organization's records. The same concern applies to command-line tools, integrations, reporting code, and maintenance operations. Each path must carry the organization context and enforce it correctly.
The article's warning follows directly from its promise about forgetting. Automating ordinary ORM queries reduces mistakes, but it cannot excuse ignoring alternative paths. Teams should document all database access, restrict privileged tools, review queries, and test cross-tenant attempts. This makes the boundary explicit as the application and its execution paths grow.
What is the difference between separating tenants with an organization_id column and separating them with different databases or schemas?
With an organization_id column, all organizations share tables, schema, and usually a connection. Tenant identity is a value on each row, and queries enforce separation by filtering that value. This is the single-database model described in the article. Its main attraction is lower cost and simpler shared infrastructure.
With separate databases or schemas, each tenant's tables live in a different physical or namespace boundary. A connection or schema selection identifies the tenant before queries run. A query pointed at organization 42's database will not normally see organization 17's tables. However, this approach brings more provisioning, configuration, migrations, monitoring, and operational overhead.
Neither choice removes the need for careful security. Shared-column tenancy makes a forgotten predicate especially dangerous because unrelated rows are nearby and queryable. Separate storage reduces that particular risk but introduces routing and administration concerns. The article presents the shared design as cheapest, while its promise depends on reliable filtering and complete access-path coverage.
This brief was written by AI from the original reporting and checked by other models. Names, figures and quotes come from the source; read it for full context.
Read more in the JupiteX app
Pulse is free. New stories every 4 hours, each one broken into the questions that explain it.
Or read more news on the web