The profiler said the query takes 20 ms. The export endpoint took 900 ms and a quarter of a gigabyte of memory. The missing 880 ms was Doctrine turning five thousand rows into five thousand entities.
Hydration is not free. For every row the ORM builds an object, fills properties through reflection, registers it in the unit of work, creates proxies for relations. Per row it is nothing. Times five thousand, it is the endpoint. The profiler shows SQL because SQL is easy to show.
The endpoint did not need entities. It read four fields and wrote CSV. So:
$rows = $qb->select('o.id, o.number, o.total, c.email')
->join('o.customer', 'c')
->getQuery()
->getArrayResult();
Same SQL. 60 ms total, memory flat. Array hydration skips the unit of work completely. For a nicer shape, select into a DTO with NEW OrderRow(o.id, o.number, ...) in DQL. Typed objects, still no tracking, still cheap.
Do this measurement yourself once, on your data. memory_get_peak_usage() before and after, three variants: full entities, arrays, DTO. On my data entities lost by an order of magnitude. After you see the number you stop arguing about it.
The wider point is read models. Entities exist for writes: identity, invariants, change tracking. A list page, a report, an export need none of that. They need rows. Giving read paths their own thin queries is not heresy against the ORM. It is using the ORM for what it is good at and SQL for what it is good at.
Keep entities for commands. Queries can eat from the table directly.
I knew all this before the incident. I still wrote getResult() there, because it was the default, and the default is the thing you do not think about.