# The Doctrine Filter That Only Breaks on Sub-Requests

Published 2025-09-16. Updated 2026-09-17.
Canonical URL https://log.delaa.dev/blog/2025-09-16-doctrine-filter-double-enable-sub-requests

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

---

<Image
  src="/images/doctrine-filter-double-enable-sub-requests/hero.png"
  alt="A grid representing a request boundary with an empty nested rectangle inside and a database icon in the corner"
  width={2000}
  height={1125}
  className="rounded-lg"
/>

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. The exact same filter, doing the exact same thing it's always
done, throws:

```
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.

```mermaid
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.
