user@elrise.io:~
2026-07-238 min readphpsymfonydoctrinedbalormhigh-loadperformancearchitecture

Why high-load Symfony needs a dedicated DBAL layer alongside ORM

In high-load Symfony projects, the same picture keeps repeating: ORM is solid on its own ground (Entity, migrations, CRUD), but it does not carry infrastructure work — bulk operations, streaming, nightly migrations over billions of parameters, reports over millions of rows. This note captures the architectural shift I settled on: keep ORM and a dedicated DBAL layer side by side, each with its own responsibility. The bundle API, components, requirements, and benchmarks live on the project page.

Not a comparison

This is not a comparison of ORM versus DBAL. It is one option for organising the data layer in a high-load project: let ORM keep its territory and add a dedicated DBAL layer next to it for infrastructure scenarios. In production the question is one of responsibility, not a technology swap.

What ORM is responsible for

Doctrine ORM in Symfony owns:

ConcernTool
SchemaDoctrine Migrations
Table mappingEntity and ORM metadata
CRUDEntityRepository, findBy, persist/flush
Aggregate integrityunit of work, identity map, lazy loading

ORM handles these well. That is what it was designed for.

Where ORM’s responsibility ends

High-load introduces tasks that ORM is not optimised for. They do not break its contract, but they need a different level of abstraction.

Hydration where the result is data, not entities. $repo->findAll() builds an object for every row, attaches proxies, calls lazy loaders, merges related collections. On 1,000 rows this is invisible. On one million rows, hydration eats 80% of the time and consumes an order of magnitude more memory than the data itself weighs, just to turn the objects back into an array for a report five seconds later.

N+1 on bulk operations. Updating 50,000 rows through ORM is either 50,000 UPDATEs or 50,000 hydrated objects in memory. Neither works. ORM is not built for “update many rows in a single SQL”.

SQL as a black box. When a 200-line query with 12 JOINs shows up in the slow log, you need to know where it came from. With ORM this becomes a quest: debug stack, Doctrine UnitOfWork, guesswork. In infrastructure operations — nightly migrations, reports, billing — you need explicit control over SQL, because the query plan decides whether the operation finishes in an hour or in eight.

Streaming reads. 10 million rows through ORM is a kludge. toIterable() appeared recently and works with caveats. For streaming you need generators on top of Statement::fetch(), not collections on top of ResultSetMapping. That is a different tool.

These tasks are not hostile to ORM. They are simply outside its abstraction.

Why DBAL, not bare PDO

Going straight to bare PDO is the standard reflex when ORM feels slow. Six months later, the team usually has:

Doctrine DBAL is already a ready intermediate layer: Connection, parameter binding, transactions, cross-vendor types. It covers most of what would otherwise have to be hand-written. On top of it remains assembling the right architecture: factories, interfaces, executors, SQL generators.

elriseio/dbal-bundle is that infrastructure, built on top of DBAL. The bundle does not replace PDO with its own wrapper; it formalises the layer that every high-load team builds anyway, in one place.

Hybrid architecture

The two data layers coexist, each with its own territory:

┌────────────────────────────────────────────┐
│ Application Layer                          │
│   Command / Query handlers                 │
│   business logic                           │
└────────────────────────────────────────────┘
       │                          │
       │                          │
       ▼                          ▼
┌─────────────────────┐  ┌──────────────────────────────────────┐
│ ORM layer           │  │ DBAL layer                           │
│ Entity + Repository │  │ BulkInserter / BulkUpdater           │
│ find / persist      │  │ CursorIterator / OffsetIterator      │
│ migrations          │  │ DbalFinder / DbalMutator / DTO       │
│ unit of work        │  │ TransactionService (advisory, row)   │
└─────────────────────┘  └──────────────────────────────────────┘
       │                          │
       └──────────┬───────────────┘
                  ▼
         Doctrine DBAL (Connection, parameters, types)
                  │
                  ▼
         MySQL / MariaDB / PostgreSQL

ORM serves the database structure and CRUD logic. The DBAL layer takes over infrastructure work: bulk, streaming, reports, data migrations. They complement each other rather than compete.

When a DBAL layer is justified

A separate DBAL layer is not mandatory. It pays off when:

Without those conditions, the extra layer is unjustified. A small CRUD project on a single schema does not need a DBAL infrastructure.

Where to see the implementation

The bundle API, signatures, requirements, cross-vendor benchmarks, and known compatibility gaps are on the project page. In dummy-market-agent you can start a reference Symfony service with Docker and see how ORM and the DBAL layer coexist inside one application.

Conclusion

The problem is not that ORM is bad, nor that Doctrine DBAL replaces it. The problem is mixing different classes of tasks at the same level of abstraction. When the ORM layer is forced to carry bulk, streaming, and nightly migrations, it stops being an ORM. When the DBAL layer stays in infrastructure scenarios, ORM keeps its territory clean.

elriseio/dbal-bundle is one option for formalising a DBAL layer for high-load Symfony. It is not a replacement for ORM; it is an infrastructure layer that sits on top of the existing doctrine/doctrine-bundle, sharing contracts but with a different load profile.


Useful links:

Discussion

Comments (0)

No comments yet.