elriseio/doctrine-shard-manager-bundle
Doctrine Shard Manager Bundle is a Symfony bundle for horizontal data sharding on top of Doctrine ORM and DBAL. The bundle provides transparent routing of Connection and EntityManager per shard, pluggable resolution strategies (hash, range, uuidv7), a PSR-6 cache layer, and a CLI for greenfield provisioning and additive migrations.
ShardedRepository calls or ShardContext::withShard() / withEntity() callbacks; the bundle selects the right Connection and EntityManager for the entity being touched.hash (SHA-256 modulo), range (binary search over a range_table partition), uuid (UUIDv7 with embedded shard index). Custom strategies register via the app.shard_strategy tag (see ADR-0002).#[Sharding] attribute on the entity class, OR YAML under doctrine_shard.sharding_configs.<FQCN>. Resolution precedence is YAML > attribute (see docs/architecture.md::I-RES1).CacheItemPoolInterface per strategy and per resolver; cache failures degrade gracefully (logged + bypassed, never propagated).ShardedFinder::find(callable $query, array $shardIds) fans out a read query to multiple shards lazily via \Generator (see ADR-0004).bin/console shard:add <id> provisions a shard's schema from filtered entity metadata. bin/console shard:migrate <id> runs Doctrine Migrations on a single named shard and is additive (see ADR-0003). +--------------------------+
| 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 |
+--------------------------+
Layering rules (see docs/architecture.md for the full invariant list):
Contract/* interfaces are the only types consumers depend on directly. All concrete implementations are un-tagged internal; consumers MUST type-hint against the interfaces.Strategy/* depends only on Contract/ShardStrategyInterface and Config/Dto/*.Resolver/* depends only on Contract/*, Attribute/*, and Strategy/* instances injected at construction.Context/* orchestrates Resolver, ConnectionManager, EntityManagerProxy — it does not touch Doctrine directly.Manager/* is a thin Doctrine binding layer (ShardConnectionManager wraps Connection lookup; EntityManagerProxy wraps EntityManager lookup).Repository/* provides a higher-level facade; it composes ShardContext and ShardResolver.ShardStrategyInterface — resolveShardId(mixed $key, array $options = []), getTotalShards(), resolveShardIndex(mixed $key), getCacheKey(mixed $key). The single seam for pluggable strategies.AbstractShardStrategy — base class that exposes three protected helpers (normalizeKey, defaultCacheKey, resolveShardIndexFromId). Override public methods on the abstract class for custom strategies; concrete strategies (HashShardStrategy, RangeShardStrategy, UuidShardStrategy) are reference implementations and may change between minor versions.ShardResolverInterface — resolveShardId(object $entity), resolveShardIdFromId(mixed $id, string $entityClass), resolveShardIdFromCriteria(array $criteria, string $entityClass), getShardingConfig(string $entityClass). Merges YAML and #[Sharding] attribute.ShardContextInterface — withShard(string $shardId, callable $cb), withEntity(object $entity, callable $cb), getCurrentShardId(), getAvailableShards(). The state machine that switches Connection and EntityManager for the duration of the callback.ShardConnectionManagerInterface — switchToShard(string $shardId), getCurrentConnection(), getConnectionForShard(string $shardId). Owns the shard-to-Connection registry.EntityManagerProxyInterface — forShard(string $shardId), getAvailableShards(). Owns the shard-to-EntityManager registry.ShardedRepositoryInterface — findOneById, findBy, findOneBy, persistToShard, removeFromShard, flushShard. Composes ShardContext and ShardResolver; business code stays unchanged.ShardedFinder (concrete) — find(callable $query, list<string> $shardIds): \Generator. Bypasses the write-path ShardContext; throws on the first shard failure with the per-shard context framed in the exception message.Run the following command to install the bundle:
composer require elriseio/doctrine-shard-manager-bundle
Register the bundle in config/bundles.php (if you are not using Symfony Flex):
Elrise\Bundle\DoctrineShardManager\ElriseDoctrineShardBundle::class => ['all' => true],
The bundle reads its configuration from config/packages/doctrine_shard.yaml (see Configuration below) and registers all six public services automatically. No additional services.yaml wiring is required for the default strategies.
config/packages/doctrine_shard.yaml example for a four-shard hash setup keyed by userId:
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 connection / EM service IDs that Symfony's doctrine bundle exposes when you declare dbal: { connections: { default_shard_0: ~, default_shard_1: ~, ... } } and orm: { entity_managers: { default_shard_0: ~, default_shard_1: ~, ... } } in config/packages/doctrine.yaml.
#[Sharding] attribute (alternative to YAML)use Elrise\Bundle\DoctrineShardManager\Attribute\Sharding;
#[Sharding(
strategy: 'hash',
key: 'userId',
shardCount: 4,
)]
class User
{
// ...
}
The key parameter may also be string[] for composite keys:
#[Sharding(
strategy: 'hash',
key: ['tenantId', 'userId'],
shardCount: 4,
)]
class User
{
// ...
}
YAML sharding_configs.<FQCN> overrides the attribute when both are present.
ShardContextuse 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, fn) is the explicit variant when the caller already knows the target shard (e.g. a cron job iterating every shard).
ShardedRepositoryuse 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()));
}
}
ShardedRepository::findBy, findOneBy, persistToShard, removeFromShard, flushShard complete the surface; each call resolves the shard ID internally and routes the call to the right EntityManager.
ShardedFinderuse 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 (per 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.
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;
}
}
Then reference it as strategy: tenant in sharding_configs.<FQCN> or #[Sharding(strategy: 'tenant', ...)]. The full worked example is in docs/adr/0002-shard-strategy-extensibility.md.
The bundle registers two Symfony Console commands on build():
bin/console shard:add <id>Provisions a new shard's schema. Filters entity metadata to #[Sharding]-annotated classes only and runs SchemaTool::updateSchema($filteredMetadata, saveMode: true).
bin/console shard:add shard_3
Safety warning.
shard:addis drop-and-recreate, not 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). Seedocs/RUNBOOK.md::Failure Mode 10for the full migration procedure.
bin/console shard:migrate <id>Runs Doctrine Migrations on a single named shard. Additive; safe to run against an existing shard.
bin/console shard:migrate shard_3
bin/console shard:migrate shard_3 --dry-run # print the plan without applying
bin/console shard:migrate shard_3 --migration-set=latest # explicit version alias
Each command supports the standard --dry-run, --migration-set=<alias-or-version>, and exit-code conventions of the underlying Doctrine Migrations tooling.
7.2.* pin in composer.json)CacheItemPoolInterface (optional but recommended for hot-path strategies)LoggerInterface (optional; graceful degradation when absent)doctrine/event-subscriber path (modern replacement for the deprecated ShardResolverListener) is planned for Wave 1. Until then, the listener remains @deprecated but is wired in services.yaml for backward compatibility with legacy consumers.bench/ and itests/ are now populated; the bounded-concurrency CI smoke (DE-011-B) is open and tracked under Issues/open/developer/.phpstan and php-cs-fixer are declared in require-dev but the CI-gated enforcement job (Wave 6 / DE-019) is open.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 uses PHPBench and runs without a database by default — subjects that need a Connection opt in via BENCH_DATABASE_URL.
composer bench
The script invokes phpbench run --report=aggregate. The default runner.path (bench/) is configured in phpbench.json, so the positional bench/ argument is no longer required. For custom runs (extra --revs, --iterations, --filter):
php vendor/bin/phpbench run --report=aggregate --revs=3 --iterations=2
# or, to filter a single subject:
php vendor/bin/phpbench run --report=aggregate --filter=ResolveCacheHit
The HTML report and per-subject memory samples are written to bench/.bench/ (gitignored). Subjects honour --filter=<substring> for targeted re-runs.
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 cache |
HashShardStrategyBench::benchResolveCacheHit |
188.438 ms | ±19.48 % | warm PSR-6 cache; 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 % | |
ShardConnectionManagerBench::benchSwitchToShard16 |
7.833 µs | ±6.38 % | |
ShardConnectionManagerBench::benchSwitchToShard64 |
11.333 µs | ±8.82 % | |
ShardConnectionManagerBench::benchGetConnectionForShard4 |
1.000 µs | ±0.00 % | |
ShardConnectionManagerBench::benchGetConnectionForShard16 |
1.000 µs | ±0.00 % | |
ShardConnectionManagerBench::benchGetConnectionForShard64 |
0.833 µs | ±20.00 % | |
ShardContextBench::benchWithShard |
13.167 µs | ±3.80 % | callback wrapping, no Doctrine round-trip |
ShardContextBench::benchWithEntity |
10.331 µs | ±12.90 % | |
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 measure strategy hot paths — sub-millisecond per call in a single PHP process.benchSwitchToShard* and benchGetConnectionForShard* measure per-shard routing inside ShardConnectionManager; the lookup is amortised to ~1 µs because the manager keeps a WeakMap cache.benchWithShard and benchWithEntity measure ShardContext callback wrapping only — no Doctrine round-trip.benchFindOneById is the only end-to-end subject that touches a real EntityManager; the value is dominated by Doctrine hydration.benchFanOutOver16Shards1kRowsEach exercises ShardedFinder against a stubbed Connection per shard and serves as a 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.
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 and PostgreSQL 16 instances under controlled concurrency, with a fixed JSON envelope. The scenarios speak to DBAL directly — the bundle ships no Symfony app.
pdo_mysql and pdo_pgsql enabled (the in-repo composer.json runtime does not load them by default; see Known issues below).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.
php itests/scenarios/<name>.php \
--dsn="mysql://root:[email protected]: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.
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_* subjects measure the strategy hot path under real DB I/O. MySQL is faster on the hash variant; PostgreSQL is consistently faster on the 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 (bench_itests_shard_routing). MariaDB is the fastest at this profile (~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.itests/bin/run-scenario.sh wrapper is unusable as-shipped. The wrapper passes --dsn "<value>" (whitespace-separated) to the underlying PHP CLI, but runner_base.php declares getopt('', ['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:' in itests/runner/runner_base.php:52 or rework the wrapper to pass DSN via env.pdo_mysql / pdo_pgsql are not auto-enabled. The runtime image ships the .so files in /usr/lib/php/modules/ but /etc/php/conf.d/ is empty in the default install. Set PHP_INI_SCAN_DIR=~/.php-confd with one extension=<name>.so line per file, or install the distro packages (php-mysql, php-pgsql).composer bench is unusable as-shipped. phpbench.json does not declare runner.path, so composer bench aborts with "You must either specify or configure a path". Pass bench/ explicitly, or add "runner.path": "bench" to phpbench.json.sharding_configs array is always required even if you rely solely on #[Sharding] — an empty [] is the canonical declaration. The YAML-vs-attribute precedence test (DE-013, closed) lives in tests/Resolver/ShardResolverTest.php.connections and entity_managers are passed as Doctrine service IDs (e.g. @doctrine.orm.default_shard_0_entity_manager), but the per-shard connection and EM MUST also be declared in config/packages/doctrine.yaml so that Doctrine itself instantiates them.ShardContext::withShard and withEntity route the active Connection and EntityManager for the duration of the callback only. The previous routing is restored in finally. Cross-shard transactions are out of scope by design (see docs/architecture.md::Non-Goals).ShardResolverListener class is kept for backward compatibility with consumers wiring doctrine.event_listener. New code should rely on ShardedRepository or ShardContext directly. The listener will be removed in 2.0.MIT — see LICENSE for the full text.
How can I help you explore Laravel packages today?