Doctrine ORM + sharding: where to start when building a service
Sharding is not something you bolt on after a table has already outgrown its server. It is an architectural decision that needs to be laid into a service from the very first commit: the shard key, the strategy, the routing mechanism, the way shards are bootstrapped and migrated. If it is not designed in from the start, a year later the team is not sharding — it is rewriting.
This article captures the architectural shift I settled on and the elrise/doctrine-shard-manager-bundle as one way to encode these invariants in code. The implementation — contracts, strategies, configuration, CLI, benchmarks — is documented on the project page.
Not a comparison
This is not a comparison of different sharding approaches. It presents one option: commit to four invariants from the very first commit, and let the bundle enforce them. The bundle is a concrete implementation of that approach on top of Doctrine ORM and DBAL.
Why sharding is a decision at the start
The path “first a single EntityManager, then sharding later” looks economical. A year in, the users table holds tens of millions of rows; another year, hundreds of millions. EXPLAIN shows that the index is no longer saving you, INSERT ... ON DUPLICATE KEY UPDATE collides on a single primary key across all records, and the team starts “doing sharding”. By then business logic is soaked with findBy(['user_id' => ...]) in dozens of places, IDs are auto-increment, reports that aggregate “from all places” break — because “all places” is now N shards, and JOIN between them does not work. Changing the strategy means rewriting half the application, shipping to production, and hoping nothing breaks.
The other path is to commit to four invariants at the first commit:
| Invariant | What it means |
|---|---|
| Shard key | Chosen at the data-modeling stage and never changed. UUIDv7 with the shard baked in, or a hash of a natural PK (user-id, order-id). |
| Strategy | Fixed in code and config. Even if physically one shard today, routing already works. |
| Transparent routing | Business logic does not know how many shards exist. ShardedRepository or ShardContext::withEntity() picks the right Connection and EntityManager for the operation. |
| First-class CLI | shard:add for a new shard, shard:migrate for schema updates. Not shell scripts only ops know. |
The bundle covers all four. It is the architectural base a service grows on. If you arrive at sharding after the table has grown, the bundle will still fit, but you will get the headache you could have avoided.
Transparent routing
The main architectural shift is not the introduction of shards as such, but transparency to business logic. The developer writes ShardedRepository::findOneById(), and the bundle itself looks at the User configuration, resolves the shard by key (through one of three strategies), switches the active Connection and EntityManager for the operation, and returns the result. Business logic does not know that the project has N shards — it only knows that it works with User.
Most hand-rolled wrappers hide sharding inside the Repository, but require the calling code to know which shard it needs. The bundle hides sharding so well that the calling code does not think about shards at all — it thinks about entities. That is the invariant that turns the rest of the architecture: the strategy can be swapped, shards can be added, the infrastructure can be re-routed, all without touching a single line of use-case services.
The boundary with the infrastructure engine
Sharding answers the question of “where and how to place data”. The bundle does not do bulk operations, streaming reads, advisory locks, or fine-grained SQL control — that is the work of the sibling elrise/dbal-bundle. The split is simple:
elrise/doctrine-shard-manager-bundleowns data architecture: which shard, which strategy, which config, how to add a new shard, how to apply a migration. This is the data map.elrise/dbal-bundleowns infrastructure interaction with the data: bulk-insert / update / upsert, streaming reads through generators, advisory locks, row-levelSELECT ... FOR UPDATE, caller-trace SQL comments, PSR-3 logging of bulk operations. This is the bulk-path engine.
The hybrid architecture emerges naturally: ORM stays as the mapping system for schema description and CRUD logic, shard routing sits on top, DBAL handles the infrastructure work inside a shard. Business logic does not change — it still works with entities.
What the bundle covers and what it does not
Covers: routing Connection and EntityManager per shard, three reference strategies (hash, range, uuid), PSR-6 cache with graceful degradation, configuration through the #[Sharding] attribute or YAML, cross-shard reads via ShardedFinder::find(), shard:add for greenfield provisioning, shard:migrate for additive migrations.
Does not cover:
- middleware-level routing (Vitess, ProxySQL, Citus) — this is application-level routing, not an SQL-aware proxy;
- cross-shard transactions (XA, 2PC) — by design, as an anti-pattern;
- automatic live re-sharding — adding a fifth shard to four remains a manual operation;
- Doctrine native sharding (
ShardFilterand similar) — an alternative contract with its own compatibility.
When to take it and when not to
Take it if:
- the table has outgrown a single DB server and you need horizontal partitioning, not a read-replica;
- you already use Doctrine ORM/DBAL and do not want to throw it out for sharding;
- you have a deterministic shard key (user-id, order-id, UUIDv7) so that a write always lands on the same shard;
- you are ready to manage shards as first-class entities: provisioning, per-shard migrations, per-shard monitoring.
Do not take it if:
- you have a single DB and vertical scaling is not yet exhausted — sharding is complexity, and you should not add it before it is needed;
- you have no natural key for sharding — no bundle will help until that architectural decision is made;
- you need cross-shard transactions — the bundle does not do them and should not;
- you need live re-sharding (data migration between shards without downtime) — the bundle picks the shard; data migration is a separate project.
Where to see the implementation
API, contracts, the three strategies, configuration, CLI commands, cross-vendor benchmarks, and known issues are on the project page. In dummy-market-agent you can start a reference Symfony service with Docker and inspect how shard routing, ORM, and the DBAL layer coexist in one application.
Conclusion
Sharding is not an optimisation layered on top of an existing service. It is an architectural decision that must land in the first commit, otherwise a year later the team rewrites half the service. The elrise/doctrine-shard-manager-bundle is one way to formalise these invariants in code: the shard key, the strategy, transparent routing, and a first-class CLI for shards.
This is not a replacement for Doctrine ORM/DBAL and not a remedy for a table that has already grown. It is a starting base for an architecture in which data growth does not become a catastrophe.
Useful links:
- doctrine-shard-manager-bundle: implementation, contracts, benchmarks
- Doctrine ORM Sharding — for context
- UUIDv7 (RFC 9562) — key format for the
uuidstrategy - Doctrine ORM and Doctrine DBAL — what the bundle runs on top of
- Doctrine Migrations — for
shard:migrate - PSR-6: Caching Interface — strategy memoisation
- PSR-3: Logger Interface — structured resolution logs
- Doctrine Migrations Bundle for Symfony — base integration