Part 1 of 2 in Multi-tenancy landmines

The Doctrine Filter That Only Breaks on Sub-Requests

A tenant-scoping filter that works perfectly under load, in every test, on every page — until one template embeds another controller.

A Doctrine filter that scopes every query to the current tenant is one of those pieces of code you write once, test once, and stop thinking about. It runs on every request, on every page, for months. Then one day a template embeds a controller — an ESI fragment, a {{ render(controller(...)) }} call, a widget that reuses an existing action instead of duplicating its logic — and the exact same filter, doing the exact same thing it's always done, throws:

js
Doctrine\ORM\Query\FilterCollection: Filter 'tenant_filter' is already enabled.

Nothing about the filter changed. Nothing about the tenant changed. The only thing that changed is that this particular request now contains two requests instead of one.

Why "one request" is often two

A page that embeds another controller's output doesn't make one call into the framework — it makes two. The outer call is the main request: the one the browser actually asked for, the one that owns the URL bar. The inner call — triggered by render(controller(...)), an ESI tag, or any mechanism that re-invokes the HTTP kernel from inside a template — is a sub-request. It goes through the exact same kernel pipeline as the main request: routing, security, and any event listener subscribed to kernel.request.

That last part is the trap. A listener that enables a Doctrine filter on kernel.request doesn't know it's being invoked a second time in the same page render. It just sees a request come in, and does what it always does.

sequenceDiagram
    participant Browser
    participant Kernel
    participant TenantFilterListener
    participant Doctrine

    Browser->>Kernel: GET /dashboard (main request)
    Kernel->>TenantFilterListener: kernel.request
    TenantFilterListener->>Doctrine: enable(tenant_filter)
    Note over Kernel: Twig renders the page,<br/>hits {{ render(controller(...)) }}
    Kernel->>Kernel: sub-request dispatched
    Kernel->>TenantFilterListener: kernel.request (again)
    TenantFilterListener->>Doctrine: enable(tenant_filter)
    Doctrine--xTenantFilterListener: already enabled

The listener that should have known better

Symfony gives every kernel.request listener a way to answer exactly one question before doing anything: is this the request, or a request?

php
public function onKernelRequest(RequestEvent $event): void
{
    if (!$event->isMainRequest()) {
        return;
    }

    $this->entityManager->getFilters()->enable('tenant_filter');
}

RequestEvent::isMainRequest() is the framework's own answer to this exact problem — it exists specifically so that listeners which only make sense once per page (starting a profiler timer, opening a transaction, enabling a filter) can opt out of running again on every embedded sub-request.

The failure mode when this guard is missing is almost designed to be confusing: it's not that the tenant scoping breaks — the data is still correctly filtered, because the filter was already enabled by the main request. It's that the act of trying to enable an already-active filter is itself an error condition Doctrine refuses to silently ignore. A listener that does its job perfectly, twice, still causes a 500.

Why it hides so well

A single-listener page will never trigger this. A page with no embedded controllers will never trigger this. It surfaces exactly at the intersection of "this route embeds another controller's output" and "the filter-enabling listener has no main-request guard" — which means it can sit correct-by-accident for a long time, right up until someone adds the first {{ render(controller(...)) }} call to a template that used to be simple.

Doctrine's FilterCollection isn't being unreasonable here, either — suspend() and restore() exist precisely for cases where you need to temporarily disable and re-enable a filter mid-request without losing its parameters. But that's a different operation from calling enable() twice on something that's already active, which is what an unguarded listener does the moment a sub-request reaches it.

The fix, and why it's not automatic

php
if (!$event->isMainRequest()) {
    return;
}

One line. The reason it's easy to miss isn't the fix — it's that nothing forces you to write it. A listener that enables a filter works identically, in every manual test, whether or not the guard is there, until a sub-request exists. If your project has several listeners doing related work on kernel.request (resolving a tenant, activating a context, enabling a filter), it's worth checking that all of them agree on this — a resolver listener with the guard sitting next to a filter listener without it is a bug that's already halfway written.

When to actually check for this

If your application has any mechanism for one controller to embed another — ESI, render(controller()), ADR-style page composition — and you also have kernel.request listeners doing anything stateful (enabling filters, opening scopes, starting timers), it's worth an explicit audit rather than waiting for the first embedded widget to find it for you. The bug doesn't announce itself until the two features finally meet.