PHPackages                             gilsegura/shared-bundle - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Framework](/categories/framework)
4. /
5. gilsegura/shared-bundle

ActiveSymfony-bundle[Framework](/categories/framework)

gilsegura/shared-bundle
=======================

Symfony integration for the `gilsegura/shared` package.

2.1.0(1mo ago)271MITPHPPHP ^8.4CI passing

Since Nov 28Pushed 1mo ago1 watchersCompare

[ Source](https://github.com/gilsegura/shared-bundle)[ Packagist](https://packagist.org/packages/gilsegura/shared-bundle)[ RSS](/packages/gilsegura-shared-bundle/feed)WikiDiscussions main Synced 2w ago

READMEChangelogDependencies (18)Versions (11)Used By (0)

SHARED BUNDLE
=============

[](#shared-bundle)

[![tests](https://github.com/gilsegura/shared-bundle/actions/workflows/tests.yaml/badge.svg)](https://github.com/gilsegura/shared-bundle/actions/workflows/tests.yaml)[![codecov](https://camo.githubusercontent.com/dece6a31d2adb41d8abf8d7e68b2495914220e3a90bcbde801035b05f11f7616/68747470733a2f2f636f6465636f762e696f2f6769746875622f67696c7365677572612f7368617265642d62756e646c652f67726170682f62616467652e737667)](https://codecov.io/github/gilsegura/shared-bundle)[![static analysis](https://github.com/gilsegura/shared-bundle/actions/workflows/static-analysis.yaml/badge.svg)](https://github.com/gilsegura/shared-bundle/actions/workflows/static-analysis.yaml)[![coding standards](https://github.com/gilsegura/shared-bundle/actions/workflows/coding-standards.yaml/badge.svg)](https://github.com/gilsegura/shared-bundle/actions/workflows/coding-standards.yaml)

Symfony integration for the `gilsegura/shared` package. It wires the DDD / CQRS / event-sourcing building blocks onto Symfony Messenger and Doctrine, so an application writes its commands, queries, handlers, projectors and repositories and the bundle takes care of the plumbing: the command, query and event buses, the Doctrine DBAL types for the shared value objects, the event store, and the attribute-driven wiring for repositories.

Installation
------------

[](#installation)

```
composer require gilsegura/shared-bundle
```

Register the bundle (DoctrineBundle is a required dependency, so it must be registered too):

```
// config/bundles.php
return [
    Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
    SharedBundle\SharedBundle::class => ['all' => true],
];
```

Configuration
-------------

[](#configuration)

The bundle defines three Messenger buses (command, query and async event) and the middleware around them. Two things are left to the application, because they are deployment decisions the bundle cannot make for you:

**1. A transport and routing for the async event bus.** Domain events are published to the `messenger.bus.event.async` bus; route the messages you want to process out of band to a transport:

```
# config/packages/messenger.yaml
framework:
   messenger:
      transports:
         async: '%env(MESSENGER_TRANSPORT_DSN)%'
      routing:
         'Shared\Domain\DomainMessage': async
```

**2. A configured Doctrine connection.** The command bus runs inside a `doctrine_transaction` middleware, so a working DBAL connection and entity manager must be configured by the application.

The bundle maps only `Shared\Domain`. **Mapping the application's own entities and read models is the application's responsibility.**

The bus ids are exposed as constants so configuration and tests never hardcode the strings:

```
SharedBundle\SharedBundle::COMMAND_BUS;  // messenger.bus.command
SharedBundle\SharedBundle::QUERY_BUS;    // messenger.bus.query
SharedBundle\SharedBundle::EVENT_BUS;    // messenger.bus.event.async
```

How events flow
---------------

[](#how-events-flow)

The bundle bridges two worlds: the synchronous, in-process domain event bus and the asynchronous Messenger bus.

1. A handler applies events on an aggregate and saves it through its repository.
2. On save, the **`SimpleEventBus`** publishes the domain messages synchronously to its **domain listeners** — anything implementing `EventListenerInterface`. Application **projectors** live here: they update read models in the same request, in order, fail-fast.
3. One of those listeners is the bundle's **`EventPublisher`**. It does not process the events; it collects them and, on `kernel.terminate` / `console.terminate` / `worker.stopped` (i.e. after the response is sent, or on `SIGTERM`), dispatches each `DomainMessage` to the async Messenger bus.
4. On the worker side, **`UnwrapDomainMessageMiddleware`** unwraps the `DomainMessage` and passes its `payload` — the actual domain event — to the regular Symfony Messenger handlers the application writes with `#[AsMessageHandler]`.

So: write a **projector** (`EventListenerInterface`) for work that must happen synchronously in the same request, and a **Messenger handler** for work that should happen asynchronously off a transport.

The pieces
----------

[](#the-pieces)

### Command and query buses

[](#command-and-query-buses)

`MessengerCommandBus` and `MessengerQueryBus` implement the domain `CommandBusInterface` / `QueryBusInterface` on top of Messenger. They unwrap Messenger's `HandlerFailedException` so callers see the real domain exception, not the framework wrapper.

Handlers are autoconfigured by the interface they implement — no tags, no bus names:

```
use Shared\CommandHandling\CommandHandlerInterface;

/** @implements CommandHandlerInterface */
final readonly class RegisterUserHandler implements CommandHandlerInterface
{
    public function __invoke(RegisterUser $command): void { ... }
}
```

`CommandHandlerInterface` is routed to the command bus and `QueryHandlerInterface`to the query bus automatically.

### Event publishing

[](#event-publishing)

`EventPublisher` and `UnwrapDomainMessageMiddleware` form the bridge described above. `EventPublisher` is registered both as a domain `EventListenerInterface`(it collects domain messages) and as a kernel/console/worker subscriber (it flushes them to the async bus when the process winds down).

### Domain event listeners (projectors)

[](#domain-event-listeners-projectors)

Any service implementing `EventListenerInterface` is collected onto the `SimpleEventBus` by a compiler pass. In practice a read-model projector extends `Shared\Projection\AbstractProjector`, which implements that interface and resolves an `applyXxx` method from each event's short name (the same convention aggregates use), so a projector only writes the handlers for the events it cares about:

```
use Shared\Projection\AbstractProjector;

final readonly class UserProjector extends AbstractProjector
{
    public function __construct(
        private UserReadModelRepositoryInterface $users,
    ) {
    }

    protected function applyUserWasCreated(UserWasCreated $event): void
    {
        $this->users->save(User::deserialize($event->serialize()));
    }

    protected function applyUserEmailWasChanged(UserEmailWasChanged $event): void
    {
        $user = $this->users->oneOrException($event->id);
        $user->changeEmail($event->email);
        $this->users->save($user);
    }
}
```

Events with no matching `applyXxx` method are simply ignored, so each projector reacts only to the events it needs. Because `AbstractProjector` implements `EventListenerInterface`, the projector is registered on the event bus with no extra configuration.

### Doctrine DBAL types

[](#doctrine-dbal-types)

Custom DBAL types map the shared value objects to columns and back, registered automatically: `Uuid`, `Email`, `HashedPassword`, `NotEmptyString`, `Serializable` and `DateTimeImmutable`, plus `aggregate_root`, which stores a whole aggregate as an opaque blob (native serialize) for snapshots. Use them as column types in the application's Doctrine mappings.

### Event store

[](#event-store)

`DoctrineEventStore` is the Doctrine-backed event store (`EventStoreInterface` + `EventStoreManagerInterface`). It extends `AbstractObjectManager` and is wired from its `#[ObjectManager(DomainMessage::class)]` attribute, so it carries no constructor.

### Persistence: object managers

[](#persistence-object-managers)

`AbstractObjectManager` is the base for Doctrine-backed repositories and read models. It resolves the Doctrine repository from the entity class and provides protected criteria-based `search` / `count` helpers for concrete managers to build their own query methods on. Concrete managers declare the entity with `#[ObjectManager]` and need no constructor:

```
use SharedBundle\Persistence\Doctrine\AbstractObjectManager;
use SharedBundle\Persistence\Doctrine\Attribute\ObjectManager;

/** @template-extends AbstractObjectManager */
#[ObjectManager(User::class)]
final readonly class UserReadModelRepository extends AbstractObjectManager
    implements UserReadModelRepositoryInterface { ... }
```

### Event-sourced repositories

[](#event-sourced-repositories)

A write-side repository extends `AbstractEventSourcingRepository` and declares its aggregate with `#[AggregateRoot]`. The bundle injects the two seams the repository delegates to — a **loader** (reads and rebuilds the aggregate) and a **register** (appends its events and publishes them) — each built for that class, so the repository has no constructor:

```
use Shared\EventSourcing\AbstractEventSourcingRepository;
use SharedBundle\EventSourcing\Attribute\AggregateRoot;

/** @template-extends AbstractEventSourcingRepository */
#[AggregateRoot(User::class)]
final readonly class UserRepository extends AbstractEventSourcingRepository
    implements UserRepositoryInterface
{
    public function get(Uuid $id): User { return $this->load($id); }
    public function store(User $user): void { $this->save($user); }
}
```

The event store is always a `UpcastingEventStore` wrapping the Doctrine store, shared by both seams, so old event shapes can be upcast as they are read. With no upcasters declared the chain is empty and events pass through unchanged. To evolve an event, declare the ordered upcaster sequence on the attribute — the array order is the order of the chain, and each upcaster receives the output of the previous one:

```
#[AggregateRoot(User::class, upcasters: [UserV1ToV2Upcaster::class, UserV2ToV3Upcaster::class])]
final readonly class UserRepository extends AbstractEventSourcingRepository
    implements UserRepositoryInterface { ... }
```

Each upcaster is a service implementing `Shared\Upcasting\UpcasterInterface`; it returns the message unchanged when the event is not its concern, or a new `DomainMessage` with the transformed payload when it is. Because upcasters are services, they can carry their own dependencies.

The store upcasts both when an aggregate is **loaded** and when its events are **visited** for replay, so the write side rehydrating an aggregate and a projector rebuilding a read model both see the current event shapes — a projector only needs an `applyXxx` for the latest shape.

### Snapshotting

[](#snapshotting)

For long-lived aggregates, snapshotting avoids replaying the whole history on load. It is opt-in per aggregate through the `snapshot` argument of `#[AggregateRoot]`, which takes a `SnapshotConfig`:

```
use SharedBundle\EventSourcing\Attribute\AggregateRoot;
use SharedBundle\EventSourcing\Attribute\SnapshotConfig;

/** @template-extends AbstractEventSourcingRepository */
#[AggregateRoot(User::class, snapshot: new SnapshotConfig(every: 100))]
final readonly class UserRepository extends AbstractEventSourcingRepository
    implements UserRepositoryInterface { ... }
```

When configured, the bundle decorates **both** seams: the loader is wrapped so a load resumes from the latest snapshot and replays only the events recorded after it, and the register is wrapped so a save captures a snapshot once the policy is due. `SnapshotConfig` declares the policy — `every: N` builds an event-count strategy that snapshots every N events, or `strategy:` names a custom `Shared\Snapshotting\SnapshotStrategyInterface` service instead. With no `snapshot` declared, neither seam is decorated and behaviour is unchanged.

Snapshots are persisted by `DoctrineSnapshotStore` in a single `snapshots` table, one row per aggregate id (the latest). The aggregate is stored through the `aggregate_root` DBAL type, so it needs no knowledge of snapshotting; a snapshot that cannot be restored is treated as corrupt and the load falls back to a full replay. Because the aggregate is captured with native serialize, a change to its internal shape invalidates older snapshots — which is safe, as they are simply rebuilt from events.

### Enriching event metadata

[](#enriching-event-metadata)

Every event a repository saves passes through the stream decorator before being appended. The bundle wires `MetadataEnrichingEventStreamDecorator` as the decorator and collects metadata enrichers into it **by interface**, so an application adds ambient context (a correlation id, the current user, a request id) to every event just by implementing `MetadataEnricherInterface`:

```
use Shared\Domain\Metadata;
use Shared\EventSourcing\MetadataEnricher\MetadataEnricherInterface;

final readonly class CorrelationIdEnricher implements MetadataEnricherInterface
{
    public function __construct(
        private RequestContext $context,
    ) {
    }

    public function enrich(Metadata $metadata): Metadata
    {
        return $metadata->merge(Metadata::kv('correlation_id', $this->context->correlationId()));
    }
}
```

No tags or configuration are needed — the bundle tags every `MetadataEnricherInterface` and injects them all into the decorator. Each enricher receives the metadata built so far and merges its own keys in; an enricher can depend on other services, since it is a regular service. Because `enrich` only sees the `Metadata`, it adds context ambient to the request, not data derived from the individual event.

### Replaying

[](#replaying)

To rebuild a read model from the event store, visit its events and feed each one to the projector. `Shared\Replaying\Replayer` takes the event store manager and an event visitor; a `CallableEventVisitor` forwards every `DomainMessage` to the projector (an `EventListenerInterface`):

```
use Shared\Criteria;
use Shared\Domain\DomainMessage;
use Shared\EventStore\CallableEventVisitor;
use Shared\EventStore\EventStoreManagerInterface;
use Shared\Replaying\Replayer;

$replayer = new Replayer(
    $eventStore,                                  // EventStoreManagerInterface
    new CallableEventVisitor(
        fn (DomainMessage $message) => ($projector)($message),
    ),
);

$replayer(new Criteria\AndX());                        // all events
$replayer(new Criteria\AndX(new Criteria\EqId($id)));  // one aggregate
```

An empty `Criteria\AndX` replays every event; `Criteria\EqId` narrows it to a single aggregate (the `id` on a `DomainMessage` is the aggregate's id). When the event store is the bundle's upcasting store, the visited events are upcast just like a load, so the projector sees the current shapes.

### Criteria converter

[](#criteria-converter)

`DoctrineCriteriaConverter` translates the `Shared\Criteria` DSL into a Doctrine `Criteria`, so repositories query with the domain's own filter/sort objects instead of Doctrine-specific expressions.

### Health check

[](#health-check)

`DBALHealthyConnection` is an invokable that reports whether the DBAL connection is reachable — useful behind a health-check endpoint.

License
-------

[](#license)

MIT. See [LICENSE](LICENSE).

###  Health Score

45

—

FairBetter than 91% of packages

Maintenance90

Actively maintained with recent releases

Popularity11

Limited adoption so far

Community4

Small or concentrated contributor base

Maturity63

Established project with proven stability

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~64 days

Recently: every ~3 days

Total

10

Last Release

47d ago

Major Versions

0.0.1 → 1.0.02026-06-13

1.1.4 → 2.0.02026-06-29

PHP version history (3 changes)0.0.1PHP &gt;=8.3

1.0.0PHP ^8.5

1.1.1PHP ^8.4

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/29969702?v=4)[josué](/maintainers/gilsegura)[@gilsegura](https://github.com/gilsegura)

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan, Rector

Code StylePHP CS Fixer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/gilsegura-shared-bundle/health.svg)

```
[![Health](https://phpackages.com/badges/gilsegura-shared-bundle/health.svg)](https://phpackages.com/packages/gilsegura-shared-bundle)
```

###  Alternatives

[pimcore/pimcore

Content &amp; Product Management Framework (CMS/PIM/E-Commerce)

3.8k3.9M535](/packages/pimcore-pimcore)[easycorp/easyadmin-bundle

Admin generator for Symfony applications

4.3k18.3M429](/packages/easycorp-easyadmin-bundle)[sulu/sulu

Core framework that implements the functionality of the Sulu content management system

1.3k1.4M235](/packages/sulu-sulu)[sylius/sylius

E-Commerce platform for PHP, based on Symfony framework.

8.5k6.0M777](/packages/sylius-sylius)[contao/core-bundle

Contao Open Source CMS

1301.7M3.1k](/packages/contao-core-bundle)[open-dxp/opendxp

Content &amp; Product Management Framework (CMS/PIM)

9626.1k68](/packages/open-dxp-opendxp)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
