Cutting Doctrine ORM Overhead with DTO Projections for Read‑Only Queries
Learn how Doctrine ORM's DTO projection and array hydration modes let you skip full entity materialization for read‑only queries, cutting memory use and identity‑map pressure with a concrete QueryBuilder example.
05 May 2026, 12:53 UTC

The problem: full entity materialization on every list request
When a Symfony or plain PHP application uses Doctrine ORM to power search results, dashboards or API list endpoints, the default QueryBuilder::getResult() returns fully managed entities. Each row becomes an object tracked by the UnitOfWork, placed in the identity map, and wired with lazy‑loaded proxies for associations. For read‑only workloads this creates unnecessary memory pressure, extra SQL for lazy loads, and CPU spent on change‑tracking that will never be used.
Why DTO hydration helps
Doctrine offers three hydration modes that bypass the UnitOfWork entirely:
- HYDRATE_ARRAY – returns a plain PHP array for each row.
- HYDRATE_SCALAR – returns a flat associative array with scalar keys (e.g.
u.id,u.email). - SELECT NEW with a custom DTO class – instantiates your own data‑transfer object directly from the SELECT clause.
All three skip lifecycle callbacks (@PostLoad, entity listeners) and do not register the result in the identity map. The result is a lightweight data structure that can be serialized to JSON or passed to a view without the overhead of managed entities.
Worked example: projecting a user list into a DTO
Assume a User entity with fields id, email, createdAt and a Profile relation. We only need a subset for an admin grid.
// src/DTO/UserListItem.php
final class UserListItem
{
public function __construct(
public readonly int $id,
public readonly string $email,
public readonly \DateTimeImmutable $createdAt,
public readonly ?string $profileName
) {}
}
Using the QueryBuilder we can select exactly those columns and instantiate the DTO:
// In a repository or service
use Doctrine\ORM\QueryBuilder;
use App\DTO\UserListItem;
public function findUserListItems(QueryBuilder $qb): array
{
$qb->select('NEW App\\DTO\\UserListItem(u.id, u.email, u.createdAt, p.name)')
->from(User::class, 'u')
->leftJoin('u.profile', 'p');
// HYDRATE_OBJECT is the default for SELECT NEW, but we can be explicit
return $qb->getQuery()
->setHint(Query::HINT_CUSTOM_OUTPUT_WALKER, 'Doctrine\\ORM\\Query\\SqlWalker')
->getResult(); // returns UserListItem[]
}
Key points:
- The fully qualified class name must be escaped with double backslashes inside the DQL string.
- No
Userentities are loaded; the generated SQL selects only the four columns. - The returned array contains plain
UserListItemobjects—no proxies, no identity‑map entries.
Trade‑offs and limitations
- No change tracking – any mutation of the DTO will not be persisted. If the same request later needs to update a user, you must fetch a managed entity separately.
- Lifecycle callbacks are skipped –
@PostLoadlisteners, entity subscribers, or Doctrine extensions that rely on them will not run for projected rows. - Version sensitivity – Doctrine ORM 2.x and 3.x differ slightly in the
SELECT NEWsyntax and the constants for hydration modes (Query::HYDRATE_ARRAYvsQuery::HYDRATE_OBJECT). Verify the exact constant names in the installed version before deploying. - Query result cache interaction – If you enable the query cache, the cache key is derived from the SQL and parameters. Manual DB changes or schema migrations can serve stale DTOs unless you invalidate the cache region explicitly.
How to verify the improvement in your project
- Enable SQL logging (e.g.
doctrine.dbal.loggingin Symfony) and run the endpoint twice: once with the default entity fetch, once with the DTO projection. Compare the number of executed statements and the column list. - Profile memory using
memory_get_peak_usage()or a profiler (Blackfire, Tideways). Expect a measurable drop when the identity map stays empty. - Check returned types – add a temporary
var_dump(get_class($result[0]));to confirm you receiveApp\DTO\UserListItem(or an array forHYDRATE_ARRAY) instead ofApp\Entity\User. - Run a load test (e.g.
wrk -t4 -c100 -d30s http://localhost/api/users) and compare latency and throughput.
If the DTO path shows fewer SQL rows, lower peak memory, and faster response times without breaking any write paths, you have a concrete win.
Actionable next step
Identify one read‑only endpoint that returns a collection of entities but only uses a handful of fields. Replace its repository method with a SELECT NEW DTO projection (or HYDRATE_ARRAY if you prefer plain arrays). Deploy behind a feature flag, run the verification steps above, and promote the flag once the metrics confirm the expected reduction in overhead.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.