user@elrise.io:~
2026-07-227 min readphpsymfonydoctrinedbalshardingarchitecture

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:

InvariantWhat it means
Shard keyChosen 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).
StrategyFixed in code and config. Even if physically one shard today, routing already works.
Transparent routingBusiness logic does not know how many shards exist. ShardedRepository or ShardContext::withEntity() picks the right Connection and EntityManager for the operation.
First-class CLIshard: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:

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:

When to take it and when not to

Take it if:

Do not take it if:

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:

Discussion

Comments (0)

No comments yet.