$order->getCustomer()->getName() inside a foreach. Every time I open a slow Doctrine endpoint, I find some version of this line within ten minutes. The PHP is rarely slow. The shape of the data access is.

Same few places every time.

Lazy loading. The classic N+1. Doctrine proxies make it invisible: you touch the relation in a loop and every touch is a query. The profiler query count tells the truth. The code does not.

Caches that are not configured. Doctrine parses mapping metadata and translates DQL on every request unless you give it a cache. On dev nobody notices. In production without APC-backed metadata and query cache you pay this tax on each request. Fifteen minutes of configuration, permanent discount.

Hydration. Turning rows into full entities is expensive. Loading five thousand entities to render a table means five thousand tracked objects in the unit of work. For read-only lists ask for array hydration, or go straight to DBAL. A report does not need entities. It needs rows.

Inheritance. Class table inheritance looks clean in the model and produces JOIN fans in SQL. Every query on the base class drags the children in. Sometimes worth it. Measure before you commit, because leaving it later is a schema migration.

Eager fetch on everything. The panic reaction to N+1, and it only moves the cost. You haul the whole object graph to show one column. This one was my reaction, on my first Doctrine project, and it made the list page slower than the N+1 it replaced.

The pattern behind all five: convenience is a loan. The ORM lets you not think about SQL, and mostly that is fine. But the SQL still executes. When an endpoint gets slow, open the profiler, read the actual queries, count them. Then decide per query: fix the fetch, change the hydration, or write the SQL by hand.

DBAL is right there. Using it is knowing when the abstraction stops paying for itself.