user@elrise.io:~/doctrine-shard-manager-bundle
· [active]

doctrine-shard-manager-bundle — Doctrine sharding for Symfony

→ репозиторий

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:

The bundle does not:

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

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:add is 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, use bin/console shard:migrate <id> instead (Wave 2 / ADR-0003). The full migration procedure is in docs/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

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

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

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