Overview
Doctrine Shard Manager is a Symfony bundle for horizontal data sharding on top of Doctrine ORM and DBAL. The bundle takes care of routing Connection and EntityManager per shard, ships pluggable strategies (hash, range, uuid with UUIDv7), an optional PSR-6 cache for resolution, and a CLI for greenfield provisioning and additive migrations.
The bundle solves application-level sharding — routing logic lives in PHP, with no separate proxy layer between the application and the database.
The architectural reasoning behind this layer — why sharding is a decision at the start of a service rather than a fix, which invariants the project must commit to, and how the architectural foundation pairs with the DBAL infrastructure engine — is in Doctrine ORM + sharding: where to start when building a service.
Quick start
Minimum scenario: one entity, one shard, the #[Sharding] attribute. This is enough to verify the integration works. Setups with N shards, range tables, and uuid are in §Configuration.
composer require elriseio/doctrine-shard-manager-bundle
use Elrise\Bundle\DoctrineShardManager\Attribute\Sharding;
#[Sharding(
strategy: 'hash',
key: 'userId',
shardCount: 1,
)]
class User
{
// ...
}
# config/packages/doctrine_shard.yaml
doctrine_shard:
sharding_configs:
App\Entity\User:
strategy: hash
key: userId
shardCount: 1
connections:
shard_0: '@doctrine.dbal.default_shard_0_connection'
entity_managers:
shard_0: '@doctrine.orm.default_shard_0_entity_manager'
final class UserService
{
public function __construct(private ShardedRepository $userRepo) {}
public function load(int $userId): ?User
{
return $this->userRepo->findOneById($userId, User::class);
}
}
Full requirements, multiple shards, and all three strategies are in §Configuration.
What it is and what it is not
The bundle does:
- route
ConnectionandEntityManagerper shard using the#[Sharding]attribute or YAML configuration (sharding_configs.<FQCN>); - ship three pluggable strategies:
hash(SHA-256 modulo),range(binary search overrange_table),uuid(UUIDv7 with an embedded shard index); - support composite keys:
key: ['tenantId', 'userId']works on top of any strategy; - cache resolution through PSR-6 (graceful degradation — cache failure is logged and bypassed, never propagated);
- switch the active
Connection/EntityManagerinside theShardContext::withShard()/withEntity()callback and restore the previous routing infinally; - expose
ShardedFinder::find()for cross-shard reads via\Generator(memory bound = the largest single-shard result set); - register
bin/console shard:add <id>(greenfield provisioning) andbin/console shard:migrate <id>(additive migrations); - be strict-typed and built for PHP 8.3+.
The bundle does not:
- deploy middleware proxies (Vitess, ProxySQL, Citus) — this is application-level routing, not an SQL-aware proxy;
- support cross-shard transactions (XA, 2PC) by design — see §Known limitations;
- solve re-sharding automatically: adding a fifth shard to four is a manual operation, and key consistency is not guaranteed;
- replace Doctrine native sharding (
ShardFilterand similar) — this is an alternative, more complete contract with its own interfaces; - ship UI or observability out of the box — sharding metrics are not exposed externally;
- version
range_tablethrough Doctrine migrations — the table lives in the database and is updated by hand (see RUNBOOK).
Requirements
| Component | Minimum |
|---|---|
| PHP | 8.3 (strict types) |
| Symfony | 7.2 (7.2.* pin in composer.json) |
| Doctrine DBAL | 4.2+ |
| Doctrine ORM | 3.3+ |
| Doctrine Migrations | 3.7+ (for shard:migrate) |
PSR-6 CacheItemPoolInterface |
optional, recommended for the hot path |
PSR-3 LoggerInterface |
optional, graceful degradation when absent |
The pdo_mysql and/or pdo_pgsql PHP extensions are enabled by hand (see §Known issues). The integration-test Docker stack ships MySQL 8.4, MariaDB 10.11, and PostgreSQL 16.
Architecture
+--------------------------+
| Symfony Application |
| (consumer code) |
+------------+-------------+
|
uses interfaces in src/Contract/
|
+------------v-------------+
| ShardContext / |
| ShardedRepository |
| ShardedFinder (read) |
+------------+-------------+
|
+------------v-------------+
| ShardResolver | <-- strategy selection, key extraction
+------------+-------------+
|
+------------v-------------+ +-----------------------+
| ShardStrategy (IF) | <--> | Hash / Range / UUID |
+--------------------------+ +-----------------------+
|
+------------v-------------+
| ShardConnectionManager | <-- DBAL Connection per shard
+------------+-------------+
|
+------------v-------------+
| EntityManagerProxy | <-- ORM EntityManager per shard
+------------+-------------+
|
+------------v-------------+
| Doctrine DBAL / ORM |
+--------------------------+
Layers and dependency rules
Contract/*are the only types consumer code depends on. Concrete implementations are internal and must not be tagged in consumer config.Strategy/*depends only onContract/ShardStrategyInterfaceandConfig/Dto/*.Resolver/*depends only onContract/*,Attribute/*, andStrategy/*instances injected at construction.Context/*orchestratesResolver,ConnectionManager, andEntityManagerProxyand does not touch Doctrine directly.Manager/*is a thin Doctrine-binding layer:ShardConnectionManagerwrapsConnectionlookup,EntityManagerProxywrapsEntityManagerlookup.Repository/*is a higher-level facade; it composesShardContextandShardResolver. Business code gets the same signatures as inEntityRepository.
Key interfaces
| Interface | Role | Notes |
|---|---|---|
ShardStrategyInterface |
Pluggable seam: resolveShardId(), getTotalShards(), resolveShardIndex(), getCacheKey() |
hash, range, uuid |
AbstractShardStrategy |
Base with three protected helpers (normalizeKey, defaultCacheKey, resolveShardIndexFromId); override public methods for custom strategies |
— |
ShardResolverInterface |
Merges YAML and #[Sharding]; resolveShardId(entity), resolveShardIdFromId(id, fqcn), resolveShardIdFromCriteria(criteria, fqcn), getShardingConfig(fqcn) |
YAML > attribute (see ADR-0002) |
ShardContextInterface |
State machine: withShard($shardId, $cb), withEntity($entity, $cb), getCurrentShardId(), getAvailableShards() |
restore in finally |
ShardConnectionManagerInterface |
shardId → Connection registry: switchToShard(), getCurrentConnection(), getConnectionForShard() |
WeakMap cache on lookup |
EntityManagerProxyInterface |
shardId → EntityManager registry: forShard(), getAvailableShards() |
— |
ShardedRepositoryInterface |
Facade: findOneById, findBy, findOneBy, persistToShard, removeFromShard, flushShard |
business code is unchanged |
ShardedFinder (concrete) |
Cross-shard reads: find(callable $query, list<string> $shardIds): \Generator |
sequential in v1 (ADR-0004 §Decision) |
ShardResolverListener is marked @deprecated and will be removed in 2.0; new code uses ShardedRepository or ShardContext directly.
Installation
composer require elriseio/doctrine-shard-manager-bundle
Register the bundle in config/bundles.php (if Symfony Flex is not wired up):
return [
Elrise\Bundle\DoctrineShardManager\ElriseDoctrineShardBundle::class => ['all' => true],
];
The bundle auto-registers the six public services; no additional services.yaml wiring is required for the default strategies.
Configuration
Full example for a four-shard hash setup keyed by userId:
# config/packages/doctrine_shard.yaml
doctrine_shard:
cache_ttl: 86400
shard:
hash:
total_shards: 4
shard_index_offset: 0
shard_prefix: 'shard_'
range:
shard_prefix: 'shard_'
shard_index_offset: 0
range_table:
- { min: 0, max: 999_999, shard: 'shard_0' }
- { min: 1_000_000, max: 1_999_999, shard: 'shard_1' }
uuid:
total_shards: 16
shard_index_offset: 0
shard_prefix: 'shard_'
shard_bits: 4
sharding_configs:
App\Entity\User:
strategy: hash
key: userId
shardCount: 4
App\Entity\Order:
strategy: uuid
key: id
shardCount: 16
resolver:
cache:
enabled: true
pool_service_id: cache.app
connections:
shard_0: '@doctrine.dbal.default_shard_0_connection'
shard_1: '@doctrine.dbal.default_shard_1_connection'
shard_2: '@doctrine.dbal.default_shard_2_connection'
shard_3: '@doctrine.dbal.default_shard_3_connection'
entity_managers:
shard_0: '@doctrine.orm.default_shard_0_entity_manager'
shard_1: '@doctrine.orm.default_shard_1_entity_manager'
shard_2: '@doctrine.orm.default_shard_2_entity_manager'
shard_3: '@doctrine.orm.default_shard_3_entity_manager'
doctrine.dbal.default_shard_<N>_connection and doctrine.orm.default_shard_<N>_entity_manager are the standard Doctrine service IDs exposed by the Symfony doctrine bundle when you declare dbal: { connections: { default_shard_0: ~, ... } } and orm: { entity_managers: { default_shard_0: ~, ... } } in config/packages/doctrine.yaml:
doctrine:
dbal:
connections:
default_shard_0: ~
default_shard_1: ~
default_shard_2: ~
default_shard_3: ~
orm:
entity_managers:
default_shard_0: ~
default_shard_1: ~
default_shard_2: ~
default_shard_3: ~
Alternative: the #[Sharding] attribute
use Elrise\Bundle\DoctrineShardManager\Attribute\Sharding;
#[Sharding(
strategy: 'hash',
key: 'userId',
shardCount: 4,
)]
class User
{
// ...
}
Composite keys — array in key:
#[Sharding(
strategy: 'hash',
key: ['tenantId', 'userId'],
shardCount: 4,
)]
class User
{
// ...
}
YAML sharding_configs.<FQCN> overrides the attribute when both are present (see ADR-0002, docs/architecture.md::I-RES1). The sharding_configs array is always required even if you rely solely on #[Sharding] — an empty [] is the canonical declaration.
Usage
Through ShardContext
use Elrise\Bundle\DoctrineShardManager\Context\ShardContext;
final class UserService
{
public function __construct(private ShardContext $shardContext) {}
public function loadFromShard(int $userId): ?User
{
return $this->shardContext->withEntity(
$this->buildStubUser($userId),
function (EntityManagerInterface $em, string $shardId) use ($userId): ?User {
return $em->getRepository(User::class)->find($userId);
},
);
}
}
withShard($shardId, $cb) is the explicit variant when the caller already knows the target shard (for example, a cron job iterating every shard).
Through ShardedRepository
use Elrise\Bundle\DoctrineShardManager\Repository\ShardedRepository;
final class UserController
{
public function __construct(private ShardedRepository $userRepo) {}
public function show(int $userId): Response
{
$user = $this->userRepo->findOneById($userId, User::class);
if (null === $user) {
throw new NotFoundHttpException();
}
return new Response(sprintf('Hello, %s!', $user->getDisplayName()));
}
}
The surface — findBy, findOneBy, persistToShard, removeFromShard, flushShard — resolves the shard ID internally and exposes the same contract as EntityRepository.
Cross-shard reads with ShardedFinder
use Elrise\Bundle\DoctrineShardManager\Finder\ShardedFinder;
final class ReportingService
{
public function __construct(private ShardedFinder $finder) {}
public function streamAllActiveUsers(): \Generator
{
$shardIds = ['shard_0', 'shard_1', 'shard_2', 'shard_3'];
return $this->finder->find(
static fn (Connection $c) => $c->iterateAssociative(
'SELECT * FROM users WHERE active = 1 ORDER BY id',
),
$shardIds,
);
}
}
ShardedFinder is sequential in v1 (ADR-0004 §Decision). The generator holds one row at a time across the fan-out, so memory is bounded by the largest single-shard result set. Failures throw with the per-shard context framed in the message.
Extending with a custom strategy
# config/services.yaml
services:
App\Infrastructure\Sharding\TenantPrefixedStrategy:
tags:
- { name: app.shard_strategy, alias: tenant }
use Elrise\Bundle\DoctrineShardManager\Strategy\AbstractShardStrategy;
final class TenantPrefixedStrategy extends AbstractShardStrategy
{
#[\Override]
public function resolveShardId(mixed $key, array $options = []): ?string
{
$tenantId = $this->normalizeKey($key);
return 'tenant_'.$tenantId;
}
}
Reference it as strategy: tenant in sharding_configs.<FQCN> or in #[Sharding(strategy: 'tenant', ...)]. The full worked example is in docs/adr/0002-shard-strategy-extensibility.md.
Console commands
The bundle registers two Symfony Console commands on build().
bin/console shard:add <id>
Greenfield provisioning: filters entity metadata down to #[Sharding]-annotated classes and runs SchemaTool::updateSchema($filteredMetadata, saveMode: true).
bin/console shard:add shard_3
Warning.
shard:addis drop-and-recreate, not an additive migration. Run it on greenfield shards only — running it on an existing shard deletes the rows. For an existing shard, usebin/console shard:migrate <id>instead (Wave 2 / ADR-0003). The full migration procedure is indocs/RUNBOOK.md::Failure Mode 10.
bin/console shard:migrate <id>
Additive: runs Doctrine Migrations on a single named shard.
bin/console shard:migrate shard_3
bin/console shard:migrate shard_3 --dry-run
bin/console shard:migrate shard_3 --migration-set=latest
Standard --dry-run, --migration-set=<alias-or-version>, and exit-code conventions of the underlying Doctrine Migrations tooling are inherited.
Compatibility
| Component | Minimum |
|---|---|
| PHP | 8.3 (strict types) |
| Symfony | 7.2 (7.2.* pin) |
| Doctrine DBAL | 4.2+ |
| Doctrine ORM | 3.3+ |
| Doctrine Migrations | 3.7+ |
PSR-6 CacheItemPoolInterface |
optional, recommended |
PSR-3 LoggerInterface |
optional, graceful degradation |
Known compatibility gaps
- The
doctrine/event-subscriberpath (modern replacement for the deprecatedShardResolverListener) is planned for Wave 1. Until then, the listener is@deprecatedbut wired inservices.yamlfor backward compatibility with legacy consumers. - The three-tier testing infrastructure (unit + bench + itests, ADR-0001) is partially landed.
bench/anditests/are now populated; the bounded-concurrency CI smoke (DE-011-B) is open and tracked underIssues/open/developer/. phpstanandphp-cs-fixerare declared inrequire-devbut the CI-gated enforcement job (Wave 6 /DE-019) is open.
Benchmarks
The bundle ships an in-process benchmark suite under bench/ that exercises the hot path of every public strategy and orchestrator (see ADR-0001). It runs without a database by default — subjects that need a Connection opt in via BENCH_DATABASE_URL.
Running
composer bench
# or with a filter:
php vendor/bin/phpbench run --report=aggregate --filter=ResolveCacheHit
The HTML report and per-subject memory samples are written to the local bench/.bench/ directory (gitignored) when running benchmarks; reproducible numbers are reported in the table above.
Latest results (2026-07-21, PHP 8.5.8 NTS, xdebug off, opcache off)
20 subjects, 0 failures, 0 errors. mode is the per-subject median; rstdev is the relative standard deviation across iterations.
| Subject | Mode | Rstdev | What it measures |
|---|---|---|---|
HashShardStrategyBench::benchResolveCacheMiss |
78.063 ms | ±1.39 % | SHA-256 modulo, cold PSR-6 |
HashShardStrategyBench::benchResolveCacheHit |
188.438 ms | ±19.48 % | warm PSR-6; high variance from pool overhead |
HashShardStrategyBench::benchResolveKeyTypes |
102.106 ms | ±5.85 % | mixed key types (string / int / UUID string) |
RangeShardStrategyBench::benchResolveHot |
74.523 ms | ±3.59 % | binary search over range_table, in-memory |
RangeShardStrategyBench::benchResolveCold |
659.241 ms | ±1.42 % | range_table re-parsed on every call (worst case) |
UuidShardStrategyBench::benchResolve |
74.659 ms | ±6.54 % | UUIDv7 parse + shard-bit extraction |
UuidShardStrategyBench::benchGenerateUuid |
181.993 ms | ±6.09 % | UUIDv7 generation with embedded shard index |
ShardResolverBench::benchResolveFromEntity |
34.833 µs | ±36.84 % | reflection + attribute lookup, high variance |
ShardResolverBench::benchResolveFromId |
16.997 µs | ±9.80 % | direct key path |
ShardResolverBench::benchResolveFromCriteria |
26.667 µs | ±11.25 % | composite key extraction |
ShardConnectionManagerBench::benchSwitchToShard4 |
8.496 µs | ±21.57 % | 4 shards |
ShardConnectionManagerBench::benchSwitchToShard16 |
7.833 µs | ±6.38 % | 16 shards |
ShardConnectionManagerBench::benchSwitchToShard64 |
11.333 µs | ±8.82 % | 64 shards |
ShardConnectionManagerBench::benchGetConnectionForShard4 |
1.000 µs | ±0.00 % | 4 shards |
ShardConnectionManagerBench::benchGetConnectionForShard16 |
1.000 µs | ±0.00 % | 16 shards |
ShardConnectionManagerBench::benchGetConnectionForShard64 |
0.833 µs | ±20.00 % | 64 shards |
ShardContextBench::benchWithShard |
13.167 µs | ±3.80 % | callback wrap, no Doctrine round-trip |
ShardContextBench::benchWithEntity |
10.331 µs | ±12.90 % | same for withEntity |
ShardedRepositoryBench::benchFindOneById |
388.528 µs | ±3.65 % | end-to-end: resolve + DBAL fetch |
ShardedFinderBench::benchFanOutOver16Shards1kRowsEach |
2.036 ms | ±13.71 % | 16-shard fan-out, 1 000 rows per shard |
benchResolve* subjects are sub-millisecond per call. benchSwitchToShard* and benchGetConnectionForShard* are amortised to ~1 µs because the manager keeps a WeakMap cache. benchFindOneById is the only end-to-end subject that touches a real EntityManager; the value is dominated by Doctrine hydration. benchFanOutOver16Shards1kRowsEach is the regression guard for the fan-out state machine in ADR-0004.
Re-run after any change to src/Strategy/*, src/Resolver/*, src/Manager/*, or src/Context/* and update the table if any subject regresses by more than its published rstdev.
Integration tests
itests/ is the third tier of the testing infrastructure (ADR-0001): end-to-end scenarios that drive the bundle's real classes against real MySQL 8.4, MariaDB 10.11, and PostgreSQL 16 instances under controlled concurrency, with a fixed JSON envelope. The scenarios speak to DBAL directly — the bundle ships no Symfony app.
Prerequisites
- Docker Engine with the Compose plugin.
- PHP extensions
pdo_mysqlandpdo_pgsqlenabled (the in-repocomposer.jsonruntime does not load them by default; see §Known issues).
Bootstrap
make itests-up # docker compose up -d --wait (mysql 8.4 + mariadb 10.11 + postgres 16)
bash itests/bin/migrate-up.sh
Default ports are offset (33061 for MySQL, 33062 for MariaDB, 54321 for PostgreSQL) so the stack coexists with the dbal-manager project's stack on the same host. Credentials default to root:itests for MySQL and MariaDB, itests:itests for PostgreSQL.
Running a scenario
php itests/scenarios/<name>.php \
--dsn="mysql://root:itests@127.0.0.1:33061/itests" \
--vendor=mysql --rows=200 --chunk=50 --warmup --reset
Each scenario prints one JSON envelope line on stdout with scenario, db_vendor, rows, chunk, duration_s, ops_per_sec, peak_rss_bytes, and errors. The full envelope (including DB counters and latency percentiles) is produced by the itests/bin/run-scenario.sh wrapper; see itests/README.md for the CI smoke gate contract.
Latest results (2026-07-22, PHP 8.5.8 NTS, rows=200, chunk=50, warmup+reset)
All 12 scenarios pass on MySQL, MariaDB, and PostgreSQL.
| Scenario | MySQL 8.4 | MariaDB 10.11 | PostgreSQL 16 |
|---|---|---|---|
shard_resolution_hash |
✅ 0.307 s, 650 ops/s, 0 errors | ✅ 0.590 s, 339 ops/s, 0 errors | ✅ 0.695 s, 287 ops/s, 0 errors |
shard_resolution_range |
✅ 1.945 s, 102 ops/s, 0 errors | ✅ 0.578 s, 345 ops/s, 0 errors | ✅ 0.702 s, 284 ops/s, 0 errors |
shard_resolution_uuid |
✅ 1.977 s, 101 ops/s, 0 errors | ✅ 0.584 s, 342 ops/s, 0 errors | ✅ 0.749 s, 267 ops/s, 0 errors |
shard_resolution_custom_strategy |
✅ 1.993 s, 100 ops/s, 0 errors | ✅ 0.584 s, 342 ops/s, 0 errors | ✅ 0.659 s, 303 ops/s, 0 errors |
bulk_write_per_shard (rows=400) |
✅ 1.605 s, 249 ops/s, 0 errors | ✅ 0.139 s, 2881 ops/s, 0 errors | ✅ 0.480 s, 833 ops/s, 0 errors |
cross_shard_lookup |
✅ 200 rows, 0 errors (re-run clean) | ✅ 200 rows, 0 errors | ✅ 200 rows, 0 errors (re-run clean) |
shard_resolution_* measures the strategy hot path under real DB I/O. MySQL is faster on the hash variant; PostgreSQL is consistently faster on range and custom-strategy variants; MariaDB sits between the two with low variance across all four. bulk_write_per_shard is a write-heavy workload that exercises the routing-table fan-out: MariaDB leads (~2881 ops/s, ~11.5× faster than MySQL on the same workload); PostgreSQL is ~3.3× faster than MySQL. cross_shard_lookup validates that the shard ID resolved at write time matches the one resolved at read time across every fan-out shard — see itests/README.md for the cross-shard bleed detection contract.
Get hands-on
dummy-market-agent is a reference Symfony service with Application Layer, CQRS, API Platform, and DBAL-based persistence. Start it with Docker to inspect API operations, command/query handlers, persistence, shard routing, container wiring, layer boundaries, and tests.
Known limitations and issues
Known issues
itests/bin/run-scenario.shis unusable as-shipped. The wrapper passes--dsn "<value>"(whitespace-separated) to the underlying PHP CLI, butrunner_base.phpdeclaresgetopt('', ['dsn::', …]), which silently drops the value for long options with optional arguments on PHP 8.5. The wrapper prints"scenario did not emit JSON"(rc=2) and exits. Workaround: invoke the scenario script directly with--dsn=<value>(equals-sign form). Fix: either change'dsn::'to'dsn:'initests/runner/runner_base.php:52, or rework the wrapper to pass DSN via env.pdo_mysql/pdo_pgsqlare not auto-enabled. The runtime image ships the.sofiles in/usr/lib/php/modules/but/etc/php/conf.d/is empty in the default install. Workaround: setPHP_INI_SCAN_DIR=~/.php-confdwith oneextension=<name>.soline per file, or install the distro packages (php-mysql,php-pgsql).composer benchis unusable as-shipped.phpbench.jsondoes not declarerunner.path, socomposer benchaborts with "You must either specify or configure a path". Workaround: passbench/explicitly, or add"runner.path": "bench"tophpbench.json.
Known compatibility gaps
See §Compatibility. ShardResolverListener is @deprecated (will be removed in 2.0); the three-tier testing infrastructure is partially landed; the CI gate for phpstan / php-cs-fixer is on the roadmap (Wave 6 / DE-019).
Sources
- github.com/elriseio/doctrine-shard-manager-bundle — MIT.
- Packagist: elriseio/doctrine-shard-manager-bundle
- Doctrine ORM Sharding — for context
- ADR-0001, ADR-0002, ADR-0003, ADR-0004 — in
docs/adr/of the repository