The CQRS Bus
What this covers / who it's for. Why every domain read and write flows through one chokepoint, the verified gate order inside that chokepoint, what each stage emits, and how the audit and DLP platforms ride on the bus events. For message/handler recipes and worked examples, defer to the skill reference and the foundation module README.
One chokepoint, on purpose
Every domain write is a Command dispatched through the CommandBus; every domain read
is a Query asked through the QueryBus (app-modules/foundation/src/Bus/ — a bespoke,
app-owned pattern, no third-party package). The bus is where the platform gates (authorization
and the business gates), tracks (structured audit lines + telemetry events), and executes
(commands transactionally by default). Because all of that lives in one place, presentation code
— HTTP controllers, Livewire components — is structurally forbidden from touching Eloquent/DB:
tests/Architecture/CqrsBoundaryTest.php fails the build on any presentation-layer model import.
The single sanctioned carve-out is Fortify's credential Action classes
(framework-mandated credential management, not domain mutations).
The payoff is that cross-cutting guarantees are impossible to forget: a new money-movement command gets authorization, freeze-gating, audit, and transactionality by construction, because there is no other way to reach the data.
Message anatomy
A message is an immutable (readonly) description of intent with exactly two obligations:
handledBy(): class-string names the handler (the only layer that touches models), and
authorize(?Authenticatable $actor): bool is abstract — every message must consciously
declare who may run it. For the standard guarded message, authorization is declared, not
hand-written (D51): the message implements ChecksPermission (or ChecksPermissions + a mode)
and uses the Authorization module's AuthorizesViaDeclaredPermission concern, which resolves the
declared PermissionKey through the PermissionGate. That single declaration feeds both the
runtime gate and the exported endpoint↔permission map. Bespoke authorize() bodies and public
return true messages must sit on AuthorizationCoverageTest's explicit allow-lists — see
auth-and-rbac.md.
All other behaviour is opted into via marker interfaces on the message
(app-modules/foundation/src/Bus/Contracts/) — the pipeline below reacts to each.
The dispatch pipeline — verified order
The order inside LaravelCommandBus::dispatch()
(app-modules/foundation/src/Bus/LaravelCommandBus.php) is load-bearing: a denied actor is never
told whether a feature exists or an account is frozen, because authorize() runs first.
authorize($actor)— actor resolved from the active guard (Auth::user()).false→ auditcommand.denied(warning), fireCommandDenied(the denied attempt is persisted durably, D69), throwAccessDeniedHttpException(403). The handler never runs.- Step-up MFA —
RequiresFreshMfa+ staleStepUpMfawindow → auditcommand.step_up_required, throwSTEP_UP_REQUIRED(403). - Freeze gate —
RequiresAccountActive→ the boundAccountFreezeGuard(compliance's implementation; Foundation ships a fail-open no-op default) asserts the tenant isn't frozen/closed → auditcommand.account_frozen,ACCOUNT_FROZEN(403). - Feature gate —
RequiresFeature→ the boundFeatureGuard(features module; fail-closed — an unbound guard throws) → auditcommand.feature_denied;FeatureUnavailable,QuotaExceeded(429), orLimitReached(409). - Rationale gate —
RequiresRationalewith a blankrationale()→ auditcommand.rationale_required,RATIONALE_REQUIRED(422). - Audit
command.dispatched, then execute the handler in one of three modes:Idempotent→DB::transactionwrapping theCommandDeduplicator(unique INSERT intocommand_dedup_recordsfirst; a completed duplicate replays the stored result — D28/D56);WithoutTransaction→ run bare (auth/session commands with no atomic multi-write invariant); default →DB::transaction. - On throw, classify via
ExpectedClientError(D76): an expected client 4xx (a codedDomainExceptionin 400–499) → auditcommand.rejected(warning, sanitized code + status only) and — only ifisDurableRejection()(D76.1) — fireCommandRejected; anything else → auditcommand.failed(error, exception class + code only, never the raw message) and fireCommandFailed. Either way the exception is re-thrown unchanged. - On success: audit
command.executed(withduration_ms) and fireCommandExecutedcarrying the message, actor, tenant id, duration, and result.
Attempt events (CommandDenied/CommandFailed/CommandRejected) are fired through a
recordAttempt() wrapper: an audit-write failure is logged but never masks the real domain
outcome — the durable trail is best-effort relative to the domain result, never the reverse.
LaravelQueryBus::ask() is the same shape minus the business gates and the transaction:
authorize() → handler (no DB::transaction, ever) → query.denied/query.failed/
query.executed + QueryExecuted. Queries emit no dispatched line and no durable attempt
events.
The D76 rejection split — why three failure shapes
A thrown exception is not one thing. The bus distinguishes:
- Genuine failures (5xx
DomainException, rawHttpException, any unforeseen throwable) — always durable (CommandFailed), logged aterrorwith class + code only (raw messages can carry PII). - Routine expected 4xx (an ordinary not-found, a plain domain conflict) — client noise:
command.rejectedon the file channel only, excluded from the durable trail so it isn't flooded. - Durable rejections (D76.1) — a 4xx that is a security/compliance-significant attempt:
payout body-tamper, dual-control/segregation-of-duties breaches (self-approval, SAR
self-review/submit), privilege-escalation grants. Recognised by the
Foundation\Exceptions\DurableRejectionmarker on the exception class or byErrorCode::isDurableRejection(), these also fireCommandRejected— a durableevent_type='rejected'row, consistent with the denied-403 path.
Durability across rollback is the point: the attempt events are persisted synchronously on the
independent pgsql_logs session (D54/D69), so the record of a denied/failed/rejected attempt
survives the command transaction that just rolled back — see
tenancy-and-rls.md for that connection's mechanics.
What listens: the event fan-out
The bus itself only writes structured lines to the audit log channel (stamped with
actor, tenant, and the per-request correlation_id). The durable platforms subscribe to the
events:
| Event | Consumer | Result |
|---|---|---|
CommandExecuted / QueryExecuted | audit module (RecordSuccessfulBusMessage, async post-commit) | hash-chained audit_events row — queries only when marked AuditableRead |
CommandDenied / CommandFailed / CommandRejected | audit module (RecordBusAttemptOutcome, synchronous) | durable attempt row that survives the rollback |
QueryExecuted (+ ReadsClassifiedData marker) | DLP module (RecordClassifiedAccess) | data_access_logs / tenant_data_access_logs entry — declare-driven, never reflection (D58) |
CommandExecuted / QueryExecuted (+ EgressesData marker) | DLP module (RecordEgress) | dlp_egress_events entry: kind, resource, redaction-safe volume read off the handler result |
The read-side markers are the mirror image of the write gates: AuditableRead,
ReadsClassifiedData, and EgressesData are not enforced by the bus — they are declarations
consumed by the audit/DLP listeners, so a compliance-significant read or an export is recorded
without the DLP layer ever seeing the data. Messages may also contribute extra redaction-safe
audit context via ProvidesAuditPayload.
Going deeper
- Recipes, worked examples, arch/behaviour test contracts: the cqrs-bus skill reference.
- The marker/guard-contract table (full set, when to use each): same reference; guard
implementations live in the compliance (
AccountFreezeGuard) and features (FeatureGuard) modules — see their READMEs. - Authorization declaration mechanics: auth-and-rbac.md and the
permission-managementskill. - The consumers:
app-modules/audit/README.md,app-modules/dlp/README.md. - Decision rationale: D28, D51, D56, D69, D76 (+ D76.1) in the decision log.