firefly/cqrs adds the command/query dispatch surface — a synchronous, in-process mediator — on
top of the shipped seams. It writes no interception code: a #[Transactional] command handler gets
transaction semantics for free from the firefly/data proxy. Read models / projections ship in
firefly/eventsourcing: see Projections for FunctionProjection, the
ProjectionRunner and the firefly:eventsourcing:projections commands.
CommandBus::send(object $command): mixed— the write side.QueryBus::ask(object $query): mixed— the read side (ask, notquery, to avoid colliding with Eloquent'squery()).
Both run a bounded pipeline of injected, no-op-by-default collaborators:
- Command: correlate → validate → authorize → resolve + invoke handler → metrics.
- Query: correlate → validate → authorize → cache-get (short-circuit on hit) → resolve + invoke handler → cache-put → metrics.
Any handler/stage throwable is re-thrown wrapped in a category-preserving
CommandProcessingException / QueryProcessingException (unless it already is one). "Category-preserving"
means a ValidationException (422) or a not-found (500 framework) keeps its own error code / HTTP status /
category / severity for RFC-7807 rendering — the wrapper never masks an expected fault to a generic 500 — and
its RFC 9457 extension members and title too, so a refused #[PreAuthorize] publishes the same
requiredAuthorities through the bus as it does from a controller.
The wrapper's message and its published detail are two different sentences. The message —
Processing command [App\Withdraw] failed: <the cause's message> — names the message class and quotes the
cause verbatim, for the log and the previous chain. The problem document publishes detail() instead, which
the wrapper sets to the cause's own: an authored FireflyException cause reaches the client word for word,
exactly as if a controller had thrown it. A cause that is not a FireflyException — a QueryException
carrying its SQL and bindings, a TypeError naming a server path — has no sentence for a client, so the
wrapper's detail is null and the web layer answers it the way it answers that throwable thrown from a
controller: the opaque An unexpected error occurred. It has been logged; quote reference <traceId> if you report it. with firefly.web.problem.disclose off (the default), and the cause's own message, without the
prefix, with it on. The wrapper keeps its own 500 COMMAND_PROCESSING_ERROR / QUERY_PROCESSING_ERROR code
either way; only the sentence is withheld.
Annotate a class with #[CommandHandler] or #[QueryHandler] (both specialise #[Component], so the
handler is a constructor-injected DI bean automatically — no separate #[Service]). The handler exposes
one public handle() method taking the message and returning the result. This is the handler the package's own
banking capstone dispatches, imports cut — and it is deliberately not final: a #[Transactional] method makes
TransactionalBeanPostProcessor swap the bean for a generated final class …Proxy extends \{Target}, which cannot
extend a final class, so marking the handler final would fatal at boot:
// …
#[CommandHandler]
class OpenAccountHandler
{
public function __construct(private readonly AccountRepository $accounts) {}
#[Transactional]
public function handle(OpenAccount $command): int
{
$account = new Account(['owner' => $command->owner, 'balance' => $command->balance]);
$account->open();
$this->accounts->save($account);
return $account->id;
}
}The handled message type is inferred from the handle() parameter at scan time (mirroring
#[AsEventListener]); pass an explicit class to disambiguate a union/object parameter:
#[CommandHandler(OpenAccount::class)]. An unresolvable type or a duplicate handler fails loud
(CqrsConfigurationException) at build/boot, never silently. HandlerScanner (the sole reflection site in
the package) discovers handlers and compiles them into a HandlerManifest; CqrsHandlerWiringPass
populates the HandlerRegistry from that manifest at boot with fresh-per-dispatch invokers (so a
#[Transactional] handler is always observed through its proxy).
The marker interfaces Command/Query/CommandHandler/QueryHandler are optional documentary
type-markers — discovery is by attribute, and PHP parameter invariance means the handler interfaces
declare no handle() method.
All default to no-ops so the pipeline is a series of skippable collaborators:
- Validation — reuse the shipped
Validator: a message implementingValidatable(validationData()/validationRules()) is validated; others are not. - Authorization — interface-only seam (
CommandAuthorizer/QueryAuthorizer), allow-all default; real policy evaluation is M11-security. - Correlation —
CorrelationContextpropagates a uuid4 correlation id, restored after each dispatch and stamped into integration-event headers (x-correlation-id). - Metrics —
CqrsMetricsseam (no-op default); the real recorder is M12-observability. - Tracing —
CqrsTracingseam (NoOpCqrsTracingdefault) wrapping validate → authorize → resolve → invoke; observability'sTracerCqrsTracingputs an INTERNAL span around every message (see Tracing). - Query cache —
QueryCacheseam,NoOpQueryCache(always-miss) by default;firefly.cqrs.query.cache.provider=cachebindsCacheQueryCacheon the Laravel cache — see Query cache.
The bridge re-emits every committed in-process DomainEvent onto the firefly/eda EventPublisher
as an integration event, mapping eventType = the event's short class name, payload = the event's
public fields, and destination = an explicit argument → the event's #[PublishDomainEvent(destination:)]
→ firefly.cqrs.default_destination (cqrs.events).
How it triggers: firefly/data publishes each committed DomainEvent to the in-process
ApplicationEventPublisher inside DB::afterCommit. DomainEventBridgeWiringPass registers a single
guarded wildcard listener on the event dispatcher that filters instanceof DomainEvent and delegates
to DomainEventBridge, which applies the failure strategy and publishes through the resolved
CommandEventPublisher (NoOpEventPublisher or EdaCommandEventPublisher). (It does NOT use
#[AsEventListener] typed on DomainEvent: Laravel's dispatcher matches an object event by its concrete
class plus interfaces — never parent classes — so a base-class listener would never fire for a concrete
subclass.)
Semantic scope (read this): the bridge fires for any aggregate committed through a firefly/data
repository — not only writes originating from a CommandBus command. This is deliberate and correct
integration-event semantics: an integration event is published because a fact was committed to the write
model, regardless of whether a CQRS command, a controller, a scheduled job, or a message consumer drove
the mutation. This is also the deliberate improvement over pyfly, which drains events by inspecting the
current handler's aggregate (duck-typing on the command-handling call) rather than listening for any
committed aggregate. An app that wants to emit an integration event WITHOUT an in-process domain event
injects CommandEventPublisher directly; an app that wants a domain event to stay in-process only does not
enable the bridge (or scopes destinations via #[PublishDomainEvent]).
Failure strategy (firefly.cqrs.event_failure_strategy): the publish runs AFTER the DB commit, inside
DB::afterCommit, so the write already succeeded. log (default) swallows + logs a publish failure — the
command result stands, the publish is best-effort; raise re-throws it wrapped in
CommandProcessingException. Either way, the bridge fires only on a real commit — a rolled-back unit of
work raises no DomainEvent at all (Laravel discards afterCommit callbacks on rollback), so nothing is
ever published for it.
Non-atomic caveat: the DB commit and the broker publish are two steps with a gap — an honest
at-least-once-ish, non-atomic guarantee. Atomic durability is the same-transaction outbox
(firefly.eda.provider=database, or postgres), which writes the event inside the aggregate's transaction
through firefly/data's PreCommitEventHook.
When no firefly/eda EventPublisher is bound, the bridge falls back to NoOpEventPublisher (drops
silently), so cqrs installed without eda still boots and simply emits no integration events.
firefly/cqrs is an independent layer that does not depend on firefly/data (no Cqrs → Data Deptrac
edge): the bridge listens for DomainEvents on the in-process Context dispatcher, never on firefly/data's
AggregateTracker/DomainEventDispatcher directly.
With firefly.cqrs.query.cache.provider=cache, a query class marked #[CachedQuery] is cached under a key derived
from its public properties — two equal value objects share an entry, 3 and '3' do not — for its ttl:
(falling back to firefly.cqrs.query.cache_ttl), in its group: (default: the query class). A Cacheable
query keeps its own cacheKey(). A null result is never cached, and a cache failure is logged and runs the
handler: the cache can make a query faster, never make it fail. A query holding private or protected state, or a
generator, cannot be keyed and simply runs uncached.
#[InvalidatesQuery] on an event class says which cached results the event makes stale: the whole group, one entry
by mapping the query's properties to the event's (with:), or one Cacheable key (key: 'statement:{account}').
Eviction happens when the event is dispatched after its transaction commits — a rolled-back write evicts nothing —
and when it arrives over the event bus from another process, matched by its event type (eventType:, default the
short class name). An envelope whose payload cannot rebuild the typed key (JSON delivers an enum as its backing
value, a date as an array) evicts the whole group instead, with a warning: serving stale data is the failure worth
preventing.
<?php
declare(strict_types=1);
namespace App\Ledger;
use Firefly\Cqrs\Attributes\CachedQuery;
use Firefly\Cqrs\Attributes\InvalidatesQuery;
use Firefly\Cqrs\Query\Query;
use Firefly\Domain\DomainEvent;
#[CachedQuery(ttl: 300)]
final readonly class AccountBalance implements Query
{
public function __construct(public string $account) {}
}
#[InvalidatesQuery(AccountBalance::class, with: ['account' => 'account'])]
final readonly class EntryPosted extends DomainEvent
{
public function __construct(public string $account, public int $amount)
{
parent::__construct();
}
}Groups are evicted by GENERATIONS, not cache tags, so they work on every store (file and database included): an evicted group's entries become unreachable at once and expire on their TTL — set one. The key is the value object, so a query whose answer depends on the tenant or the user must carry them as properties.
The original keys are snake_case and stay so; the query-cache keys are kebab-case.
| Key | Default | Meaning |
|---|---|---|
firefly.cqrs.default_destination |
cqrs.events |
Fallback integration-event destination. |
firefly.cqrs.event_failure_strategy |
log |
log (swallow) or raise (re-throw) on a post-commit publish failure. |
firefly.cqrs.query.cache_ttl |
(unset) | Default TTL in seconds for a cached query whose #[CachedQuery] names none (and for a Cacheable query). Unset means "no TTL", not zero. |
firefly.cqrs.query.cache.provider |
none |
none (NoOp) or cache (CacheQueryCache). Anything else refuses the boot. |
firefly.cqrs.query.cache.store |
'' |
Laravel cache store ('' = default). |
firefly.cqrs.query.cache.prefix |
firefly:cqrs: |
Key prefix. |
firefly.cqrs.query.cache.invalidation |
[] |
Event type ⇒ list of query classes whose groups it evicts (for events with no local class). |
| Concern | Plain Laravel | LaraFly (firefly/cqrs) |
|---|---|---|
| Command/query dispatch | dispatch() a job, or call a service directly; no read/write split |
CommandBus::send / QueryBus::ask with attribute-discovered handlers + a bounded pipeline |
| Handler discovery | manual binding / $dispatchesJobs |
#[CommandHandler]/#[QueryHandler] compiled to a manifest, zero boot reflection |
| Transactions | DB::transaction(fn () => …) in the handler body |
#[Transactional] on handle() — interception is free (firefly/data proxy) |
| Domain → integration events | hand-rolled (dispatch a job in the model event) | committed DomainEvents auto-re-emitted onto the eda EventPublisher after commit |
Read-model / projection scaffolding ships in firefly/eventsourcing. Real
authorization (→ M11), CQRS metrics + health (→ M12), attribute-driven command validation, the fluent builder /
distributed-tracing ergonomics are deferred and documented honestly. The HandlerManifest needs no hand-wiring: like every other
compiled manifest it resolves to the firefly:cache artifact if present, otherwise an in-process scan of
firefly.scan.paths, otherwise empty — so an uncached app registers the same handlers a cached one does. A
handler outside firefly.scan.paths still never registers. Under Octane, CorrelationContext is
per-request state and the HandlerRegistry is rebuilt per worker boot.