PHPackages                             etel/cqrs - 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. [Utility &amp; Helpers](/categories/utility)
4. /
5. etel/cqrs

ActiveLibrary[Utility &amp; Helpers](/categories/utility)

etel/cqrs
=========

Represents contracts and implementation for the CQRS pattern.

v1.0.4(3mo ago)054MITPHPPHP ^8.4CI passing

Since Feb 25Pushed 1mo agoCompare

[ Source](https://github.com/etel-soft/cqrs)[ Packagist](https://packagist.org/packages/etel/cqrs)[ Docs](https://github.com/etel-soft/cqrs)[ RSS](/packages/etel-cqrs/feed)WikiDiscussions master Synced 1w ago

READMEChangelog (4)Dependencies (11)Versions (7)Used By (0)

[![CI Status](https://github.com/etel-soft/cqrs/workflows/CI/badge.svg)](https://github.com/etel-soft/cqrs/actions)[![codecov](https://camo.githubusercontent.com/874938994f54604cbadd77e9373d987ff84238a099eecd21dccd418bd81c2595/68747470733a2f2f636f6465636f762e696f2f67682f6574656c2d736f66742f637172732f67726170682f62616467652e7376673f746f6b656e3d3847443553514c584152)](https://codecov.io/gh/etel-soft/cqrs)[![Latest Stable Version](https://camo.githubusercontent.com/c3962e6a7a08dc46d6310b340d8ca8ee60b84d8bfd8683371f3b494df6b03650/687474703a2f2f706f7365722e707567782e6f72672f6574656c2f637172732f76)](https://packagist.org/packages/etel/cqrs)[![License](https://camo.githubusercontent.com/f78bbafe94446491060e5eede8767ba53548368ade52cf973d9199efe13e7884/687474703a2f2f706f7365722e707567782e6f72672f6574656c2f637172732f6c6963656e7365)](https://packagist.org/packages/etel/cqrs)[![PHP Version Require](https://camo.githubusercontent.com/706ccb87a701aca1a43663b8c154f56286bb14ec35bbf66c502780a795819cf6/687474703a2f2f706f7365722e707567782e6f72672f6574656c2f637172732f726571756972652f706870)](https://packagist.org/packages/etel/cqrs)

Etel CQRS Component
===================

[](#etel-cqrs-component)

Contracts and a [Symfony Messenger](https://symfony.com/doc/current/messenger.html)-based implementation of the CQRS pattern for PHP 8.4+.

The library ships **three buses** — Command, Query and Event — each following the same layering, and lets a handler's result type be declared **once, on the message**, so that:

- the bus enforces it at runtime (throws on a mismatch);
- static analysis (PHPStan) infers the concrete, optionally-nullable return type of the dispatch call.

Table of contents
-----------------

[](#table-of-contents)

- [Concepts](#concepts)
- [Requirements](#requirements)
- [Installation](#installation)
- [Wiring with Symfony Messenger](#wiring-with-symfony-messenger)
- [Commands](#commands)
- [Queries](#queries)
- [Events](#events)
- [Declaring &amp; checking handler results](#declaring--checking-handler-results)
- [Inputs: raw data before validation](#inputs-raw-data-before-validation)
- [Per-bus options](#per-bus-options)
- [Exceptions](#exceptions)
- [Static analysis (PHPStan)](#static-analysis-phpstan)
- [Development](#development)

Concepts
--------

[](#concepts)

The package is split into two layers:

LayerNamespaceDependencies**Contracts**`Etel\CQRS\{Command,Query,Event}\`None — pure PHP**Implementation**`Etel\CQRS\{Command,Query,Event}\Implementation\``symfony/messenger`The contracts layer has **no external dependencies**; `symfony/messenger` is a `suggest` only. Depend on the contracts from your domain and only pull the implementation in at the composition root. This keeps your code testable against the interfaces and lets you swap the transport without touching handlers.

The three buses differ by intent, following Command/Query Separation:

BusMethodResultHandlers`CommandBus``command()`Optional (fire-and-forget by default)Exactly one`QueryBus``query()`Always returned (synchronous)Exactly one`EventBus``publish()`None (`void`)Zero or moreRequirements
------------

[](#requirements)

- PHP **8.4+**
- `symfony/messenger` **^7.0 || ^8.0** (only for the `*\Implementation\` classes)

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

[](#installation)

```
composer require etel/cqrs
```

To use the Messenger-based implementation, also require Messenger:

```
composer require symfony/messenger
```

Wiring with Symfony Messenger
-----------------------------

[](#wiring-with-symfony-messenger)

Each bus is a thin decorator over a Messenger `MessageBusInterface`. Configure one Messenger bus per CQRS bus and register the decorators.

```
# config/packages/messenger.yaml
framework:
    messenger:
        default_bus: command.bus
        buses:
            command.bus:
                middleware:
                    # 1. translate CommandBusOptions (delay, validation groups) into native stamps
                    - Etel\CQRS\Command\Implementation\Middleware\CommandBusOptionsMiddleware
                    # 2. validate the raw message (Symfony Validator)
                    - validation
                    # 3. transform a validated CommandInput into the final command — AFTER validation
                    - Etel\CQRS\Command\Implementation\Middleware\CommandInputTransformerMiddleware
            query.bus:
                middleware:
                    - Etel\CQRS\Query\Implementation\Middleware\QueryBusOptionsMiddleware
                    - validation
                    - Etel\CQRS\Query\Implementation\Middleware\QueryInputTransformerMiddleware
            event.bus:
                middleware:
                    - Etel\CQRS\Event\Implementation\Middleware\EventBusOptionsMiddleware
```

```
# config/services.yaml
services:
    _defaults:
        autowire: true
        autoconfigure: true

    # Bind each contract to its Messenger-backed implementation.
    Etel\CQRS\Command\CommandBus:
        class: Etel\CQRS\Command\Implementation\MessengerCommandBus
        arguments: ['@command.bus']

    Etel\CQRS\Query\QueryBus:
        class: Etel\CQRS\Query\Implementation\MessengerQueryBus
        arguments: ['@query.bus']

    Etel\CQRS\Event\EventBus:
        class: Etel\CQRS\Event\Implementation\MessengerEventBus
        arguments: ['@event.bus']

    # Middleware are stateless and autowirable.
    Etel\CQRS\Command\Implementation\Middleware\:
        resource: '../vendor/etel/cqrs/src/Command/Implementation/Middleware/'
    Etel\CQRS\Query\Implementation\Middleware\:
        resource: '../vendor/etel/cqrs/src/Query/Implementation/Middleware/'
    Etel\CQRS\Event\Implementation\Middleware\:
        resource: '../vendor/etel/cqrs/src/Event/Implementation/Middleware/'
```

> **Middleware order matters.** The options middleware runs **first** (it emits the `ValidationStamp`/`DelayStamp`that later steps rely on); the input transformer runs **after** `validation`, so raw input is validated before it becomes the final message.

Mark handlers so the container can autoconfigure them (the library markers are optional but convenient):

```
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Etel\CQRS\Command\AsCommandHandler;

#[AsCommandHandler] // library marker — tag handlers for your DI compiler pass
#[AsMessageHandler(bus: 'command.bus')] // Symfony Messenger routing
final readonly class CreateUserHandler { /* ... */ }
```

`AsCommandHandler`, `AsQueryHandler` and `AsEventHandler` are plain class markers; wire them to a compiler pass or use them alongside Messenger's own `#[AsMessageHandler]`.

Commands
--------

[](#commands)

A command expresses intent to change state. By default it is **fire-and-forget**.

```
use Etel\CQRS\Command\CommandBus;

final readonly class CreateUser
{
    public function __construct(
        public string $email,
        public string $name
    ) {}
}

final readonly class CreateUserHandler
{
    public function __invoke(CreateUser $command): void
    {
        // ... persist ...
    }
}

// Dispatch — returns null, does not wait for a result.
$commandBus->command(command: new CreateUser(email: 'jane@example.com', name: 'Jane'));
```

When you need the handler's result synchronously (e.g. a freshly created id), declare it with `#[CommandResult]` — see [Declaring &amp; checking handler results](#declaring--checking-handler-results).

```
use Etel\CQRS\Command\CommandResult;

#[CommandResult(CreatedUser::class)]
final readonly class CreateUser { /* ... */ }

$user = $commandBus->command(command: new CreateUser(/* ... */)); // $user is CreatedUser (statically + at runtime)
```

Queries
-------

[](#queries)

A query reads state and **always** returns a result synchronously (a query that only enqueues work is a command).

```
use Etel\CQRS\Query\QueryBus;
use Etel\CQRS\Query\QueryResult;

#[QueryResult(UserView::class)]
final readonly class GetUserById
{
    public function __construct(public int $id) {}
}

final readonly class GetUserByIdHandler
{
    public function __invoke(GetUserById $query): UserView
    {
        // ... load and return a UserView ...
    }
}

$user = $queryBus->query(query: new GetUserById(id: 42)); // $user is UserView
```

A nullable result (e.g. "not found") is declared with `nullable: true`:

```
#[QueryResult(UserView::class, nullable: true)]
final readonly class FindUserByEmail
{
    public function __construct(public string $email) {}
}

$user = $queryBus->query(query: new FindUserByEmail(email: 'jane@example.com')); // UserView|null
```

Events
------

[](#events)

An event is a domain fact that already happened. It may have **zero or more** handlers and never returns a result. Events carry no validation layer by design — their data is guaranteed valid at the point of emission.

```
use Etel\CQRS\Event\EventBus;

final readonly class UserRegistered
{
    public function __construct(public int $userId) {}
}

$eventBus->publish(event: new UserRegistered(userId: $user->id));
```

Declaring &amp; checking handler results
----------------------------------------

[](#declaring--checking-handler-results)

The `#[QueryResult]` / `#[CommandResult]` attributes declare a message's result **once, on the message class**. The bus reads them to verify the handler's return value at runtime, and a bundled PHPStan extension uses them to infer the dispatch call's return type — so you never repeat the FQCN at the call site.

### Queries

[](#queries-1)

A query always returns; the attribute pins the type:

```
#[QueryResult(UserView::class)]                 // returns UserView
#[QueryResult(UserView::class, nullable: true)] // returns UserView|null
#[QueryResult]                                  // any result (scalar/array/union) — returns mixed
```

Attribute`query()` return typeRuntime check`#[QueryResult(Foo::class)]``Foo`Result must be a `Foo`, otherwise throws`#[QueryResult(Foo::class, nullable: true)]``Foo|null``Foo` or `null``#[QueryResult]``mixed`none*no attribute*`mixed`none### Commands

[](#commands-1)

A command is fire-and-forget unless it opts in. **Omitting the attribute means "returns nothing"** (void / async / always-void handler) — that is the canonical way to say a command has no result:

AttributeSemantics`command()` return type*no attribute*Void / fire-and-forget`null``#[CommandResult(Foo::class)]`Synchronous result of type `Foo``Foo` (or `Foo|null` with `nullable: true`)`#[CommandResult]`A result is expected, but not a class (e.g. an id)`mixed````
#[CommandResult(CreatedUser::class)]
final readonly class CreateUser { /* ... */ }

$created = $commandBus->command(command: new CreateUser(/* ... */)); // CreatedUser
```

### Runtime guarantees

[](#runtime-guarantees)

When a result type is declared, the bus throws if the handler violates it:

- the handler returns the wrong type → `UnexpectedQueryResult` / `UnexpectedCommandResult`;
- the handler returns `null` but the attribute is not `nullable` → same exception;
- the message reaches no handler (e.g. it was routed async) while a result is expected → `InvalidQueryReturnConfiguration` / `InvalidCommandReturnConfiguration`.

### `$expectResult`: the per-call escape hatch

[](#expectresult-the-per-call-escape-hatch)

Both `command()` and `query()` still accept an explicit `$expectResult` argument. The attribute is the recommended, declarative source of truth; `$expectResult` exists for cases the attribute cannot cover:

- **third-party messages** you cannot annotate;
- a one-off **override** at a single call site;
- typing that works **without the PHPStan extension** installed.

```
// Force a typed result for a query you don't own:
$result = $queryBus->query(query: $thirdPartyQuery, expectResult: SomeResult::class); // SomeResult

// Commands accept true to mean "expect any result", false (default) for fire-and-forget:
$any = $commandBus->command(command: $command, expectResult: true);
```

An explicit class-string in `$expectResult` takes precedence over the attribute.

Inputs: raw data before validation
----------------------------------

[](#inputs-raw-data-before-validation)

When a message must contain **valid, strongly-typed** data (non-nullable properties, enums/VOs instead of raw strings/ints), use an intermediate input DTO. `CommandInput` / `QueryInput` hold raw data and expose `toCommand()` / `toQuery()`, which either return the final typed message or throw if the data is invalid. The transformer middleware performs this swap after validation, preserving all stamps.

```
use Etel\CQRS\Command\CommandInput;

/** @implements CommandInput */
final readonly class CreateUserInput implements CommandInput
{
    public function __construct(
        public ?string $email = null,
        public ?string $name = null,
    ) {}

    public function toCommand(): CreateUser
    {
        if ($this->email === null || $this->name === null) {
            throw new InvalidCreateUserData(); // implements Etel\CQRS\Command\Exception\InvalidCommandData
        }

        return new CreateUser(email: $this->email, name: $this->name);
    }
}

// Dispatch the input; the middleware validates and transforms it into CreateUser.
$commandBus->command(command: new CreateUserInput(email: $request->email, name: $request->name));
```

Events have no input equivalent: event data is always valid at emission.

Per-bus options
---------------

[](#per-bus-options)

Each bus takes its own typed options value object instead of raw Messenger stamps. Buses know nothing about each other's options.

```
use Etel\CQRS\Command\CommandBusOptions;
use Etel\CQRS\Query\QueryBusOptions;
use Etel\CQRS\Event\EventBusOptions;

// Command: delay (ms) and validation groups.
$commandBus->command(command: $command, options: new CommandBusOptions(
    delayMs: 5000,
    validationGroups: ['Default', 'strict']
));

// Query: validation groups only.
$user = $queryBus->query(query: $query, options: new QueryBusOptions(validationGroups: ['Default']));

// Event: delay and dispatch-after-current-bus.
$eventBus->publish(event: $event, options: new EventBusOptions(
    delayMs: 1000,
    dispatchAfterCurrentBus: true
));
```

Option VOFields`CommandBusOptions``int $delayMs = 0`, `?list $validationGroups = null``QueryBusOptions``?list $validationGroups = null``EventBusOptions``int $delayMs = 0`, `bool $dispatchAfterCurrentBus = false`The matching `*BusOptionsMiddleware` translates these into native Messenger stamps (`DelayStamp`, `ValidationStamp`, `DispatchAfterCurrentBusStamp`).

Exceptions
----------

[](#exceptions)

You catch **interfaces** (in the contracts layer); the implementation throws concrete classes that implement them.

InterfaceThrown when`InvalidCommandData` / `InvalidQueryData`Input passed validation but is still invalid for some reason`UnexpectedCommandPropertyValue` / `UnexpectedQueryPropertyValue`A validated property still holds an unexpected value`InvalidCommandReturnConfiguration` / `InvalidQueryReturnConfiguration`A result is expected but the message reached no synchronous handler`UnexpectedCommandResult` / `UnexpectedQueryResult`The handler returned a value that does not match the declared result type```
use Etel\CQRS\Query\Exception\UnexpectedQueryResult;

try {
    $user = $queryBus->query(query: new GetUserById(42));
} catch (UnexpectedQueryResult $e) {
    // the handler returned something other than the declared UserView
}
```

Static analysis (PHPStan)
-------------------------

[](#static-analysis-phpstan)

The package bundles a PHPStan extension that infers `query()` / `command()` return types from the result attributes:

CallInferred type`query(new GetUserById(1))` with `#[QueryResult(UserView::class)]``UserView``query(new FindUser(...))` with `#[QueryResult(UserView::class, nullable: true)]``UserView|null``query($q, expectResult: UserView::class)``UserView``command(new CreateUser(...))` with `#[CommandResult(CreatedUser::class)]``CreatedUser``command($c)` (no attribute)`null`If you use [`phpstan/extension-installer`](https://github.com/phpstan/extension-installer) the extension is registered automatically. Otherwise include it manually:

```
# phpstan.neon
includes:
    - vendor/etel/cqrs/extension.neon
```

> **PhpStorm note.** The extension drives PHPStan; it cannot drive PhpStorm's own type inference. The explicit `$expectResult: Foo::class` form is understood by PhpStorm natively; attribute-based inference is a PHPStan feature.

Development
-----------

[](#development)

This project targets PHP 8.4.

```
# Run the test suite
vendor/bin/phpunit

# Static analysis (PHPStan, level 10)
vendor/bin/phpstan analyse

# Check / fix code style (PSR-12 + PHP 8.x rulesets, strict types)
vendor/bin/php-cs-fixer check --diff
vendor/bin/php-cs-fixer fix
```

License
-------

[](#license)

Released under the [MIT License](LICENSE).

###  Health Score

43

—

FairBetter than 89% of packages

Maintenance87

Actively maintained with recent releases

Popularity11

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity57

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

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 ~17 days

Total

5

Last Release

96d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/409683?v=4)[etel](/maintainers/etel)[@etel](https://github.com/etel)

---

Top Contributors

[![relo-san](https://avatars.githubusercontent.com/u/322613?v=4)](https://github.com/relo-san "relo-san (15 commits)")

---

Tags

command buscqrsquery-buscommand query responsibility segregation

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Code StylePHP CS Fixer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/etel-cqrs/health.svg)

```
[![Health](https://phpackages.com/badges/etel-cqrs/health.svg)](https://phpackages.com/packages/etel-cqrs)
```

###  Alternatives

[prooph/service-bus

PHP Enterprise Service Bus Implementation supporting CQRS and DDD

4421.4M32](/packages/prooph-service-bus)[ecotone/ecotone

Enterprise architecture layer for Laravel and Symfony — CQRS, Event Sourcing, Durable Workflows (Sagas, Orchestrators), Projections, and Outbox messaging via PHP attributes.

568591.1k61](/packages/ecotone-ecotone)[simple-bus/asynchronous

Generic classes and interfaces for asynchronous messaging using SimpleBus

241.6M6](/packages/simple-bus-asynchronous)[prooph/event-store-bus-bridge

Marry CQRS with Event Sourcing

37529.1k16](/packages/prooph-event-store-bus-bridge)[php-service-bus/sagas

Saga pattern implementation

3915.0k5](/packages/php-service-bus-sagas)[dykyi-roman/awesome-claude-code

Claude Code extension for PHP: audits (architecture, DDD, security, performance, PSR, design patterns, Docker, CI/CD, tests, docs), 3-level code review, automated bug fix, generators (DDD, CQRS, GoF patterns, PSR, tests, documentation, Docker, CI/CD), code explanation, refactoring. 26 commands, 62 agents, 259 skills.

931.4k](/packages/dykyi-roman-awesome-claude-code)

PHPackages © 2026

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