DEV Community
Follow
Single-database multi-tenancy in Symfony: a 31-line Doctrine filter, and the five places it never runs
Single-database multi-tenancy relies on an organization_id column to enforce data isolation, but developers must remember to include it in queries. Doctrine's SQLFilter can automate this, but its effectiveness depends on where it's implemented and where it's omitted. The organization filter is intentionally disabled by default in Doctrine's configuration to prevent issues with fixtures, migrations, and repair scripts. It is enabled by a listener later in the request lifecycle, specifically after the firewall, to access the authenticated user and their organization.This filter, however, has several blind spots where it does not apply or is bypassed. Console commands and message queue workers do not benefit from the request listener, meaning they execute without the filter enabled. While this is intentional for tasks like batch processing across all tenants, it requires explicit scoping for commands touching tenant data. Admin panels are also intentionally exempted via path prefixes so they can view data across tenants, making these exemptions security-critical.Entities found in Doctrine's identity map before a query is executed bypass the filter entirely, as no SQL is generated. Similarly, getReference() creates proxies without database interaction, meaning the filter isn't consulted until the proxy is initialized. Native SQL queries executed through DBAL completely ignore Doctrine's filters, posing a risk for reporting and export functionalities. Joined inheritance can also lead to missed filtering if the organization_id and marker interface are not on the root entity. Despite folklore, filters do apply to bulk UPDATE and DELETE DQL statements, but this only covers the DQL path. Thorough, automated testing is crucial to ensure the filter's reliability and catch regressions.