diff --git a/CHANGELOG.md b/CHANGELOG.md
index e11f1fc0..b53bd79a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -112,6 +112,22 @@ All notable changes to LaraFly are documented here. This project uses CalVer (`Y
endpoint; partner callbacks through `firefly/callbacks`; `GetBalance` as a `#[CachedQuery]` evicted per wallet by
`#[InvalidatesQuery]` on `FundsDeposited`/`FundsWithdrawn`; and the portable outbox, proven on PostgreSQL, MySQL,
MariaDB, SQL Server and SQLite by `OutboxDatabaseRoundTripTest`.
+- **Book: Chapter 9B, in English and Spanish.** *Event Sourcing: Storing What Happened* walks Lumen's event-sourced
+ loans — `#[EventType]` events, an `EventSourcedAggregateRoot` replayed from history and from snapshots, the 409 a
+ stale writer gets, one transaction with the wallet, a checkpointed `loan_book` projection run, listed and rebuilt by
+ the projection commands, an upcaster for a renamed field, publication through the outbox in the append's own
+ transaction, and the payout saga on `firefly.orchestration.persistence.provider=event-sourced` — slotted after
+ Chapter 9A without renumbering. The preface, Chapter 9A, Appendix A and the glossary (checkpoint, event sourcing,
+ event store, snapshot, upcaster) follow. The book now has nineteen chapters.
+- **Tutorial Step 14, in English and Spanish.** The greeting app records every greeting as a `greeting.said` event
+ in the database event store and projects the counts into a `greeting_counts` read model at
+ `POST`/`GET /journal/{name}`; the step's files are quoted from `tests/Fixtures/Tutorial`, which
+ `tests/TutorialGreetingJournalTest.php` boots through the real web and eventsourcing pipeline. "What's Next" is now
+ Step 15.
+- **`samples/lumen` exercises W3.** An event-sourced `Loan` with `GrantLoan`/`RepayLoan` handlers that move the money
+ through the wallet in the same transaction, `LoanRepository`, `LoanBookProjection`, `LoanRepaidV1Upcaster` and the
+ payout saga on the event-sourced orchestration store, proven on PostgreSQL, MySQL, MariaDB, SQL Server and SQLite
+ by `LoanLedgerRoundTripTest` and `EventSourcedPayoutRoundTripTest`.
### Changed
@@ -133,6 +149,23 @@ All notable changes to LaraFly are documented here. This project uses CalVer (`Y
### Fixed
+- **The Lumen sample let any wallet owner debit any wallet.** `WithdrawHandler` and `RepayLoanHandler` were guarded
+ by `hasRole('ADMIN') or hasRole('WALLET_OWNER')`, so every customer could withdraw from, pay out of or repay a
+ loan against someone else's wallet. Both now require `hasRole('ADMIN') or (hasRole('WALLET_OWNER') and
+ hasPermission(#command, 'debit'))`, answered by the sample's `WalletPermissionEvaluator`, which grants the debit
+ only to the wallet's owner and refuses unknown targets and permissions; Chapters 4, 6, 9A, 9B and 10 of the book
+ follow, in both editions.
+- **The Lumen sample let anyone, even an anonymous caller, transfer money out of any wallet.** `TransferHandler`
+ (`POST /api/v1/wallets/transfers`) had no `#[PreAuthorize]`. It now carries the same rule as `WithdrawHandler`,
+ and `WalletPermissionEvaluator` grants `debit` on a `Transfer` only to the owner of its source wallet: an
+ anonymous transfer is a 401, a non-owner's a 403, and neither moves money on either wallet. Chapters 6, 9 and 10
+ and the README follow in both editions, and Chapter 10's exercise 2, which asked the reader to add this guard, now
+ asks them to guard a read.
+- **A refused command or query lost its `requiredAuthorities`.** `CommandProcessingException` and
+ `QueryProcessingException` copied the cause's code, status, category and severity but not its RFC 9457 extension
+ members or title, so the problem document of a `#[PreAuthorize]` refused at the bus dropped the
+ `requiredAuthorities` the same refusal publishes from a controller (and any handler fault lost its `field`,
+ `allowed` or own title). Both wrappers now carry them; only `detail`, which names the message class, differs.
- **`firefly:callbacks:replay` honors deactivation and endpoint changes.** The deliverer refuses an inactive
subscription on every path (replay needs `--include-inactive`, which the output reports), and every delivery goes
to the subscription's current URL — checked against `http.require-https` / `http.allowed-hosts` — instead of the
diff --git a/README.md b/README.md
index 4fed842d..0316edad 100644
--- a/README.md
+++ b/README.md
@@ -71,9 +71,9 @@ event-driven, actuator-observed microservice, chapter by chapter — every listi
**[Download the book in English or Spanish, as PDF or EPUB](https://fireflyframework.github.io/fireflyframework-php/book/).**
The published editions include the single-package installation and bundled installer.
-The book is **structurally complete and bilingual (English + Spanish)**: a quick start, **eighteen chapters**
+The book is **structurally complete and bilingual (English + Spanish)**: a quick start, **nineteen chapters**
across four parts — Foundations (DI, config, HTTP), Modelling & Persisting the Domain (repositories, DDD),
-Coordinating & Securing the App (CQRS, EDA + transactional outbox, webhooks and callbacks, `#[Transactional]`, sagas/TCC/durable workflows, security, OAuth2 and
+Coordinating & Securing the App (CQRS, EDA + transactional outbox, webhooks and callbacks, `#[Transactional]`, sagas/TCC/durable workflows, event sourcing, security, OAuth2 and
OpenID Connect), and Observability, Testing & Delivery (actuator, testing, the CLI + zero-reflection cache,
feature flags)
— plus a Laravel→LaraFly cheat-sheet and a glossary. The two editions are line-for-line the same book: every
@@ -158,7 +158,7 @@ LaraFly is not a fork of Laravel and does not hide it — every package layers c
`Illuminate\Container`, Eloquent, the HTTP kernel, and the queue/cache/scheduler, so everything you already
know about Laravel keeps working underneath.
-Five capabilities in that list are recent enough — and far enough from what teams assume a PHP framework
+Six capabilities in that list are recent enough — and far enough from what teams assume a PHP framework
will have — that they are worth naming outright:
- **Both halves of OAuth2, as two packages you install.**
@@ -196,11 +196,17 @@ will have — that they are worth naming outright:
- **Work that spans systems.** [`firefly/orchestration`](docs/modules/orchestration.md) runs sagas whose
completed steps are compensated newest first when a later one fails, TCC transactions that reserve with every
participant before confirming any, and durable workflows that wait for signals and timers across processes —
- every step checkpointed (memory, cache or a portable database store), stale executions recovered, failures
+ every step checkpointed (memory, cache, a portable database store or the event store), stale executions recovered, failures
dead-lettered. [`firefly/webhooks`](docs/modules/webhooks.md) verifies Stripe, GitHub, Twilio or HMAC
signatures, refuses replays and duplicates and publishes the rest to the event bus;
[`firefly/callbacks`](docs/modules/callbacks.md) sends the bus's events to subscribers as signed, retried POSTs
behind a circuit breaker per endpoint.
+- **Event sourcing, on any database.** [`firefly/eventsourcing`](docs/modules/eventsourcing.md) stores an
+ aggregate as its events — appended under optimistic concurrency to a portable event store, read back from the
+ newest snapshot, moved forward by upcasters when an event's shape changes — and keeps read models current from a
+ global stream numbered in commit order, so a projection's checkpoint never skips an event that committed late.
+ Appended events can be published through the database outbox in the append's own transaction, and sagas,
+ TCCs and workflows can store their executions as event streams.
### Who is LaraFly for?
@@ -774,6 +780,7 @@ class TransferHandler
{
public function __construct(private readonly WalletRepository $wallets) {}
+ #[PreAuthorize("hasRole('ADMIN') or (hasRole('WALLET_OWNER') and hasPermission(#command, 'debit'))")]
#[Transactional(propagation: Propagation::REQUIRED)]
public function handle(Transfer $command): void
{
@@ -812,7 +819,7 @@ class WithdrawHandler
{
public function __construct(private readonly WalletRepository $wallets) {}
- #[PreAuthorize("hasRole('ADMIN') or hasRole('WALLET_OWNER')")]
+ #[PreAuthorize("hasRole('ADMIN') or (hasRole('WALLET_OWNER') and hasPermission(#command, 'debit'))")]
#[Transactional]
public function handle(Withdraw $command): int
{
@@ -828,7 +835,10 @@ class WithdrawHandler
```
`SecurityCommandAuthorizer` enforces this **before** the handler runs, at the bus — a denied withdraw surfaces
-as a `CommandProcessingException` wrapping an `AuthorizationException` (403), never a silent no-op. The same
+as a `CommandProcessingException` wrapping an `AuthorizationException` (403), never a silent no-op. The role
+alone is not ownership: `hasPermission(#command, 'debit')` hands the `Withdraw` to the application's
+`PermissionEvaluator` bean (Lumen's `WalletPermissionEvaluator`), which grants the debit only when the
+principal owns that wallet, so one customer cannot debit another's. The same
rules hold on the controller dispatcher and on **any stereotyped bean**, through the one compiled proxy chain
`#[Transactional]` already used — so a `#[Service]` method carrying `#[PreAuthorize]`, `#[PostAuthorize]`,
`#[PreFilter]` or `#[PostFilter]` is guarded wherever it is called from, and a refusal never opens a
@@ -1142,7 +1152,7 @@ Start at the **[documentation table of contents](docs/README.md)** — it groups
- [Laravel ↔ Spring Boot Comparison](docs/laravel-comparison.md) — concept-by-concept mapping for both audiences.
- [Versioning](docs/versioning.md) · [Contributing](docs/contributing.md) · [Publishing](docs/publishing.md).
- Every [module guide](#modules) above.
-- [*larafly by example*](book/README.md) — the complete bilingual book (18 chapters + appendices, PDF + EPUB).
+- [*larafly by example*](book/README.md) — the complete bilingual book (19 chapters + appendices, PDF + EPUB).
- [`samples/lumen/`](samples/lumen/) — the wallet-and-ledger sample this README's showcases are drawn from;
run its own test suite with `vendor/bin/pest samples/lumen/tests`.
@@ -1175,13 +1185,8 @@ accurately:
credentials, refresh rotation, introspection, revocation, userinfo and RP-initiated logout). IdP adapters
beyond those presets (Cognito, internal-db) and MFA are deferred to their own future packages, matching the
Java Firefly Framework's module topology.
-- **Event sourcing in the tutorial and the book.** Event stores, aggregates, snapshots, upcasters and
- checkpointed, rebuildable projections ship in [`firefly/eventsourcing`](docs/modules/eventsourcing.md),
- shaped after the Java library and PyFly's `pyfly.eventsourcing`. What remains is the walkthrough: the
- sample's `LedgerProjector` still builds its read model from a plain `#[EventListener]`, and neither the
- tutorial nor the book has a chapter on the package.
-- **Documentation.** The end-to-end [tutorial](docs/tutorial.md) (EN + ES, through a database outbox and a saga), the *larafly by example*
- [book](book/README.md) (18 chapters + appendices, EN + ES, PDF + EPUB), and a
+- **Documentation.** The end-to-end [tutorial](docs/tutorial.md) (EN + ES, through a database outbox, a saga and an event-sourced journal), the *larafly by example*
+ [book](book/README.md) (19 chapters + appendices, EN + ES, PDF + EPUB), and a
[docs table of contents](docs/README.md) all shipped with the documentation-parity milestone.
Deeper guides (more recipes, more diagrams) continue to grow from here.
diff --git a/book/README.md b/book/README.md
index a9320320..690b3a1b 100644
--- a/book/README.md
+++ b/book/README.md
@@ -113,7 +113,7 @@ book/
src/ # EN manuscript (Markdown)
00-front/ # title/copyright/dedication/preface/conventions
00-quickstart.md # "Build Lumen step by step" quick start
- 01..13a-*.md # the eighteen chapters, 4A, 8A, 9A, 10A and 13A included (Parts I-IV)
+ 01..13a-*.md # the nineteen chapters, 4A, 8A, 9A, 9B, 10A and 13A included (Parts I-IV)
90-appendix-a-laravel.md # Laravel -> LaraFly cheat-sheet
94-glossary.md # glossary
src-es/ # ES manuscript, same structure/filenames
@@ -139,7 +139,7 @@ book/
## Manuscript status
The manuscript is **structurally complete** in both languages: a five-file front
-matter, a "Build Lumen step by step" quick start, eighteen chapters across four
+matter, a "Build Lumen step by step" quick start, nineteen chapters across four
parts —
- **Part I — Foundations**: Why LaraFly, Dependency Injection & Auto-Configuration,
@@ -148,7 +148,7 @@ parts —
Domain-Driven Design
- **Part III — Coordinating & Securing the Application**: CQRS, Event-Driven
Architecture & the Transactional Outbox, Webhooks and Callbacks, Transactions & the `#[Transactional]`
- proxy, Sagas, TCC and Durable Workflows, Security, OAuth2 and OpenID Connect
+ proxy, Sagas, TCC and Durable Workflows, Event Sourcing, Security, OAuth2 and OpenID Connect
- **Part IV — Observability, Testing & Delivery**: Observability/Actuator,
Testing, the CLI & the Zero-Reflection Cache, Feature Flags
@@ -161,7 +161,10 @@ the reader's own code, which is the kind `php -l` checks. Both editions build to
`book/dist/` as PDF + EPUB. Chapters 8A and 9A walk the code W1, W2 and W4
added to Lumen — `CardTopUpListener`, the partner callbacks, `PayoutSaga` and
`SimulatedBankGateway` — and every listing of that code is a `source:` excerpt
-of the file the sample's own tests run.
+of the file the sample's own tests run. Chapter 9B does the same for W3's event
+sourcing — `Loan`, `LoanRepository`, `LoanBookProjection` and
+`LoanRepaidV1Upcaster` are quoted from the sample, whose tests also run them on
+every integration lane.
**The two editions are the same book, line for line.** Every chapter file has
the same sections in the same order at the same line numbers, and every `php`
diff --git a/book/book.es.yaml b/book/book.es.yaml
index 71de88c4..5049ad3a 100644
--- a/book/book.es.yaml
+++ b/book/book.es.yaml
@@ -55,6 +55,10 @@ labels:
# sale de ESTA lista, así que los Capítulos 9-13A conservan su número y siguen siendo ciertas todas las referencias
# "Capítulo N" de ambos manuscritos y de book/README.md. Webhooks y callbacks comparten capítulo porque son las dos
# direcciones de un mismo contrato: la firma de un callback de LaraFly es exactamente la que comprueba el validador `hmac`.
+# Tarea D2: firefly/eventsourcing añadido como Capítulo 9B, justo después del Capítulo 9A, cuyas ejecuciones puede
+# guardar como flujos de eventos, y del capítulo de transacciones dentro de cuya única frontera añade sus eventos.
+# Intercalado exactamente igual que el 4A, el 8A, el 9A y el 10A: `num` es una etiqueta libre y el orden sale de ESTA
+# lista, así que los Capítulos 10-13A conservan su número y cada referencia cruzada "Capítulo N" sigue siendo cierta.
front:
- {id: title, file: 00-front/00-title.md, nav: false}
- {id: copyright, file: 00-front/00-copyright.md, nav: false}
@@ -83,6 +87,7 @@ parts:
- {id: ch08a, file: 08a-webhooks-callbacks.md, num: "8A", title: "Webhooks y Callbacks: Recibir y Enviar Eventos Firmados"}
- {id: ch09, file: 09-transactions.md, num: 9, title: "Transacciones y el Proxy #[Transactional]"}
- {id: ch09a, file: 09a-orchestration.md, num: "9A", title: "Sagas, TCC y Workflows Duraderos"}
+ - {id: ch09b, file: 09b-eventsourcing.md, num: "9B", title: "Event Sourcing: guardar lo que ocurrió"}
- {id: ch10, file: 10-security.md, num: 10, title: "Seguridad: Autenticación y Autorización"}
- {id: ch10a, file: 10a-oauth2.md, num: "10A", title: "OAuth2 y OpenID Connect: Iniciar Sesión, y Ser el Proveedor"}
- title: "Parte IV — Observabilidad, Pruebas y Entrega"
diff --git a/book/book.yaml b/book/book.yaml
index 5a344c06..7c926a99 100644
--- a/book/book.yaml
+++ b/book/book.yaml
@@ -53,6 +53,10 @@ labels:
# the order comes from THIS list, so Chapters 9-13A keep their numbers and every "Chapter N" cross-reference in both
# manuscripts and in book/README.md stays true. Webhooks and callbacks share one chapter because they are the two
# directions of one contract — LaraFly's callback signature is exactly what the webhooks `hmac` validator checks.
+# Task D2: firefly/eventsourcing added as Chapter 9B, immediately after Chapter 9A, whose executions it can store as
+# event streams, and after the transactions chapter whose single boundary it appends inside. Slotted exactly the way
+# 4A, 8A, 9A and 10A were: `num` is a free-form label and the order comes from THIS list, so Chapters 10-13A keep their
+# numbers and every "Chapter N" cross-reference in both manuscripts and in book/README.md stays true.
front:
- {id: title, file: 00-front/00-title.md, nav: false}
- {id: copyright, file: 00-front/00-copyright.md, nav: false}
@@ -81,6 +85,7 @@ parts:
- {id: ch08a, file: 08a-webhooks-callbacks.md, num: "8A", title: "Webhooks and Callbacks: Receiving and Sending Signed Events"}
- {id: ch09, file: 09-transactions.md, num: 9, title: "Transactions and the #[Transactional] Proxy"}
- {id: ch09a, file: 09a-orchestration.md, num: "9A", title: "Sagas, TCC and Durable Workflows"}
+ - {id: ch09b, file: 09b-eventsourcing.md, num: "9B", title: "Event Sourcing: Storing What Happened"}
- {id: ch10, file: 10-security.md, num: 10, title: "Security: Authentication and Authorization"}
- {id: ch10a, file: 10a-oauth2.md, num: "10A", title: "OAuth2 and OpenID Connect: Signing In, and Being the Provider"}
- title: "Part IV — Observability, Testing & Delivery"
diff --git a/book/src-es/00-front/00-preface.md b/book/src-es/00-front/00-preface.md
index d2cd6bb0..231713b6 100644
--- a/book/src-es/00-front/00-preface.md
+++ b/book/src-es/00-front/00-preface.md
@@ -20,6 +20,8 @@ El recorrido empieza con suavidad. El **Inicio rápido** te lleva desde el insta
Los Capítulos 8A y 9A llevan a Lumen más allá de una sola base de datos: un procesador de tarjetas recarga wallets mediante un webhook firmado, un socio se entera de cada movimiento de dinero mediante callbacks firmados, y un pago saliente a un banco se ejecuta como una saga que se deshace sola, paso a paso, cuando el banco dice que no.
+El Capítulo 9B guarda otro tipo de registro: los préstamos de Lumen se almacenan como los eventos que les ocurrieron — concedido, amortizado, liquidado —, se reconstruyen desde esa historia y desde instantáneas, se proyectan en una cartera de préstamos y se publican por el outbox en la misma transacción que el monedero a través del que mueven el dinero.
+
El Capítulo 13A cierra el recorrido de producción con feature flags: Lumen mantiene apagada una vista de la wallet, selecciona una cohorte compartida y prueba la ruta y el ciclo del override contra el framework real.
Cuando hayas terminado de trabajar todo el libro, tendrás un modelo mental de cada capa de un servicio LaraFly de producción, y una aplicación real y probada que lo demuestre.
diff --git a/book/src-es/04-first-http-api.md b/book/src-es/04-first-http-api.md
index 4c689558..8dfd2871 100644
--- a/book/src-es/04-first-http-api.md
+++ b/book/src-es/04-first-http-api.md
@@ -50,7 +50,7 @@ use Lumen\Web\Dto\TransferRequest;
* command/query, dispatches it through the bus, and shapes the return array — the same rule the sample's
* CQRS handlers already enforce for the domain layer. A domain/business fault raised by the bus (e.g. an
* unknown wallet's ResourceNotFoundException, an overdraw's ConflictException, or a denied #[PreAuthorize]
- * on withdraw) surfaces as CommandProcessingException/QueryProcessingException and renders RFC-7807
+ * on withdraw or transfer) surfaces as CommandProcessingException/QueryProcessingException and renders RFC-7807
* problem-details via the framework's global ProblemDetailsRenderer — no local #[ExceptionHandler] needed.
*/
#[RestController]
@@ -84,11 +84,12 @@ final class WalletController
/**
* Debits `amount_minor` from the wallet. Guarded upstream at the bus: WithdrawHandler carries
- * #[PreAuthorize("hasRole('ADMIN') or hasRole('WALLET_OWNER')")] (S6), enforced by
- * SecurityCommandAuthorizer BEFORE the handler runs. With no principal in the SecurityContextHolder
- * the bus refuses the command with an AuthenticationException that renders as a 401 problem-details
- * response (authenticate first); with a principal that is signed in but carries neither role it is an
- * AuthorizationException rendered as 403 — this endpoint is secured, not broken.
+ * #[PreAuthorize("hasRole('ADMIN') or (hasRole('WALLET_OWNER') and hasPermission(#command, 'debit'))")],
+ * enforced by SecurityCommandAuthorizer BEFORE the handler runs. With no principal in the
+ * SecurityContextHolder the bus refuses the command with an AuthenticationException that renders as a 401
+ * problem-details response (authenticate first); with a principal that is signed in but is neither an ADMIN
+ * nor the WALLET_OWNER who owns this wallet it is an AuthorizationException rendered as 403 — this endpoint
+ * is secured, not broken.
*
* @return array{wallet_id: string, balance_minor: int}
*/
@@ -565,7 +566,7 @@ Un intento de retiro se rechaza por dos vías distintas, y las dos **no dan el m
}
```
-El `403` queda reservado para un principal que **sí** ha iniciado sesión y aun así no tiene `ROLE_ADMIN` ni `ROLE_WALLET_OWNER` — *volver a autenticarse no ayudaría* — y ese documento nombra las autoridades que la expresión pedía:
+El `403` queda reservado para un principal que **sí** ha iniciado sesión y aun así no puede cargar en este monedero — no tiene `ROLE_ADMIN`, ni `ROLE_WALLET_OWNER` junto con la titularidad del monedero — *volver a autenticarse no ayudaría* — y ese documento nombra las autoridades que la expresión pedía:
```json
{
@@ -584,7 +585,7 @@ El `403` queda reservado para un principal que **sí** ha iniciado sesión y aun
}
```
-Los dos se producen sin ningún cambio de código en `WalletController::withdraw()`: el bus envuelve la excepción de seguridad que haya levantado el evaluador en una `CommandProcessingException` que copia el código, el estado y la categoría de la causa, así que el cable lleva la identidad de la causa y no un error genérico del bus. El Capítulo 10 vuelve sobre este par — y sobre las dos pruebas distribuidas que lo fijan — en detalle.
+Los dos se producen sin ningún cambio de código en `WalletController::withdraw()`: el bus envuelve la excepción de seguridad que haya levantado el evaluador en una `CommandProcessingException` que copia el código, el estado, la categoría y los miembros de extensión de la causa, así que el cable lleva la identidad de la causa y no un error genérico del bus, y el 403 nombra las mismas `requiredAuthorities` que nombraría un método de controlador rechazado. Solo `detail` delata que pasó por el bus: el envoltorio antepone a la frase de la causa el comando que estaba procesando. El Capítulo 10 vuelve sobre este par — y sobre las dos pruebas distribuidas que lo fijan — en detalle.
### Manejar una excepción tú mismo
diff --git a/book/src-es/06-domain-driven-design.md b/book/src-es/06-domain-driven-design.md
index b0996f41..ebb81c51 100644
--- a/book/src-es/06-domain-driven-design.md
+++ b/book/src-es/06-domain-driven-design.md
@@ -309,6 +309,14 @@ final class Wallet extends Model implements RecordsDomainEvents
});
}
+ /** Who the wallet belongs to: the principal name WalletPermissionEvaluator compares against before a debit. */
+ public function ownerId(): string
+ {
+ $value = $this->getAttribute('owner_id');
+
+ return is_string($value) ? $value : '';
+ }
+
public function balanceMoney(): Money
{
$value = $this->getAttribute('balance_minor');
@@ -592,6 +600,7 @@ use Firefly\Cqrs\Attributes\CommandHandler;
use Firefly\Data\Transaction\Attributes\Transactional;
use Firefly\Data\Transaction\Propagation;
use Firefly\Kernel\Exception\Business\ResourceNotFoundException;
+use Firefly\Security\Access\Attributes\PreAuthorize;
use Lumen\Domain\Money;
use Lumen\Infrastructure\WalletRepository;
@@ -605,6 +614,12 @@ use Lumen\Infrastructure\WalletRepository;
* back on any Throwable — the source debit is undone too. That is the money-cannot-vanish invariant: there is no path
* where the source loses funds the destination never receives.
*
+ * Guarded by the same #[PreAuthorize] as WithdrawHandler, because a transfer IS a debit: an ADMIN may move money out
+ * of any wallet, a WALLET_OWNER only out of the wallet it owns. WalletPermissionEvaluator reads the Transfer bound to
+ * #command and checks the owner of its SOURCE wallet — owning the destination grants nothing, or anyone could pull
+ * money out of any wallet by naming it as the source. SecurityCommandAuthorizer enforces the rule at the bus before
+ * the handler runs, so a refused transfer touches neither wallet.
+ *
* Intentionally NOT final: the generated transactional proxy subclasses this handler (same reason as the S4 handlers).
*/
#[CommandHandler]
@@ -612,6 +627,7 @@ class TransferHandler
{
public function __construct(private readonly WalletRepository $wallets) {}
+ #[PreAuthorize("hasRole('ADMIN') or (hasRole('WALLET_OWNER') and hasPermission(#command, 'debit'))")]
#[Transactional(propagation: Propagation::REQUIRED)]
public function handle(Transfer $command): void
{
@@ -632,6 +648,8 @@ class TransferHandler
}
```
+La línea `#[PreAuthorize]` sobre `#[Transactional]` pertenece al capítulo 10, no a este: una transferencia es un cargo, así que solo un administrador o el propietario del monedero de **origen** puede iniciarla, y el bus rechaza a cualquier otro antes de que `handle()` se ejecute, sin tocar ninguno de los dos monederos.
+
`recordTransferTo()` es donde se lanza el hecho `TransferCompleted` prometido antes — deliberadamente la *última* línea, después de que ambas piernas ya han tenido éxito, sobre el monedero de origen (que `save()` ya registró con el tracker, de modo que un evento lanzado tras ese guardado igual se drena en el commit). Es un pequeño método de `Wallet` que solo llama a `raiseEvent(new TransferCompleted(...))`; como se ejecuta únicamente en el camino de éxito, una transferencia revertida nunca lo alcanza y nunca lo publica.
Si el `deposit()` del destino lanza — un desajuste de moneda — el `rollbackFor` por defecto de `#[Transactional]` (cualquier `Throwable`) revierte todo el método, deshaciendo también el débito del origen. Ninguno de `FundsWithdrawn`, `FundsDeposited` o `TransferCompleted` se había publicado todavía en ese punto — cada uno solo estaba almacenado en búfer en su agregado — así que un listener nunca ve la mitad de una transferencia que en realidad nunca ocurrió. El dinero no puede ni desvanecerse ni duplicarse: o ambas piernas confirman y sus eventos se publican, o ninguna de las dos cosas.
diff --git a/book/src-es/07-cqrs.md b/book/src-es/07-cqrs.md
index c9243032..7a34672a 100644
--- a/book/src-es/07-cqrs.md
+++ b/book/src-es/07-cqrs.md
@@ -410,12 +410,15 @@ final class CommandProcessingException extends CqrsException
$cause instanceof FireflyException ? $cause->category() : ErrorCategory::Internal,
$cause instanceof FireflyException ? $cause->severity() : ErrorSeverity::Error,
$cause,
+ // …
+ $cause instanceof FireflyException ? $cause->extensions() : [],
+ $cause instanceof FireflyException ? $cause->title() : null,
);
}
}
```
-Tres cosas merecen destacarse. Primero, la causa siempre está disponible en `getPrevious()` — el quinto argumento del constructor hacia el padre — así que nada del fallo original se pierde, solo se envuelve. Segundo, esto **preserva la categoría**: cuando la causa es una `FireflyException` (todas las excepciones del kernel lo son), su propio `httpStatus()`/`category()`/`severity()` se copian sobre el envoltorio en vez de recaer en un genérico `500`/`Internal`. Una `ResourceNotFoundException` (404, negocio) lanzada por un id de monedero desconocido sigue renderizándose como una respuesta de detalles de problema 404 incluso después de ser envuelta — el bus añade contexto, nunca enmascara el fallo. Tercero, `send()` comprueba `$e instanceof CommandProcessingException` antes de envolver, así que un despacho de comando anidado (un manejador que a su vez llama a `$commands->send(...)`) nunca queda envuelto dos veces en dos capas de la misma excepción. `QueryProcessingException` es el gemelo del lado de lectura, idéntico en forma.
+Tres cosas merecen destacarse. Primero, la causa siempre está disponible en `getPrevious()` — el quinto argumento del constructor hacia el padre — así que nada del fallo original se pierde, solo se envuelve. Segundo, esto **preserva la categoría**: cuando la causa es una `FireflyException` (todas las excepciones del kernel lo son), su propio `httpStatus()`/`category()`/`severity()` se copian sobre el envoltorio en vez de recaer en un genérico `500`/`Internal`, y también sus miembros de extensión RFC 9457 y su título, razón por la cual un `#[PreAuthorize]` rechazado sigue nombrando sus `requiredAuthorities` en el documento de problema después de que el bus lo envuelva (el Capítulo 4 muestra ese documento). Una `ResourceNotFoundException` (404, negocio) lanzada por un id de monedero desconocido sigue renderizándose como una respuesta de detalles de problema 404 incluso después de ser envuelta — el bus añade contexto, nunca enmascara el fallo. Tercero, `send()` comprueba `$e instanceof CommandProcessingException` antes de envolver, así que un despacho de comando anidado (un manejador que a su vez llama a `$commands->send(...)`) nunca queda envuelto dos veces en dos capas de la misma excepción. `QueryProcessingException` es el gemelo del lado de lectura, idéntico en forma.
`HandlerRegistry` lanza sus propias excepciones, más estrechas, para un error de **cableado** en vez de un fallo de negocio — un mensaje sin mapear enviado a través del bus:
diff --git a/book/src-es/09-transactions.md b/book/src-es/09-transactions.md
index 0a9b0805..2c19fad7 100644
--- a/book/src-es/09-transactions.md
+++ b/book/src-es/09-transactions.md
@@ -521,6 +521,8 @@ it('transfers atomically: money is conserved across debit + credit', function ()
$dst = $commands->send(new OpenWallet('owner-B', Currency::EUR));
$commands->send(new Deposit($src, 10000));
+ // TransferHandler is guarded like WithdrawHandler: the source wallet's owner may move money out of it.
+ lumenSignInWalletOwner('owner-A');
$commands->send(new Transfer($src, $dst, 4000));
// Debit + credit committed as one unit of work: the 10000 that left nowhere reappears split 6000/4000.
@@ -542,8 +544,17 @@ it('rolls the whole transfer back when the credit leg fails (money cannot vanish
$dst = $commands->send(new OpenWallet('owner-B', Currency::USD));
$commands->send(new Deposit($src, 10000));
- expect(fn () => $commands->send(new Transfer($src, $dst, 4000)))
- ->toThrow(CommandProcessingException::class);
+ // Signed in as the source's owner, so the guard lets the transfer through and it is the CREDIT leg that fails.
+ lumenSignInWalletOwner('owner-A');
+ $failed = null;
+ try {
+ $commands->send(new Transfer($src, $dst, 4000));
+ } catch (CommandProcessingException $e) {
+ $failed = $e;
+ }
+
+ expect($failed)->toBeInstanceOf(CommandProcessingException::class);
+ expect($failed?->getPrevious())->toBeInstanceOf(ConflictException::class);
// Load-bearing, non-tautological proof that money cannot vanish: the source debit was ROLLED BACK (still 10000,
// not 6000) and the destination never received anything (still 0). No value was created or destroyed.
@@ -552,7 +563,7 @@ it('rolls the whole transfer back when the credit leg fails (money cannot vanish
})->group('lumen');
```
-La segunda prueba es la que importa. `Wallet::withdraw()` sobre el origen corrió con éxito y lanzó `FundsWithdrawn` a su propio búfer; luego `Wallet::deposit()` sobre el destino lanzó un `ConflictException` de moneda no coincidente — *después* de que la llamada `save()` del débito ya se hubiera ejecutado dentro de la misma transacción, todavía abierta. Porque el `rollbackFor` por defecto de `#[Transactional]` captura cualquier `Throwable`, el método entero revierte: el débito del origen se deshace a nivel de base de datos, y — porque el rollback ocurre *antes* de que `dispatchAfterCommit()` fuera a alcanzarse en el camino de éxito, y `TransactionTemplate` también lo llama en la rama `catch` precisamente para que un drenado revertido igual vacíe el rastreador sin llegar jamás a programar una publicación — ni `FundsWithdrawn` ni un `FundsDeposited` que ni siquiera llegó a lanzarse alcanza a ningún listener. El `CommandProcessingException` del Capítulo 7 envuelve el `ConflictException` subyacente, y las dos aserciones de saldo son todo el punto: no "la transferencia falló" en abstracto, sino que el propio `10000` del origen volvió **exactamente**, y el `0` del destino nunca se movió. No se creó valor; no se destruyó ninguno.
+La segunda prueba es la que importa. `Wallet::withdraw()` sobre el origen corrió con éxito y lanzó `FundsWithdrawn` a su propio búfer; luego `Wallet::deposit()` sobre el destino lanzó un `ConflictException` de moneda no coincidente — *después* de que la llamada `save()` del débito ya se hubiera ejecutado dentro de la misma transacción, todavía abierta. Porque el `rollbackFor` por defecto de `#[Transactional]` captura cualquier `Throwable`, el método entero revierte: el débito del origen se deshace a nivel de base de datos, y — porque el rollback ocurre *antes* de que `dispatchAfterCommit()` fuera a alcanzarse en el camino de éxito, y `TransactionTemplate` también lo llama en la rama `catch` precisamente para que un drenado revertido igual vacíe el rastreador sin llegar jamás a programar una publicación — ni `FundsWithdrawn` ni un `FundsDeposited` que ni siquiera llegó a lanzarse alcanza a ningún listener. Las dos pruebas inician sesión antes como propietario del monedero de origen, porque `TransferHandler` lleva el `#[PreAuthorize]` del Capítulo 10: sin principal, el bus rechazaría la transferencia antes de que el manejador se ejecutara, y la segunda prueba pasaría por el motivo equivocado. Por eso también comprueba la causa: el `CommandProcessingException` del Capítulo 7 envuelve el `ConflictException` que lanzó la pierna de crédito, no un rechazo de seguridad, y las dos aserciones de saldo son todo el punto: no "la transferencia falló" en abstracto, sino que el propio `10000` del origen volvió **exactamente**, y el `0` del destino nunca se movió. No se creó valor; no se destruyó ninguno.
---
diff --git a/book/src-es/09a-orchestration.md b/book/src-es/09a-orchestration.md
index 3b6a7919..ddfcb6f0 100644
--- a/book/src-es/09a-orchestration.md
+++ b/book/src-es/09a-orchestration.md
@@ -78,7 +78,7 @@ final class PayoutSaga
}
```
-Léela como tres transacciones locales y dos formas de deshacer. `debit` envía el mismo comando `Withdraw` que envía el endpoint REST, así que se ejecuta en su propia frontera `#[Transactional]` y bajo su propio `#[PreAuthorize]` — un paso de saga no es una puerta trasera para saltarse la seguridad de métodos. `compensate: 'refund'` nombra el método que lo deshace; `dependsOn` ordena los pasos en capas de la topología, y `#[Input('…')]` lee una clave de la entrada de la ejecución, `#[FromStep('submit')]` el resultado que devolvió un paso anterior. Una compensación recibe el resultado del paso hacia delante como argumento implícito (`cancel()` recibe la referencia bancaria que devolvió `submit()`) y además puede pedir valores `#[Input]`, que es como `refund()` sabe cuánto tiene que devolver. `confirm` no tiene compensación porque no se ejecuta nada después de él.
+Léela como tres transacciones locales y dos formas de deshacer. `debit` envía el mismo comando `Withdraw` que envía el endpoint REST, así que se ejecuta en su propia frontera `#[Transactional]` y bajo su propio `#[PreAuthorize]` — un paso de saga no es una puerta trasera para saltarse la seguridad de métodos: un cliente solo puede ordenar un pago desde un monedero suyo (Capítulo 10), y cualquier otro pago se rechaza con el propio 403 del retiro antes de llamar al banco. `compensate: 'refund'` nombra el método que lo deshace; `dependsOn` ordena los pasos en capas de la topología, y `#[Input('…')]` lee una clave de la entrada de la ejecución, `#[FromStep('submit')]` el resultado que devolvió un paso anterior. Una compensación recibe el resultado del paso hacia delante como argumento implícito (`cancel()` recibe la referencia bancaria que devolvió `submit()`) y además puede pedir valores `#[Input]`, que es como `refund()` sabe cuánto tiene que devolver. `confirm` no tiene compensación porque no se ejecuta nada después de él.
---
@@ -177,7 +177,7 @@ Tres tipos de fallo, tres respuestas. Cuando una compensación no terminó — e
## Puntos de control, recuperación y la cola de cartas muertas
-Cada frontera de paso escribe un punto de control `ExecutionState` a través del puerto `ExecutionPersistenceProvider`. `memory` es el valor por defecto y no sobrevive a nada; `cache` usa cualquier store de caché de Laravel; `database` usa tres tablas portables, probadas en PostgreSQL, MySQL, MariaDB, SQL Server y SQLite. El harness de Lumen ejecuta la saga de pagos salientes sobre `database`, y por eso el test de arriba puede leer `firefly_orchestration_executions`:
+Cada frontera de paso escribe un punto de control `ExecutionState` a través del puerto `ExecutionPersistenceProvider`. `memory` es el valor por defecto y no sobrevive a nada; `cache` usa cualquier store de caché de Laravel; `database` usa tres tablas portables, probadas en PostgreSQL, MySQL, MariaDB, SQL Server y SQLite. Un cuarto, `event-sourced`, guarda cada ejecución como un flujo de eventos en el almacén de eventos (Capítulo 9B). El harness de Lumen ejecuta la saga de pagos salientes sobre `database`, y por eso el test de arriba puede leer `firefly_orchestration_executions`:
```php
@@ -380,7 +380,7 @@ Los pasos de una capa de la topología se ejecutan uno tras otro, en orden de de
| `SagaEngine::execute()` / `SagaResult` | Ejecuta una saga en el proceso; `success`, `status`, `firstErrorStepId()`, `compensatedSteps()` |
| TCC | Primero `#[TryMethod]` en todas partes, luego `#[ConfirmMethod]` en todas partes, o `#[CancelMethod]` para cada participante que lo intentó |
| Workflow duradero | `#[WaitForSignal]` / `#[WaitForTimer]` lo aparcan en `WAITING`; una señal o el tic lo reanudan desde su punto de control |
-| `firefly.orchestration.persistence.provider` | Puntos de control en `memory`, `cache` o `database`; la recuperación informa, reanuda o compensa las ejecuciones obsoletas |
+| `firefly.orchestration.persistence.provider` | Puntos de control en `memory`, `cache`, `database` o `event-sourced`; la recuperación informa, reanuda o compensa las ejecuciones obsoletas |
| Cola de cartas muertas | Una entrada por paso fallido; `firefly:orchestration:dlq` las enumera, `firefly:orchestration:dlq:retry` la arranca de nuevo |
| `firefly.scheduling.lock.provider` | `none`, `cache`, `database`, `mysql` o `postgres` — mantiene un tic programado en un solo nodo |
diff --git a/book/src-es/09b-eventsourcing.md b/book/src-es/09b-eventsourcing.md
new file mode 100644
index 00000000..05bbaa40
--- /dev/null
+++ b/book/src-es/09b-eventsourcing.md
@@ -0,0 +1,478 @@
+Parte III — Coordinar y Asegurar la Aplicación · Capítulo 9B
+
+# Event Sourcing: guardar lo que ocurrió {.chtitle}
+
+Al terminar este capítulo sabrás guardar lo que ocurrió en lugar de lo que es. Modelarás los préstamos de Lumen como un agregado con event sourcing (`EventSourcedAggregateRoot`, `recordThat()`) cuyos eventos llevan un nombre estable y una versión (`#[EventType]`), los añadirás al almacén de eventos de base de datos bajo concurrencia optimista (`firefly.eventsourcing.store.provider`, `ConcurrencyException`) y reconstruirás un préstamo desde su historia o desde una instantánea (`SnapshotAware`, `firefly.eventsourcing.snapshot.threshold`) a través de un `EventSourcedRepository`. Mantendrás al día un modelo de lectura con una `Projection` y su punto de control (`firefly:eventsourcing:projections:run`, `firefly:eventsourcing:projections:rebuild`), leerás eventos guardados con un nombre de campo antiguo a través de un `EventUpcaster`, publicarás cada evento añadido a través del outbox en la propia transacción del append (`firefly.eventsourcing.publish.enabled`) y guardarás las ejecuciones de saga del Capítulo 9A como flujos de eventos (`firefly.orchestration.persistence.provider`).
+
+!!! note "Término nuevo: event sourcing"
+ El **event sourcing** guarda un objeto como la secuencia de hechos que le ocurrieron — concedido, amortizado, amortizado, liquidado — en lugar de como una fila con sus últimos valores. El estado actual es lo que obtienes al aplicar esos hechos en orden; nada del pasado se sobrescribe nunca, así que la historia no es una tabla de auditoría que esperas que se mantenga al día, es el propio registro.
+
+---
+
+## Lo que recuerda un préstamo
+
+Un `Wallet` es una fila: el Capítulo 5 guarda su saldo, y el `LedgerProjector` del Capítulo 6 escribe una línea del libro mayor después de que cada cambio se confirma. Es el modelo adecuado para un saldo. Un préstamo es distinto: lo que importa es la historia — cuánto se prestó, cada amortización y cuándo, el momento en que quedó liquidado — y el saldo no es más que aritmética sobre ella. Así que Lumen guarda los préstamos al revés. Ninguna tabla contiene el saldo de un préstamo; el almacén de eventos contiene sus eventos, y el préstamo se reconstruye a partir de ellos cada vez que un comando lo necesita. El monedero se queda exactamente como lo construyeron los Capítulos 5 a 9A, y los dos se encuentran en una sola transacción más adelante en este capítulo.
+
+---
+
+## Eventos con un nombre y una versión
+
+Un evento con event sourcing es un `DomainEvent` corriente de `firefly/domain` con un atributo más. Esta es la amortización:
+
+
+```php
+#[EventType(name: 'loan.repaid', version: 2)]
+final readonly class LoanRepaid extends DomainEvent
+{
+ public function __construct(
+ public string $loanId,
+ public int $amountMinor,
+ public int $outstandingMinor,
+ ?string $eventId = null,
+ ?DateTimeImmutable $occurredAt = null,
+ ) {
+ parent::__construct($eventId, $occurredAt);
+ }
+}
+```
+
+`#[EventType]` da al evento el nombre que guarda el almacén — `loan.repaid`, nunca el nombre de la clase, así que la clase puede moverse o renombrarse sin dejar huérfana ni una sola fila guardada — y la versión de su carga útil. El mapeador por convención guarda las propiedades públicas y reconstruye el evento a través de su constructor, y por eso `eventId` y `occurredAt` se pasan al padre: un evento recargado conserva la identidad y la hora con las que se registró. `php artisan firefly:cache` compila cada clase `#[EventType]` en el manifiesto `eventsourcing.php` (Capítulo 13), y una clase de evento que no esté en él se rechaza en el append, así que nunca se guarda nada que no pueda leerse de vuelta. La versión es 2 porque los primeros préstamos llamaban `amount` al importe; de eso trata la sección sobre upcasting más abajo.
+
+---
+
+## Un agregado que es su historia
+
+`Loan` extiende `EventSourcedAggregateRoot`. Un método de comando comprueba su invariante contra el estado actual y llama a `recordThat()`, que aplica el evento al instante y lo retiene en un búfer hasta que el repositorio lo guarda; los métodos protegidos `apply…()` son el único código que cambia el estado:
+
+
+```php
+final class Loan extends EventSourcedAggregateRoot implements SnapshotAware
+{
+ // …
+ public static function grant(string $id, string $walletId, Money $principal): self
+ {
+ if (! $principal->isPositive()) {
+ throw new ConflictException('a loan principal must be > 0');
+ }
+
+ $loan = new self($id);
+ $loan->recordThat(new LoanGranted($id, $walletId, $principal->minorUnits, $principal->currency->value));
+
+ return $loan;
+ }
+
+ public function repay(Money $amount): void
+ {
+ if ($this->settled) {
+ throw new ConflictException("loan {$this->id()} is already settled");
+ }
+ if ($amount->currency !== $this->currency || ! $amount->isPositive()) {
+ throw new ConflictException("a repayment must be a positive amount in {$this->currency->value}");
+ }
+ if ($amount->minorUnits > $this->outstanding()->minorUnits) {
+ throw new ConflictException("cannot repay {$amount}; {$this->outstanding()} is outstanding");
+ }
+
+ $this->recordThat(new LoanRepaid($this->id(), $amount->minorUnits, $this->outstanding()->minorUnits - $amount->minorUnits));
+ if ($this->outstanding()->minorUnits === 0) {
+ $this->recordThat(new LoanSettled($this->id(), $this->repayments));
+ }
+ }
+ // …
+ protected function applyLoanGranted(LoanGranted $event): void
+ {
+ $this->walletId = $event->walletId;
+ $this->currency = Currency::from($event->currency);
+ $this->principalMinor = $event->principalMinor;
+ }
+
+ protected function applyLoanRepaid(LoanRepaid $event): void
+ {
+ $this->repaidMinor += $event->amountMinor;
+ $this->repayments++;
+ }
+
+ protected function applyLoanSettled(LoanSettled $event): void
+ {
+ $this->settled = true;
+ }
+```
+
+Dos reglas hacen que esto funcione. Primera, las invariantes se comprueban *antes* de `recordThat()`, contra un estado que solo han escrito los métodos `apply…()`, así que reproducir los mismos eventos siempre reconstruye un objeto que habría tomado las mismas decisiones. Segunda, la aplicación se enruta por convención — `applyLoanRepaid()` para `LoanRepaid` — y un método `apply…()` no debe fallar: el evento ya ocurrió, y la historia no se puede rechazar al reproducirla. El constructor pertenece a la clase base y recibe solo el id, y así es como el repositorio puede crear un préstamo vacío antes de reproducir uno; `aggregateType()` devuelve `lumen.loan`, el nombre estable que se guarda con cada evento.
+
+---
+
+## El almacén de eventos y el repositorio
+
+Los eventos viven en un `EventStore`. `memory` es el valor por defecto y el adecuado para probar el modelo; `database` los guarda en cuatro tablas portables sobre cualquier conexión de Laravel:
+
+
+```php
+'store' => [
+ 'provider' => env('FIREFLY_EVENTSOURCING_STORE', 'memory'),
+ 'connection' => env('FIREFLY_EVENTSOURCING_DB_CONNECTION'),
+ 'batch-size' => 500,
+],
+```
+
+El arnés de event sourcing de Lumen fija `firefly.eventsourcing.store.provider=database`, y los almacenes de instantáneas y de puntos de control lo siguen. El repositorio es un constructor de una línea sobre el bean `EventSourcingSupport`:
+
+
+```php
+#[Component]
+#[Lazy]
+final class LoanRepository extends EventSourcedRepository
+{
+ public function __construct(EventSourcingSupport $support)
+ {
+ parent::__construct($support, Loan::class);
+ }
+}
+```
+
+`get()` reproduce el flujo de eventos de un préstamo (un 404 `AggregateNotFoundException` cuando no tiene ninguno), `find()` devuelve null en su lugar, y `save()` añade los eventos del búfer bajo la versión con la que se cargó el préstamo. `#[Lazy]` está ahí por el bien del ejemplo: `EventSourcingSupport` solo existe donde arrancan los proveedores de `firefly/eventsourcing`, y los demás arneses de test de Lumen nunca piden un préstamo, así que nunca deben construir este bean al arrancar — la misma razón por la que lo lleva `PayoutController` en el Capítulo 9A.
+
+---
+
+## Instantáneas: reproducir menos
+
+Reproducir tres eventos no cuesta nada; reproducir tres mil en cada comando, sí. De un agregado que implementa `SnapshotAware` se toma una instantánea cada vez que un guardado lleva su versión más allá de un múltiplo de `firefly.eventsourcing.snapshot.threshold`, y una carga parte de la instantánea más reciente y reproduce solo lo que vino después:
+
+
+```php
+'snapshot' => [
+ 'enabled' => env('FIREFLY_EVENTSOURCING_SNAPSHOT_ENABLED', true),
+ 'store' => env('FIREFLY_EVENTSOURCING_SNAPSHOT_STORE'),
+ 'threshold' => (int) env('FIREFLY_EVENTSOURCING_SNAPSHOT_THRESHOLD', 50),
+ 'keep-count' => 3,
+],
+```
+
+`Loan` dice qué contiene una instantánea — todo lo que reproducir reconstruiría, como valores planos:
+
+
+```php
+ /** @return array{walletId: string, currency: string, principalMinor: int, repaidMinor: int, repayments: int, settled: bool} */
+ public function snapshotState(): array
+ {
+ return [
+ 'walletId' => $this->walletId,
+ 'currency' => $this->currency->value,
+ 'principalMinor' => $this->principalMinor,
+ 'repaidMinor' => $this->repaidMinor,
+ 'repayments' => $this->repayments,
+ 'settled' => $this->settled,
+ ];
+ }
+```
+
+El umbral por defecto es 50; el arnés lo baja a 3 para que un test corto lo cruce. Un préstamo concedido y luego amortizado tres veces tiene cuatro eventos, y el test muestra qué hace una carga con ellos:
+
+
+```php
+ $snapshot = $this->app()->make(SnapshotStore::class)->latest('loan-r2');
+ $replayed = $this->app()->make(EventStore::class)->load('loan-r2', 4)->events;
+ $loan = $loans->get('loan-r2');
+
+ expect($snapshot?->version)->toBe(3)
+ ->and($snapshot?->state)->toMatchArray(['repaidMinor' => 40000, 'repayments' => 2])
+ ->and(array_map(static fn (StoredEvent $event): string => $event->eventType, $replayed))->toBe(['loan.repaid'])
+ ->and([$loan->repaidMinor(), $loan->outstanding()->minorUnits, $loan->committedVersion()])->toBe([60000, 30000, 4]);
+```
+
+La instantánea se tomó en la versión 3, así que cargar el préstamo la restaura y reproduce un evento — el cuarto. Una instantánea es una optimización y nada más: una que no puede escribirse se registra en el log y el guardado se mantiene, una cuyo `snapshotVersion()` ya no coincide con la clase se ignora y el préstamo se reproduce desde su primer evento, así que cambiar la forma de la instantánea es subir una versión, nunca una migración.
+
+---
+
+## Dos escritores, una versión
+
+Cada evento de un flujo tiene una versión — 1, 2, 3 — y el almacén rechaza un segundo evento en una versión ya ocupada (`UNIQUE(aggregate_id, aggregate_version)`). Eso es concurrencia optimista, y es lo que impide que dos peticiones amorticen el mismo préstamo a partir de la misma imagen desfasada de él:
+
+
+```php
+ $first = $loans->get('loan-r3');
+ $second = $loans->get('loan-r3');
+
+ $first->repay(new Money(10000, Currency::EUR));
+ $loans->save($first);
+ $second->repay(new Money(10000, Currency::EUR));
+
+ expect(fn () => $loans->save($second))->toThrow(ConcurrencyException::class)
+ ->and($loans->get('loan-r3')->repaidMinor())->toBe(10000);
+```
+
+Los dos escritores cargaron la versión 1. El primer guardado añadió la versión 2; el segundo intentó añadir también la versión 2 y recibió un 409 `ConcurrencyException`. Su decisión se tomó contra un préstamo que ya no existe, así que la respuesta honesta es recargar y decidir de nuevo — o dejar que el 409 llegue al cliente, que la capa web presenta como problem+json igual que cualquier otro conflicto.
+
+---
+
+## Una transacción con el monedero
+
+Conceder un préstamo ingresa el principal en el monedero; amortizarlo vuelve a sacar el importe. Lumen hace las dos cosas en un solo comando, así que el almacén de eventos y el monedero de Eloquent comparten una sola frontera `#[Transactional]`:
+
+
+```php
+ #[PreAuthorize("hasRole('ADMIN') or (hasRole('WALLET_OWNER') and hasPermission(#command, 'debit'))")]
+ #[Transactional]
+ public function handle(RepayLoan $command): int
+ {
+ $loan = $this->loans->get($command->loanId);
+ $wallet = $this->wallets->findById($loan->walletId())
+ ?? throw new ResourceNotFoundException("Wallet [{$loan->walletId()}] not found");
+
+ $amount = new Money($command->amountMinor, $loan->currency());
+ $loan->repay($amount);
+ $this->loans->save($loan);
+
+ $wallet->withdraw($amount);
+ $this->wallets->save($wallet);
+
+ return $loan->outstanding()->minorUnits;
+ }
+```
+
+El orden es deliberado. Primero se añade la amortización, y solo entonces se carga el monedero — así que cuando el monedero no puede pagar, `Wallet::withdraw()` lanza la excepción *después* de que se escribiera el evento, y la única transacción deshace ambas cosas:
+
+
+```php
+ $this->commands()->send(new Withdraw($walletId, 85000));
+
+ $caught = null;
+ try {
+ $this->commands()->send(new RepayLoan($loanId, 30000));
+ } catch (CommandProcessingException $e) {
+ $caught = $e;
+ }
+
+ // The repayment was appended, then the wallet refused the withdrawal: one transaction, so both are undone.
+ expect($caught?->getPrevious())->toBeInstanceOf(ConflictException::class)
+ ->and(DB::table(EventSourcingSchema::EVENTS)->where('aggregate_id', $loanId)->count())->toBe(1)
+ ->and($this->app()->make(LoanRepository::class)->get($loanId)->outstanding()->minorUnits)->toBe(90000)
+ ->and($this->queries()->ask(new GetBalance($walletId)))->toBe(5000);
+```
+
+El flujo sigue conteniendo solo `loan.granted`, el préstamo sigue debiendo 900,00 y el monedero sigue teniendo sus 50,00: nada se quedó a medias. El manejador está protegido como el `Withdraw` del Capítulo 10: un `ADMIN` puede amortizar cualquier préstamo, y un `WALLET_OWNER` solo uno abonado en un monedero suyo — `WalletPermissionEvaluator` carga el préstamo para averiguar ese monedero, y deniega si no lo encuentra. Es `#[Lazy]` por la razón del repositorio. Una consecuencia más de compartir la transacción: `save()` vacía el búfer del préstamo, así que el cableado posterior al commit de `firefly/data` (Capítulo 9) nunca despacha sus eventos una segunda vez; el `FundsWithdrawn` del propio monedero, sí.
+
+---
+
+## Proyecciones y sus puntos de control
+
+Los eventos responden a "qué le pasó a este préstamo"; son una mala respuesta a "qué préstamos siguen abiertos". Esa pregunta corresponde a un modelo de lectura, y una **proyección** mantiene uno a partir de los eventos. `LoanBookProjection` es un componente que el ejecutor encuentra por sí solo:
+
+
+```php
+ public function name(): string
+ {
+ return self::NAME;
+ }
+
+ public function handles(): array
+ {
+ return ['loan.granted', 'loan.repaid', 'loan.settled'];
+ }
+
+ public function handle(StoredEvent $event): void
+ {
+ $payload = $event->payload;
+ $loan = DB::table(self::TABLE)->where('loan_id', $event->aggregateId);
+
+ if ($event->eventType === 'loan.granted') {
+ DB::table(self::TABLE)->insert([
+ 'loan_id' => $event->aggregateId,
+ 'wallet_id' => self::text($payload, 'walletId'),
+ 'currency' => self::text($payload, 'currency'),
+ 'principal_minor' => self::number($payload, 'principalMinor'),
+ 'repaid_minor' => 0,
+ 'outstanding_minor' => self::number($payload, 'principalMinor'),
+ 'repayments' => 0,
+ 'status' => 'open',
+ ]);
+ } elseif ($event->eventType === 'loan.repaid') {
+ $loan->incrementEach(
+ ['repaid_minor' => self::number($payload, 'amountMinor'), 'repayments' => 1],
+ ['outstanding_minor' => self::number($payload, 'outstandingMinor')],
+ );
+ } else {
+ $loan->update(['status' => 'settled']);
+ }
+ }
+```
+
+Una proyección lee el **flujo global** — todos los eventos de todos los agregados, en un solo orden — desde su **punto de control**, la posición hasta la que ha proyectado. El test muestra la distancia entre los dos mundos y el comando que la cierra:
+
+
+```php
+ // The events are stored; the loan book has not been told yet.
+ expect(DB::table(LoanBookProjection::TABLE)->count())->toBe(0);
+
+ $exit = Artisan::call('firefly:eventsourcing:projections:run', ['projection' => LoanBookProjection::NAME]);
+
+ expect($exit)->toBe(0)
+ ->and(Artisan::output())->toContain('lumen.loan-book: caught-up, 2 event(s) handled, at position 2')
+ ->and((array) DB::table(LoanBookProjection::TABLE)->where('loan_id', $loanId)->first())->toMatchArray([
+ 'wallet_id' => $walletId,
+ 'principal_minor' => 90000,
+ 'repaid_minor' => 30000,
+ 'outstanding_minor' => 60000,
+ 'repayments' => 1,
+ 'status' => 'open',
+ ])
+ ->and(DB::table(EventSourcingSchema::CHECKPOINTS)->where('projection', LoanBookProjection::NAME)->value('position'))->toEqual(2);
+```
+
+Cada lote es una transacción sobre la conexión del almacén de puntos de control: el ejecutor entrega a la proyección los eventos que `handles()`, mueve el punto de control desde la posición que leyó hasta la última del lote, y confirma ambas cosas. `LoanBookProjection` escribe a través de la conexión por defecto, que es esa conexión mientras `firefly.eventsourcing.store.connection` sea null, así que sus filas y su punto de control se confirman juntos y cada evento se proyecta exactamente una vez; una proyección que escribe en cualquier otro sitio es al-menos-una-vez y debe ser idempotente. Cada lote se toma bajo un arrendamiento sobre el puerto `DistributedLock` (`firefly.scheduling.lock.provider`, durante `firefly.eventsourcing.projection.lease-ttl`), así que puedes ejecutar el comando en todos los nodos y solo uno hace avanzar cada proyección a la vez — y el avance del punto de control está protegido por una valla, así que un ejecutor cuyo arrendamiento caducó a mitad de lote revierte su lote en lugar de sobrescribir una posición más reciente. Cada escritura de `handle()` nombra la fila por su clave primaria, y eso es lo que permite a SQL Server ejecutarla sin esperar a las filas sin confirmar de otro trabajador. Los operadores manejan las proyecciones desde la línea de comandos:
+
+
+```bash
+php artisan firefly:eventsourcing:projections
+php artisan firefly:eventsourcing:projections:run
+php artisan firefly:eventsourcing:projections:run --live
+php artisan firefly:eventsourcing:projections:rebuild lumen.loan-book
+```
+
+El primero enumera cada proyección con su punto de control, la cabeza global y su retraso; el segundo pone al día todas las proyecciones y termina, y `--live` sigue el flujo indefinidamente bajo un supervisor de procesos; el último reinicia la proyección y su punto de control y reproduce en ella todo el flujo — `LoanBookProjectionTest` borra la cartera de préstamos y la recupera así. El flujo global se numera en orden de **commit**, no en orden de inserción: una secuencia entrega su valor cuando se inserta una fila, así que una transacción que tiene la posición 10 podría confirmarse después de la que tiene la 11, y un lector que hubiera guardado el punto de control 11 se saltaría la 10 para siempre. El almacén numera los eventos confirmados en una breve transacción propia antes de cada lectura global, así que un lector nunca ve aparecer un evento por debajo de una posición que ya ha pasado.
+
+---
+
+## Upcasting de un campo renombrado
+
+Los eventos son inmutables, así que una forma cambiada es una versión nueva. Los primeros préstamos guardaron sus amortizaciones como `loan.repaid` versión 1, con el importe llamado `amount`; la clase dice ahora `amountMinor`, como cualquier otro campo monetario de Lumen. Las filas guardadas nunca se reescriben. Un `EventUpcaster` hace avanzar una carga útil de la versión 1 cada vez que se lee una:
+
+
+```php
+#[Component]
+final class LoanRepaidV1Upcaster implements EventUpcaster
+{
+ public function eventType(): string
+ {
+ return 'loan.repaid';
+ }
+
+ public function fromVersion(): int
+ {
+ return 1;
+ }
+
+ public function priority(): int
+ {
+ return 0;
+ }
+
+ public function upcast(StoredEvent $event): StoredEvent
+ {
+ $payload = $event->payload;
+ $payload['amountMinor'] = $payload['amount'] ?? 0;
+ unset($payload['amount']);
+
+ return $event->upcasted('loan.repaid', 2, $payload);
+ }
+}
+```
+
+El upcasting ocurre en el almacén, al leer, así que el repositorio y todas las proyecciones ven el mismo evento de la versión 2. El test escribe una amortización exactamente como la dejó un despliegue antiguo y la lee de las dos maneras:
+
+
+```php
+ // A repayment exactly as the first loans were stored: version 1, its amount still called `amount`.
+ $this->app()->make(EventStore::class)->append('loan-legacy', Loan::aggregateType(), [
+ new NewEvent('loan.repaid', ['loanId' => 'loan-legacy', 'amount' => 20000, 'outstandingMinor' => 30000], 1),
+ ], 1);
+
+ $loan = $loans->get('loan-legacy');
+ Artisan::call('firefly:eventsourcing:projections:run', ['projection' => LoanBookProjection::NAME]);
+
+ expect([$loan->repaidMinor(), $loan->outstanding()->minorUnits, $loan->repayments()])->toBe([20000, 30000, 1])
+ ->and(DB::table(LoanBookProjection::TABLE)->where('loan_id', 'loan-legacy')->value('repaid_minor'))->toEqual(20000)
+ ->and(DB::table(EventSourcingSchema::EVENTS)->where('aggregate_id', 'loan-legacy')->where('event_type', 'loan.repaid')->value('event_version'))->toEqual(1);
+```
+
+La fila de `firefly_eventsourcing_events` sigue diciendo versión 1; el préstamo y la cartera de préstamos leen los dos 200,00 amortizados. Los upcasters se encadenan por tipo y versión (`v1 → v2 → v3`), gana el de mayor `priority()` entre dos para el mismo paso, y un evento cuya versión sigue sin ser la registrada tras el upcasting se rechaza indicando el upcaster que le falta — el primer test de ese fichero falla exactamente así hasta que existe `LoanRepaidV1Upcaster`.
+
+---
+
+## Publicar a través del outbox, en la misma transacción
+
+Otros servicios quieren enterarse de que un préstamo se ha amortizado. El outbox del Capítulo 8 ya resuelve "publicar solo lo que se confirmó, y no perderlo nunca"; el almacén de eventos se enchufa a él:
+
+
+```php
+'publish' => [
+ 'enabled' => env('FIREFLY_EVENTSOURCING_PUBLISH_ENABLED', false),
+ 'destination' => env('FIREFLY_EVENTSOURCING_PUBLISH_DESTINATION', 'eventsourcing.events'),
+ 'exclude-aggregate-types' => ['orchestration.execution'],
+],
+```
+
+Con `firefly.eventsourcing.publish.enabled` activado y `firefly.eda.provider=database` (o `postgres`), cada evento añadido se escribe en `firefly_eda_outbox` *dentro de la transacción del append*, con sus cabeceras `x-event-id`, `x-event-version`, `x-aggregate-id`, `x-aggregate-type`, `x-aggregate-version` y `x-occurred-at`. El arnés de outbox de Lumen publica los préstamos en `lumen.loans`:
+
+
+```php
+ expect(DB::table(OutboxSchema::TABLE)->where('destination', LumenEventSourcingOutboxTestCase::DESTINATION)->orderBy('id')->pluck('event_type')->all())
+ ->toBe(['loan.granted', 'loan.repaid'])
+ ->and(DB::table(OutboxSchema::TABLE)->where('destination', 'wallet.events')->orderBy('id')->pluck('event_type')->all())
+ ->toBe(['WalletOpened', 'FundsDeposited', 'FundsWithdrawn'])
+ ->and(is_string($headers) ? json_decode($headers, true) : null)->toHaveKey('x-aggregate-id', $loanId)
+ ->and(DB::table(OutboxSchema::TABLE)->where('status', OutboxSchema::STATUS_PENDING)->count())->toBe(5);
+```
+
+Dos orígenes, una tabla, una transacción: los eventos del préstamo vienen del almacén de eventos, los del monedero del hook previo al commit de `firefly/data`, y se confirman o se revierten juntos — el segundo test del fichero repite la amortización sin fondos y encuentra solo `loan.granted` en el outbox. Después, `php artisan firefly:eda:consume` entrega los cinco, al menos una vez, exactamente como en el Capítulo 8. El almacén de eventos y el outbox deben compartir conexión, o el arranque se rechaza nombrando ambas claves. Con cualquier otro proveedor de eda los eventos se publican después de que el append se confirme, como mucho una vez; cuando cada evento tiene que llegar al broker, usa el outbox.
+
+---
+
+## Orquestación sobre el almacén de eventos
+
+Los puntos de control del Capítulo 9A iban a `memory`, a `cache` o a tres tablas portables. `firefly/eventsourcing` añade un cuarto proveedor de persistencia, `event-sourced`, que guarda cada ejecución de saga, TCC y workflow como un agregado en el almacén de eventos:
+
+
+```dotenv
+FIREFLY_EVENTSOURCING_STORE=database
+FIREFLY_ORCHESTRATION_PERSISTENCE=event-sourced
+```
+
+Nada cambia en la saga. Lumen ejecuta sobre él la misma saga de pagos salientes:
+
+
+```php
+ $result = $this->app()->make(SagaEngine::class)->execute('payout', ['walletId' => $walletId, 'iban' => 'ES9121000418450200051332', 'amountMinor' => 20000]);
+ $stream = $this->app()->make(EventStore::class)->load($result->correlationId);
+
+ expect($result->success)->toBeTrue()
+ ->and($this->app()->make(ExecutionPersistenceProvider::class))->toBeInstanceOf(EventSourcedExecutionPersistenceProvider::class)
+ ->and($stream->aggregateType())->toBe('orchestration.execution')
+ ->and($stream->events[0]->eventType)->toBe('orchestration.execution.started')
+ ->and(DB::table(OrchestrationSchema::EXECUTIONS)->where('correlation_id', $result->correlationId)->value('status'))->toBe('COMPLETED')
+ ->and($this->queries()->ask(new GetBalance($walletId)))->toBe(30000);
+```
+
+El flujo de la ejecución empieza con `orchestration.execution.started` y registra cada punto de control como los eventos que lo cambiaron, así que una reproducción devuelve exactamente el estado que se guardó. Las consultas que ejecutan los motores — ejecuciones en curso, obsoletas y vencidas, atributos de búsqueda — siguen leyendo las propias tablas de orquestación, ahora una proyección *inline* escrita en la misma transacción que los eventos; `php artisan firefly:eventsourcing:projections:rebuild orchestration.executions` las vuelve a escribir a partir de los flujos, que es lo que hace el segundo test del fichero después de borrarlas. Las ejecuciones quedan excluidas de la publicación por defecto (`exclude-aggregate-types`).
+
+---
+
+## En cada base de datos
+
+Todo lo anterior se ejecuta sobre la base de datos SQLite `:memory:` del ejemplo en la puerta por defecto, y de nuevo sobre servidores reales en las vías de integración del Capítulo 12: `LoanLedgerRoundTripTest` concede, amortiza y liquida un préstamo con su instantánea, sus filas del outbox y su cartera de préstamos, y lee una amortización de la versión 1, en PostgreSQL, MySQL, MariaDB, SQL Server y un fichero SQLite; `EventSourcedPayoutRoundTripTest` hace lo mismo para el pago saliente con event sourcing. Ejecuta una vía con su variable DSN definida y `composer test:integration -- samples/lumen/tests/Integration`. La numeración en orden de commit es lo que esas vías ponen más a prueba: usa `READ COMMITTED` en MySQL y MariaDB y `READPAST` en SQL Server para que un append abierto ni se vea ni haga esperar.
+
+!!! laravel "Paridad con Laravel"
+ Los eventos de Laravel son notificaciones: `Event::dispatch()` no deja nada detrás salvo que un listener lo anote, y un modelo de Eloquent solo conserva la última versión de su fila. Un registro de auditoría es una segunda tabla que mantienes al día a mano. `firefly/eventsourcing` lo invierte para los agregados que lo necesitan — los eventos son el registro, la fila es una proyección que puedes borrar y reconstruir — mientras Eloquent sigue siendo exactamente lo que es para todo lo demás, en la misma transacción.
+
+---
+
+## Lo que aprendiste {.recap}
+
+| Concepto | Qué hace |
+|---|---|
+| `EventSourcedAggregateRoot` / `recordThat()` | El estado es la reproducción de los eventos registrados; los métodos `apply…()` son los únicos que escriben |
+| `#[EventType]` | Un nombre guardado estable y una versión de la carga útil, compilados por `firefly:cache` |
+| `EventSourcedRepository` | `get()` reproduce, `save()` añade bajo la versión cargada — un escritor desfasado recibe un 409 `ConcurrencyException` |
+| `SnapshotAware` | Una instantánea cada `firefly.eventsourcing.snapshot.threshold` eventos; una carga reproduce solo lo que vino después |
+| `Projection` + punto de control | Un modelo de lectura a partir del flujo global en orden de commit, filas y punto de control en una transacción, bajo un arrendamiento |
+| `EventUpcaster` | Hace avanzar una versión antigua de un evento al leer, en el almacén, igual para repositorios que para proyecciones |
+| `firefly.eventsourcing.publish.enabled` | Escribe cada evento añadido en el outbox dentro de la transacción del append |
+| Orquestación `event-sourced` | Ejecuciones de saga, TCC y workflow como flujos de eventos, con las tablas de orquestación como su modelo de lectura inline |
+
+---
+
+## Ponlo en práctica {.exercises}
+
+1. **Liquida de una vez.** Añade a `LoanCommandsTest` un test que amortice un préstamo entero con un solo `RepayLoan` y compruebe que ese único guardado añadió dos eventos — `loan.repaid` y `loan.settled` — y luego comprueba si ese guardado cruzó el umbral de instantáneas.
+2. **Una tercera versión.** Imagina que la versión 3 de `loan.repaid` añade un campo `channel` cuyo valor por defecto es `wallet`. Escribe el upcaster de la versión 2 a la 3, sube la versión de `#[EventType]` y confirma con `LoanRepaidUpcasterTest` que una fila de la versión 1 recorre ahora `v1 → v2 → v3` al leerse.
+3. **Un segundo modelo de lectura.** Escribe una proyección que cuente las amortizaciones por monedero en una tabla propia, ejecuta `php artisan firefly:eventsourcing:projections:run` y después reconstruye solo esa con `php artisan firefly:eventsourcing:projections:rebuild` — el punto de control de la cartera de préstamos no se mueve.
diff --git a/book/src-es/10-security.md b/book/src-es/10-security.md
index 6fc7806d..7b4fb7a3 100644
--- a/book/src-es/10-security.md
+++ b/book/src-es/10-security.md
@@ -723,13 +723,15 @@ use Lumen\Infrastructure\WalletRepository;
* raises a ConflictException that rolls the transaction back and surfaces as a CommandProcessingException), persists,
* and returns the new balance in minor units. #[Transactional] for the same reason as the sibling handlers.
*
- * Guarded by #[PreAuthorize]: only an ADMIN or the WALLET_OWNER may debit. SecurityExpressionEvaluator is a CLOSED
- * whitelist (booleans and/or/not, string literals, #param, and eight fixed functions — no `==`, no `.property`
- * navigation), so ownership is modelled as a granted WALLET_OWNER authority rather than an `#command.ownerId ==
- * authentication.name` comparison, which the parser cannot express. SecurityCommandAuthorizer enforces this at the bus
- // …
- * (The faithful owner-only alternative — hasPermission(#command, 'withdraw') backed by a custom PermissionEvaluator
- * bean that loads the wallet and compares its owner_id to the current principal — is left to a later step.)
+ * Guarded by #[PreAuthorize]: an ADMIN may debit any wallet; a WALLET_OWNER only the wallet it owns. The role alone
+ * is not ownership — any customer holds it — so the rule also asks hasPermission(#command, 'debit'), which the
+ * framework hands, with the Withdraw bound to #command, to the application's PermissionEvaluator:
+ * WalletPermissionEvaluator loads the wallet and grants the debit only when its owner_id is the principal's name.
+ * SecurityExpressionEvaluator is a CLOSED whitelist (booleans and/or/not, string literals, #param, and a fixed set of
+ * functions — no `==`, no `.property` navigation), so the comparison cannot be written in the expression; the
+ * evaluator is where it lives. SecurityCommandAuthorizer enforces this at the bus BEFORE the handler runs; a denied
+ * withdraw surfaces as CommandProcessingException wrapping AuthorizationException (403) for a signed-in principal
+ * without the role or without the wallet, or AuthenticationException (401) when there is no principal at all.
*
* Intentionally NOT final: the generated transactional proxy subclasses this handler.
*/
@@ -738,7 +740,7 @@ class WithdrawHandler
{
public function __construct(private readonly WalletRepository $wallets) {}
- #[PreAuthorize("hasRole('ADMIN') or hasRole('WALLET_OWNER')")]
+ #[PreAuthorize("hasRole('ADMIN') or (hasRole('WALLET_OWNER') and hasPermission(#command, 'debit'))")]
#[Transactional]
public function handle(Withdraw $command): int
{
@@ -753,7 +755,83 @@ class WithdrawHandler
}
```
-Lee con atención el segundo párrafo de ese docblock — es toda la lección en miniatura. La forma *ingenua* de expresar "solo el propio dueño del monedero puede retirar" sería algo como `#command.ownerId == authentication.name`, y la gramática sencillamente no puede decir eso: no hay operador `.` ni `==`. La alternativa real, distribuida, es más burda pero genuinamente expresable: conceder una autoridad `WALLET_OWNER` a quien deba tener permiso, y comprobar `hasRole('WALLET_OWNER')`. Una comprobación de propiedad genuina por recurso, cuando la necesitas, pasa por `hasPermission(#command, 'withdraw')` y un bean `PermissionEvaluator` a medida que hace la comparación real en PHP ordinario — la gramática despacha hacia tu código, no intenta volverse lo bastante expresiva como para escribir ella misma la comparación.
+Lee con atención el segundo párrafo de ese docblock — es toda la lección en miniatura. La forma *ingenua* de expresar "solo el propio dueño del monedero puede retirar" sería algo como `#command.ownerId == authentication.name`, y la gramática sencillamente no puede decir eso: no hay operador `.` ni `==`. Tampoco basta el rol por sí solo. Todos los clientes tienen `WALLET_OWNER`, así que una regla que se quedara en `hasRole('WALLET_OWNER')` dejaría a cualquier cliente cargar en *cualquier* monedero cuyo id llegara a conocer — una referencia directa insegura a objetos, el agujero que deja siempre una regla basada solo en el rol. El rol dice qué clase de principal es; no dice *cuál* de los monederos es suyo.
+
+Esa pregunta va a `hasPermission(#command, 'debit')`. El escáner enlaza `#command` con el parámetro `$command` del manejador, el bus entrega a la expresión el `Withdraw` que está a punto de despachar, y `hasPermission()` pasa ese comando, junto con la `Authentication` actual, al `PermissionEvaluator` de la aplicación. La gramática despacha hacia tu código; no intenta volverse lo bastante expresiva como para escribir ella misma la comparación. El evaluador de Lumen es un componente normal:
+
+
+```php
+isAuthenticated()) {
+ return false;
+ }
+
+ $walletId = match (true) {
+ $target instanceof Withdraw => $target->walletId,
+ $target instanceof Transfer => $target->sourceWalletId,
+ $target instanceof RepayLoan => $this->walletOfLoan($target->loanId),
+ default => null,
+ };
+ if ($walletId === null) {
+ return false;
+ }
+
+ $owner = $this->wallets->findById($walletId)?->ownerId() ?? '';
+
+ return $owner !== '' && $owner === $authentication->getName();
+ }
+
+ private function walletOfLoan(string $loanId): ?string
+ {
+ $loans = $this->container->make(LoanRepository::class);
+
+ return $loans->find($loanId)?->walletId();
+ }
+}
+```
+
+Hay tres cosas en él que merece la pena copiar. **Se registra por ser un bean**: `SecurityAutoConfiguration` define su `DenyAllPermissionEvaluator` solo `#[ConditionalOnMissingBean(PermissionEvaluator::class)]`, así que un componente escaneado que implemente la interfaz desplaza al predeterminado sin más cableado. **Falla cerrado**: un nombre de permiso distinto de `debit`, un tipo de objetivo para el que no se escribió, un monedero o un préstamo que no existe, o un principal no autenticado responden todos `false`, y una excepción lanzada dentro lo captura el `catch (\Throwable)` del evaluador de expresiones y también se convierte en `false`. **Responde a una sola pregunta para cada comando que carga en un monedero**: un `Withdraw` nombra su monedero, un `Transfer` carga en su monedero de *origen* —ser propietario del de destino no concede nada— y un `RepayLoan` (Capítulo 9B) nombra un préstamo cuyo monedero el evaluador lee del almacén de eventos, así que los tres manejadores llevan la misma regla. El analizador no cortocircuita `or`, así que el evaluador también se ejecuta para un `ADMIN`; allí su respuesta, sencillamente, no cambia el resultado.
---
@@ -1009,7 +1087,62 @@ Dos archivos de prueba reales y distribuidos ejercitan la cadena completa — pe
Las tres costuras de aplicación dan el mismo par de respuestas, porque comparten el mismo evaluador: 401 significa *iniciar sesión ayudaría*, 403 significa *no ayudaría*. Un cliente puede distinguirlas y actuar en consecuencia.
-El bus envuelve cualquiera de las dos en una `CommandProcessingException`, que copia el código de error, el estado HTTP y la categoría de la causa — así que el cable lleva `AUTHENTICATION_FAILED` o `ACCESS_DENIED` y no el código genérico del bus. Y en las dos pruebas la aserción sobre el saldo, después, hace trabajo real: demuestra que el débito genuinamente nunca ocurrió, no solo que la respuesta HTTP tuviera apariencia de rechazo.
+El bus envuelve cualquiera de las dos en una `CommandProcessingException`, que copia el código de error, el estado HTTP, la categoría y los miembros de extensión de la causa — así que el cable lleva `AUTHENTICATION_FAILED` o `ACCESS_DENIED` y no el código genérico del bus. Y en las dos pruebas la aserción sobre el saldo, después, hace trabajo real: demuestra que el débito genuinamente nunca ocurrió, no solo que la respuesta HTTP tuviera apariencia de rechazo.
+
+El rol sin el monedero se rechaza igual. Un principal con `ROLE_WALLET_OWNER` que es dueño de *algún* monedero, pero no de este, recibe el 403 y no se mueve nada:
+
+
+```php
+it('denies a withdraw from a WALLET_OWNER who does not own the wallet as 403 problem+json', function () {
+ // …
+ SecurityContextHolder::setContext(new SecurityContext(
+ Authentication::authenticated('owner-7b', 'owner-7b', [new SimpleGrantedAuthority('ROLE_WALLET_OWNER')])
+ ));
+ // …
+ $id = $this->postJson('/api/v1/wallets', ['owner_id' => 'owner-7a', 'currency' => 'EUR'])->json('wallet_id');
+ $this->postJson("/api/v1/wallets/{$id}/deposit", ['amount_minor' => 5000]);
+
+ $this->postJson("/api/v1/wallets/{$id}/withdraw", ['amount_minor' => 1000])
+ ->assertStatus(403)
+ // …
+ ->assertJsonPath('code', 'ACCESS_DENIED')
+ ->assertJsonPath('category', 'security');
+
+ // Nothing was debited, and the ledger never saw a withdrawal.
+ $this->getJson("/api/v1/wallets/{$id}/balance")->assertJson(['balance_minor' => 5000]);
+```
+
+Esa es la prueba que más importa de este capítulo: es la petición que de verdad enviaría un atacante. El 401 y el 403 sin rol de más arriba demuestran que el endpoint está protegido; esta demuestra que la protección mira el monedero.
+
+Una transferencia también es un cargo, y `TransferHandler` lleva la misma regla. Su `Transfer` nombra dos monederos, y el evaluador pregunta por el de **origen**, el monedero del que sale el dinero. Ser propietario del de destino no basta: el principal de abajo tiene el rol y es dueño del monedero que recibe, y se le rechaza antes de tocar ninguno de los dos:
+
+
+```php
+it('denies a transfer out of a wallet the WALLET_OWNER does not own as 403 problem+json and moves no money', function () {
+ // …
+ SecurityContextHolder::setContext(new SecurityContext(
+ Authentication::authenticated('owner-10b', 'owner-10b', [new SimpleGrantedAuthority('ROLE_WALLET_OWNER')])
+ ));
+ // …
+ $src = $this->postJson('/api/v1/wallets', ['owner_id' => 'owner-10a', 'currency' => 'EUR'])->json('wallet_id');
+ // …
+ $dst = $this->postJson('/api/v1/wallets', ['owner_id' => 'owner-10b', 'currency' => 'EUR'])->json('wallet_id');
+ $this->postJson("/api/v1/wallets/{$src}/deposit", ['amount_minor' => 5000]);
+
+ $this->postJson('/api/v1/wallets/transfers', [
+ 'source_wallet_id' => $src,
+ 'destination_wallet_id' => $dst,
+ 'amount_minor' => 1000,
+ ])->assertStatus(403)
+ // …
+ ->assertJsonPath('code', 'ACCESS_DENIED')
+ ->assertJsonPath('category', 'security');
+
+ $this->getJson("/api/v1/wallets/{$src}/balance")->assertJson(['balance_minor' => 5000]);
+ $this->getJson("/api/v1/wallets/{$dst}/balance")->assertJson(['balance_minor' => 0]);
+```
+
+El mismo archivo demuestra que una transferencia anónima es un 401 y que un `ADMIN` puede mover dinero entre dos monederos de los que no es dueño; `TransferSecurityTest.php` lo demuestra todo de nuevo a través del bus.
Concede la autoridad correcta y el mismo comando tiene éxito:
@@ -1035,7 +1168,7 @@ it('allows an authorized withdraw and renders an overdraw as 409 problem+json',
->assertJsonPath('category', 'business');
```
-La autoridad concedida es `'ROLE_WALLET_OWNER'` — ya con el prefijo `ROLE_` incorporado — y la expresión de `WithdrawHandler` llama a `hasRole('WALLET_OWNER')`, que `SecurityExpressionRoot::hasRole()` normaliza a la cadena idéntica `'ROLE_WALLET_OWNER'` antes de comprobar la pertenencia; las dos formas de escribirlo se encuentran en el medio. El `409` de la segunda llamada es el contraste importante: la autorización y la validación de dominio son dos capas genuinamente independientes — que se te *permita* retirar no dice nada sobre si la retirada en sí es *válida*, y la propia invariante de no-sobregiro de `Wallet::withdraw()` (Capítulo 6) sigue aplicándose exactamente igual que antes.
+La autoridad concedida es `'ROLE_WALLET_OWNER'` — ya con el prefijo `ROLE_` incorporado — y la expresión de `WithdrawHandler` llama a `hasRole('WALLET_OWNER')`, que `SecurityExpressionRoot::hasRole()` normaliza a la cadena idéntica `'ROLE_WALLET_OWNER'` antes de comprobar la pertenencia; las dos formas de escribirlo se encuentran en el medio. El principal es `owner-4` y el monedero se abre para `owner-4`, y eso es lo que permite que `hasPermission(#command, 'debit')` conceda el cargo. El `409` de la segunda llamada es el contraste importante: la autorización y la validación de dominio son dos capas genuinamente independientes — que se te *permita* retirar no dice nada sobre si la retirada en sí es *válida*, y la propia invariante de no-sobregiro de `Wallet::withdraw()` (Capítulo 6) sigue aplicándose exactamente igual que antes.
`samples/lumen/tests/Application/TransferSecurityTest.php` demuestra el mismo guardián directamente contra el `CommandBus`, con el patrón de desenvolver mediante `getPrevious()` que introdujo el Capítulo 7:
@@ -1093,6 +1226,7 @@ it('enforces #[PreAuthorize] on withdraw: denies without the owner role, allows
| `HttpSecurity` | Reglas de URL que deniegan por defecto, primera-coincidencia-gana, compilando hacia la misma gramática de expresiones que la seguridad de método |
| `SecurityExpressionEvaluator` | Un tokenizador/analizador hecho a mano, de lista blanca cerrada — sin `eval`, sin `==`, sin navegación `.propiedad`; diez funciones en total (`hasRole`, `hasAnyRole`, `hasAuthority`, `hasAnyAuthority`, `hasScope`, `hasAnyScope`, `hasPermission`, `isAuthenticated`, `permitAll`, `denyAll`) más las referencias `#param` |
| `hasRole('X')` | Normaliza a `hasAuthority('ROLE_X')` — las formas desnuda y con prefijo `ROLE_` son equivalentes |
+| `hasPermission(#command, 'debit')` | Entrega el comando y el principal al `PermissionEvaluator` de la aplicación; el `WalletPermissionEvaluator` de Lumen concede un cargo solo al dueño del monedero y deniega todo lo demás |
| `SecurityCommandAuthorizer` / `SecurityQueryAuthorizer` | Las implementaciones reales en `#[Order(500)]` que reemplazan al `AllowAllAuthorizer` del Capítulo 7, solo cuando `firefly.security.enabled=true` |
| `MethodSecurityMessageEnforcer` | Une el `HandlerManifest` de CQRS con el `SecurityMethodManifest` compilado; hace cumplir la regla antes de que `handle()` llegue a ejecutarse |
| `SecurityContextPersistenceFilter` | Carga el contexto desde la sesión en `-94`, guarda lo que cambió, y limpia el portador en un `finally` |
@@ -1110,7 +1244,7 @@ it('enforces #[PreAuthorize] on withdraw: denies without the owner role, allows
## Ponlo en práctica {.exercises}
1. **Escribe una expresión rechazada.** Intenta compilar `#[PreAuthorize("#command.ownerId == authentication.name")]` en un manejador de prueba y ejecuta `php artisan firefly:cache`. Confirma que `MethodSecurityScanner` falla de forma ruidosa con una `ConfigurationException` que nombra la clase y el método exactos, en vez de aceptar silenciosamente una expresión que siempre denegaría (o fallaría al analizar) en tiempo de ejecución.
-2. **Añade una comprobación real de propiedad.** En una copia de prueba del proyecto, implementa un bean `PermissionEvaluator` que cargue un monedero por id y compare su `owner_id` con `Authentication::getName()`, enlázalo para que reemplace a `DenyAllPermissionEvaluator`, y cambia la expresión de `WithdrawHandler` a `hasPermission(#command, 'withdraw')`. Confirma que un principal sin `WALLET_OWNER` que genuinamente sea dueño del monedero ahora puede retirar, mientras que un dueño distinto no puede.
+2. **Protege una lectura y cuenta lo que cuesta.** Ahora todo cargo es del propietario o de un administrador: `WithdrawHandler`, `TransferHandler` y `RepayLoanHandler` llevan la misma regla. Las lecturas siguen abiertas, así que cualquiera puede pedir el saldo de cualquier monedero. En una copia de prueba del proyecto, da a `WalletPermissionEvaluator` un segundo permiso, `view`, que un `GetBalance` concede al propietario de su `walletId`, y pon `#[PreAuthorize("hasRole('ADMIN') or hasPermission(#query, 'view')")]` en `GetBalanceHandler::handle()`. Confirma que el `GET /api/v1/wallets/{id}/balance` de otro propietario es ahora un 403, y que un saldo cacheado para el propietario se le sigue negando a todos los demás, porque el bus de consultas autoriza antes de leer la caché. Después ejecuta la suite del ejemplo y cuenta las pruebas que leen un saldo sin principal: esa cifra es lo que de verdad cuesta proteger una lectura.
3. **Adueñate de la página de login.** Añade una acción `#[GetMapping('/login')]` propia a un proyecto de prueba con `form_login.enabled` encendido, arráncalo, y confirma que la página del framework *no* reemplaza a la tuya — después confirma que tu formulario sigue dando acceso a la gente enviando el token de sesión a la URL de procesamiento, porque `FormLoginFilter` responde antes del enrutado de cualquier modo.
4. **Mira al punto de entrada cambiar de respuesta.** Contra una ruta protegida, envía `Accept: text/html` y después `Accept: application/json`, y confirma que obtienes una redirección a `/login` y un `401 application/problem+json` respectivamente. Después pon `firefly.security.http.entry_point` a `problem` y confirma que la petición del navegador también recibe el 401.
5. **Pon una regla sobre un servicio corriente.** Añade `#[PreAuthorize("hasRole('ADMIN')")]` a un método de un `#[Service]` no final que no sea ni controlador ni manejador, ejecuta `php artisan firefly:cache`, y confirma que la llamada se rechaza. Después marca la clase como `final`, vuelve a ejecutar la caché, y confirma que el escáner rechaza en tiempo de compilación en vez de distribuir una regla que nada podría hacer cumplir.
diff --git a/book/src-es/90-appendix-a-laravel.md b/book/src-es/90-appendix-a-laravel.md
index 37879a5e..4f9d5842 100644
--- a/book/src-es/90-appendix-a-laravel.md
+++ b/book/src-es/90-appendix-a-laravel.md
@@ -28,6 +28,7 @@ LaraFly es **aditivo**: superpone un modelo de aplicación con forma de Spring B
| Webhooks salientes | Un job en cola por evento que llama a `Http::post()` | `firefly/callbacks`: suscripciones, entregas firmadas, reintentos, un breaker por endpoint, un registro reproducible | 8A |
| Procesos de larga duración | `Bus::chain()` / `Bus::batch()` — sin deshacer los jobs que ya tuvieron éxito | `firefly/orchestration`: sagas con compensación, TCC, workflows duraderos con puntos de control | 9A |
| Un nodo por tarea planificada | `->onOneServer()` sobre un cerrojo de caché | `#[Scheduled(lock: …)]` sobre `firefly.scheduling.lock.provider` (`cache`, `database`, `mysql`, `postgres`) | 9A |
+| Event sourcing | Sin almacén de eventos — Eloquent guarda la última fila; la historia es una tabla de auditoría mantenida a mano | `firefly/eventsourcing`: agregados con event sourcing sobre un almacén de eventos en base de datos, instantáneas, upcasters, proyecciones con punto de control, publicación por el outbox | 9B |
---
diff --git a/book/src-es/94-glossary.md b/book/src-es/94-glossary.md
index f15d7a13..1b2d9bf4 100644
--- a/book/src-es/94-glossary.md
+++ b/book/src-es/94-glossary.md
@@ -26,6 +26,8 @@
**Callback (outbound webhook)** — Una petición HTTP que LaraFly envía a un suscriptor cuando ocurre en el bus un evento que el suscriptor pidió: firmada con `X-Firefly-Timestamp` y `X-Firefly-Signature`, reintentada con espera creciente, protegida por un circuit breaker por endpoint y anotada en un registro de ejecuciones reproducible. La distribuye `firefly/callbacks` (Capítulo 8A).
+**Checkpoint** — La posición del flujo global de eventos hasta la que una proyección ha proyectado, guardada en `firefly_eventsourcing_checkpoints` y avanzada en la misma transacción que las filas de la propia proyección; una reconstrucción la devuelve a 0 (Capítulo 9B).
+
**Circuit breaker** — Un patrón de resiliencia que salta a un estado abierto tras un umbral de fallos consecutivos contra una dependencia, cortocircuitando llamadas posteriores hasta que la dependencia haya tenido tiempo de recuperarse. El `MeterBindingsPass` de `firefly/observability` expone el estado en vivo de cada breaker configurado como un gauge `resilience_circuit_breaker_state{name}` (Capítulo 11).
**Claim strategy** — Cómo elige un consumidor del outbox una fila sin que otro consumidor elija la misma: `skip-locked` (PostgreSQL, MySQL 8.0.1+, MariaDB 10.6+), `read-past` (SQL Server) o la portable `optimistic-lease`, elegida por `auto` a partir de la versión que declara el servidor (Capítulo 8).
@@ -52,6 +54,10 @@
**Entity** — Un objeto de dominio con una identidad estable que persiste a través de los cambios de estado. El `make:firefly-entity` de LaraFly andamia una clase que extiende `Firefly\Domain\Entity` — no hay ningún atributo `#[Entity]` (Capítulos 6, 13).
+**Event sourcing** — Guardar un agregado como los eventos ordenados que le ocurrieron en lugar de como una fila con sus últimos valores; su estado es su reproducción (`EventSourcedAggregateRoot`, `recordThat()`) (Capítulo 9B).
+
+**Event store** — El hogar de solo-añadir de esos eventos: un flujo por agregado, añadido bajo concurrencia optimista, y un flujo global numerado en orden de commit para las proyecciones; `memory` o `database` (`firefly.eventsourcing.store.provider`) (Capítulo 9B).
+
**EventListener** — Cualquiera de dos superficies distintas y deliberadamente no relacionadas: `#[AsEventListener]` para el propio despacho de eventos en-proceso de Laravel, y `#[EventListener]` para el bus respaldado por broker de `firefly/eda`, que se suscribe a un *patrón* de tipo de evento en lugar de a una clase de PHP (Capítulo 8).
**Exposure** — Un evento opcional `FeatureFlagEvaluated` que registra una evaluación ordinaria para analizar un experimento. La vista previa de gestión no emite ninguno, y un grafo de valores de más de 10 000 ocurrencias omite el evento sin cambiar el resultado (Capítulo 13A).
@@ -88,7 +94,7 @@
**Problem Details (RFC 7807)** — La forma de error estándar `application/problem+json` (`type`, `title`, `status`, más claves de extensión específicas del framework como `code`/`category`) como la que renderiza cada excepción sin manejar. `ProblemDetailsRenderer` es lo que la produce (Capítulos 4, 12).
-**Projection** — Un modelo de lectura construido consumiendo un flujo de eventos de dominio, mantenido separado del modelo de escritura que los produjo — el libro mayor de Lumen es una proyección sobre los propios eventos de dominio de `Wallet` (Capítulo 6).
+**Projection** — Un modelo de lectura construido consumiendo un flujo de eventos de dominio, mantenido separado del modelo de escritura que los produjo — el libro mayor de Lumen es una proyección sobre los propios eventos de dominio de `Wallet` (Capítulo 6), y su cartera de préstamos una proyección con punto de control del flujo global del almacén de eventos (Capítulo 9B).
**Proxy (`#[Transactional]` proxy)** — Una subclase generada que envuelve un método con atributo `#[Transactional]` con fronteras de transacción declarativas (`beginTransaction()`/`commit()`/`rollBack()`), producida por el mismo `ProxyClassGenerator` sin reflexión ya sea en tiempo de arranque (dev) o de forma anticipada por `firefly:cache` (producción) (Capítulos 9, 13).
@@ -110,6 +116,8 @@
**Slice test** — Una prueba que arranca solo los beans que necesita una estrecha vertical — el pipeline web sobre un controlador (`WebSliceTestCase`), o el pipeline de datos sobre un repositorio (`DataSliceTestCase`) — en lugar de toda la aplicación, de modo que una rebanada mal cableada falla rápido en lugar de pasar por accidente (Capítulo 12).
+**Snapshot** — Una copia guardada del estado de un agregado con event sourcing en una versión, para que una carga reproduzca solo los eventos posteriores; se toma cada `firefly.eventsourcing.snapshot.threshold` eventos para un agregado `SnapshotAware` y se ignora en cuanto la versión de su forma deja de coincidir (Capítulo 9B).
+
**Stereotype** — Un atributo que a la vez registra una clase como bean y señala su rol arquitectónico — `#[Service]`, `#[Repository]`, `#[Component]`, `#[Configuration]`, `#[RestController]`. Cada estereotipo es una especialización del `#[Component]` base (Capítulo 2).
**TCC (Try-Confirm-Cancel)** — Un patrón de orquestación en el que cada participante primero reserva (*try*), y solo cuando todos los intentos tuvieron éxito se confirman todos; si falla cualquier intento, se cancela a cada participante que lo intentó (`#[Tcc]`, `#[TccParticipant]`) (Capítulo 9A).
@@ -118,6 +126,8 @@
**Transactional (`#[Transactional]`)** — Un atributo de clase/método que declara propagación (`REQUIRED`, `REQUIRES_NEW`, `NESTED`, …), aislamiento, solo-lectura y reglas de rollback — hecho cumplir por un proxy generado en lugar de una clausura manual `DB::transaction()` (Capítulo 9).
+**Upcaster** — Un `EventUpcaster` que hace avanzar un evento guardado de una versión antigua cada vez que se lee — un campo renombrado, un valor por defecto nuevo — sin reescribir nunca la fila guardada, así que repositorios y proyecciones ven la misma forma actual (Capítulo 9B).
+
**Value object** — Un objeto de dominio inmutable identificado por su valor en lugar de por un campo de identidad; dos objetos de valor son iguales si todos sus campos son iguales. `Money` — un importe entero en unidades menores más una `Currency` — es el objeto de valor canónico de Lumen (Capítulo 6).
**Webhook** — Una petición HTTP que un sistema envía a otro para avisar de que algo ocurrió. `firefly/webhooks` las recibe en `POST /webhooks/{provider}`, verifica la firma, deduplica las reentregas y las publica en el bus de eventos (Capítulo 8A).
diff --git a/book/src/00-front/00-preface.md b/book/src/00-front/00-preface.md
index b2d00c6e..42167bea 100644
--- a/book/src/00-front/00-preface.md
+++ b/book/src/00-front/00-preface.md
@@ -20,6 +20,8 @@ The journey starts gently. The **Quick Start** takes you from the bundled `firef
Chapters 8A and 9A take Lumen past a single database: a card processor tops wallets up through a signed webhook, a partner hears about every movement of money through signed callbacks, and a payout to a bank runs as a saga that undoes itself, step by step, when the bank says no.
+Chapter 9B keeps a different kind of record: Lumen's loans are stored as the events that happened to them — granted, repaid, settled — rebuilt from that history and from snapshots, projected into a loan book, and published through the outbox in the same transaction as the wallet they move money through.
+
Chapter 13A closes the production loop with feature flags: Lumen keeps a wallet view dark, targets a shared cohort, and tests the route and override lifecycle against the real framework.
By the time you have worked through the full book, you will have a mental model for every layer of a production LaraFly service, and a real, tested application to show for it.
diff --git a/book/src/04-first-http-api.md b/book/src/04-first-http-api.md
index 6894d7a5..46aeb907 100644
--- a/book/src/04-first-http-api.md
+++ b/book/src/04-first-http-api.md
@@ -50,7 +50,7 @@ use Lumen\Web\Dto\TransferRequest;
* command/query, dispatches it through the bus, and shapes the return array — the same rule the sample's
* CQRS handlers already enforce for the domain layer. A domain/business fault raised by the bus (e.g. an
* unknown wallet's ResourceNotFoundException, an overdraw's ConflictException, or a denied #[PreAuthorize]
- * on withdraw) surfaces as CommandProcessingException/QueryProcessingException and renders RFC-7807
+ * on withdraw or transfer) surfaces as CommandProcessingException/QueryProcessingException and renders RFC-7807
* problem-details via the framework's global ProblemDetailsRenderer — no local #[ExceptionHandler] needed.
*/
#[RestController]
@@ -84,11 +84,12 @@ final class WalletController
/**
* Debits `amount_minor` from the wallet. Guarded upstream at the bus: WithdrawHandler carries
- * #[PreAuthorize("hasRole('ADMIN') or hasRole('WALLET_OWNER')")] (S6), enforced by
- * SecurityCommandAuthorizer BEFORE the handler runs. With no principal in the SecurityContextHolder
- * the bus refuses the command with an AuthenticationException that renders as a 401 problem-details
- * response (authenticate first); with a principal that is signed in but carries neither role it is an
- * AuthorizationException rendered as 403 — this endpoint is secured, not broken.
+ * #[PreAuthorize("hasRole('ADMIN') or (hasRole('WALLET_OWNER') and hasPermission(#command, 'debit'))")],
+ * enforced by SecurityCommandAuthorizer BEFORE the handler runs. With no principal in the
+ * SecurityContextHolder the bus refuses the command with an AuthenticationException that renders as a 401
+ * problem-details response (authenticate first); with a principal that is signed in but is neither an ADMIN
+ * nor the WALLET_OWNER who owns this wallet it is an AuthorizationException rendered as 403 — this endpoint
+ * is secured, not broken.
*
* @return array{wallet_id: string, balance_minor: int}
*/
@@ -565,7 +566,7 @@ A withdraw attempt is refused twice over, and the two refusals are **not the sam
}
```
-`403` is reserved for a principal who **is** signed in and still lacks `ROLE_ADMIN` or `ROLE_WALLET_OWNER` — *signing in would not help* — and that document names the authorities the expression wanted:
+`403` is reserved for a principal who **is** signed in and still may not debit this wallet — neither `ROLE_ADMIN`, nor `ROLE_WALLET_OWNER` together with ownership of the wallet — *signing in would not help* — and that document names the authorities the expression wanted:
```json
{
@@ -584,7 +585,7 @@ A withdraw attempt is refused twice over, and the two refusals are **not the sam
}
```
-Both are produced with no code change to `WalletController::withdraw()` at all: the bus wraps whichever security exception the evaluator raised in a `CommandProcessingException` that copies the cause's code, status and category, so the wire carries the cause's identity rather than a generic bus error. Chapter 10 returns to this pair — and to the two shipped tests that pin it — in full.
+Both are produced with no code change to `WalletController::withdraw()` at all: the bus wraps whichever security exception the evaluator raised in a `CommandProcessingException` that copies the cause's code, status, category and extension members, so the wire carries the cause's identity rather than a generic bus error, and the 403 names the same `requiredAuthorities` a refused controller method would. Only `detail` says it came through the bus: the wrapper prefixes the cause's sentence with the command it was processing. Chapter 10 returns to this pair — and to the two shipped tests that pin it — in full.
### Handling an exception yourself
diff --git a/book/src/06-domain-driven-design.md b/book/src/06-domain-driven-design.md
index 460d0e83..8da0e71f 100644
--- a/book/src/06-domain-driven-design.md
+++ b/book/src/06-domain-driven-design.md
@@ -309,6 +309,14 @@ final class Wallet extends Model implements RecordsDomainEvents
});
}
+ /** Who the wallet belongs to: the principal name WalletPermissionEvaluator compares against before a debit. */
+ public function ownerId(): string
+ {
+ $value = $this->getAttribute('owner_id');
+
+ return is_string($value) ? $value : '';
+ }
+
public function balanceMoney(): Money
{
$value = $this->getAttribute('balance_minor');
@@ -592,6 +600,7 @@ use Firefly\Cqrs\Attributes\CommandHandler;
use Firefly\Data\Transaction\Attributes\Transactional;
use Firefly\Data\Transaction\Propagation;
use Firefly\Kernel\Exception\Business\ResourceNotFoundException;
+use Firefly\Security\Access\Attributes\PreAuthorize;
use Lumen\Domain\Money;
use Lumen\Infrastructure\WalletRepository;
@@ -605,6 +614,12 @@ use Lumen\Infrastructure\WalletRepository;
* back on any Throwable — the source debit is undone too. That is the money-cannot-vanish invariant: there is no path
* where the source loses funds the destination never receives.
*
+ * Guarded by the same #[PreAuthorize] as WithdrawHandler, because a transfer IS a debit: an ADMIN may move money out
+ * of any wallet, a WALLET_OWNER only out of the wallet it owns. WalletPermissionEvaluator reads the Transfer bound to
+ * #command and checks the owner of its SOURCE wallet — owning the destination grants nothing, or anyone could pull
+ * money out of any wallet by naming it as the source. SecurityCommandAuthorizer enforces the rule at the bus before
+ * the handler runs, so a refused transfer touches neither wallet.
+ *
* Intentionally NOT final: the generated transactional proxy subclasses this handler (same reason as the S4 handlers).
*/
#[CommandHandler]
@@ -612,6 +627,7 @@ class TransferHandler
{
public function __construct(private readonly WalletRepository $wallets) {}
+ #[PreAuthorize("hasRole('ADMIN') or (hasRole('WALLET_OWNER') and hasPermission(#command, 'debit'))")]
#[Transactional(propagation: Propagation::REQUIRED)]
public function handle(Transfer $command): void
{
@@ -632,6 +648,8 @@ class TransferHandler
}
```
+The `#[PreAuthorize]` line above `#[Transactional]` belongs to Chapter 10, not to this one: a transfer is a debit, so only an admin or the owner of the **source** wallet may start one, and the bus refuses everyone else before `handle()` runs, with neither wallet touched.
+
`recordTransferTo()` is where the `TransferCompleted` fact promised earlier gets raised — deliberately the *last* line, after both legs have already succeeded, on the source wallet (which `save()` already registered with the tracker, so an event raised after that save still drains at commit). It is a small `Wallet` method that just calls `raiseEvent(new TransferCompleted(...))`; because it runs only on the success path, a rolled-back transfer never reaches it and never publishes it.
If the destination's `deposit()` throws — a currency mismatch — `#[Transactional]`'s default `rollbackFor` (every `Throwable`) rolls the whole method back, undoing the source's debit too. None of `FundsWithdrawn`, `FundsDeposited`, or `TransferCompleted` was published yet at that point — each was only buffered on its aggregate — so a listener never sees the half of a transfer that never actually happened. Money can neither vanish nor double: either both legs commit and their events publish, or neither does.
diff --git a/book/src/07-cqrs.md b/book/src/07-cqrs.md
index 27a95005..3d8f8bf7 100644
--- a/book/src/07-cqrs.md
+++ b/book/src/07-cqrs.md
@@ -410,12 +410,15 @@ final class CommandProcessingException extends CqrsException
$cause instanceof FireflyException ? $cause->category() : ErrorCategory::Internal,
$cause instanceof FireflyException ? $cause->severity() : ErrorSeverity::Error,
$cause,
+ // …
+ $cause instanceof FireflyException ? $cause->extensions() : [],
+ $cause instanceof FireflyException ? $cause->title() : null,
);
}
}
```
-Three things to notice. First, the cause is always available on `getPrevious()` — the constructor's fifth argument to the parent — so nothing about the original failure is lost, only wrapped. Second, this is **category-preserving**: when the cause is a `FireflyException` (every kernel exception is), its own `httpStatus()`/`category()`/`severity()` are copied onto the wrapper rather than defaulting to a generic `500`/`Internal`. A `ResourceNotFoundException` (404, business) thrown for an unknown wallet id still renders as a 404 problem-details response even after being wrapped — the bus adds context, it never masks the fault. Third, `send()` checks `$e instanceof CommandProcessingException` before wrapping, so a nested command dispatch (a handler that itself calls `$commands->send(...)`) never gets double-wrapped in two layers of the same exception. `QueryProcessingException` is the read-side twin, identical in shape.
+Three things to notice. First, the cause is always available on `getPrevious()` — the constructor's fifth argument to the parent — so nothing about the original failure is lost, only wrapped. Second, this is **category-preserving**: when the cause is a `FireflyException` (every kernel exception is), its own `httpStatus()`/`category()`/`severity()` are copied onto the wrapper rather than defaulting to a generic `500`/`Internal`, and so are its RFC 9457 extension members and title, which is why a refused `#[PreAuthorize]` still names its `requiredAuthorities` in the problem document after the bus has wrapped it (Chapter 4 shows that document). A `ResourceNotFoundException` (404, business) thrown for an unknown wallet id still renders as a 404 problem-details response even after being wrapped — the bus adds context, it never masks the fault. Third, `send()` checks `$e instanceof CommandProcessingException` before wrapping, so a nested command dispatch (a handler that itself calls `$commands->send(...)`) never gets double-wrapped in two layers of the same exception. `QueryProcessingException` is the read-side twin, identical in shape.
`HandlerRegistry` throws its own, narrower exceptions for a **wiring** mistake rather than a business fault — an unmapped message sent through the bus:
diff --git a/book/src/09-transactions.md b/book/src/09-transactions.md
index bec13452..74827078 100644
--- a/book/src/09-transactions.md
+++ b/book/src/09-transactions.md
@@ -521,6 +521,8 @@ it('transfers atomically: money is conserved across debit + credit', function ()
$dst = $commands->send(new OpenWallet('owner-B', Currency::EUR));
$commands->send(new Deposit($src, 10000));
+ // TransferHandler is guarded like WithdrawHandler: the source wallet's owner may move money out of it.
+ lumenSignInWalletOwner('owner-A');
$commands->send(new Transfer($src, $dst, 4000));
// Debit + credit committed as one unit of work: the 10000 that left nowhere reappears split 6000/4000.
@@ -542,8 +544,17 @@ it('rolls the whole transfer back when the credit leg fails (money cannot vanish
$dst = $commands->send(new OpenWallet('owner-B', Currency::USD));
$commands->send(new Deposit($src, 10000));
- expect(fn () => $commands->send(new Transfer($src, $dst, 4000)))
- ->toThrow(CommandProcessingException::class);
+ // Signed in as the source's owner, so the guard lets the transfer through and it is the CREDIT leg that fails.
+ lumenSignInWalletOwner('owner-A');
+ $failed = null;
+ try {
+ $commands->send(new Transfer($src, $dst, 4000));
+ } catch (CommandProcessingException $e) {
+ $failed = $e;
+ }
+
+ expect($failed)->toBeInstanceOf(CommandProcessingException::class);
+ expect($failed?->getPrevious())->toBeInstanceOf(ConflictException::class);
// Load-bearing, non-tautological proof that money cannot vanish: the source debit was ROLLED BACK (still 10000,
// not 6000) and the destination never received anything (still 0). No value was created or destroyed.
@@ -552,7 +563,7 @@ it('rolls the whole transfer back when the credit leg fails (money cannot vanish
})->group('lumen');
```
-The second test is the one that matters. `Wallet::withdraw()` on the source ran successfully and raised `FundsWithdrawn` into its own buffer; then `Wallet::deposit()` on the destination threw a currency-mismatch `ConflictException` — *after* the debit's `save()` call had already executed inside the same, still-open transaction. Because `#[Transactional]`'s default `rollbackFor` catches every `Throwable`, the entire method rolls back: the source's debit is undone at the database level, and — because rollback happens *before* `dispatchAfterCommit()` would ever be reached on the success path, and `TransactionTemplate` also calls it on the `catch` arm precisely so a rolled-back drain still empties the tracker without ever scheduling a publish — neither `FundsWithdrawn` nor a `FundsDeposited` that never even got raised reaches any listener. `Chapter 7`'s `CommandProcessingException` wraps the underlying `ConflictException`, and the two balance assertions are the whole point: not "the transfer failed" in the abstract, but the source's own `10000` came back **exactly**, and the destination's `0` never moved. No value was created; none was destroyed.
+The second test is the one that matters. `Wallet::withdraw()` on the source ran successfully and raised `FundsWithdrawn` into its own buffer; then `Wallet::deposit()` on the destination threw a currency-mismatch `ConflictException` — *after* the debit's `save()` call had already executed inside the same, still-open transaction. Because `#[Transactional]`'s default `rollbackFor` catches every `Throwable`, the entire method rolls back: the source's debit is undone at the database level, and — because rollback happens *before* `dispatchAfterCommit()` would ever be reached on the success path, and `TransactionTemplate` also calls it on the `catch` arm precisely so a rolled-back drain still empties the tracker without ever scheduling a publish — neither `FundsWithdrawn` nor a `FundsDeposited` that never even got raised reaches any listener. Both tests sign in as the source wallet's owner first, because `TransferHandler` carries Chapter 10's `#[PreAuthorize]`: without a principal the bus would refuse the transfer before the handler ran, and the second test would pass for the wrong reason. That is why it asserts the cause too: Chapter 7's `CommandProcessingException` wraps the `ConflictException` the credit leg threw, not a security refusal, and the two balance assertions are the whole point: not "the transfer failed" in the abstract, but the source's own `10000` came back **exactly**, and the destination's `0` never moved. No value was created; none was destroyed.
---
diff --git a/book/src/09a-orchestration.md b/book/src/09a-orchestration.md
index 0774c38f..e2b1b98a 100644
--- a/book/src/09a-orchestration.md
+++ b/book/src/09a-orchestration.md
@@ -78,7 +78,7 @@ final class PayoutSaga
}
```
-Read it as three local transactions and two undos. `debit` sends the same `Withdraw` command the REST endpoint sends, so it runs in its own `#[Transactional]` boundary and under its own `#[PreAuthorize]` — a saga step is not a back door around method security. `compensate: 'refund'` names the method that undoes it; `dependsOn` orders the steps into topology layers, and `#[Input('…')]` reads a key of the execution's input, `#[FromStep('submit')]` the result an earlier step returned. A compensation receives the forward step's result as its implicit argument (`cancel()` gets the bank reference `submit()` returned) and may also ask for `#[Input]` values, which is how `refund()` knows how much to give back. `confirm` has no compensation because nothing runs after it.
+Read it as three local transactions and two undos. `debit` sends the same `Withdraw` command the REST endpoint sends, so it runs in its own `#[Transactional]` boundary and under its own `#[PreAuthorize]` — a saga step is not a back door around method security: a customer can pay out only from a wallet they own (Chapter 10), and anyone else's payout is refused with the withdraw's own 403 before the bank is called. `compensate: 'refund'` names the method that undoes it; `dependsOn` orders the steps into topology layers, and `#[Input('…')]` reads a key of the execution's input, `#[FromStep('submit')]` the result an earlier step returned. A compensation receives the forward step's result as its implicit argument (`cancel()` gets the bank reference `submit()` returned) and may also ask for `#[Input]` values, which is how `refund()` knows how much to give back. `confirm` has no compensation because nothing runs after it.
---
@@ -177,7 +177,7 @@ Three kinds of failure, three answers. When a compensation did not finish — th
## Checkpoints, recovery and the dead-letter queue
-Every step boundary writes an `ExecutionState` checkpoint through the `ExecutionPersistenceProvider` port. `memory` is the default and survives nothing; `cache` uses any Laravel cache store; `database` uses three portable tables, proven on PostgreSQL, MySQL, MariaDB, SQL Server and SQLite. Lumen's harness runs the payout saga on `database`, which is why the test above can read `firefly_orchestration_executions`:
+Every step boundary writes an `ExecutionState` checkpoint through the `ExecutionPersistenceProvider` port. `memory` is the default and survives nothing; `cache` uses any Laravel cache store; `database` uses three portable tables, proven on PostgreSQL, MySQL, MariaDB, SQL Server and SQLite. A fourth, `event-sourced`, keeps every execution as an event stream in the event store (Chapter 9B). Lumen's harness runs the payout saga on `database`, which is why the test above can read `firefly_orchestration_executions`:
```php
@@ -380,7 +380,7 @@ Steps of one topology layer run one after another, in declaration order: a layer
| `SagaEngine::execute()` / `SagaResult` | Runs a saga in process; `success`, `status`, `firstErrorStepId()`, `compensatedSteps()` |
| TCC | `#[TryMethod]` everywhere first, then `#[ConfirmMethod]` everywhere, or `#[CancelMethod]` for every participant that tried |
| Durable workflow | `#[WaitForSignal]` / `#[WaitForTimer]` park it `WAITING`; a signal or the tick resumes it from its checkpoint |
-| `firefly.orchestration.persistence.provider` | `memory`, `cache` or `database` checkpoints; recovery reports, resumes or compensates stale executions |
+| `firefly.orchestration.persistence.provider` | `memory`, `cache`, `database` or `event-sourced` checkpoints; recovery reports, resumes or compensates stale executions |
| Dead-letter queue | One entry per failed step; `firefly:orchestration:dlq` lists, `firefly:orchestration:dlq:retry` starts it again |
| `firefly.scheduling.lock.provider` | `none`, `cache`, `database`, `mysql` or `postgres` — keeps a scheduled tick on one node |
diff --git a/book/src/09b-eventsourcing.md b/book/src/09b-eventsourcing.md
new file mode 100644
index 00000000..91e8e4de
--- /dev/null
+++ b/book/src/09b-eventsourcing.md
@@ -0,0 +1,478 @@
+Part III — Coordinating and Securing the Application · Chapter 9B
+
+# Event Sourcing: Storing What Happened {.chtitle}
+
+By the end of this chapter you will know how to store what happened instead of what is. You will model Lumen's loans as an event-sourced aggregate (`EventSourcedAggregateRoot`, `recordThat()`) whose events carry a stable name and a version (`#[EventType]`), append them to the database event store under optimistic concurrency (`firefly.eventsourcing.store.provider`, `ConcurrencyException`), and rebuild a loan from its history or from a snapshot (`SnapshotAware`, `firefly.eventsourcing.snapshot.threshold`) through an `EventSourcedRepository`. You will keep a read model current with a `Projection` and its checkpoint (`firefly:eventsourcing:projections:run`, `firefly:eventsourcing:projections:rebuild`), read events stored under an old field name through an `EventUpcaster`, publish every appended event through the outbox in the append's own transaction (`firefly.eventsourcing.publish.enabled`), and store Chapter 9A's saga executions as event streams (`firefly.orchestration.persistence.provider`).
+
+!!! note "New term: event sourcing"
+ **Event sourcing** stores an object as the sequence of facts that happened to it — granted, repaid, repaid, settled — instead of as a row holding its latest values. The current state is what you get by applying those facts in order; nothing about the past is ever overwritten, so the history is not an audit table you hope stays in step, it is the record itself.
+
+---
+
+## What a loan remembers
+
+A `Wallet` is a row: Chapter 5 saves its balance, and Chapter 6's `LedgerProjector` writes a ledger line after each change commits. That is the right model for a balance. A loan is different: what matters is the history — how much was lent, every repayment and when, the moment it was settled — and the balance is just arithmetic over it. So Lumen stores loans the other way round. No table holds a loan's balance; the event store holds its events, and the loan is rebuilt from them whenever a command needs it. The wallet stays exactly as Chapters 5 to 9A built it, and the two meet in one transaction later in this chapter.
+
+---
+
+## Events with a name and a version
+
+An event-sourced event is an ordinary `firefly/domain` `DomainEvent` with one attribute more. This is the repayment:
+
+
+```php
+#[EventType(name: 'loan.repaid', version: 2)]
+final readonly class LoanRepaid extends DomainEvent
+{
+ public function __construct(
+ public string $loanId,
+ public int $amountMinor,
+ public int $outstandingMinor,
+ ?string $eventId = null,
+ ?DateTimeImmutable $occurredAt = null,
+ ) {
+ parent::__construct($eventId, $occurredAt);
+ }
+}
+```
+
+`#[EventType]` gives the event the name the store keeps — `loan.repaid`, never the class name, so the class can move or be renamed without orphaning a single stored row — and the version of its payload. The convention mapper stores the public properties and rebuilds the event through its constructor, which is why `eventId` and `occurredAt` are forwarded to the parent: a reloaded event keeps the identity and the time it was recorded with. `php artisan firefly:cache` compiles every `#[EventType]` class into the `eventsourcing.php` manifest (Chapter 13), and an event class that is not in it is refused at append, so nothing is ever stored that cannot be read back. The version is 2 because the first loans called the amount `amount`; the section on upcasting below is about that.
+
+---
+
+## An aggregate that is its history
+
+`Loan` extends `EventSourcedAggregateRoot`. A command method checks its invariant against the current state and calls `recordThat()`, which applies the event at once and buffers it until the repository saves it; the protected `apply…()` methods are the only code that changes state:
+
+
+```php
+final class Loan extends EventSourcedAggregateRoot implements SnapshotAware
+{
+ // …
+ public static function grant(string $id, string $walletId, Money $principal): self
+ {
+ if (! $principal->isPositive()) {
+ throw new ConflictException('a loan principal must be > 0');
+ }
+
+ $loan = new self($id);
+ $loan->recordThat(new LoanGranted($id, $walletId, $principal->minorUnits, $principal->currency->value));
+
+ return $loan;
+ }
+
+ public function repay(Money $amount): void
+ {
+ if ($this->settled) {
+ throw new ConflictException("loan {$this->id()} is already settled");
+ }
+ if ($amount->currency !== $this->currency || ! $amount->isPositive()) {
+ throw new ConflictException("a repayment must be a positive amount in {$this->currency->value}");
+ }
+ if ($amount->minorUnits > $this->outstanding()->minorUnits) {
+ throw new ConflictException("cannot repay {$amount}; {$this->outstanding()} is outstanding");
+ }
+
+ $this->recordThat(new LoanRepaid($this->id(), $amount->minorUnits, $this->outstanding()->minorUnits - $amount->minorUnits));
+ if ($this->outstanding()->minorUnits === 0) {
+ $this->recordThat(new LoanSettled($this->id(), $this->repayments));
+ }
+ }
+ // …
+ protected function applyLoanGranted(LoanGranted $event): void
+ {
+ $this->walletId = $event->walletId;
+ $this->currency = Currency::from($event->currency);
+ $this->principalMinor = $event->principalMinor;
+ }
+
+ protected function applyLoanRepaid(LoanRepaid $event): void
+ {
+ $this->repaidMinor += $event->amountMinor;
+ $this->repayments++;
+ }
+
+ protected function applyLoanSettled(LoanSettled $event): void
+ {
+ $this->settled = true;
+ }
+```
+
+Two rules make this work. First, the invariants are checked *before* `recordThat()`, against state that only `apply…()` methods ever wrote, so replaying the same events always rebuilds an object that would have made the same decisions. Second, applying is routed by convention — `applyLoanRepaid()` for `LoanRepaid` — and an `apply…()` method must not fail: the event already happened, and history cannot be refused on replay. The constructor belongs to the base class and takes only the id, which is how the repository can create an empty loan before it replays one; `aggregateType()` returns `lumen.loan`, the stable name stored with every event.
+
+---
+
+## The event store and the repository
+
+The events live in an `EventStore`. `memory` is the default and right for trying the model out; `database` keeps them in four portable tables on any Laravel connection:
+
+
+```php
+'store' => [
+ 'provider' => env('FIREFLY_EVENTSOURCING_STORE', 'memory'),
+ 'connection' => env('FIREFLY_EVENTSOURCING_DB_CONNECTION'),
+ 'batch-size' => 500,
+],
+```
+
+Lumen's event-sourcing harness sets `firefly.eventsourcing.store.provider=database`, and the snapshot and checkpoint stores follow it. The repository is a one-line constructor over the `EventSourcingSupport` bean:
+
+
+```php
+#[Component]
+#[Lazy]
+final class LoanRepository extends EventSourcedRepository
+{
+ public function __construct(EventSourcingSupport $support)
+ {
+ parent::__construct($support, Loan::class);
+ }
+}
+```
+
+`get()` replays a loan's stream (404 `AggregateNotFoundException` when it has none), `find()` returns null instead, and `save()` appends the buffered events under the version the loan was loaded at. `#[Lazy]` is there for the sample's sake: `EventSourcingSupport` exists only where `firefly/eventsourcing`'s providers boot, and Lumen's other test harnesses never ask for a loan, so they must never build this bean at boot — the same reason `PayoutController` carries it in Chapter 9A.
+
+---
+
+## Snapshots: replaying less
+
+Replaying three events is free; replaying three thousand on every command is not. An aggregate that implements `SnapshotAware` is snapshotted each time a save moves its version across a multiple of `firefly.eventsourcing.snapshot.threshold`, and a load starts from the newest snapshot and replays only what came after it:
+
+
+```php
+'snapshot' => [
+ 'enabled' => env('FIREFLY_EVENTSOURCING_SNAPSHOT_ENABLED', true),
+ 'store' => env('FIREFLY_EVENTSOURCING_SNAPSHOT_STORE'),
+ 'threshold' => (int) env('FIREFLY_EVENTSOURCING_SNAPSHOT_THRESHOLD', 50),
+ 'keep-count' => 3,
+],
+```
+
+`Loan` says what a snapshot holds — everything replaying would rebuild, as plain values:
+
+
+```php
+ /** @return array{walletId: string, currency: string, principalMinor: int, repaidMinor: int, repayments: int, settled: bool} */
+ public function snapshotState(): array
+ {
+ return [
+ 'walletId' => $this->walletId,
+ 'currency' => $this->currency->value,
+ 'principalMinor' => $this->principalMinor,
+ 'repaidMinor' => $this->repaidMinor,
+ 'repayments' => $this->repayments,
+ 'settled' => $this->settled,
+ ];
+ }
+```
+
+The default threshold is 50; the harness lowers it to 3 so a short test crosses it. A loan granted and then repaid three times has four events, and the test shows what a load does with them:
+
+
+```php
+ $snapshot = $this->app()->make(SnapshotStore::class)->latest('loan-r2');
+ $replayed = $this->app()->make(EventStore::class)->load('loan-r2', 4)->events;
+ $loan = $loans->get('loan-r2');
+
+ expect($snapshot?->version)->toBe(3)
+ ->and($snapshot?->state)->toMatchArray(['repaidMinor' => 40000, 'repayments' => 2])
+ ->and(array_map(static fn (StoredEvent $event): string => $event->eventType, $replayed))->toBe(['loan.repaid'])
+ ->and([$loan->repaidMinor(), $loan->outstanding()->minorUnits, $loan->committedVersion()])->toBe([60000, 30000, 4]);
+```
+
+The snapshot was taken at version 3, so loading the loan restores it and replays one event — the fourth. A snapshot is an optimization and nothing more: one that cannot be written is logged and the save stands, one whose `snapshotVersion()` no longer matches the class is ignored and the loan is replayed from its first event, so changing the snapshot's shape is a version bump, never a migration.
+
+---
+
+## Two writers, one version
+
+Every event of a stream has a version — 1, 2, 3 — and the store refuses a second event at a version that is taken (`UNIQUE(aggregate_id, aggregate_version)`). That is optimistic concurrency, and it is what keeps two requests from repaying the same loan from the same stale picture of it:
+
+
+```php
+ $first = $loans->get('loan-r3');
+ $second = $loans->get('loan-r3');
+
+ $first->repay(new Money(10000, Currency::EUR));
+ $loans->save($first);
+ $second->repay(new Money(10000, Currency::EUR));
+
+ expect(fn () => $loans->save($second))->toThrow(ConcurrencyException::class)
+ ->and($loans->get('loan-r3')->repaidMinor())->toBe(10000);
+```
+
+Both writers loaded version 1. The first save appended version 2; the second tried to append version 2 as well and got a 409 `ConcurrencyException`. Its decision was made against a loan that no longer exists, so the honest answer is to reload and decide again — or to let the 409 reach the client, which the web layer renders as problem+json like any other conflict.
+
+---
+
+## One transaction with the wallet
+
+Granting a loan pays the principal into the wallet; repaying takes the amount back out. Lumen does both in one command, so the event store and the Eloquent wallet share one `#[Transactional]` boundary:
+
+
+```php
+ #[PreAuthorize("hasRole('ADMIN') or (hasRole('WALLET_OWNER') and hasPermission(#command, 'debit'))")]
+ #[Transactional]
+ public function handle(RepayLoan $command): int
+ {
+ $loan = $this->loans->get($command->loanId);
+ $wallet = $this->wallets->findById($loan->walletId())
+ ?? throw new ResourceNotFoundException("Wallet [{$loan->walletId()}] not found");
+
+ $amount = new Money($command->amountMinor, $loan->currency());
+ $loan->repay($amount);
+ $this->loans->save($loan);
+
+ $wallet->withdraw($amount);
+ $this->wallets->save($wallet);
+
+ return $loan->outstanding()->minorUnits;
+ }
+```
+
+The order is deliberate. The repayment is appended first, and only then is the wallet debited — so when the wallet cannot pay, `Wallet::withdraw()` throws *after* the event was written, and the one transaction undoes both:
+
+
+```php
+ $this->commands()->send(new Withdraw($walletId, 85000));
+
+ $caught = null;
+ try {
+ $this->commands()->send(new RepayLoan($loanId, 30000));
+ } catch (CommandProcessingException $e) {
+ $caught = $e;
+ }
+
+ // The repayment was appended, then the wallet refused the withdrawal: one transaction, so both are undone.
+ expect($caught?->getPrevious())->toBeInstanceOf(ConflictException::class)
+ ->and(DB::table(EventSourcingSchema::EVENTS)->where('aggregate_id', $loanId)->count())->toBe(1)
+ ->and($this->app()->make(LoanRepository::class)->get($loanId)->outstanding()->minorUnits)->toBe(90000)
+ ->and($this->queries()->ask(new GetBalance($walletId)))->toBe(5000);
+```
+
+The stream still holds only `loan.granted`, the loan still owes 900.00, and the wallet still holds its 50.00: nothing half-happened. The handler is guarded like Chapter 10's `Withdraw`: an `ADMIN` may repay any loan, a `WALLET_OWNER` only a loan paid into a wallet they own — `WalletPermissionEvaluator` loads the loan to find that wallet, and refuses a loan it cannot find. It is `#[Lazy]` for the repository's reason. One more consequence of sharing the transaction: `save()` drains the loan's buffer, so its events are never dispatched a second time by `firefly/data`'s after-commit wiring (Chapter 9); the wallet's own `FundsWithdrawn` still is.
+
+---
+
+## Projections and their checkpoints
+
+The events answer "what happened to this loan"; they are a poor answer to "which loans are still open". That question belongs to a read model, and a **projection** keeps one from the events. `LoanBookProjection` is a component the runner finds by itself:
+
+
+```php
+ public function name(): string
+ {
+ return self::NAME;
+ }
+
+ public function handles(): array
+ {
+ return ['loan.granted', 'loan.repaid', 'loan.settled'];
+ }
+
+ public function handle(StoredEvent $event): void
+ {
+ $payload = $event->payload;
+ $loan = DB::table(self::TABLE)->where('loan_id', $event->aggregateId);
+
+ if ($event->eventType === 'loan.granted') {
+ DB::table(self::TABLE)->insert([
+ 'loan_id' => $event->aggregateId,
+ 'wallet_id' => self::text($payload, 'walletId'),
+ 'currency' => self::text($payload, 'currency'),
+ 'principal_minor' => self::number($payload, 'principalMinor'),
+ 'repaid_minor' => 0,
+ 'outstanding_minor' => self::number($payload, 'principalMinor'),
+ 'repayments' => 0,
+ 'status' => 'open',
+ ]);
+ } elseif ($event->eventType === 'loan.repaid') {
+ $loan->incrementEach(
+ ['repaid_minor' => self::number($payload, 'amountMinor'), 'repayments' => 1],
+ ['outstanding_minor' => self::number($payload, 'outstandingMinor')],
+ );
+ } else {
+ $loan->update(['status' => 'settled']);
+ }
+ }
+```
+
+A projection reads the **global stream** — every event of every aggregate, in one order — from its **checkpoint**, the position it has projected up to. The test shows the gap between the two worlds and the command that closes it:
+
+
+```php
+ // The events are stored; the loan book has not been told yet.
+ expect(DB::table(LoanBookProjection::TABLE)->count())->toBe(0);
+
+ $exit = Artisan::call('firefly:eventsourcing:projections:run', ['projection' => LoanBookProjection::NAME]);
+
+ expect($exit)->toBe(0)
+ ->and(Artisan::output())->toContain('lumen.loan-book: caught-up, 2 event(s) handled, at position 2')
+ ->and((array) DB::table(LoanBookProjection::TABLE)->where('loan_id', $loanId)->first())->toMatchArray([
+ 'wallet_id' => $walletId,
+ 'principal_minor' => 90000,
+ 'repaid_minor' => 30000,
+ 'outstanding_minor' => 60000,
+ 'repayments' => 1,
+ 'status' => 'open',
+ ])
+ ->and(DB::table(EventSourcingSchema::CHECKPOINTS)->where('projection', LoanBookProjection::NAME)->value('position'))->toEqual(2);
+```
+
+Each batch is one transaction on the checkpoint store's connection: the runner hands the projection the events it `handles()`, moves the checkpoint from the position it read to the batch's last one, and commits both. `LoanBookProjection` writes through the default connection, which is that connection while `firefly.eventsourcing.store.connection` is null, so its rows and its checkpoint commit together and every event is projected exactly once; a projection that writes anywhere else is at-least-once and must be idempotent. A batch is taken under a lease on the `DistributedLock` port (`firefly.scheduling.lock.provider`, for `firefly.eventsourcing.projection.lease-ttl`), so you can run the command on every node and only one advances each projection at a time — and the checkpoint advance is fenced, so a runner whose lease expired mid-batch rolls its batch back instead of overwriting a newer position. Every write in `handle()` names the row by its primary key, which is what lets SQL Server run it without waiting on another worker's uncommitted rows. Operators drive projections from the command line:
+
+
+```bash
+php artisan firefly:eventsourcing:projections
+php artisan firefly:eventsourcing:projections:run
+php artisan firefly:eventsourcing:projections:run --live
+php artisan firefly:eventsourcing:projections:rebuild lumen.loan-book
+```
+
+The first lists every projection with its checkpoint, the global head and its lag; the second catches every projection up and exits, and `--live` keeps following the stream under a process supervisor; the last resets the projection and its checkpoint and replays the whole stream into it — `LoanBookProjectionTest` deletes the loan book and gets it back that way. The global stream is numbered in **commit** order, not insert order: a sequence hands out its value when a row is inserted, so a transaction holding position 10 could commit after the one holding 11, and a reader that had checkpointed 11 would skip 10 forever. The store numbers committed events in a short transaction of its own before every global read, so a reader never sees an event appear below a position it has already passed.
+
+---
+
+## Upcasting a renamed field
+
+Events are immutable, so a changed shape is a new version. The first loans stored their repayments as `loan.repaid` version 1, with the amount called `amount`; the class now says `amountMinor`, like every other money field in Lumen. The stored rows are never rewritten. An `EventUpcaster` moves a version-1 payload forward each time one is read:
+
+
+```php
+#[Component]
+final class LoanRepaidV1Upcaster implements EventUpcaster
+{
+ public function eventType(): string
+ {
+ return 'loan.repaid';
+ }
+
+ public function fromVersion(): int
+ {
+ return 1;
+ }
+
+ public function priority(): int
+ {
+ return 0;
+ }
+
+ public function upcast(StoredEvent $event): StoredEvent
+ {
+ $payload = $event->payload;
+ $payload['amountMinor'] = $payload['amount'] ?? 0;
+ unset($payload['amount']);
+
+ return $event->upcasted('loan.repaid', 2, $payload);
+ }
+}
+```
+
+Upcasting happens in the store, on read, so the repository and every projection see the same version-2 event. The test writes a repayment exactly as an old deployment left it and reads it both ways:
+
+
+```php
+ // A repayment exactly as the first loans were stored: version 1, its amount still called `amount`.
+ $this->app()->make(EventStore::class)->append('loan-legacy', Loan::aggregateType(), [
+ new NewEvent('loan.repaid', ['loanId' => 'loan-legacy', 'amount' => 20000, 'outstandingMinor' => 30000], 1),
+ ], 1);
+
+ $loan = $loans->get('loan-legacy');
+ Artisan::call('firefly:eventsourcing:projections:run', ['projection' => LoanBookProjection::NAME]);
+
+ expect([$loan->repaidMinor(), $loan->outstanding()->minorUnits, $loan->repayments()])->toBe([20000, 30000, 1])
+ ->and(DB::table(LoanBookProjection::TABLE)->where('loan_id', 'loan-legacy')->value('repaid_minor'))->toEqual(20000)
+ ->and(DB::table(EventSourcingSchema::EVENTS)->where('aggregate_id', 'loan-legacy')->where('event_type', 'loan.repaid')->value('event_version'))->toEqual(1);
+```
+
+The row in `firefly_eventsourcing_events` still says version 1; the loan and the loan book both read 200.00 repaid. Upcasters chain by type and version (`v1 → v2 → v3`), the highest `priority()` wins among two for the same step, and an event whose version is still not the registered one after upcasting is refused with the upcaster it is missing — the first test in that file fails exactly that way until `LoanRepaidV1Upcaster` exists.
+
+---
+
+## Publishing through the outbox, in the same transaction
+
+Other services want to hear that a loan was repaid. Chapter 8's outbox already solves "publish only what committed, and never lose it"; the event store plugs into it:
+
+
+```php
+'publish' => [
+ 'enabled' => env('FIREFLY_EVENTSOURCING_PUBLISH_ENABLED', false),
+ 'destination' => env('FIREFLY_EVENTSOURCING_PUBLISH_DESTINATION', 'eventsourcing.events'),
+ 'exclude-aggregate-types' => ['orchestration.execution'],
+],
+```
+
+With `firefly.eventsourcing.publish.enabled` on and `firefly.eda.provider=database` (or `postgres`), every appended event is written to `firefly_eda_outbox` *inside the append's transaction*, with its `x-event-id`, `x-event-version`, `x-aggregate-id`, `x-aggregate-type`, `x-aggregate-version` and `x-occurred-at` headers. Lumen's outbox harness publishes loans on `lumen.loans`:
+
+
+```php
+ expect(DB::table(OutboxSchema::TABLE)->where('destination', LumenEventSourcingOutboxTestCase::DESTINATION)->orderBy('id')->pluck('event_type')->all())
+ ->toBe(['loan.granted', 'loan.repaid'])
+ ->and(DB::table(OutboxSchema::TABLE)->where('destination', 'wallet.events')->orderBy('id')->pluck('event_type')->all())
+ ->toBe(['WalletOpened', 'FundsDeposited', 'FundsWithdrawn'])
+ ->and(is_string($headers) ? json_decode($headers, true) : null)->toHaveKey('x-aggregate-id', $loanId)
+ ->and(DB::table(OutboxSchema::TABLE)->where('status', OutboxSchema::STATUS_PENDING)->count())->toBe(5);
+```
+
+Two sources, one table, one transaction: the loan's events come from the event store, the wallet's from `firefly/data`'s pre-commit hook, and they commit or roll back together — the second test in the file repeats the overdrawn repayment and finds only `loan.granted` in the outbox. `php artisan firefly:eda:consume` then delivers all five, at least once, exactly as in Chapter 8. The event store and the outbox must share a connection, or the boot is refused naming both keys. Under any other eda provider the events are published after the append commits, at most once; when every event must reach the broker, use the outbox.
+
+---
+
+## Orchestration on the event store
+
+Chapter 9A's checkpoints went to `memory`, `cache` or three portable tables. `firefly/eventsourcing` adds a fourth persistence provider, `event-sourced`, which keeps every saga, TCC and workflow execution as an aggregate in the event store:
+
+
+```dotenv
+FIREFLY_EVENTSOURCING_STORE=database
+FIREFLY_ORCHESTRATION_PERSISTENCE=event-sourced
+```
+
+Nothing in the saga changes. Lumen runs the same payout saga on it:
+
+
+```php
+ $result = $this->app()->make(SagaEngine::class)->execute('payout', ['walletId' => $walletId, 'iban' => 'ES9121000418450200051332', 'amountMinor' => 20000]);
+ $stream = $this->app()->make(EventStore::class)->load($result->correlationId);
+
+ expect($result->success)->toBeTrue()
+ ->and($this->app()->make(ExecutionPersistenceProvider::class))->toBeInstanceOf(EventSourcedExecutionPersistenceProvider::class)
+ ->and($stream->aggregateType())->toBe('orchestration.execution')
+ ->and($stream->events[0]->eventType)->toBe('orchestration.execution.started')
+ ->and(DB::table(OrchestrationSchema::EXECUTIONS)->where('correlation_id', $result->correlationId)->value('status'))->toBe('COMPLETED')
+ ->and($this->queries()->ask(new GetBalance($walletId)))->toBe(30000);
+```
+
+The execution's stream starts with `orchestration.execution.started` and records each checkpoint as the events that changed it, so a replay gives back exactly the state that was saved. The queries the engines run — in-flight, stale and due executions, search attributes — still read orchestration's own tables, now an *inline* projection written in the same transaction as the events; `php artisan firefly:eventsourcing:projections:rebuild orchestration.executions` writes them again from the streams, which the file's second test does after deleting them. Executions are excluded from publishing by default (`exclude-aggregate-types`).
+
+---
+
+## On every database
+
+Everything above runs on the sample's SQLite `:memory:` database in the default gate, and again on real servers in the integration lanes of Chapter 12: `LoanLedgerRoundTripTest` grants, repays and settles a loan with its snapshot, its outbox rows and its loan book, and reads a version-1 repayment, on PostgreSQL, MySQL, MariaDB, SQL Server and a SQLite file; `EventSourcedPayoutRoundTripTest` does the same for the event-sourced payout. Run one lane with its DSN variable set and `composer test:integration -- samples/lumen/tests/Integration`. The commit-order numbering is what those lanes prove hardest: it uses `READ COMMITTED` on MySQL and MariaDB and `READPAST` on SQL Server so an open append is neither seen nor waited for.
+
+!!! laravel "Laravel parity"
+ Laravel's events are notifications: `Event::dispatch()` leaves nothing behind unless a listener writes it down, and an Eloquent model keeps only the latest version of its row. An audit trail is a second table you keep in step by hand. `firefly/eventsourcing` inverts that for the aggregates that need it — the events are the record, the row is a projection you can drop and rebuild — while Eloquent stays exactly what it is for everything else, in the same transaction.
+
+---
+
+## What you learned {.recap}
+
+| Concept | What it does |
+|---|---|
+| `EventSourcedAggregateRoot` / `recordThat()` | State is the replay of recorded events; `apply…()` methods are the only writers |
+| `#[EventType]` | A stable stored name and a payload version, compiled by `firefly:cache` |
+| `EventSourcedRepository` | `get()` replays, `save()` appends under the loaded version — a stale writer gets a 409 `ConcurrencyException` |
+| `SnapshotAware` | A snapshot every `firefly.eventsourcing.snapshot.threshold` events; a load replays only what came after it |
+| `Projection` + checkpoint | A read model from the commit-ordered global stream, rows and checkpoint in one transaction, under a lease |
+| `EventUpcaster` | Moves an old event version forward on read, in the store, for repositories and projections alike |
+| `firefly.eventsourcing.publish.enabled` | Writes each appended event to the outbox in the append's transaction |
+| `event-sourced` orchestration | Saga, TCC and workflow executions as event streams, orchestration's tables as their inline read model |
+
+---
+
+## Try it yourself {.exercises}
+
+1. **Settle in one go.** Add a test to `LoanCommandsTest` that repays a whole loan in one `RepayLoan` and asserts that the single save appended two events — `loan.repaid` and `loan.settled` — then check whether that save crossed the snapshot threshold.
+2. **A third version.** Imagine `loan.repaid` version 3 adds a `channel` field defaulting to `wallet`. Write the version-2-to-3 upcaster, raise the `#[EventType]` version, and confirm with `LoanRepaidUpcasterTest` that a version-1 row now travels `v1 → v2 → v3` on read.
+3. **A second read model.** Write a projection that counts repayments per wallet into a table of its own, run `php artisan firefly:eventsourcing:projections:run`, and then rebuild only it with `php artisan firefly:eventsourcing:projections:rebuild` — the loan book's checkpoint does not move.
diff --git a/book/src/10-security.md b/book/src/10-security.md
index fcebd3c1..1095ff82 100644
--- a/book/src/10-security.md
+++ b/book/src/10-security.md
@@ -723,13 +723,15 @@ use Lumen\Infrastructure\WalletRepository;
* raises a ConflictException that rolls the transaction back and surfaces as a CommandProcessingException), persists,
* and returns the new balance in minor units. #[Transactional] for the same reason as the sibling handlers.
*
- * Guarded by #[PreAuthorize]: only an ADMIN or the WALLET_OWNER may debit. SecurityExpressionEvaluator is a CLOSED
- * whitelist (booleans and/or/not, string literals, #param, and eight fixed functions — no `==`, no `.property`
- * navigation), so ownership is modelled as a granted WALLET_OWNER authority rather than an `#command.ownerId ==
- * authentication.name` comparison, which the parser cannot express. SecurityCommandAuthorizer enforces this at the bus
- // …
- * (The faithful owner-only alternative — hasPermission(#command, 'withdraw') backed by a custom PermissionEvaluator
- * bean that loads the wallet and compares its owner_id to the current principal — is left to a later step.)
+ * Guarded by #[PreAuthorize]: an ADMIN may debit any wallet; a WALLET_OWNER only the wallet it owns. The role alone
+ * is not ownership — any customer holds it — so the rule also asks hasPermission(#command, 'debit'), which the
+ * framework hands, with the Withdraw bound to #command, to the application's PermissionEvaluator:
+ * WalletPermissionEvaluator loads the wallet and grants the debit only when its owner_id is the principal's name.
+ * SecurityExpressionEvaluator is a CLOSED whitelist (booleans and/or/not, string literals, #param, and a fixed set of
+ * functions — no `==`, no `.property` navigation), so the comparison cannot be written in the expression; the
+ * evaluator is where it lives. SecurityCommandAuthorizer enforces this at the bus BEFORE the handler runs; a denied
+ * withdraw surfaces as CommandProcessingException wrapping AuthorizationException (403) for a signed-in principal
+ * without the role or without the wallet, or AuthenticationException (401) when there is no principal at all.
*
* Intentionally NOT final: the generated transactional proxy subclasses this handler.
*/
@@ -738,7 +740,7 @@ class WithdrawHandler
{
public function __construct(private readonly WalletRepository $wallets) {}
- #[PreAuthorize("hasRole('ADMIN') or hasRole('WALLET_OWNER')")]
+ #[PreAuthorize("hasRole('ADMIN') or (hasRole('WALLET_OWNER') and hasPermission(#command, 'debit'))")]
#[Transactional]
public function handle(Withdraw $command): int
{
@@ -753,7 +755,83 @@ class WithdrawHandler
}
```
-Read that docblock's second paragraph carefully — it is the whole lesson in miniature. The *naive* way to express "only the wallet's own owner may withdraw" would be something like `#command.ownerId == authentication.name`, and the grammar simply cannot say that: there is no `.` operator and no `==`. The real, shipped alternative is coarser but genuinely expressible: grant a `WALLET_OWNER` authority to whoever should be allowed, and check `hasRole('WALLET_OWNER')`. A true per-resource ownership check, when you need one, routes through `hasPermission(#command, 'withdraw')` and a custom `PermissionEvaluator` bean that does the actual comparison in ordinary PHP — the grammar dispatches to your code, it does not try to become expressive enough to write the comparison itself.
+Read that docblock's second paragraph carefully — it is the whole lesson in miniature. The *naive* way to express "only the wallet's own owner may withdraw" would be something like `#command.ownerId == authentication.name`, and the grammar simply cannot say that: there is no `.` operator and no `==`. Nor is the role enough on its own. Every customer holds `WALLET_OWNER`, so a rule that stopped at `hasRole('WALLET_OWNER')` would let any customer debit *any* wallet whose id they could learn — an insecure direct object reference, the hole a role-only rule always leaves. The role says what kind of principal this is; it says nothing about *which* wallet is theirs.
+
+That question goes to `hasPermission(#command, 'debit')`. The scanner binds `#command` to the handler's `$command` parameter, the bus hands the expression the `Withdraw` it is about to dispatch, and `hasPermission()` passes that command, with the current `Authentication`, to the application's `PermissionEvaluator`. The grammar dispatches to your code; it does not try to become expressive enough to write the comparison itself. Lumen's evaluator is a plain component:
+
+
+```php
+isAuthenticated()) {
+ return false;
+ }
+
+ $walletId = match (true) {
+ $target instanceof Withdraw => $target->walletId,
+ $target instanceof Transfer => $target->sourceWalletId,
+ $target instanceof RepayLoan => $this->walletOfLoan($target->loanId),
+ default => null,
+ };
+ if ($walletId === null) {
+ return false;
+ }
+
+ $owner = $this->wallets->findById($walletId)?->ownerId() ?? '';
+
+ return $owner !== '' && $owner === $authentication->getName();
+ }
+
+ private function walletOfLoan(string $loanId): ?string
+ {
+ $loans = $this->container->make(LoanRepository::class);
+
+ return $loans->find($loanId)?->walletId();
+ }
+}
+```
+
+Three things about it are worth copying. **It is registered by being a bean**: `SecurityAutoConfiguration` defines its `DenyAllPermissionEvaluator` only `#[ConditionalOnMissingBean(PermissionEvaluator::class)]`, so a scanned component that implements the interface displaces the default with no further wiring. **It fails closed**: a permission name other than `debit`, a target type it was not written for, a wallet or loan that does not exist, or a principal that is not authenticated each answer `false`, and an exception thrown inside it is caught by the evaluator's `catch (\Throwable)` and becomes `false` too. **It answers one question for every command that debits**: a `Withdraw` names its wallet, a `Transfer` debits its *source* wallet — owning the destination grants nothing — and a `RepayLoan` (Chapter 9B) names a loan whose wallet the evaluator reads from the event store, so all three handlers carry the same rule. The parser does not short-circuit `or`, so the evaluator runs for an `ADMIN` as well; its answer simply does not change the outcome there.
---
@@ -1009,7 +1087,62 @@ Two real, shipped test files exercise the entire chain — HTTP request in, `Sec
The three enforcement seams give the same pair of answers, because they share the same evaluator: 401 means *signing in would help*, 403 means *it would not*. A client can tell which, and act accordingly.
-The bus wraps either one in `CommandProcessingException`, which copies the cause's error code, HTTP status and category — so the wire carries `AUTHENTICATION_FAILED` or `ACCESS_DENIED` rather than the bus's own generic code. And in both tests the balance assertion afterwards is doing real work: it proves the debit genuinely never happened, not merely that the HTTP response looked like a rejection.
+The bus wraps either one in `CommandProcessingException`, which copies the cause's error code, HTTP status, category and extension members — so the wire carries `AUTHENTICATION_FAILED` or `ACCESS_DENIED` rather than the bus's own generic code. And in both tests the balance assertion afterwards is doing real work: it proves the debit genuinely never happened, not merely that the HTTP response looked like a rejection.
+
+The role without the wallet is refused the same way. A principal holding `ROLE_WALLET_OWNER` who owns *some* wallet, just not this one, gets the 403 and nothing moves:
+
+
+```php
+it('denies a withdraw from a WALLET_OWNER who does not own the wallet as 403 problem+json', function () {
+ // …
+ SecurityContextHolder::setContext(new SecurityContext(
+ Authentication::authenticated('owner-7b', 'owner-7b', [new SimpleGrantedAuthority('ROLE_WALLET_OWNER')])
+ ));
+ // …
+ $id = $this->postJson('/api/v1/wallets', ['owner_id' => 'owner-7a', 'currency' => 'EUR'])->json('wallet_id');
+ $this->postJson("/api/v1/wallets/{$id}/deposit", ['amount_minor' => 5000]);
+
+ $this->postJson("/api/v1/wallets/{$id}/withdraw", ['amount_minor' => 1000])
+ ->assertStatus(403)
+ // …
+ ->assertJsonPath('code', 'ACCESS_DENIED')
+ ->assertJsonPath('category', 'security');
+
+ // Nothing was debited, and the ledger never saw a withdrawal.
+ $this->getJson("/api/v1/wallets/{$id}/balance")->assertJson(['balance_minor' => 5000]);
+```
+
+That test is the one that matters most in this chapter: it is the request an attacker would actually send. The 401 and the role-less 403 above prove the endpoint is guarded; this one proves the guard looks at the wallet.
+
+A transfer is a debit too, and `TransferHandler` carries the same rule. Its `Transfer` names two wallets, and the evaluator asks about the **source** — the wallet the money leaves. Owning the destination is not enough: the principal below holds the role and owns the receiving wallet, and is refused before either wallet is touched:
+
+
+```php
+it('denies a transfer out of a wallet the WALLET_OWNER does not own as 403 problem+json and moves no money', function () {
+ // …
+ SecurityContextHolder::setContext(new SecurityContext(
+ Authentication::authenticated('owner-10b', 'owner-10b', [new SimpleGrantedAuthority('ROLE_WALLET_OWNER')])
+ ));
+ // …
+ $src = $this->postJson('/api/v1/wallets', ['owner_id' => 'owner-10a', 'currency' => 'EUR'])->json('wallet_id');
+ // …
+ $dst = $this->postJson('/api/v1/wallets', ['owner_id' => 'owner-10b', 'currency' => 'EUR'])->json('wallet_id');
+ $this->postJson("/api/v1/wallets/{$src}/deposit", ['amount_minor' => 5000]);
+
+ $this->postJson('/api/v1/wallets/transfers', [
+ 'source_wallet_id' => $src,
+ 'destination_wallet_id' => $dst,
+ 'amount_minor' => 1000,
+ ])->assertStatus(403)
+ // …
+ ->assertJsonPath('code', 'ACCESS_DENIED')
+ ->assertJsonPath('category', 'security');
+
+ $this->getJson("/api/v1/wallets/{$src}/balance")->assertJson(['balance_minor' => 5000]);
+ $this->getJson("/api/v1/wallets/{$dst}/balance")->assertJson(['balance_minor' => 0]);
+```
+
+The same file proves that an anonymous transfer is a 401 and that an `ADMIN` may move money between two wallets it owns neither of; `TransferSecurityTest.php` proves all of it again through the bus.
Grant the right authority and the same command succeeds:
@@ -1035,7 +1168,7 @@ it('allows an authorized withdraw and renders an overdraw as 409 problem+json',
->assertJsonPath('category', 'business');
```
-The granted authority is `'ROLE_WALLET_OWNER'` — already carrying the `ROLE_` prefix — and `WithdrawHandler`'s expression calls `hasRole('WALLET_OWNER')`, which `SecurityExpressionRoot::hasRole()` normalises to the identical `'ROLE_WALLET_OWNER'` string before checking membership; the two spellings meet in the middle. The `409` in the second call is the important contrast: authorization and domain validation are two genuinely independent layers — being *allowed* to withdraw says nothing about whether the withdrawal itself is *valid*, and `Wallet::withdraw()`'s own no-overdraw invariant (Chapter 6) still applies exactly as before.
+The granted authority is `'ROLE_WALLET_OWNER'` — already carrying the `ROLE_` prefix — and `WithdrawHandler`'s expression calls `hasRole('WALLET_OWNER')`, which `SecurityExpressionRoot::hasRole()` normalises to the identical `'ROLE_WALLET_OWNER'` string before checking membership; the two spellings meet in the middle. The principal is `owner-4` and the wallet is opened for `owner-4`, which is what lets `hasPermission(#command, 'debit')` grant the debit. The `409` in the second call is the important contrast: authorization and domain validation are two genuinely independent layers — being *allowed* to withdraw says nothing about whether the withdrawal itself is *valid*, and `Wallet::withdraw()`'s own no-overdraw invariant (Chapter 6) still applies exactly as before.
`samples/lumen/tests/Application/TransferSecurityTest.php` proves the same guard directly against the `CommandBus`, with the `getPrevious()` unwrap pattern Chapter 7 introduced:
@@ -1093,6 +1226,7 @@ it('enforces #[PreAuthorize] on withdraw: denies without the owner role, allows
| `HttpSecurity` | Deny-by-default URL rules, first-match-wins, compiling to the same expression grammar as method security |
| `SecurityExpressionEvaluator` | A hand-rolled, closed-whitelist tokenizer/parser — no `eval`, no `==`, no `.property` navigation; ten functions total (`hasRole`, `hasAnyRole`, `hasAuthority`, `hasAnyAuthority`, `hasScope`, `hasAnyScope`, `hasPermission`, `isAuthenticated`, `permitAll`, `denyAll`) plus `#param` references |
| `hasRole('X')` | Normalises to `hasAuthority('ROLE_X')` — bare and `ROLE_`-prefixed spellings are equivalent |
+| `hasPermission(#command, 'debit')` | Hands the command and the principal to the application's `PermissionEvaluator`; Lumen's `WalletPermissionEvaluator` grants a debit only to the wallet's owner and refuses everything else |
| `SecurityCommandAuthorizer` / `SecurityQueryAuthorizer` | The real `#[Order(500)]` implementations that replace Chapter 7's `AllowAllAuthorizer`, only when `firefly.security.enabled=true` |
| `MethodSecurityMessageEnforcer` | Joins the CQRS `HandlerManifest` to the compiled `SecurityMethodManifest`; enforces before `handle()` ever runs |
| `SecurityContextPersistenceFilter` | Loads the context from the session at `-94`, saves what changed, and clears the holder in a `finally` |
@@ -1110,7 +1244,7 @@ it('enforces #[PreAuthorize] on withdraw: denies without the owner role, allows
## Try it yourself {.exercises}
1. **Write a rejected expression.** Try compiling `#[PreAuthorize("#command.ownerId == authentication.name")]` on a scratch handler and run `php artisan firefly:cache`. Confirm `MethodSecurityScanner` fails loud with a `ConfigurationException` naming the exact class and method, rather than silently accepting an expression that would always deny (or fail to parse) at runtime.
-2. **Add a real ownership check.** In a scratch copy of the project, implement a `PermissionEvaluator` bean that loads a wallet by id and compares its `owner_id` to `Authentication::getName()`, bind it to replace `DenyAllPermissionEvaluator`, and change `WithdrawHandler`'s expression to `hasPermission(#command, 'withdraw')`. Confirm a `WALLET_OWNER`-less principal who genuinely owns the wallet can now withdraw, while a different owner cannot.
+2. **Guard a read, and count the cost.** Every debit is now owner-or-admin: `WithdrawHandler`, `TransferHandler` and `RepayLoanHandler` carry the same rule. Reads are still open, so anyone can ask for any wallet's balance. In a scratch copy of the project, give `WalletPermissionEvaluator` a second permission, `view`, that a `GetBalance` grants to the owner of its `walletId`, and put `#[PreAuthorize("hasRole('ADMIN') or hasPermission(#query, 'view')")]` on `GetBalanceHandler::handle()`. Confirm that another owner's `GET /api/v1/wallets/{id}/balance` is now a 403, and that a balance cached for the owner is still refused to everyone else, because the query bus authorizes before it reads the cache. Then run the sample's suite and count the tests that read a balance with no principal: that count is what guarding a read really costs.
3. **Own the login page.** Add a `#[GetMapping('/login')]` action of your own to a scratch project with `form_login.enabled` on, boot it, and confirm the framework's page does *not* replace yours — then confirm your form still signs people in by posting the session token to the processing URL, because `FormLoginFilter` answers before routing either way.
4. **Watch the entry point switch.** Against a protected path, send `Accept: text/html` and then `Accept: application/json`, and confirm you get a redirect to `/login` and a `401 application/problem+json` respectively. Then set `firefly.security.http.entry_point` to `problem` and confirm the browser request gets the 401 too.
5. **Put a rule on a plain service.** Add `#[PreAuthorize("hasRole('ADMIN')")]` to a method on a non-final `#[Service]` that is neither a controller nor a handler, run `php artisan firefly:cache`, and confirm the call is refused. Then mark the class `final`, re-run the cache, and confirm the scanner refuses at compile time rather than shipping a rule nothing could enforce.
diff --git a/book/src/90-appendix-a-laravel.md b/book/src/90-appendix-a-laravel.md
index 3b9e9791..4a9286c8 100644
--- a/book/src/90-appendix-a-laravel.md
+++ b/book/src/90-appendix-a-laravel.md
@@ -28,6 +28,7 @@ LaraFly is **additive**: it layers a Spring-Boot-shaped application model on top
| Outbound webhooks | A queued job per event calling `Http::post()` | `firefly/callbacks`: subscriptions, signed deliveries, retries, a breaker per endpoint, a replayable log | 8A |
| Long-running processes | `Bus::chain()` / `Bus::batch()` — no undo for the jobs that succeeded | `firefly/orchestration`: sagas with compensation, TCC, durable workflows with checkpoints | 9A |
| One node per scheduled task | `->onOneServer()` on a cache lock | `#[Scheduled(lock: …)]` over `firefly.scheduling.lock.provider` (`cache`, `database`, `mysql`, `postgres`) | 9A |
+| Event sourcing | No event store — Eloquent keeps the latest row; history is an audit table kept in step by hand | `firefly/eventsourcing`: event-sourced aggregates over a database event store, snapshots, upcasters, checkpointed projections, outbox publication | 9B |
---
diff --git a/book/src/94-glossary.md b/book/src/94-glossary.md
index 5a1dd2c0..7a5d4e5c 100644
--- a/book/src/94-glossary.md
+++ b/book/src/94-glossary.md
@@ -26,6 +26,8 @@
**Callback (outbound webhook)** — An HTTP request LaraFly sends to a subscriber when an event the subscriber asked for happens on the bus: signed with `X-Firefly-Timestamp` and `X-Firefly-Signature`, retried with back-off, guarded by a circuit breaker per endpoint, and recorded in a replayable execution log. Shipped by `firefly/callbacks` (Chapter 8A).
+**Checkpoint** — The position on the global event stream up to which a projection has projected, kept in `firefly_eventsourcing_checkpoints` and advanced in the same transaction as the projection's own rows; a rebuild resets it to 0 (Chapter 9B).
+
**Circuit breaker** — A resilience pattern that trips to an open state after a threshold of consecutive failures against a dependency, short-circuiting further calls until the dependency has had time to recover. `firefly/observability`'s `MeterBindingsPass` exposes each configured breaker's live state as a `resilience_circuit_breaker_state{name}` gauge (Chapter 11).
**Claim strategy** — How an outbox consumer picks a row without another consumer picking the same one: `skip-locked` (PostgreSQL, MySQL 8.0.1+, MariaDB 10.6+), `read-past` (SQL Server) or the portable `optimistic-lease`, chosen by `auto` from the server's reported version (Chapter 8).
@@ -52,6 +54,10 @@
**Entity** — A domain object with a stable identity that persists through state changes. LaraFly's `make:firefly-entity` scaffolds a class extending `Firefly\Domain\Entity` — there is no `#[Entity]` attribute (Chapters 6, 13).
+**Event sourcing** — Storing an aggregate as the ordered events that happened to it rather than as a row of its latest values; its state is their replay (`EventSourcedAggregateRoot`, `recordThat()`) (Chapter 9B).
+
+**Event store** — The append-only home of those events: one stream per aggregate, appended under optimistic concurrency, and one global stream numbered in commit order for projections; `memory` or `database` (`firefly.eventsourcing.store.provider`) (Chapter 9B).
+
**EventListener** — Either of two distinct, deliberately unrelated surfaces: `#[AsEventListener]` for Laravel's own in-process event dispatch, and `#[EventListener]` for `firefly/eda`'s broker-backed bus, which subscribes to an event-type *pattern* rather than a PHP class (Chapter 8).
**Exposure** — An optional `FeatureFlagEvaluated` event recording an ordinary evaluation for experiment analysis. A management preview emits none, and a value graph over 10,000 occurrences omits the event without changing the result (Chapter 13A).
@@ -88,7 +94,7 @@
**Problem Details (RFC 7807)** — The standard `application/problem+json` error shape (`type`, `title`, `status`, plus framework-specific extension keys like `code`/`category`) every unhandled exception renders as. `ProblemDetailsRenderer` is what produces it (Chapters 4, 12).
-**Projection** — A read model built by consuming a stream of domain events, kept separate from the write model that produced them — Lumen's ledger is a projection over `Wallet`'s own domain events (Chapter 6).
+**Projection** — A read model built by consuming a stream of domain events, kept separate from the write model that produced them — Lumen's ledger is a projection over `Wallet`'s own domain events (Chapter 6), and its loan book a checkpointed projection of the event store's global stream (Chapter 9B).
**Proxy (`#[Transactional]` proxy)** — A generated subclass that wraps a `#[Transactional]`-attributed method with declarative transaction boundaries (`beginTransaction()`/`commit()`/`rollBack()`), produced by the same reflection-free `ProxyClassGenerator` whether at boot time (dev) or ahead of time by `firefly:cache` (production) (Chapters 9, 13).
@@ -110,6 +116,8 @@
**Slice test** — A test that boots only the beans a narrow vertical needs — the web pipeline over one controller (`WebSliceTestCase`), or the data pipeline over one repository (`DataSliceTestCase`) — rather than the whole application, so a mis-wired slice fails fast rather than passing by accident (Chapter 12).
+**Snapshot** — A saved copy of an event-sourced aggregate's state at one version, so a load replays only the events after it; taken every `firefly.eventsourcing.snapshot.threshold` events for a `SnapshotAware` aggregate, and ignored once its shape version no longer matches (Chapter 9B).
+
**Stereotype** — An attribute that both registers a class as a bean and signals its architectural role — `#[Service]`, `#[Repository]`, `#[Component]`, `#[Configuration]`, `#[RestController]`. Every stereotype is a specialization of the base `#[Component]` (Chapter 2).
**TCC (Try-Confirm-Cancel)** — An orchestration pattern in which every participant first reserves (*try*), and only when every try succeeded are all of them confirmed; if any try fails, every participant that tried is cancelled (`#[Tcc]`, `#[TccParticipant]`) (Chapter 9A).
@@ -118,6 +126,8 @@
**Transactional (`#[Transactional]`)** — A class/method attribute declaring propagation (`REQUIRED`, `REQUIRES_NEW`, `NESTED`, …), isolation, read-only, and rollback rules — enforced by a generated proxy rather than a manual `DB::transaction()` closure (Chapter 9).
+**Upcaster** — An `EventUpcaster` that moves a stored event of an old version forward each time it is read — a renamed field, a new default — without ever rewriting the stored row, so repositories and projections see the same current shape (Chapter 9B).
+
**Value object** — An immutable domain object identified by its value rather than by an identity field; two value objects are equal if all their fields are equal. `Money` — an integer amount in minor units plus a `Currency` — is Lumen's canonical value object (Chapter 6).
**Webhook** — An HTTP request one system sends another to say that something happened. `firefly/webhooks` receives them at `POST /webhooks/{provider}`, verifies the signature, deduplicates redeliveries and publishes them to the event bus (Chapter 8A).
diff --git a/docs/book.md b/docs/book.md
index 500670b5..0f7112d5 100644
--- a/docs/book.md
+++ b/docs/book.md
@@ -1,7 +1,7 @@
# larafly by example
Learn LaraFly by building **Lumen**, the wallet-and-ledger service included in the framework.
-Both editions cover the same application: a quick start, eighteen chapters, a Laravel cheat-sheet,
+Both editions cover the same application: a quick start, nineteen chapters, a Laravel cheat-sheet,
and a glossary. Every PHP listing is checked against the repository or linted by PHP.
## Download the book
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 6a664e06..f02dd7da 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -94,6 +94,10 @@ each off until its key is set:
- **Sagas, TCC and durable workflows.** `firefly/orchestration` coordinates work that spans more than one
transaction, with compensation, checkpoints, recovery and a dead-letter queue —
[Tutorial Step 13](tutorial.md#step-13-a-saga-with-compensation-saga), [Orchestration](modules/orchestration.md).
+- **Event sourcing.** `FIREFLY_EVENTSOURCING_STORE=database` keeps event-sourced aggregates in a portable event
+ store with snapshots, upcasters and checkpointed projections, and can publish every appended event through the
+ outbox in the same transaction — [Tutorial Step 14](tutorial.md#step-14-event-sourced-greetings-eventtype),
+ [Event Sourcing](modules/eventsourcing.md).
- **Webhooks in, callbacks out.** `firefly/webhooks` verifies and deduplicates inbound webhooks and publishes them
to the event bus; `firefly/callbacks` sends signed, retried callbacks to subscribers —
[Webhooks](modules/webhooks.md), [Callbacks](modules/callbacks.md).
@@ -101,7 +105,7 @@ each off until its key is set:
RabbitMQ for `firefly/messaging` ([Messaging](modules/messaging.md)), and scheduler locks on any database
([Scheduling Database Locks](modules/scheduling-database.md)).
-The [book](book.md) walks all of them on the Lumen sample, in Chapters 7, 8, 8A and 9A.
+The [book](book.md) walks all of them on the Lumen sample, in Chapters 7, 8, 8A, 9A and 9B.
## Where to next
diff --git a/docs/index.md b/docs/index.md
index 4d55dc53..d396f38e 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -77,6 +77,9 @@ See [Installation](installation.md) for requirements and manual setup, and
- **Work that spans systems** — [sagas with compensation, TCC and durable workflows](modules/orchestration.md)
with checkpoints, recovery and a dead-letter queue, signed inbound [webhooks](modules/webhooks.md) and
outbound [callbacks](modules/callbacks.md), all on the same event bus.
+- **Event sourcing** — [an event store](modules/eventsourcing.md) on any database with optimistic concurrency, a
+ commit-ordered global stream, snapshots, upcasters and leased, rebuildable projections, publishing through the
+ same-transaction outbox.
- **Secure by default** — a session-persisted `SecurityContext`, form login on the framework's own page, HTTP
Basic, remember-me, logout, deny-by-default [`HttpSecurity`](modules/security.md) URL rules, and method
security (`#[PreAuthorize]`, `#[PostAuthorize]`, `#[PreFilter]`, `#[PostFilter]`) enforced on **any**
@@ -129,7 +132,7 @@ Want to see it all working together? The
[Lumen sample](https://github.com/fireflyframework/fireflyframework-php/tree/main/samples/lumen) is a
runnable digital-wallet & ledger vertical slice exercising `#[Transactional]`, CQRS, domain events over EDA,
method security, and a REST layer with RFC-7807 problem-details. The guided, book-style *larafly by example*
-book — 18 chapters plus appendices, bilingual (English + Spanish), building this exact sample — is available
+book — 19 chapters plus appendices, bilingual (English + Spanish), building this exact sample — is available
as [PDF and EPUB downloads in English and Spanish](book.md).
## Quick Links
diff --git a/docs/modules/cqrs.md b/docs/modules/cqrs.md
index e3ba7b4b..850e31d1 100644
--- a/docs/modules/cqrs.md
+++ b/docs/modules/cqrs.md
@@ -22,8 +22,11 @@ Both run a bounded pipeline of injected, no-op-by-default collaborators:
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 HTTP status / category /
-severity for RFC-7807 rendering — the wrapper never masks an expected fault to a generic 500.
+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. Only `detail` differs: the wrapper prefixes
+the cause's message with the message class it was processing.
## Handlers
diff --git a/docs/modules/eventsourcing.md b/docs/modules/eventsourcing.md
index 592578e9..575c923b 100644
--- a/docs/modules/eventsourcing.md
+++ b/docs/modules/eventsourcing.md
@@ -9,7 +9,9 @@ appended under optimistic concurrency and read back two ways:
Snapshots shorten replays, upcasters move old event versions forward on read, and appended events can be
published to `firefly/eda` in the same transaction through the database outbox. The package is shaped after
-the Java `fireflyframework-eventsourcing` library and PyFly's `pyfly.eventsourcing`.
+the Java `fireflyframework-eventsourcing` library and PyFly's `pyfly.eventsourcing`. Chapter 9B of the
+[book](../book.md) walks it on the Lumen sample — an event-sourced loan, its loan book and its outbox
+publication — and [Tutorial Step 14](../tutorial.md#step-14-event-sourced-greetings-eventtype) on the greeting app.
## Choosing a store
diff --git a/docs/modules/security.md b/docs/modules/security.md
index 22631fa5..740756cf 100644
--- a/docs/modules/security.md
+++ b/docs/modules/security.md
@@ -190,7 +190,10 @@ and whose problem document carries the authorities the rule asked for as an RFC
`hasAuthority`/`hasAnyAuthority` literals with roles normalised to `ROLE_`. The guarded **class and method go
to the log**, at warning, with the principal and its authorities — they used to be the wire sentence
("Access is denied for [App\Ctrl::admin].") and a PHP class name is not something a person should read on a
-panel. A rule can carry its own product code and sentence:
+panel. A rule on a command or query handler refuses with the same document through the bus: the CQRS
+wrappers copy the cause's code, status and extension members, so `requiredAuthorities` survives the wrap, and
+only `detail` differs, prefixed with `Processing command [App\…] failed:`. A rule can carry its own product
+code and sentence:
```php
diff --git a/docs/tutorial.es.md b/docs/tutorial.es.md
index 3efb0c01..d41345ea 100644
--- a/docs/tutorial.es.md
+++ b/docs/tutorial.es.md
@@ -4,7 +4,7 @@ Este es un recorrido paso a paso, construido a mano, de cómo levantar una funci
una aplicación creada con `firefly new` hasta una porción completa gobernada por atributos — un controlador REST,
un servicio, configuración tipada, un modelo de dominio respaldado por un repositorio, validación de
peticiones, manejo de errores RFC-7807, un par de comando/consulta CQRS, un listener de evento de dominio,
-un outbox agnóstico de la base de datos y una saga que se compensa. Cada bloque de código de abajo es fiel
+un outbox agnóstico de la base de datos, una saga que se compensa y un diario de greetings con event sourcing. Cada bloque de código de abajo es fiel
al framework tal como se distribuye en este repositorio — los atributos, nombres de clase y firmas de
método se toman literalmente de las fuentes de los paquetes `firefly/*` y del sample de wallet ejecutable
`samples/lumen`; no se inventan para el tutorial.
@@ -12,8 +12,9 @@ método se toman literalmente de las fuentes de los paquetes `firefly/*` y del s
Al terminar habrás hecho crecer la porción de ejemplo `Greeting` del starter `firefly/skeleton` hasta
convertirla en una pequeña funcionalidad de "greetings" con una tabla `greetings` persistida, un
`#[EventListener]` que reacciona a un evento de dominio entregado a través de un outbox de base de datos, una
-saga de tarjetas de felicitación que se deshace sola y un arranque cacheado sin reflexión — la misma
-arquitectura que usa el [sample de Lumen](#paso-14-y-ahora-que) a mayor escala.
+saga de tarjetas de felicitación que se deshace sola, un diario de greetings con event sourcing proyectado en un
+modelo de lectura y un arranque cacheado sin reflexión — la misma arquitectura que usa el
+[sample de Lumen](#paso-15-y-ahora-que) a mayor escala.
## Tabla de contenidos
@@ -30,7 +31,8 @@ arquitectura que usa el [sample de Lumen](#paso-14-y-ahora-que) a mayor escala.
11. [Paso 11: la caché sin reflexión y la introspección de salud](#paso-11-la-cache-sin-reflexion-y-la-introspeccion-de-salud)
12. [Paso 12: un outbox agnóstico de la base de datos y un consumidor](#paso-12-un-outbox-agnostico-de-la-base-de-datos-y-un-consumidor)
13. [Paso 13: una saga con compensación — `#[Saga]`](#paso-13-una-saga-con-compensacion-saga)
-14. [Paso 14: y ahora qué](#paso-14-y-ahora-que)
+14. [Paso 14: greetings con event sourcing — `#[EventType]`](#paso-14-greetings-con-event-sourcing-eventtype)
+15. [Paso 15: y ahora qué](#paso-15-y-ahora-que)
## Requisitos previos
@@ -1444,12 +1446,355 @@ que esperan señales y temporizadores, la recuperación y los comandos de operac
---
-## Paso 14: y ahora qué
+## Paso 14: greetings con event sourcing — `#[EventType]`
+
+El Paso 6 guardó cada greeting como una fila, y el renombrado del Paso 9 la sobrescribió: la tabla conoce el
+mensaje actual y nada de los anteriores. El event sourcing conserva esa otra mitad. En este paso cada greeting que
+envías a un nombre se registra como un **evento** en un almacén de eventos, un pequeño agregado se reconstruye a
+partir de esos eventos en cada petición, y una **proyección** mantiene al día un modelo de lectura — cuántas veces
+se ha saludado a cada nombre, y el último mensaje — desde un **punto de control**.
+
+El proveedor del almacén de eventos está en el bloque `eventsourcing` de `config/firefly.php`:
+
+
+
+```php
+'eventsourcing' => [
+ // …
+ 'store' => [
+ 'provider' => env('FIREFLY_EVENTSOURCING_STORE', 'memory'),
+ 'connection' => env('FIREFLY_EVENTSOURCING_DB_CONNECTION'),
+ 'batch-size' => 500,
+ ],
+ // …
+],
+```
+
+`memory` lo olvida todo cuando termina el proceso; pásalo a la base de datos:
+
+```bash
+echo 'FIREFLY_EVENTSOURCING_STORE=database' >> .env
+```
+
+Primero el evento, `app/Greetings/GreetingSaid.php`. `#[EventType]` le da el nombre estable que guarda el almacén:
+
+
+
+```php
+
+
+```php
+recordThat(new GreetingSaid($name, $message));
+
+ return $journal;
+ }
+
+ public function say(string $message): void
+ {
+ $this->recordThat(new GreetingSaid($this->id(), $message));
+ }
+
+ public function times(): int
+ {
+ return $this->times;
+ }
+
+ public function lastMessage(): string
+ {
+ return $this->lastMessage;
+ }
+
+ protected function applyGreetingSaid(GreetingSaid $event): void
+ {
+ $this->times++;
+ $this->lastMessage = $event->message;
+ }
+}
+```
+
+Su repositorio, `app/Greetings/GreetingJournals.php`, es un único constructor:
+
+
+
+```php
+ */
+#[Component]
+final class GreetingJournals extends EventSourcedRepository
+{
+ public function __construct(EventSourcingSupport $support)
+ {
+ parent::__construct($support, GreetingJournal::class);
+ }
+}
+```
+
+El modelo de lectura es una tabla corriente. Crea `database/migrations/2026_10_13_000000_create_greeting_counts_table.php`:
+
+
+
+```php
+string('name')->primary();
+ $table->unsignedInteger('times');
+ $table->string('last_message');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('greeting_counts');
+ }
+};
+```
+
+y la proyección que la rellena, `app/Greetings/GreetingCounts.php`:
+
+
+
+```php
+payload['message'] ?? '';
+ $message = is_string($message) ? $message : '';
+
+ if (DB::table(self::TABLE)->where('name', $event->aggregateId)->exists()) {
+ DB::table(self::TABLE)->where('name', $event->aggregateId)->increment('times', 1, ['last_message' => $message]);
+
+ return;
+ }
+
+ DB::table(self::TABLE)->insert(['name' => $event->aggregateId, 'times' => 1, 'last_message' => $message]);
+ }
+
+ public function reset(): void
+ {
+ DB::table(self::TABLE)->delete();
+ }
+
+ /** @return array{name: string, times: int, lastMessage: string}|null */
+ public static function find(string $name): ?array
+ {
+ $row = DB::table(self::TABLE)->where('name', $name)->first();
+ if ($row === null) {
+ return null;
+ }
+
+ $values = (array) $row;
+ $times = $values['times'] ?? 0;
+ $message = $values['last_message'] ?? '';
+
+ return ['name' => $name, 'times' => is_numeric($times) ? (int) $times : 0, 'lastMessage' => is_string($message) ? $message : ''];
+ }
+}
+```
+
+Por último los endpoints, `app/Http/GreetingJournalController.php` — el `POST` escribe a través del agregado y el
+`GET` lee la proyección:
+
+
+
+```php
+journals->find($name);
+
+ if ($journal === null) {
+ $journal = GreetingJournal::begin($name, $message);
+ } else {
+ $journal->say($message);
+ }
+
+ $this->journals->save($journal);
+
+ return ['name' => $name, 'times' => $journal->times()];
+ }
+
+ /** @return array{name: string, times: int, lastMessage: string} */
+ #[GetMapping('/journal/{name}')]
+ public function show(#[PathVariable] string $name): array
+ {
+ return GreetingCounts::find($name)
+ ?? throw new ResourceNotFoundException("No greeting to {$name} has been projected yet.");
+ }
+}
+```
+
+Elemento a elemento:
+
+- **`#[EventType(name: 'greeting.said')]`** da nombre al evento para el almacén; `firefly:cache` lo compila, y una
+ clase de evento sin él se rechaza al hacer append, así que no se guarda nada que no se pueda volver a leer.
+- **`GreetingJournal`** no tiene fila. `recordThat()` aplica el evento en el acto y lo deja en el búfer;
+ `applyGreetingSaid()` es el único código que cambia `times` y `lastMessage`, así que reproducir los eventos
+ reconstruye el mismo diario.
+- **`GreetingJournals::find()`** reproduce los eventos de un nombre (`null` cuando no hay ninguno); **`save()`**
+ añade los nuevos bajo la versión que cargó — dos peticiones que saludan a Ada desde la misma versión no pueden
+ ganar las dos: la segunda recibe un 409 `ConcurrencyException`.
+- **`GreetingCounts`** es un `#[Component]`, así que el ejecutor de proyecciones lo encuentra. Lee el flujo global
+ desde su punto de control, y sus filas y el punto de control se confirman en una sola transacción sobre la
+ misma conexión.
+
+Vuelve a compilar, migra y saluda dos veces a Ada:
+
+```bash
+php artisan firefly:cache
+php artisan migrate # las tablas del almacén de eventos y greeting_counts
+
+curl -X POST http://127.0.0.1:8000/journal/Ada
+# {"name":"Ada","times":1}
+curl -X POST http://127.0.0.1:8000/journal/Ada
+# {"name":"Ada","times":2}
+
+curl -i http://127.0.0.1:8000/journal/Ada
+# HTTP/1.1 404 Not Found <- problem+json: nada ha proyectado aún los greetings
+```
+
+Los dos eventos están en `firefly_eventsourcing_events`; el modelo de lectura no ha oído hablar de ellos. Ponlo al
+día:
+
+```bash
+php artisan firefly:eventsourcing:projections:run
+# INFO greeting.counts: caught-up, 2 event(s) handled, at position 2.
+
+curl http://127.0.0.1:8000/journal/Ada
+# {"name":"Ada","times":2,"lastMessage":"Hello, Ada!"}
+```
+
+El propio test del repositorio (`tests/TutorialGreetingJournalTest.php`) hace exactamente estas peticiones y
+ejecuta exactamente este comando. En producción mantén `php artisan firefly:eventsourcing:projections:run --live`
+en marcha bajo un supervisor de procesos; `php artisan firefly:eventsourcing:projections` muestra el punto de
+control y el retraso de cada proyección, y `php artisan firefly:eventsourcing:projections:rebuild greeting.counts`
+tira el modelo de lectura y reproduce en él cada evento — el test borra `greeting_counts` y lo recupera así.
+Consulta [Event Sourcing](modules/eventsourcing.md) para las instantáneas, los upcasters, la publicación de los
+eventos añadidos por el outbox y el almacén de orquestación con event sourcing, y el Capítulo 9B del
+[libro](book.md) para todos ellos sobre el sample de Lumen.
+
+---
+
+## Paso 15: y ahora qué
Has construido una pequeña porción de funcionalidad — `#[RestController]` → `#[Service]` →
`#[ConfigProperties]` → `#[Repository]` → `#[Valid]` → errores RFC-7807 →
`#[CommandHandler]`/`#[QueryHandler]` → `#[EventListener]` → un outbox de base de datos → una `#[Saga]` →
-un arranque cacheado, sin reflexión — usando
+un diario `#[EventType]` y su proyección → un arranque cacheado, sin reflexión — usando
únicamente los atributos reales y ya distribuidos del propio framework. Desde aquí:
### Fundamentos
@@ -1488,6 +1833,8 @@ un arranque cacheado, sin reflexión — usando
fuera.
- [Webhooks](modules/webhooks.md) y [Callbacks](modules/callbacks.md) — webhooks firmados de entrada,
callbacks firmados de salida, ambos sobre el bus de eventos.
+- [Event Sourcing](modules/eventsourcing.md) — el almacén de eventos del Paso 14, más instantáneas, upcasters,
+ la publicación por el outbox y las sagas guardadas como flujos de eventos.
### Producción
diff --git a/docs/tutorial.md b/docs/tutorial.md
index 6edc3fa4..66c7deef 100644
--- a/docs/tutorial.md
+++ b/docs/tutorial.md
@@ -3,15 +3,16 @@
This is a hand-built, step-by-step walkthrough of building a real LaraFly feature from an empty
`firefly new` application to a fully attribute-driven slice — a REST controller, a service, typed
configuration, a repository-backed domain model, request validation, RFC-7807 error handling, a CQRS
-command/query pair, a domain event listener, a database-agnostic outbox, and a compensating saga. Every
+command/query pair, a domain event listener, a database-agnostic outbox, a compensating saga, and an event-sourced journal. Every
code block below is accurate to the framework as shipped in this repository — attributes, class names, and
method signatures are taken verbatim from `firefly/*` package sources and the runnable `samples/lumen`
wallet sample, not invented for the tutorial.
By the end you will have grown the `firefly/skeleton` starter's sample `Greeting` slice into a small
"greetings" feature with a persisted `greetings` table, an `#[EventListener]` reacting to a domain event
-delivered through a database outbox, a greeting-card saga that undoes itself, and a zero-reflection cached
-boot — the same architecture the [Lumen sample](#step-14-whats-next) uses at larger scale.
+delivered through a database outbox, a greeting-card saga that undoes itself, an event-sourced greeting journal
+projected into a read model, and a zero-reflection cached boot — the same architecture the
+[Lumen sample](#step-15-whats-next) uses at larger scale.
## Table of Contents
@@ -28,7 +29,8 @@ boot — the same architecture the [Lumen sample](#step-14-whats-next) uses at l
11. [Step 11: The Zero-Reflection Cache and Health Introspection](#step-11-the-zero-reflection-cache-and-health-introspection)
12. [Step 12: A Database-Agnostic Outbox and a Consumer](#step-12-a-database-agnostic-outbox-and-a-consumer)
13. [Step 13: A Saga with Compensation — `#[Saga]`](#step-13-a-saga-with-compensation-saga)
-14. [Step 14: What's Next](#step-14-whats-next)
+14. [Step 14: Event-Sourced Greetings — `#[EventType]`](#step-14-event-sourced-greetings-eventtype)
+15. [Step 15: What's Next](#step-15-whats-next)
## Prerequisites
@@ -1417,12 +1419,351 @@ operator commands.
---
-## Step 14: What's Next
+## Step 14: Event-Sourced Greetings — `#[EventType]`
+
+Step 6 stored each greeting as a row, and Step 9's rename overwrote it: the table knows the current message and
+nothing about the ones before it. Event sourcing keeps that other half. In this step every greeting you send to a
+name is recorded as an **event** in an event store, a small aggregate is rebuilt from those events on every
+request, and a **projection** keeps a read model — how many times each name was greeted, and the last message — up
+to date from a **checkpoint**.
+
+The event store's provider sits in the `eventsourcing` block of `config/firefly.php`:
+
+
+
+```php
+'eventsourcing' => [
+ // …
+ 'store' => [
+ 'provider' => env('FIREFLY_EVENTSOURCING_STORE', 'memory'),
+ 'connection' => env('FIREFLY_EVENTSOURCING_DB_CONNECTION'),
+ 'batch-size' => 500,
+ ],
+ // …
+],
+```
+
+`memory` forgets everything when the process ends; move it to the database:
+
+```bash
+echo 'FIREFLY_EVENTSOURCING_STORE=database' >> .env
+```
+
+First the event, `app/Greetings/GreetingSaid.php`. `#[EventType]` gives it the stable name the store keeps:
+
+
+
+```php
+
+
+```php
+recordThat(new GreetingSaid($name, $message));
+
+ return $journal;
+ }
+
+ public function say(string $message): void
+ {
+ $this->recordThat(new GreetingSaid($this->id(), $message));
+ }
+
+ public function times(): int
+ {
+ return $this->times;
+ }
+
+ public function lastMessage(): string
+ {
+ return $this->lastMessage;
+ }
+
+ protected function applyGreetingSaid(GreetingSaid $event): void
+ {
+ $this->times++;
+ $this->lastMessage = $event->message;
+ }
+}
+```
+
+Its repository, `app/Greetings/GreetingJournals.php`, is one constructor:
+
+
+
+```php
+ */
+#[Component]
+final class GreetingJournals extends EventSourcedRepository
+{
+ public function __construct(EventSourcingSupport $support)
+ {
+ parent::__construct($support, GreetingJournal::class);
+ }
+}
+```
+
+The read model is an ordinary table. Create `database/migrations/2026_10_13_000000_create_greeting_counts_table.php`:
+
+
+
+```php
+string('name')->primary();
+ $table->unsignedInteger('times');
+ $table->string('last_message');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('greeting_counts');
+ }
+};
+```
+
+and the projection that fills it, `app/Greetings/GreetingCounts.php`:
+
+
+
+```php
+payload['message'] ?? '';
+ $message = is_string($message) ? $message : '';
+
+ if (DB::table(self::TABLE)->where('name', $event->aggregateId)->exists()) {
+ DB::table(self::TABLE)->where('name', $event->aggregateId)->increment('times', 1, ['last_message' => $message]);
+
+ return;
+ }
+
+ DB::table(self::TABLE)->insert(['name' => $event->aggregateId, 'times' => 1, 'last_message' => $message]);
+ }
+
+ public function reset(): void
+ {
+ DB::table(self::TABLE)->delete();
+ }
+
+ /** @return array{name: string, times: int, lastMessage: string}|null */
+ public static function find(string $name): ?array
+ {
+ $row = DB::table(self::TABLE)->where('name', $name)->first();
+ if ($row === null) {
+ return null;
+ }
+
+ $values = (array) $row;
+ $times = $values['times'] ?? 0;
+ $message = $values['last_message'] ?? '';
+
+ return ['name' => $name, 'times' => is_numeric($times) ? (int) $times : 0, 'lastMessage' => is_string($message) ? $message : ''];
+ }
+}
+```
+
+Finally the endpoints, `app/Http/GreetingJournalController.php` — the `POST` writes through the aggregate, the `GET`
+reads the projection:
+
+
+
+```php
+journals->find($name);
+
+ if ($journal === null) {
+ $journal = GreetingJournal::begin($name, $message);
+ } else {
+ $journal->say($message);
+ }
+
+ $this->journals->save($journal);
+
+ return ['name' => $name, 'times' => $journal->times()];
+ }
+
+ /** @return array{name: string, times: int, lastMessage: string} */
+ #[GetMapping('/journal/{name}')]
+ public function show(#[PathVariable] string $name): array
+ {
+ return GreetingCounts::find($name)
+ ?? throw new ResourceNotFoundException("No greeting to {$name} has been projected yet.");
+ }
+}
+```
+
+Element by element:
+
+- **`#[EventType(name: 'greeting.said')]`** names the event for the store; `firefly:cache` compiles it, and an
+ event class without one is refused at append, so nothing is stored that cannot be read back.
+- **`GreetingJournal`** holds no row. `recordThat()` applies the event at once and buffers it; `applyGreetingSaid()`
+ is the only code that changes `times` and `lastMessage`, so replaying the events rebuilds the same journal.
+- **`GreetingJournals::find()`** replays a name's events (`null` when there are none); **`save()`** appends the new
+ ones under the version it loaded — two requests that greet Ada from the same version cannot both win: the second
+ gets a 409 `ConcurrencyException`.
+- **`GreetingCounts`** is a `#[Component]`, so the projection runner finds it. It reads the global stream from its
+ checkpoint, and its rows and the checkpoint commit in one transaction on the same connection.
+
+Recompile, migrate, and greet Ada twice:
+
+```bash
+php artisan firefly:cache
+php artisan migrate # the event store's tables and greeting_counts
+
+curl -X POST http://127.0.0.1:8000/journal/Ada
+# {"name":"Ada","times":1}
+curl -X POST http://127.0.0.1:8000/journal/Ada
+# {"name":"Ada","times":2}
+
+curl -i http://127.0.0.1:8000/journal/Ada
+# HTTP/1.1 404 Not Found <- problem+json: nothing has projected the greetings yet
+```
+
+The two events are in `firefly_eventsourcing_events`; the read model has not heard of them. Catch it up:
+
+```bash
+php artisan firefly:eventsourcing:projections:run
+# INFO greeting.counts: caught-up, 2 event(s) handled, at position 2.
+
+curl http://127.0.0.1:8000/journal/Ada
+# {"name":"Ada","times":2,"lastMessage":"Hello, Ada!"}
+```
+
+The repository's own test (`tests/TutorialGreetingJournalTest.php`) makes exactly these requests and runs exactly
+this command. In production keep `php artisan firefly:eventsourcing:projections:run --live` running under a process
+supervisor; `php artisan firefly:eventsourcing:projections` shows every projection's checkpoint and lag, and
+`php artisan firefly:eventsourcing:projections:rebuild greeting.counts` throws the read model away and replays
+every event into it — the test deletes `greeting_counts` and gets it back that way. See
+[Event Sourcing](modules/eventsourcing.md) for snapshots, upcasters, publishing appended events through the outbox,
+and the event-sourced orchestration store, and Chapter 9B of the [book](book.md) for all of them on the Lumen sample.
+
+---
+
+## Step 15: What's Next
You've built one small feature slice — `#[RestController]` → `#[Service]` → `#[ConfigProperties]` →
`#[Repository]` → `#[Valid]` → RFC-7807 errors → `#[CommandHandler]`/`#[QueryHandler]` →
-`#[EventListener]` → a database outbox → a `#[Saga]` → a cached, zero-reflection boot — using nothing but
-the framework's own real, shipped attributes. From here:
+`#[EventListener]` → a database outbox → a `#[Saga]` → an `#[EventType]` journal and its projection →
+a cached, zero-reflection boot — using nothing but the framework's own real, shipped attributes. From here:
### Foundation
@@ -1458,6 +1799,8 @@ the framework's own real, shipped attributes. From here:
- [Orchestration](modules/orchestration.md) — sagas, TCC and durable workflows: everything Step 13 left out.
- [Webhooks](modules/webhooks.md) and [Callbacks](modules/callbacks.md) — signed webhooks in, signed callbacks
out, both on the event bus.
+- [Event Sourcing](modules/eventsourcing.md) — the event store from Step 14, plus snapshots, upcasters,
+ publishing through the outbox, and sagas stored as event streams.
### Production
diff --git a/packages/cqrs/src/Exception/CommandProcessingException.php b/packages/cqrs/src/Exception/CommandProcessingException.php
index 9e30f54a..92cd7763 100644
--- a/packages/cqrs/src/Exception/CommandProcessingException.php
+++ b/packages/cqrs/src/Exception/CommandProcessingException.php
@@ -13,7 +13,8 @@
* The wrapper DefaultCommandBus re-throws any handler/stage throwable in (unless it is already a
* CommandProcessingException, in which case the bus re-throws as-is). IDENTITY-PRESERVING: when the cause is a
* FireflyException, its errorCode/httpStatus/category/severity are copied so an expected client/domain fault (validation,
- * not-found) keeps its own kernel category and is NOT masked into a generic 500; a plain Throwable becomes
+ * not-found) keeps its own kernel category and is NOT masked into a generic 500, and its RFC 9457 extension members and
+ * title are copied so the problem document says what the cause would have said on its own; a plain Throwable becomes
* internal/500. Carries the command class + the cause as `previous` (design §2.3 / pyfly command/bus.py:153-164).
*/
final class CommandProcessingException extends CqrsException
@@ -35,6 +36,15 @@ public function __construct(string $commandClass, Throwable $cause)
$cause instanceof FireflyException ? $cause->category() : ErrorCategory::Internal,
$cause instanceof FireflyException ? $cause->severity() : ErrorSeverity::Error,
$cause,
+ /*
+ | And the cause's RFC 9457 extension members and title, for the same reason: they are what the
+ | problem document carries beside the code. ErrorResponse::fromException() reads them off the
+ | exception it is handed, which over the bus is this wrapper, so a refused rule's
+ | `requiredAuthorities` (or a fault's `field`, `allowed`, own title) was dropped from every
+ | command while the same fault thrown from a controller published it.
+ */
+ $cause instanceof FireflyException ? $cause->extensions() : [],
+ $cause instanceof FireflyException ? $cause->title() : null,
);
}
}
diff --git a/packages/cqrs/src/Exception/QueryProcessingException.php b/packages/cqrs/src/Exception/QueryProcessingException.php
index 6e930c40..8a40ec1a 100644
--- a/packages/cqrs/src/Exception/QueryProcessingException.php
+++ b/packages/cqrs/src/Exception/QueryProcessingException.php
@@ -11,7 +11,8 @@
/**
* The query-side twin of CommandProcessingException: DefaultQueryBus wraps any handler/stage throwable in this
- * (unless already one), preserving a FireflyException cause's httpStatus/category/severity, else internal/500.
+ * (unless already one), preserving a FireflyException cause's code/httpStatus/category/severity and its RFC 9457
+ * extension members and title, else internal/500.
*/
final class QueryProcessingException extends CqrsException
{
@@ -32,6 +33,15 @@ public function __construct(string $queryClass, Throwable $cause)
$cause instanceof FireflyException ? $cause->category() : ErrorCategory::Internal,
$cause instanceof FireflyException ? $cause->severity() : ErrorSeverity::Error,
$cause,
+ /*
+ | And the cause's RFC 9457 extension members and title, for the same reason: they are what the
+ | problem document carries beside the code. ErrorResponse::fromException() reads them off the
+ | exception it is handed, which over the bus is this wrapper, so a refused rule's
+ | `requiredAuthorities` (or a fault's `field`, `allowed`, own title) was dropped from every
+ | query while the same fault thrown from a controller published it.
+ */
+ $cause instanceof FireflyException ? $cause->extensions() : [],
+ $cause instanceof FireflyException ? $cause->title() : null,
);
}
}
diff --git a/packages/cqrs/tests/Exception/CqrsExceptionTaxonomyTest.php b/packages/cqrs/tests/Exception/CqrsExceptionTaxonomyTest.php
index ab7c7249..e1dc7141 100644
--- a/packages/cqrs/tests/Exception/CqrsExceptionTaxonomyTest.php
+++ b/packages/cqrs/tests/Exception/CqrsExceptionTaxonomyTest.php
@@ -82,3 +82,37 @@
->and((new QueryProcessingException('App\FindOrder', new RuntimeException('boom')))->errorCode())
->toBe('QUERY_PROCESSING_ERROR');
});
+
+/*
+ | The extension members and the title are part of the fault's identity on the wire too.
+ |
+ | ErrorResponse::fromException() reads both off the exception it is handed, and over the bus that exception
+ | is the wrapper. Copying the code, status, category and severity but not these two left a refused command's
+ | `requiredAuthorities` (and a rule's own `field`, `allowed` or title) on the cause, where nothing rendering
+ | the wrapper ever looked: the same refusal said less through a command than through a controller.
+ */
+it('carries the cause extension members and title through both wrappers', function (string $class, string $subject) {
+ $cause = (new ResourceNotFoundException('no such room', 'ROOM_NOT_FOUND'))
+ ->withExtensions(['field' => 'roomId', 'requiredAuthorities' => ['ROLE_ADMIN']])
+ ->withTitle('Room not found');
+ /** @var CqrsException $wrapped */
+ $wrapped = new $class($subject, $cause);
+
+ expect($wrapped->extensions())->toBe(['field' => 'roomId', 'requiredAuthorities' => ['ROLE_ADMIN']])
+ ->and($wrapped->title())->toBe('Room not found');
+})->with([
+ 'command' => [CommandProcessingException::class, 'App\IngestMessage'],
+ 'query' => [QueryProcessingException::class, 'App\FindRoom'],
+]);
+
+it('carries no extension members and no title of its own when the cause has none, or is not a FireflyException', function (string $class, Throwable $cause) {
+ /** @var CqrsException $wrapped */
+ $wrapped = new $class('App\Subject', $cause);
+
+ expect($wrapped->extensions())->toBe([])
+ ->and($wrapped->title())->toBeNull();
+})->with([
+ 'command, plain cause' => [CommandProcessingException::class, new RuntimeException('boom')],
+ 'query, plain cause' => [QueryProcessingException::class, new RuntimeException('boom')],
+ 'command, bare FireflyException' => [CommandProcessingException::class, new ResourceNotFoundException('gone')],
+]);
diff --git a/packages/security/tests/Cqrs/BusRefusalProblemDocumentTest.php b/packages/security/tests/Cqrs/BusRefusalProblemDocumentTest.php
new file mode 100644
index 00000000..95b13692
--- /dev/null
+++ b/packages/security/tests/Cqrs/BusRefusalProblemDocumentTest.php
@@ -0,0 +1,173 @@
+ SecurityContextHolder::clearContext());
+
+/**
+ * A real DefaultCommandBus whose authorizer is the real SecurityCommandAuthorizer over one rule on AdminCommand.
+ */
+function busRefusalCommandBus(SecurityMethodDescriptor $rule): DefaultCommandBus
+{
+ $enforcer = new MethodSecurityMessageEnforcer(
+ new HandlerManifest(
+ [new HandlerDescriptor(AdminCommand::class, AdminCommandHandler::class, 'handle', HandlerKind::Command)],
+ [],
+ ),
+ new SecurityMethodManifest([$rule]),
+ new SecurityExpressionEvaluator,
+ RoleHierarchy::fromRules([]),
+ new DenyAllPermissionEvaluator,
+ new Config(new Repository(['firefly' => ['security' => ['enabled' => true]]])),
+ );
+
+ $registry = new HandlerRegistry;
+ // The handler is never reached in these tests — the authorizer refuses first — so it only has to exist.
+ $registry->registerCommandHandler(AdminCommand::class, fn (object $command): string => $command instanceof AdminCommand
+ ? (new AdminCommandHandler)->handle($command)
+ : throw new LogicException('not an AdminCommand'));
+
+ return new DefaultCommandBus(
+ $registry,
+ new MessageValidator(null),
+ new SecurityCommandAuthorizer($enforcer),
+ new CorrelationContext,
+ new NoOpCqrsMetrics,
+ );
+}
+
+/**
+ * The problem document the renderer publishes for a throwable, as a decoded array.
+ *
+ * @return array
+ */
+function busRefusalDocument(Throwable $e): array
+{
+ $response = (new ProblemDetailsRenderer)->render($e, Request::create('/refused', 'POST'));
+
+ /** @var array $payload */
+ $payload = json_decode((string) $response->getContent(), true, flags: JSON_THROW_ON_ERROR);
+
+ return $payload;
+}
+
+/**
+ * Refuses the same expression twice, once through the controller guard and once through the command bus, and
+ * returns both refusals.
+ *
+ * @return array{0: Throwable, 1: Throwable}
+ */
+function busRefusalBothWays(string $expression, ?string $code = null, ?string $message = null): array
+{
+ SecurityContextHolder::setContext(new SecurityContext(
+ Authentication::authenticated('u', 'u', [new SimpleGrantedAuthority('ROLE_USER')])
+ ));
+
+ $controller = null;
+ try {
+ (new MethodSecurityControllerGuard(
+ new SecurityMethodManifest([new SecurityMethodDescriptor('App\\Ctrl', 'admin', $expression, [], $code, $message)]),
+ new SecurityExpressionEvaluator, RoleHierarchy::fromRules([]), new DenyAllPermissionEvaluator,
+ ))->check('App\\Ctrl', 'admin', []);
+ } catch (AuthorizationException $e) {
+ $controller = $e;
+ }
+
+ $bus = null;
+ try {
+ busRefusalCommandBus(new SecurityMethodDescriptor(AdminCommandHandler::class, 'handle', $expression, ['command'], $code, $message))
+ ->send(new AdminCommand);
+ } catch (CommandProcessingException $e) {
+ $bus = $e;
+ }
+
+ expect($controller)->toBeInstanceOf(AuthorizationException::class)
+ ->and($bus)->toBeInstanceOf(CommandProcessingException::class)
+ ->and($bus?->getPrevious())->toBeInstanceOf(AuthorizationException::class);
+
+ /** @var array{0: Throwable, 1: Throwable} */
+ return [$controller, $bus];
+}
+
+/**
+ * The document minus the members that describe the request rather than the refusal.
+ *
+ * @param array $document
+ * @return array
+ */
+function busRefusalIdentity(array $document): array
+{
+ return array_diff_key($document, array_flip(['detail', 'instance', 'traceId', 'correlationId', 'timestamp']));
+}
+
+it('publishes requiredAuthorities for a refused command exactly as it does for a refused controller method', function () {
+ [$controller, $bus] = busRefusalBothWays("hasRole('ADMIN') or hasAuthority('orders:write')");
+
+ $fromController = busRefusalDocument($controller);
+ $fromBus = busRefusalDocument($bus);
+
+ expect($fromController['requiredAuthorities'] ?? null)->toBe(['ROLE_ADMIN', 'orders:write'])
+ ->and($fromBus['requiredAuthorities'] ?? null)->toBe(['ROLE_ADMIN', 'orders:write'])
+ ->and(busRefusalIdentity($fromBus))->toBe(busRefusalIdentity($fromController))
+ ->and($fromBus['status'])->toBe(403)
+ ->and($fromBus['code'])->toBe('ACCESS_DENIED');
+});
+
+it('keeps the rule\'s own code and sentence on a refused command, and only prefixes the sentence', function () {
+ [$controller, $bus] = busRefusalBothWays("hasAnyRole('MANAGER', 'TENANT_ADMIN')", 'RUN_ROLE_REQUIRED', 'Only a manager may start a run.');
+
+ $fromController = busRefusalDocument($controller);
+ $fromBus = busRefusalDocument($bus);
+
+ expect(busRefusalIdentity($fromBus))->toBe(busRefusalIdentity($fromController))
+ ->and($fromBus['code'])->toBe('RUN_ROLE_REQUIRED')
+ ->and($fromBus['requiredAuthorities'] ?? null)->toBe(['ROLE_MANAGER', 'ROLE_TENANT_ADMIN'])
+ ->and($fromController['detail'])->toBe('Only a manager may start a run.')
+ ->and($fromBus['detail'])->toBe('Processing command ['.AdminCommand::class.'] failed: Only a manager may start a run.');
+});
diff --git a/samples/lumen/README.md b/samples/lumen/README.md
index ae3ae504..7f1e62d7 100644
--- a/samples/lumen/README.md
+++ b/samples/lumen/README.md
@@ -16,9 +16,9 @@ sensitive command, and a thin REST layer with RFC-7807 problem-details.
```
samples/lumen/
├── src/
-│ ├── Domain/ # Wallet aggregate + Money value object + domain events
-│ ├── Application/ # CQRS: Command/Query + Handler pairs, listeners, the payout saga + bank port
-│ ├── Infrastructure/ # WalletRepository + EloquentWalletRepository, the simulated bank, migrations
+│ ├── Domain/ # Wallet aggregate + Money value object + domain events; the event-sourced Loan
+│ ├── Application/ # CQRS: Command/Query + Handler pairs, listeners, the payout saga + bank port, loans + loan book
+│ ├── Infrastructure/ # WalletRepository + EloquentWalletRepository, LoanRepository + upcaster, the simulated bank, migrations
│ └── Web/ # WalletController, PayoutController (#[RestController]) + validated DTOs
├── tests/ # Pest coverage: domain, infrastructure, application, security, web, integration
└── config/firefly.php # Framework configuration (scan paths)
@@ -48,9 +48,14 @@ HTTP endpoints — none of which contain business logic of their own.
are bridged to the real `InMemoryEventBus`; `LedgerProjector`
(`#[EventListener]`) turns them into append-only `ledger_entries` rows the
`GetLedger` query reads back.
-- **Method security** — `WithdrawHandler` carries
- `#[PreAuthorize("hasRole('ADMIN') or hasRole('WALLET_OWNER')")]`, enforced
- by `SecurityCommandAuthorizer` at the bus, before the handler ever runs.
+- **Method security** — `WithdrawHandler`, `TransferHandler` and
+ `RepayLoanHandler` carry
+ `#[PreAuthorize("hasRole('ADMIN') or (hasRole('WALLET_OWNER') and hasPermission(#command, 'debit'))")]`,
+ enforced by `SecurityCommandAuthorizer` at the bus, before the handler ever
+ runs. `hasPermission` is answered by `WalletPermissionEvaluator`, the
+ sample's own `PermissionEvaluator` bean: it grants a debit only when the
+ wallet's `owner_id` is the principal's name (for a transfer, the source
+ wallet's), and refuses everything else.
- **A thin REST controller** — `WalletController` maps HTTP onto
commands/queries and dispatches through the bus; a `#[Valid]` request body
is validated before the DTO is even hydrated, and any business/security
@@ -73,6 +78,18 @@ HTTP endpoints — none of which contain business logic of their own.
domain events are written to `firefly_eda_outbox` in the wallet's own
transaction and delivered by `php artisan firefly:eda:consume`, proven on
PostgreSQL, MySQL, MariaDB, SQL Server and SQLite.
+- **An event-sourced loan** — `Loan` (`firefly/eventsourcing`) keeps no balance
+ row: its `loan.granted`, `loan.repaid` and `loan.settled` events are its state,
+ appended to the database event store under optimistic concurrency and
+ replayed — from a snapshot every third event in the tests — by
+ `LoanRepository`. `GrantLoan`/`RepayLoan` move the money through the wallet in
+ the same `#[Transactional]` command, so an overdrawn wallet rolls the
+ repayment's event back; `LoanBookProjection` keeps the `loan_book` read model
+ from a checkpoint; `LoanRepaidV1Upcaster` reads the first loans' `amount` as
+ `amountMinor`; with publishing on, each event is written to the outbox in the
+ append's transaction; and the payout saga runs on
+ `firefly.orchestration.persistence.provider=event-sourced` — all proven on
+ PostgreSQL, MySQL, MariaDB, SQL Server and SQLite.
## REST API
@@ -81,19 +98,21 @@ HTTP endpoints — none of which contain business logic of their own.
| POST | `/api/v1/wallets` | Open a wallet (201) |
| POST | `/api/v1/wallets/{id}/deposit` | Deposit funds (minor units) |
| POST | `/api/v1/wallets/{id}/withdraw` | Withdraw funds (minor units) — secured |
-| POST | `/api/v1/wallets/transfers` | Transfer funds between two wallets |
+| POST | `/api/v1/wallets/transfers` | Transfer funds between two wallets — secured |
| POST | `/api/v1/wallets/{id}/payouts` | Pay out to a bank account (saga) — secured |
| GET | `/api/v1/wallets/{id}/balance` | Fetch just the balance |
| GET | `/api/v1/wallets/{id}/ledger` | Fetch the wallet's projected ledger rows |
| GET | `/api/v1/wallet-rollout` | Gated rollout probe; 404 while dark |
Amounts are in **minor units** (cents): `1500` means €15.00 for an EUR
-wallet. The withdraw endpoint requires an authenticated principal carrying
-`ROLE_ADMIN` or `ROLE_WALLET_OWNER` in the `SecurityContextHolder` — with no
-principal at all it renders a 401 `application/problem+json` response
-(`AUTHENTICATION_FAILED`: authenticate first), and with a principal that is
-signed in but lacks the role a 403 (`ACCESS_DENIED`). The endpoint is
-secured, not broken.
+wallet. The withdraw and transfer endpoints require an authenticated principal
+in the `SecurityContextHolder` that carries `ROLE_ADMIN`, or carries
+`ROLE_WALLET_OWNER` and owns the wallet the money leaves (its name is the
+wallet's `owner_id`; for a transfer, the source wallet's) — with no principal at all it renders a 401
+`application/problem+json` response (`AUTHENTICATION_FAILED`: authenticate
+first), and with a principal that is signed in but is neither an admin nor
+the wallet's owner a 403 (`ACCESS_DENIED`). Either way no balance moves. The
+endpoints are secured, not broken.
The payout endpoint runs the same Withdraw first, so it needs the same principal; a refusal there is the withdraw's own 401/403/404/409. A refusal by the bank afterwards is a 409 PAYOUT_FAILED once every completed step has been undone, so the money is back in the wallet. When an undo itself fails (the refund, for instance), the money is not back: the answer is a 500 PAYOUT_COMPENSATION_FAILED that names the `uncompensated_steps` and the `payout_id` the execution is checkpointed and dead-lettered under, for an operator to settle.
diff --git a/samples/lumen/composer.json b/samples/lumen/composer.json
index 9015ce72..8e3a37f2 100644
--- a/samples/lumen/composer.json
+++ b/samples/lumen/composer.json
@@ -29,6 +29,7 @@
"firefly/data": "*@dev",
"firefly/domain": "*@dev",
"firefly/eda": "*@dev",
+ "firefly/eventsourcing": "*@dev",
"firefly/feature-flags": "*@dev",
"firefly/kernel": "*@dev",
"firefly/orchestration": "*@dev",
diff --git a/samples/lumen/config/firefly.php b/samples/lumen/config/firefly.php
index d3eee39d..4435210d 100644
--- a/samples/lumen/config/firefly.php
+++ b/samples/lumen/config/firefly.php
@@ -11,4 +11,7 @@
// firefly.eda.provider is unset here -> the in-memory EventPublisher (zero external infra).
// Set 'eda' => ['provider' => 'database'] for the portable same-transaction outbox on any database (deliver it
// with `php artisan firefly:eda:consume`), or 'postgres' for the same outbox plus LISTEN/NOTIFY wake-ups.
+ // firefly.eventsourcing.store.provider is unset too -> the in-memory event store. The loan tests run it on
+ // 'database' (FIREFLY_EVENTSOURCING_STORE=database): events, snapshots and projection checkpoints in four portable
+ // tables, with 'publish' => ['enabled' => true] writing each appended event to the outbox in the same transaction.
];
diff --git a/samples/lumen/src/Application/Command/TransferHandler.php b/samples/lumen/src/Application/Command/TransferHandler.php
index b3dde25b..98ad1236 100644
--- a/samples/lumen/src/Application/Command/TransferHandler.php
+++ b/samples/lumen/src/Application/Command/TransferHandler.php
@@ -8,6 +8,7 @@
use Firefly\Data\Transaction\Attributes\Transactional;
use Firefly\Data\Transaction\Propagation;
use Firefly\Kernel\Exception\Business\ResourceNotFoundException;
+use Firefly\Security\Access\Attributes\PreAuthorize;
use Lumen\Domain\Money;
use Lumen\Infrastructure\WalletRepository;
@@ -21,6 +22,12 @@
* back on any Throwable — the source debit is undone too. That is the money-cannot-vanish invariant: there is no path
* where the source loses funds the destination never receives.
*
+ * Guarded by the same #[PreAuthorize] as WithdrawHandler, because a transfer IS a debit: an ADMIN may move money out
+ * of any wallet, a WALLET_OWNER only out of the wallet it owns. WalletPermissionEvaluator reads the Transfer bound to
+ * #command and checks the owner of its SOURCE wallet — owning the destination grants nothing, or anyone could pull
+ * money out of any wallet by naming it as the source. SecurityCommandAuthorizer enforces the rule at the bus before
+ * the handler runs, so a refused transfer touches neither wallet.
+ *
* Intentionally NOT final: the generated transactional proxy subclasses this handler (same reason as the S4 handlers).
*/
#[CommandHandler]
@@ -28,6 +35,7 @@ class TransferHandler
{
public function __construct(private readonly WalletRepository $wallets) {}
+ #[PreAuthorize("hasRole('ADMIN') or (hasRole('WALLET_OWNER') and hasPermission(#command, 'debit'))")]
#[Transactional(propagation: Propagation::REQUIRED)]
public function handle(Transfer $command): void
{
diff --git a/samples/lumen/src/Application/Command/WithdrawHandler.php b/samples/lumen/src/Application/Command/WithdrawHandler.php
index c27d4664..d3dc1f6d 100644
--- a/samples/lumen/src/Application/Command/WithdrawHandler.php
+++ b/samples/lumen/src/Application/Command/WithdrawHandler.php
@@ -16,14 +16,15 @@
* raises a ConflictException that rolls the transaction back and surfaces as a CommandProcessingException), persists,
* and returns the new balance in minor units. #[Transactional] for the same reason as the sibling handlers.
*
- * Guarded by #[PreAuthorize]: only an ADMIN or the WALLET_OWNER may debit. SecurityExpressionEvaluator is a CLOSED
- * whitelist (booleans and/or/not, string literals, #param, and eight fixed functions — no `==`, no `.property`
- * navigation), so ownership is modelled as a granted WALLET_OWNER authority rather than an `#command.ownerId ==
- * authentication.name` comparison, which the parser cannot express. SecurityCommandAuthorizer enforces this at the bus
- * BEFORE the handler runs; a denied withdraw surfaces as CommandProcessingException wrapping AuthorizationException
- * (403) for a signed-in principal without the role, or AuthenticationException (401) when there is no principal at all.
- * (The faithful owner-only alternative — hasPermission(#command, 'withdraw') backed by a custom PermissionEvaluator
- * bean that loads the wallet and compares its owner_id to the current principal — is left to a later step.)
+ * Guarded by #[PreAuthorize]: an ADMIN may debit any wallet; a WALLET_OWNER only the wallet it owns. The role alone
+ * is not ownership — any customer holds it — so the rule also asks hasPermission(#command, 'debit'), which the
+ * framework hands, with the Withdraw bound to #command, to the application's PermissionEvaluator:
+ * WalletPermissionEvaluator loads the wallet and grants the debit only when its owner_id is the principal's name.
+ * SecurityExpressionEvaluator is a CLOSED whitelist (booleans and/or/not, string literals, #param, and a fixed set of
+ * functions — no `==`, no `.property` navigation), so the comparison cannot be written in the expression; the
+ * evaluator is where it lives. SecurityCommandAuthorizer enforces this at the bus BEFORE the handler runs; a denied
+ * withdraw surfaces as CommandProcessingException wrapping AuthorizationException (403) for a signed-in principal
+ * without the role or without the wallet, or AuthenticationException (401) when there is no principal at all.
*
* Intentionally NOT final: the generated transactional proxy subclasses this handler.
*/
@@ -32,7 +33,7 @@ class WithdrawHandler
{
public function __construct(private readonly WalletRepository $wallets) {}
- #[PreAuthorize("hasRole('ADMIN') or hasRole('WALLET_OWNER')")]
+ #[PreAuthorize("hasRole('ADMIN') or (hasRole('WALLET_OWNER') and hasPermission(#command, 'debit'))")]
#[Transactional]
public function handle(Withdraw $command): int
{
diff --git a/samples/lumen/src/Application/Loan/GrantLoan.php b/samples/lumen/src/Application/Loan/GrantLoan.php
new file mode 100644
index 00000000..58776324
--- /dev/null
+++ b/samples/lumen/src/Application/Loan/GrantLoan.php
@@ -0,0 +1,14 @@
+wallets->findById($command->walletId)
+ ?? throw new ResourceNotFoundException("Wallet [{$command->walletId}] not found");
+
+ $id = 'loan-'.bin2hex(random_bytes(8));
+ $principal = new Money($command->principalMinor, $wallet->currency());
+ $this->loans->save(Loan::grant($id, $command->walletId, $principal));
+
+ $wallet->deposit($principal);
+ $this->wallets->save($wallet);
+
+ return $id;
+ }
+}
diff --git a/samples/lumen/src/Application/Loan/LoanBookProjection.php b/samples/lumen/src/Application/Loan/LoanBookProjection.php
new file mode 100644
index 00000000..c45983be
--- /dev/null
+++ b/samples/lumen/src/Application/Loan/LoanBookProjection.php
@@ -0,0 +1,84 @@
+payload;
+ $loan = DB::table(self::TABLE)->where('loan_id', $event->aggregateId);
+
+ if ($event->eventType === 'loan.granted') {
+ DB::table(self::TABLE)->insert([
+ 'loan_id' => $event->aggregateId,
+ 'wallet_id' => self::text($payload, 'walletId'),
+ 'currency' => self::text($payload, 'currency'),
+ 'principal_minor' => self::number($payload, 'principalMinor'),
+ 'repaid_minor' => 0,
+ 'outstanding_minor' => self::number($payload, 'principalMinor'),
+ 'repayments' => 0,
+ 'status' => 'open',
+ ]);
+ } elseif ($event->eventType === 'loan.repaid') {
+ $loan->incrementEach(
+ ['repaid_minor' => self::number($payload, 'amountMinor'), 'repayments' => 1],
+ ['outstanding_minor' => self::number($payload, 'outstandingMinor')],
+ );
+ } else {
+ $loan->update(['status' => 'settled']);
+ }
+ }
+
+ public function reset(): void
+ {
+ DB::table(self::TABLE)->delete();
+ }
+
+ /** @param array $payload */
+ private static function number(array $payload, string $key): int
+ {
+ $value = $payload[$key] ?? 0;
+
+ return is_int($value) ? $value : 0;
+ }
+
+ /** @param array $payload */
+ private static function text(array $payload, string $key): string
+ {
+ $value = $payload[$key] ?? '';
+
+ return is_string($value) ? $value : '';
+ }
+}
diff --git a/samples/lumen/src/Application/Loan/RepayLoan.php b/samples/lumen/src/Application/Loan/RepayLoan.php
new file mode 100644
index 00000000..d368d543
--- /dev/null
+++ b/samples/lumen/src/Application/Loan/RepayLoan.php
@@ -0,0 +1,14 @@
+loans->get($command->loanId);
+ $wallet = $this->wallets->findById($loan->walletId())
+ ?? throw new ResourceNotFoundException("Wallet [{$loan->walletId()}] not found");
+
+ $amount = new Money($command->amountMinor, $loan->currency());
+ $loan->repay($amount);
+ $this->loans->save($loan);
+
+ $wallet->withdraw($amount);
+ $this->wallets->save($wallet);
+
+ return $loan->outstanding()->minorUnits;
+ }
+}
diff --git a/samples/lumen/src/Domain/Loan/Event/LoanGranted.php b/samples/lumen/src/Domain/Loan/Event/LoanGranted.php
new file mode 100644
index 00000000..99e47c35
--- /dev/null
+++ b/samples/lumen/src/Domain/Loan/Event/LoanGranted.php
@@ -0,0 +1,30 @@
+() methods are the only code that changes state, so replaying the history rebuilds exactly
+ * the object that recorded it.
+ */
+final class Loan extends EventSourcedAggregateRoot implements SnapshotAware
+{
+ private string $walletId = '';
+
+ private Currency $currency = Currency::EUR;
+
+ private int $principalMinor = 0;
+
+ private int $repaidMinor = 0;
+
+ private int $repayments = 0;
+
+ private bool $settled = false;
+
+ public static function aggregateType(): string
+ {
+ return 'lumen.loan';
+ }
+
+ public static function grant(string $id, string $walletId, Money $principal): self
+ {
+ if (! $principal->isPositive()) {
+ throw new ConflictException('a loan principal must be > 0');
+ }
+
+ $loan = new self($id);
+ $loan->recordThat(new LoanGranted($id, $walletId, $principal->minorUnits, $principal->currency->value));
+
+ return $loan;
+ }
+
+ public function repay(Money $amount): void
+ {
+ if ($this->settled) {
+ throw new ConflictException("loan {$this->id()} is already settled");
+ }
+ if ($amount->currency !== $this->currency || ! $amount->isPositive()) {
+ throw new ConflictException("a repayment must be a positive amount in {$this->currency->value}");
+ }
+ if ($amount->minorUnits > $this->outstanding()->minorUnits) {
+ throw new ConflictException("cannot repay {$amount}; {$this->outstanding()} is outstanding");
+ }
+
+ $this->recordThat(new LoanRepaid($this->id(), $amount->minorUnits, $this->outstanding()->minorUnits - $amount->minorUnits));
+ if ($this->outstanding()->minorUnits === 0) {
+ $this->recordThat(new LoanSettled($this->id(), $this->repayments));
+ }
+ }
+
+ public function walletId(): string
+ {
+ return $this->walletId;
+ }
+
+ public function currency(): Currency
+ {
+ return $this->currency;
+ }
+
+ public function principal(): Money
+ {
+ return new Money($this->principalMinor, $this->currency);
+ }
+
+ public function outstanding(): Money
+ {
+ return new Money($this->principalMinor - $this->repaidMinor, $this->currency);
+ }
+
+ public function repaidMinor(): int
+ {
+ return $this->repaidMinor;
+ }
+
+ public function repayments(): int
+ {
+ return $this->repayments;
+ }
+
+ public function isSettled(): bool
+ {
+ return $this->settled;
+ }
+
+ protected function applyLoanGranted(LoanGranted $event): void
+ {
+ $this->walletId = $event->walletId;
+ $this->currency = Currency::from($event->currency);
+ $this->principalMinor = $event->principalMinor;
+ }
+
+ protected function applyLoanRepaid(LoanRepaid $event): void
+ {
+ $this->repaidMinor += $event->amountMinor;
+ $this->repayments++;
+ }
+
+ protected function applyLoanSettled(LoanSettled $event): void
+ {
+ $this->settled = true;
+ }
+
+ /** @return array{walletId: string, currency: string, principalMinor: int, repaidMinor: int, repayments: int, settled: bool} */
+ public function snapshotState(): array
+ {
+ return [
+ 'walletId' => $this->walletId,
+ 'currency' => $this->currency->value,
+ 'principalMinor' => $this->principalMinor,
+ 'repaidMinor' => $this->repaidMinor,
+ 'repayments' => $this->repayments,
+ 'settled' => $this->settled,
+ ];
+ }
+
+ public function restoreSnapshot(array $state): void
+ {
+ $walletId = $state['walletId'] ?? '';
+ $currency = $state['currency'] ?? '';
+ $principal = $state['principalMinor'] ?? 0;
+ $repaid = $state['repaidMinor'] ?? 0;
+ $repayments = $state['repayments'] ?? 0;
+
+ $this->walletId = is_string($walletId) ? $walletId : '';
+ $this->currency = Currency::tryFrom(is_string($currency) ? $currency : '') ?? Currency::EUR;
+ $this->principalMinor = is_int($principal) ? $principal : 0;
+ $this->repaidMinor = is_int($repaid) ? $repaid : 0;
+ $this->repayments = is_int($repayments) ? $repayments : 0;
+ $this->settled = ($state['settled'] ?? false) === true;
+ }
+
+ /** Raise it whenever snapshotState()'s shape changes: an older snapshot is then ignored and the loan replayed. */
+ public static function snapshotVersion(): int
+ {
+ return 1;
+ }
+}
diff --git a/samples/lumen/src/Domain/Wallet.php b/samples/lumen/src/Domain/Wallet.php
index 64a489fd..798542df 100644
--- a/samples/lumen/src/Domain/Wallet.php
+++ b/samples/lumen/src/Domain/Wallet.php
@@ -55,6 +55,14 @@ public function currency(): Currency
});
}
+ /** Who the wallet belongs to: the principal name WalletPermissionEvaluator compares against before a debit. */
+ public function ownerId(): string
+ {
+ $value = $this->getAttribute('owner_id');
+
+ return is_string($value) ? $value : '';
+ }
+
public function balanceMoney(): Money
{
$value = $this->getAttribute('balance_minor');
diff --git a/samples/lumen/src/Infrastructure/Loan/LoanRepaidV1Upcaster.php b/samples/lumen/src/Infrastructure/Loan/LoanRepaidV1Upcaster.php
new file mode 100644
index 00000000..0cd3462a
--- /dev/null
+++ b/samples/lumen/src/Infrastructure/Loan/LoanRepaidV1Upcaster.php
@@ -0,0 +1,43 @@
+payload;
+ $payload['amountMinor'] = $payload['amount'] ?? 0;
+ unset($payload['amount']);
+
+ return $event->upcasted('loan.repaid', 2, $payload);
+ }
+}
diff --git a/samples/lumen/src/Infrastructure/Loan/LoanRepository.php b/samples/lumen/src/Infrastructure/Loan/LoanRepository.php
new file mode 100644
index 00000000..7ad14dde
--- /dev/null
+++ b/samples/lumen/src/Infrastructure/Loan/LoanRepository.php
@@ -0,0 +1,30 @@
+
+ */
+#[Component]
+#[Lazy]
+final class LoanRepository extends EventSourcedRepository
+{
+ public function __construct(EventSourcingSupport $support)
+ {
+ parent::__construct($support, Loan::class);
+ }
+}
diff --git a/samples/lumen/src/Infrastructure/Migration/2026_10_13_000001_create_loan_book_table.php b/samples/lumen/src/Infrastructure/Migration/2026_10_13_000001_create_loan_book_table.php
new file mode 100644
index 00000000..f6a88cf6
--- /dev/null
+++ b/samples/lumen/src/Infrastructure/Migration/2026_10_13_000001_create_loan_book_table.php
@@ -0,0 +1,35 @@
+string('loan_id', 64)->primary();
+ $table->string('wallet_id');
+ $table->string('currency', 3);
+ $table->bigInteger('principal_minor');
+ $table->bigInteger('repaid_minor');
+ $table->bigInteger('outstanding_minor');
+ $table->integer('repayments');
+ $table->string('status', 16);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('loan_book');
+ }
+};
diff --git a/samples/lumen/src/Security/WalletPermissionEvaluator.php b/samples/lumen/src/Security/WalletPermissionEvaluator.php
new file mode 100644
index 00000000..20f6107e
--- /dev/null
+++ b/samples/lumen/src/Security/WalletPermissionEvaluator.php
@@ -0,0 +1,68 @@
+isAuthenticated()) {
+ return false;
+ }
+
+ $walletId = match (true) {
+ $target instanceof Withdraw => $target->walletId,
+ $target instanceof Transfer => $target->sourceWalletId,
+ $target instanceof RepayLoan => $this->walletOfLoan($target->loanId),
+ default => null,
+ };
+ if ($walletId === null) {
+ return false;
+ }
+
+ $owner = $this->wallets->findById($walletId)?->ownerId() ?? '';
+
+ return $owner !== '' && $owner === $authentication->getName();
+ }
+
+ private function walletOfLoan(string $loanId): ?string
+ {
+ $loans = $this->container->make(LoanRepository::class);
+
+ return $loans->find($loanId)?->walletId();
+ }
+}
diff --git a/samples/lumen/src/Web/WalletController.php b/samples/lumen/src/Web/WalletController.php
index 2b9f9bf2..8f45659f 100644
--- a/samples/lumen/src/Web/WalletController.php
+++ b/samples/lumen/src/Web/WalletController.php
@@ -31,7 +31,7 @@
* command/query, dispatches it through the bus, and shapes the return array — the same rule the sample's
* CQRS handlers already enforce for the domain layer. A domain/business fault raised by the bus (e.g. an
* unknown wallet's ResourceNotFoundException, an overdraw's ConflictException, or a denied #[PreAuthorize]
- * on withdraw) surfaces as CommandProcessingException/QueryProcessingException and renders RFC-7807
+ * on withdraw or transfer) surfaces as CommandProcessingException/QueryProcessingException and renders RFC-7807
* problem-details via the framework's global ProblemDetailsRenderer — no local #[ExceptionHandler] needed.
*/
#[RestController]
@@ -65,11 +65,12 @@ public function deposit(#[PathVariable] string $id, #[Valid] #[RequestBody] Amou
/**
* Debits `amount_minor` from the wallet. Guarded upstream at the bus: WithdrawHandler carries
- * #[PreAuthorize("hasRole('ADMIN') or hasRole('WALLET_OWNER')")] (S6), enforced by
- * SecurityCommandAuthorizer BEFORE the handler runs. With no principal in the SecurityContextHolder
- * the bus refuses the command with an AuthenticationException that renders as a 401 problem-details
- * response (authenticate first); with a principal that is signed in but carries neither role it is an
- * AuthorizationException rendered as 403 — this endpoint is secured, not broken.
+ * #[PreAuthorize("hasRole('ADMIN') or (hasRole('WALLET_OWNER') and hasPermission(#command, 'debit'))")],
+ * enforced by SecurityCommandAuthorizer BEFORE the handler runs. With no principal in the
+ * SecurityContextHolder the bus refuses the command with an AuthenticationException that renders as a 401
+ * problem-details response (authenticate first); with a principal that is signed in but is neither an ADMIN
+ * nor the WALLET_OWNER who owns this wallet it is an AuthorizationException rendered as 403 — this endpoint
+ * is secured, not broken.
*
* @return array{wallet_id: string, balance_minor: int}
*/
diff --git a/samples/lumen/tests/Application/LedgerProjectorTest.php b/samples/lumen/tests/Application/LedgerProjectorTest.php
index 6244857d..152677dc 100644
--- a/samples/lumen/tests/Application/LedgerProjectorTest.php
+++ b/samples/lumen/tests/Application/LedgerProjectorTest.php
@@ -5,6 +5,10 @@
namespace Lumen\Tests\Application;
use Firefly\Cqrs\Command\CommandBus;
+use Firefly\Security\Core\Authentication;
+use Firefly\Security\Core\SecurityContext;
+use Firefly\Security\Core\SecurityContextHolder;
+use Firefly\Security\Core\SimpleGrantedAuthority;
use Lumen\Application\Command\Deposit;
use Lumen\Application\Command\OpenWallet;
use Lumen\Application\Command\Transfer;
@@ -14,6 +18,8 @@
uses(LumenTestCase::class);
+afterEach(fn () => SecurityContextHolder::clearContext());
+
it('projects committed wallet events into ledger entries', function () {
/** @var LumenTestCase $this */
/** @var CommandBus $commands */
@@ -43,6 +49,10 @@
$destination = $commands->send(new OpenWallet('owner-B', Currency::EUR));
$commands->send(new Deposit($source, 10000));
+ // TransferHandler is guarded: the source wallet's owner may move money out of it.
+ SecurityContextHolder::setContext(new SecurityContext(
+ Authentication::authenticated('owner-A', 'owner-A', [new SimpleGrantedAuthority('ROLE_WALLET_OWNER')])
+ ));
$commands->send(new Transfer($source, $destination, 4000));
// A completed transfer raises TransferCompleted on the source AFTER both legs commit; the projector keys the row
diff --git a/samples/lumen/tests/Application/TransferSecurityTest.php b/samples/lumen/tests/Application/TransferSecurityTest.php
index 6005dac4..c92781c8 100644
--- a/samples/lumen/tests/Application/TransferSecurityTest.php
+++ b/samples/lumen/tests/Application/TransferSecurityTest.php
@@ -7,6 +7,8 @@
use Firefly\Cqrs\Command\CommandBus;
use Firefly\Cqrs\Exception\CommandProcessingException;
use Firefly\Cqrs\Query\QueryBus;
+use Firefly\Kernel\Exception\Business\ConflictException;
+use Firefly\Kernel\Exception\Security\AuthenticationException;
use Firefly\Kernel\Exception\Security\AuthorizationException;
use Firefly\Security\Core\Authentication;
use Firefly\Security\Core\SecurityContext;
@@ -26,6 +28,14 @@
// finally/afterEach, so even a throwing test (the deny case below) still clears the request-scoped holder.
afterEach(fn () => SecurityContextHolder::clearContext());
+/** Signs in a ROLE_WALLET_OWNER whose name is the owner_id of the wallets it may debit. */
+function lumenSignInWalletOwner(string $name): void
+{
+ SecurityContextHolder::setContext(new SecurityContext(
+ Authentication::authenticated($name, $name, [new SimpleGrantedAuthority('ROLE_WALLET_OWNER')])
+ ));
+}
+
it('transfers atomically: money is conserved across debit + credit', function () {
/** @var LumenTestCase $this */
/** @var CommandBus $commands */
@@ -39,6 +49,8 @@
$dst = $commands->send(new OpenWallet('owner-B', Currency::EUR));
$commands->send(new Deposit($src, 10000));
+ // TransferHandler is guarded like WithdrawHandler: the source wallet's owner may move money out of it.
+ lumenSignInWalletOwner('owner-A');
$commands->send(new Transfer($src, $dst, 4000));
// Debit + credit committed as one unit of work: the 10000 that left nowhere reappears split 6000/4000.
@@ -61,8 +73,17 @@
$dst = $commands->send(new OpenWallet('owner-B', Currency::USD));
$commands->send(new Deposit($src, 10000));
- expect(fn () => $commands->send(new Transfer($src, $dst, 4000)))
- ->toThrow(CommandProcessingException::class);
+ // Signed in as the source's owner, so the guard lets the transfer through and it is the CREDIT leg that fails.
+ lumenSignInWalletOwner('owner-A');
+ $failed = null;
+ try {
+ $commands->send(new Transfer($src, $dst, 4000));
+ } catch (CommandProcessingException $e) {
+ $failed = $e;
+ }
+
+ expect($failed)->toBeInstanceOf(CommandProcessingException::class);
+ expect($failed?->getPrevious())->toBeInstanceOf(ConflictException::class);
// Load-bearing, non-tautological proof that money cannot vanish: the source debit was ROLLED BACK (still 10000,
// not 6000) and the destination never received anything (still 0). No value was created or destroyed.
@@ -109,3 +130,118 @@
expect($queries->ask(new GetBalance($walletId)))->toBe(4000);
})->group('lumen');
+
+it('refuses a withdraw from a WALLET_OWNER who does not own that wallet, and lets an ADMIN through', function () {
+ /** @var LumenTestCase $this */
+ /** @var CommandBus $commands */
+ $commands = $this->fireflyContext()->get(CommandBus::class);
+ /** @var QueryBus $queries */
+ $queries = $this->fireflyContext()->get(QueryBus::class);
+
+ /** @var string $walletId */
+ $walletId = $commands->send(new OpenWallet('owner-D', Currency::EUR));
+ $commands->send(new Deposit($walletId, 5000));
+
+ // The role alone is not enough: hasPermission(#command, 'debit') asks WalletPermissionEvaluator, which grants
+ // the debit only to the wallet's owner. 'owner-E' owns some other wallet and holds the same role.
+ SecurityContextHolder::setContext(new SecurityContext(
+ Authentication::authenticated('owner-E', 'owner-E', [new SimpleGrantedAuthority('ROLE_WALLET_OWNER')])
+ ));
+
+ $denied = null;
+ try {
+ $commands->send(new Withdraw($walletId, 1000));
+ } catch (CommandProcessingException $e) {
+ $denied = $e;
+ }
+
+ expect($denied?->getPrevious())->toBeInstanceOf(AuthorizationException::class);
+ expect($queries->ask(new GetBalance($walletId)))->toBe(5000);
+
+ // An ADMIN may debit any wallet: the first alternative of the rule, no ownership asked.
+ SecurityContextHolder::setContext(new SecurityContext(
+ Authentication::authenticated('teller', 'teller', [new SimpleGrantedAuthority('ROLE_ADMIN')])
+ ));
+
+ $commands->send(new Withdraw($walletId, 1000));
+
+ expect($queries->ask(new GetBalance($walletId)))->toBe(4000);
+})->group('lumen');
+
+it('refuses a transfer from nobody with an AuthenticationException, before either wallet is touched', function () {
+ /** @var LumenTestCase $this */
+ /** @var CommandBus $commands */
+ $commands = $this->fireflyContext()->get(CommandBus::class);
+ /** @var QueryBus $queries */
+ $queries = $this->fireflyContext()->get(QueryBus::class);
+
+ /** @var string $src */
+ $src = $commands->send(new OpenWallet('owner-F', Currency::EUR));
+ /** @var string $dst */
+ $dst = $commands->send(new OpenWallet('owner-G', Currency::EUR));
+ $commands->send(new Deposit($src, 5000));
+
+ // No principal at all: TransferHandler's #[PreAuthorize] answers "authenticate first" (401), not 403.
+ $denied = null;
+ try {
+ $commands->send(new Transfer($src, $dst, 1000));
+ } catch (CommandProcessingException $e) {
+ $denied = $e;
+ }
+
+ expect($denied?->getPrevious())->toBeInstanceOf(AuthenticationException::class);
+ expect($queries->ask(new GetBalance($src)))->toBe(5000);
+ expect($queries->ask(new GetBalance($dst)))->toBe(0);
+})->group('lumen');
+
+it('refuses a transfer out of a wallet the WALLET_OWNER does not own, even into its own wallet', function () {
+ /** @var LumenTestCase $this */
+ /** @var CommandBus $commands */
+ $commands = $this->fireflyContext()->get(CommandBus::class);
+ /** @var QueryBus $queries */
+ $queries = $this->fireflyContext()->get(QueryBus::class);
+
+ /** @var string $src */
+ $src = $commands->send(new OpenWallet('owner-H', Currency::EUR));
+ /** @var string $dst */
+ $dst = $commands->send(new OpenWallet('owner-I', Currency::EUR));
+ $commands->send(new Deposit($src, 5000));
+
+ // owner-I owns the DESTINATION. A transfer debits its source, so owning the receiving wallet grants nothing:
+ // without this guard, anyone could pull money out of any wallet by naming it as the source of a transfer.
+ lumenSignInWalletOwner('owner-I');
+
+ $denied = null;
+ try {
+ $commands->send(new Transfer($src, $dst, 1000));
+ } catch (CommandProcessingException $e) {
+ $denied = $e;
+ }
+
+ expect($denied?->getPrevious())->toBeInstanceOf(AuthorizationException::class);
+ expect($queries->ask(new GetBalance($src)))->toBe(5000);
+ expect($queries->ask(new GetBalance($dst)))->toBe(0);
+})->group('lumen');
+
+it('lets an ADMIN transfer between two wallets it owns neither of', function () {
+ /** @var LumenTestCase $this */
+ /** @var CommandBus $commands */
+ $commands = $this->fireflyContext()->get(CommandBus::class);
+ /** @var QueryBus $queries */
+ $queries = $this->fireflyContext()->get(QueryBus::class);
+
+ /** @var string $src */
+ $src = $commands->send(new OpenWallet('owner-J', Currency::EUR));
+ /** @var string $dst */
+ $dst = $commands->send(new OpenWallet('owner-K', Currency::EUR));
+ $commands->send(new Deposit($src, 5000));
+
+ SecurityContextHolder::setContext(new SecurityContext(
+ Authentication::authenticated('teller', 'teller', [new SimpleGrantedAuthority('ROLE_ADMIN')])
+ ));
+
+ $commands->send(new Transfer($src, $dst, 1000));
+
+ expect($queries->ask(new GetBalance($src)))->toBe(4000);
+ expect($queries->ask(new GetBalance($dst)))->toBe(1000);
+})->group('lumen');
diff --git a/samples/lumen/tests/Application/WalletPermissionEvaluatorTest.php b/samples/lumen/tests/Application/WalletPermissionEvaluatorTest.php
new file mode 100644
index 00000000..303f8a7a
--- /dev/null
+++ b/samples/lumen/tests/Application/WalletPermissionEvaluatorTest.php
@@ -0,0 +1,114 @@
+ */
+ private array $byId = [];
+
+ public function save(Wallet $wallet): Wallet
+ {
+ $key = $wallet->getKey();
+ $this->byId[is_string($key) ? $key : ''] = $wallet;
+
+ return $wallet;
+ }
+
+ public function findById(string $id): ?Wallet
+ {
+ return $this->byId[$id] ?? null;
+ }
+
+ public function findByOwnerId(string $ownerId): array
+ {
+ return array_values(array_filter($this->byId, fn (Wallet $wallet): bool => $wallet->ownerId() === $ownerId));
+ }
+ };
+ $wallets->save(Wallet::open('wlt-alice', 'alice', Currency::EUR));
+ $wallets->save(Wallet::open('wlt-bob', 'bob', Currency::EUR));
+
+ return new WalletPermissionEvaluator($wallets, new Container);
+}
+
+function lumenWalletOwner(string $name): Authentication
+{
+ return Authentication::authenticated($name, $name, [new SimpleGrantedAuthority('ROLE_WALLET_OWNER')]);
+}
+
+it('grants debit on a withdraw to the owner of the wallet', function () {
+ expect(lumenWalletPermissionEvaluator()->hasPermission(lumenWalletOwner('alice'), new Withdraw('wlt-alice', 100), 'debit'))
+ ->toBeTrue();
+})->group('lumen');
+
+it('refuses debit on a withdraw to a principal who does not own the wallet', function () {
+ expect(lumenWalletPermissionEvaluator()->hasPermission(lumenWalletOwner('mallory'), new Withdraw('wlt-alice', 100), 'debit'))
+ ->toBeFalse();
+})->group('lumen');
+
+it('grants debit on a transfer to the owner of the source wallet', function () {
+ expect(lumenWalletPermissionEvaluator()->hasPermission(lumenWalletOwner('alice'), new Transfer('wlt-alice', 'wlt-bob', 100), 'debit'))
+ ->toBeTrue();
+})->group('lumen');
+
+it('refuses debit on a transfer to the owner of the destination wallet: a transfer debits its source', function () {
+ expect(lumenWalletPermissionEvaluator()->hasPermission(lumenWalletOwner('bob'), new Transfer('wlt-alice', 'wlt-bob', 100), 'debit'))
+ ->toBeFalse();
+})->group('lumen');
+
+it('refuses debit on a transfer to a principal who owns neither wallet', function () {
+ expect(lumenWalletPermissionEvaluator()->hasPermission(lumenWalletOwner('mallory'), new Transfer('wlt-alice', 'wlt-bob', 100), 'debit'))
+ ->toBeFalse();
+})->group('lumen');
+
+it('refuses debit on a transfer out of a wallet that does not exist', function () {
+ expect(lumenWalletPermissionEvaluator()->hasPermission(lumenWalletOwner('alice'), new Transfer('wlt-nope', 'wlt-alice', 100), 'debit'))
+ ->toBeFalse();
+})->group('lumen');
+
+it('refuses debit on a wallet that does not exist', function () {
+ expect(lumenWalletPermissionEvaluator()->hasPermission(lumenWalletOwner('alice'), new Withdraw('wlt-nope', 100), 'debit'))
+ ->toBeFalse();
+})->group('lumen');
+
+it('refuses a principal that is not authenticated, even when its name is the owner', function () {
+ $anonymous = Authentication::unauthenticated('alice', 'alice', null);
+
+ expect(lumenWalletPermissionEvaluator()->hasPermission($anonymous, new Withdraw('wlt-alice', 100), 'debit'))
+ ->toBeFalse();
+})->group('lumen');
+
+it('refuses a permission it does not know, even to the owner', function (string $permission) {
+ expect(lumenWalletPermissionEvaluator()->hasPermission(lumenWalletOwner('alice'), new Withdraw('wlt-alice', 100), $permission))
+ ->toBeFalse();
+})->with(['read', 'Debit', 'withdraw', ''])->group('lumen');
+
+it('refuses a target it does not know, even to the owner', function (mixed $target) {
+ expect(lumenWalletPermissionEvaluator()->hasPermission(lumenWalletOwner('alice'), $target, 'debit'))
+ ->toBeFalse();
+})->with([
+ 'a deposit' => fn () => new Deposit('wlt-alice', 100),
+ 'a bare wallet id' => 'wlt-alice',
+ 'an object' => fn () => new stdClass,
+ 'null' => null,
+])->group('lumen');
diff --git a/samples/lumen/tests/Domain/LoanTest.php b/samples/lumen/tests/Domain/LoanTest.php
new file mode 100644
index 00000000..9e6e1b16
--- /dev/null
+++ b/samples/lumen/tests/Domain/LoanTest.php
@@ -0,0 +1,67 @@
+uncommittedEvents();
+ expect($event)->toBeInstanceOf(LoanGranted::class)
+ ->and([$loan->walletId(), $loan->principal()->minorUnits, $loan->outstanding()->minorUnits, $loan->version()])
+ ->toBe(['wlt-1', 90000, 90000, 1]);
+})->group('lumen');
+
+it('refuses a principal that is not positive', function () {
+ expect(fn () => Loan::grant('loan-0', 'wlt-0', new Money(0, Currency::EUR)))
+ ->toThrow(ConflictException::class);
+})->group('lumen');
+
+it('records the repayment that clears the balance together with the settlement', function () {
+ $loan = Loan::grant('loan-2', 'wlt-2', new Money(90000, Currency::EUR));
+ $loan->repay(new Money(60000, Currency::EUR));
+ $loan->repay(new Money(30000, Currency::EUR));
+
+ expect(array_map(static fn (object $event): string => $event::class, $loan->uncommittedEvents()))
+ ->toBe([LoanGranted::class, LoanRepaid::class, LoanRepaid::class, LoanSettled::class])
+ ->and([$loan->outstanding()->minorUnits, $loan->repaidMinor(), $loan->repayments(), $loan->isSettled()])
+ ->toBe([0, 90000, 2, true]);
+})->group('lumen');
+
+it('refuses to repay more than is outstanding, in another currency, or once settled', function () {
+ $loan = Loan::grant('loan-3', 'wlt-3', new Money(10000, Currency::EUR));
+
+ expect(fn () => $loan->repay(new Money(10001, Currency::EUR)))->toThrow(ConflictException::class)
+ ->and(fn () => $loan->repay(new Money(5000, Currency::USD)))->toThrow(ConflictException::class);
+
+ $loan->repay(new Money(10000, Currency::EUR));
+
+ expect(fn () => $loan->repay(new Money(1, Currency::EUR)))->toThrow(ConflictException::class)
+ ->and($loan->version())->toBe(3);
+})->group('lumen');
+
+it('rebuilds the same state from its history, and from a snapshot of that state', function () {
+ $loan = Loan::grant('loan-4', 'wlt-4', new Money(90000, Currency::EUR));
+ $loan->repay(new Money(20000, Currency::EUR));
+ $history = $loan->uncommittedEvents();
+
+ $replayed = Loan::reconstitute('loan-4', $history);
+ $restored = Loan::reconstitute('loan-4', [], 2, $loan->snapshotState());
+
+ expect([$replayed->outstanding()->minorUnits, $replayed->repayments(), $replayed->committedVersion()])->toBe([70000, 1, 2])
+ ->and($restored->snapshotState())->toBe($loan->snapshotState())
+ ->and($restored->committedVersion())->toBe(2)
+ ->and(Loan::snapshotVersion())->toBe(1);
+})->group('lumen');
diff --git a/samples/lumen/tests/EventSourcing/EventSourcedPayoutTest.php b/samples/lumen/tests/EventSourcing/EventSourcedPayoutTest.php
new file mode 100644
index 00000000..6408f995
--- /dev/null
+++ b/samples/lumen/tests/EventSourcing/EventSourcedPayoutTest.php
@@ -0,0 +1,49 @@
+ SecurityContextHolder::clearContext());
+
+it('keeps the payout saga\'s execution as an event stream in the event store', function () {
+ /** @var LumenEventSourcedOrchestrationTestCase $this */
+ $this->signInAdmin();
+ $walletId = $this->openWallet(50000);
+
+ $result = $this->app()->make(SagaEngine::class)->execute('payout', ['walletId' => $walletId, 'iban' => 'ES9121000418450200051332', 'amountMinor' => 20000]);
+ $stream = $this->app()->make(EventStore::class)->load($result->correlationId);
+
+ expect($result->success)->toBeTrue()
+ ->and($this->app()->make(ExecutionPersistenceProvider::class))->toBeInstanceOf(EventSourcedExecutionPersistenceProvider::class)
+ ->and($stream->aggregateType())->toBe('orchestration.execution')
+ ->and($stream->events[0]->eventType)->toBe('orchestration.execution.started')
+ ->and(DB::table(OrchestrationSchema::EXECUTIONS)->where('correlation_id', $result->correlationId)->value('status'))->toBe('COMPLETED')
+ ->and($this->queries()->ask(new GetBalance($walletId)))->toBe(30000);
+})->group('lumen');
+
+it('rebuilds the executions read model from the event streams', function () {
+ /** @var LumenEventSourcedOrchestrationTestCase $this */
+ $this->signInAdmin();
+ $result = $this->app()->make(SagaEngine::class)->execute('payout', ['walletId' => $this->openWallet(50000), 'iban' => 'ES9121000418450200051332', 'amountMinor' => 20000]);
+ DB::table(OrchestrationSchema::SEARCH_ATTRIBUTES)->delete();
+ DB::table(OrchestrationSchema::EXECUTIONS)->delete();
+
+ $exit = Artisan::call('firefly:eventsourcing:projections:rebuild', ['projection' => 'orchestration.executions']);
+
+ expect($exit)->toBe(0)
+ ->and(Artisan::output())->toContain('orchestration.executions rebuilt')
+ ->and(DB::table(OrchestrationSchema::EXECUTIONS)->where('correlation_id', $result->correlationId)->value('status'))->toBe('COMPLETED');
+})->group('lumen');
diff --git a/samples/lumen/tests/EventSourcing/LoanBookProjectionTest.php b/samples/lumen/tests/EventSourcing/LoanBookProjectionTest.php
new file mode 100644
index 00000000..8988144d
--- /dev/null
+++ b/samples/lumen/tests/EventSourcing/LoanBookProjectionTest.php
@@ -0,0 +1,83 @@
+ SecurityContextHolder::clearContext());
+
+it('projects the loan events into the loan book and keeps its checkpoint in the database', function () {
+ /** @var LumenEventSourcingTestCase $this */
+ $this->signInAdmin();
+ $walletId = $this->openWallet();
+ $loanId = $this->grantLoan($walletId, 90000);
+ $this->commands()->send(new RepayLoan($loanId, 30000));
+
+ // The events are stored; the loan book has not been told yet.
+ expect(DB::table(LoanBookProjection::TABLE)->count())->toBe(0);
+
+ $exit = Artisan::call('firefly:eventsourcing:projections:run', ['projection' => LoanBookProjection::NAME]);
+
+ expect($exit)->toBe(0)
+ ->and(Artisan::output())->toContain('lumen.loan-book: caught-up, 2 event(s) handled, at position 2')
+ ->and((array) DB::table(LoanBookProjection::TABLE)->where('loan_id', $loanId)->first())->toMatchArray([
+ 'wallet_id' => $walletId,
+ 'principal_minor' => 90000,
+ 'repaid_minor' => 30000,
+ 'outstanding_minor' => 60000,
+ 'repayments' => 1,
+ 'status' => 'open',
+ ])
+ ->and(DB::table(EventSourcingSchema::CHECKPOINTS)->where('projection', LoanBookProjection::NAME)->value('position'))->toEqual(2);
+})->group('lumen');
+
+it('catches up from its checkpoint: a second run hands over only the new events', function () {
+ /** @var LumenEventSourcingTestCase $this */
+ $this->signInAdmin();
+ $loanId = $this->grantLoan($this->openWallet(), 60000);
+ Artisan::call('firefly:eventsourcing:projections:run');
+
+ $this->commands()->send(new RepayLoan($loanId, 60000));
+ Artisan::call('firefly:eventsourcing:projections:run');
+
+ expect(Artisan::output())->toContain('lumen.loan-book: caught-up, 2 event(s) handled, at position 3')
+ ->and(DB::table(LoanBookProjection::TABLE)->where('loan_id', $loanId)->value('status'))->toBe('settled');
+})->group('lumen');
+
+it('lists the projection with its position, the head and its lag', function () {
+ /** @var LumenEventSourcingTestCase $this */
+ $this->signInAdmin();
+ $this->grantLoan($this->openWallet(), 10000);
+
+ Artisan::call('firefly:eventsourcing:projections');
+ $before = Artisan::output();
+ Artisan::call('firefly:eventsourcing:projections:run');
+ Artisan::call('firefly:eventsourcing:projections');
+
+ expect($before)->toMatch('/lumen\.loan-book\s*\|\s*catch-up\s*\|\s*0\s*\|\s*1\s*\|\s*1\s*\|\s*ok/')
+ ->and(Artisan::output())->toMatch('/lumen\.loan-book\s*\|\s*catch-up\s*\|\s*1\s*\|\s*1\s*\|\s*0\s*\|\s*ok/');
+})->group('lumen');
+
+it('rebuilds the loan book from position 0', function () {
+ /** @var LumenEventSourcingTestCase $this */
+ $this->signInAdmin();
+ $loanId = $this->grantLoan($this->openWallet(), 40000);
+ $this->commands()->send(new RepayLoan($loanId, 10000));
+ Artisan::call('firefly:eventsourcing:projections:run');
+ DB::table(LoanBookProjection::TABLE)->delete();
+
+ $exit = Artisan::call('firefly:eventsourcing:projections:rebuild', ['projection' => LoanBookProjection::NAME]);
+
+ expect($exit)->toBe(0)
+ ->and(Artisan::output())->toContain('lumen.loan-book rebuilt: 2 event(s) handled, at position 2')
+ ->and(DB::table(LoanBookProjection::TABLE)->where('loan_id', $loanId)->value('outstanding_minor'))->toEqual(30000);
+})->group('lumen');
diff --git a/samples/lumen/tests/EventSourcing/LoanCommandsTest.php b/samples/lumen/tests/EventSourcing/LoanCommandsTest.php
new file mode 100644
index 00000000..2a24691a
--- /dev/null
+++ b/samples/lumen/tests/EventSourcing/LoanCommandsTest.php
@@ -0,0 +1,158 @@
+ SecurityContextHolder::clearContext());
+
+it('grants a loan: one event in the store and the principal in the wallet', function () {
+ /** @var LumenEventSourcingTestCase $this */
+ $this->signInAdmin();
+ $walletId = $this->openWallet();
+
+ $loanId = $this->grantLoan($walletId, 90000);
+
+ expect($this->queries()->ask(new GetBalance($walletId)))->toBe(90000)
+ ->and(DB::table(EventSourcingSchema::EVENTS)->where('aggregate_id', $loanId)->pluck('event_type')->all())->toBe(['loan.granted'])
+ ->and($this->app()->make(LoanRepository::class)->get($loanId)->outstanding()->minorUnits)->toBe(90000);
+})->group('lumen');
+
+it('repays in parts, and the repayment that clears the balance settles the loan', function () {
+ /** @var LumenEventSourcingTestCase $this */
+ $this->signInAdmin();
+ $walletId = $this->openWallet();
+ $loanId = $this->grantLoan($walletId, 90000);
+
+ $outstanding = array_map(fn (int $amountMinor): mixed => $this->commands()->send(new RepayLoan($loanId, $amountMinor)), [30000, 30000, 30000]);
+
+ expect($outstanding)->toBe([60000, 30000, 0])
+ ->and(DB::table(EventSourcingSchema::EVENTS)->where('aggregate_id', $loanId)->orderBy('aggregate_version')->pluck('event_type')->all())
+ ->toBe(['loan.granted', 'loan.repaid', 'loan.repaid', 'loan.repaid', 'loan.settled'])
+ ->and($this->app()->make(LoanRepository::class)->get($loanId)->isSettled())->toBeTrue()
+ ->and($this->queries()->ask(new GetBalance($walletId)))->toBe(0);
+})->group('lumen');
+
+it('rolls the repayment back with the wallet when the wallet cannot pay it', function () {
+ /** @var LumenEventSourcingTestCase $this */
+ $this->signInAdmin();
+ $walletId = $this->openWallet();
+ $loanId = $this->grantLoan($walletId, 90000);
+ $this->commands()->send(new Withdraw($walletId, 85000));
+
+ $caught = null;
+ try {
+ $this->commands()->send(new RepayLoan($loanId, 30000));
+ } catch (CommandProcessingException $e) {
+ $caught = $e;
+ }
+
+ // The repayment was appended, then the wallet refused the withdrawal: one transaction, so both are undone.
+ expect($caught?->getPrevious())->toBeInstanceOf(ConflictException::class)
+ ->and(DB::table(EventSourcingSchema::EVENTS)->where('aggregate_id', $loanId)->count())->toBe(1)
+ ->and($this->app()->make(LoanRepository::class)->get($loanId)->outstanding()->minorUnits)->toBe(90000)
+ ->and($this->queries()->ask(new GetBalance($walletId)))->toBe(5000);
+})->group('lumen');
+
+it('refuses a repayment larger than what is outstanding with a 409', function () {
+ /** @var LumenEventSourcingTestCase $this */
+ $this->signInAdmin();
+ $walletId = $this->openWallet(50000);
+ $loanId = $this->grantLoan($walletId, 10000);
+
+ $caught = null;
+ try {
+ $this->commands()->send(new RepayLoan($loanId, 20000));
+ } catch (CommandProcessingException $e) {
+ $caught = $e;
+ }
+
+ expect($caught?->getPrevious())->toBeInstanceOf(ConflictException::class)
+ ->and($caught?->getPrevious()?->getMessage())->toContain('is outstanding')
+ ->and(DB::table(EventSourcingSchema::EVENTS)->where('aggregate_id', $loanId)->count())->toBe(1);
+})->group('lumen');
+
+it('lets the owner of the loan\'s wallet repay it', function () {
+ /** @var LumenEventSourcingTestCase $this */
+ $this->signInAdmin();
+ $walletId = $this->openWallet(10000);
+ $loanId = $this->grantLoan($walletId, 90000);
+
+ $this->signInWalletOwner('owner-loans');
+
+ expect($this->commands()->send(new RepayLoan($loanId, 30000)))->toBe(60000)
+ ->and($this->queries()->ask(new GetBalance($walletId)))->toBe(70000);
+})->group('lumen');
+
+it('refuses a repayment from a WALLET_OWNER who does not own the loan\'s wallet, appending and debiting nothing', function () {
+ /** @var LumenEventSourcingTestCase $this */
+ $this->signInAdmin();
+ $walletId = $this->openWallet(10000);
+ $loanId = $this->grantLoan($walletId, 90000);
+
+ // Same role, someone else's loan: hasPermission(#command, 'debit') loads the loan, finds its wallet, and that
+ // wallet's owner is owner-loans, not mallory.
+ $this->signInWalletOwner('mallory');
+
+ $caught = null;
+ try {
+ $this->commands()->send(new RepayLoan($loanId, 30000));
+ } catch (CommandProcessingException $e) {
+ $caught = $e;
+ }
+
+ expect($caught?->getPrevious())->toBeInstanceOf(AuthorizationException::class)
+ ->and(DB::table(EventSourcingSchema::EVENTS)->where('aggregate_id', $loanId)->count())->toBe(1)
+ ->and($this->app()->make(LoanRepository::class)->get($loanId)->outstanding()->minorUnits)->toBe(90000)
+ ->and($this->queries()->ask(new GetBalance($walletId)))->toBe(100000);
+})->group('lumen');
+
+it('refuses a WALLET_OWNER\'s repayment of a loan that does not exist with a 403, not a 404', function () {
+ /** @var LumenEventSourcingTestCase $this */
+ $this->signInWalletOwner('owner-loans');
+
+ $caught = null;
+ try {
+ $this->commands()->send(new RepayLoan('loan-nope', 100));
+ } catch (CommandProcessingException $e) {
+ $caught = $e;
+ }
+
+ // Fail closed: an unknown loan has no owner to match, so the evaluator denies; the caller learns nothing about
+ // which loan ids exist. An ADMIN asking the same reaches the handler and gets its not-found.
+ expect($caught?->getPrevious())->toBeInstanceOf(AuthorizationException::class);
+})->group('lumen');
+
+it('replaces the deny-all default with WalletPermissionEvaluator, which reads a repayment\'s wallet from the loan', function () {
+ /** @var LumenEventSourcingTestCase $this */
+ $this->signInAdmin();
+ $walletId = $this->openWallet();
+ $loanId = $this->grantLoan($walletId, 90000);
+
+ /** @var PermissionEvaluator $evaluator */
+ $evaluator = $this->fireflyContext()->get(PermissionEvaluator::class);
+ $owner = fn (string $name): Authentication => Authentication::authenticated($name, $name, [new SimpleGrantedAuthority('ROLE_WALLET_OWNER')]);
+
+ expect($evaluator)->toBeInstanceOf(WalletPermissionEvaluator::class)
+ ->and($evaluator->hasPermission($owner('owner-loans'), new RepayLoan($loanId, 100), 'debit'))->toBeTrue()
+ ->and($evaluator->hasPermission($owner('mallory'), new RepayLoan($loanId, 100), 'debit'))->toBeFalse()
+ ->and($evaluator->hasPermission($owner('owner-loans'), new RepayLoan($loanId, 100), 'read'))->toBeFalse()
+ ->and($evaluator->hasPermission($owner('owner-loans'), new RepayLoan('loan-nope', 100), 'debit'))->toBeFalse();
+})->group('lumen');
diff --git a/samples/lumen/tests/EventSourcing/LoanOutboxPublicationTest.php b/samples/lumen/tests/EventSourcing/LoanOutboxPublicationTest.php
new file mode 100644
index 00000000..a799e476
--- /dev/null
+++ b/samples/lumen/tests/EventSourcing/LoanOutboxPublicationTest.php
@@ -0,0 +1,52 @@
+ SecurityContextHolder::clearContext());
+
+it('writes each loan event to the outbox in the transaction that appends it, and a consumer delivers it', function () {
+ /** @var LumenEventSourcingOutboxTestCase $this */
+ $this->signInAdmin();
+ $walletId = $this->openWallet();
+ $loanId = $this->grantLoan($walletId, 90000);
+ $this->commands()->send(new RepayLoan($loanId, 30000));
+
+ $headers = DB::table(OutboxSchema::TABLE)->where('event_type', 'loan.granted')->value('headers');
+
+ expect(DB::table(OutboxSchema::TABLE)->where('destination', LumenEventSourcingOutboxTestCase::DESTINATION)->orderBy('id')->pluck('event_type')->all())
+ ->toBe(['loan.granted', 'loan.repaid'])
+ ->and(DB::table(OutboxSchema::TABLE)->where('destination', 'wallet.events')->orderBy('id')->pluck('event_type')->all())
+ ->toBe(['WalletOpened', 'FundsDeposited', 'FundsWithdrawn'])
+ ->and(is_string($headers) ? json_decode($headers, true) : null)->toHaveKey('x-aggregate-id', $loanId)
+ ->and(DB::table(OutboxSchema::TABLE)->where('status', OutboxSchema::STATUS_PENDING)->count())->toBe(5);
+
+ $exit = Artisan::call('firefly:eda:consume', ['--max-messages' => 5, '--time-limit' => 10, '--poll-timeout' => 100]);
+
+ expect($exit)->toBe(0)
+ ->and(DB::table(OutboxSchema::TABLE)->where('status', OutboxSchema::STATUS_PUBLISHED)->count())->toBe(5);
+})->group('lumen');
+
+it('rolls the outbox row back with the event when the wallet cannot pay the repayment', function () {
+ /** @var LumenEventSourcingOutboxTestCase $this */
+ $this->signInAdmin();
+ $walletId = $this->openWallet();
+ $loanId = $this->grantLoan($walletId, 90000);
+ $this->commands()->send(new Withdraw($walletId, 85000));
+
+ expect(fn () => $this->commands()->send(new RepayLoan($loanId, 30000)))->toThrow(CommandProcessingException::class)
+ ->and(DB::table(OutboxSchema::TABLE)->where('destination', LumenEventSourcingOutboxTestCase::DESTINATION)->pluck('event_type')->all())->toBe(['loan.granted'])
+ ->and(DB::table(EventSourcingSchema::EVENTS)->where('aggregate_id', $loanId)->count())->toBe(1);
+})->group('lumen');
diff --git a/samples/lumen/tests/EventSourcing/LoanRepaidUpcasterTest.php b/samples/lumen/tests/EventSourcing/LoanRepaidUpcasterTest.php
new file mode 100644
index 00000000..e17699ad
--- /dev/null
+++ b/samples/lumen/tests/EventSourcing/LoanRepaidUpcasterTest.php
@@ -0,0 +1,47 @@
+app()->make(LoanRepository::class);
+ $loans->save(Loan::grant('loan-legacy', 'wlt-legacy', new Money(50000, Currency::EUR)));
+
+ // A repayment exactly as the first loans were stored: version 1, its amount still called `amount`.
+ $this->app()->make(EventStore::class)->append('loan-legacy', Loan::aggregateType(), [
+ new NewEvent('loan.repaid', ['loanId' => 'loan-legacy', 'amount' => 20000, 'outstandingMinor' => 30000], 1),
+ ], 1);
+
+ $loan = $loans->get('loan-legacy');
+ Artisan::call('firefly:eventsourcing:projections:run', ['projection' => LoanBookProjection::NAME]);
+
+ expect([$loan->repaidMinor(), $loan->outstanding()->minorUnits, $loan->repayments()])->toBe([20000, 30000, 1])
+ ->and(DB::table(LoanBookProjection::TABLE)->where('loan_id', 'loan-legacy')->value('repaid_minor'))->toEqual(20000)
+ ->and(DB::table(EventSourcingSchema::EVENTS)->where('aggregate_id', 'loan-legacy')->where('event_type', 'loan.repaid')->value('event_version'))->toEqual(1);
+})->group('lumen');
+
+it('stores new repayments as version 2 and reads them untouched', function () {
+ /** @var LumenEventSourcingTestCase $this */
+ $loans = $this->app()->make(LoanRepository::class);
+ $loan = Loan::grant('loan-current', 'wlt-current', new Money(50000, Currency::EUR));
+ $loan->repay(new Money(5000, Currency::EUR));
+ $loans->save($loan);
+
+ expect($loans->get('loan-current')->repaidMinor())->toBe(5000)
+ ->and(DB::table(EventSourcingSchema::EVENTS)->where('aggregate_id', 'loan-current')->where('event_type', 'loan.repaid')->value('event_version'))->toEqual(2);
+})->group('lumen');
diff --git a/samples/lumen/tests/EventSourcing/LoanRepositoryTest.php b/samples/lumen/tests/EventSourcing/LoanRepositoryTest.php
new file mode 100644
index 00000000..b55b67d7
--- /dev/null
+++ b/samples/lumen/tests/EventSourcing/LoanRepositoryTest.php
@@ -0,0 +1,68 @@
+app()->make(LoanRepository::class);
+ $loan = Loan::grant('loan-r1', 'wlt-r1', new Money(90000, Currency::EUR));
+ $loan->repay(new Money(20000, Currency::EUR));
+ $loans->save($loan);
+
+ $reloaded = $loans->get('loan-r1');
+
+ expect(DB::table(EventSourcingSchema::EVENTS)->where('aggregate_id', 'loan-r1')->orderBy('aggregate_version')->pluck('event_type')->all())
+ ->toBe(['loan.granted', 'loan.repaid'])
+ ->and([$reloaded->outstanding()->minorUnits, $reloaded->repayments(), $reloaded->committedVersion()])->toBe([70000, 1, 2])
+ ->and($this->app()->make(SnapshotStore::class)->latest('loan-r1'))->toBeNull();
+})->group('lumen');
+
+it('snapshots the loan every third event and replays only the events after the snapshot', function () {
+ /** @var LumenEventSourcingTestCase $this */
+ $loans = $this->app()->make(LoanRepository::class);
+ $loans->save(Loan::grant('loan-r2', 'wlt-r2', new Money(90000, Currency::EUR)));
+ foreach ([20000, 20000, 20000] as $amountMinor) {
+ $loan = $loans->get('loan-r2');
+ $loan->repay(new Money($amountMinor, Currency::EUR));
+ $loans->save($loan);
+ }
+
+ $snapshot = $this->app()->make(SnapshotStore::class)->latest('loan-r2');
+ $replayed = $this->app()->make(EventStore::class)->load('loan-r2', 4)->events;
+ $loan = $loans->get('loan-r2');
+
+ expect($snapshot?->version)->toBe(3)
+ ->and($snapshot?->state)->toMatchArray(['repaidMinor' => 40000, 'repayments' => 2])
+ ->and(array_map(static fn (StoredEvent $event): string => $event->eventType, $replayed))->toBe(['loan.repaid'])
+ ->and([$loan->repaidMinor(), $loan->outstanding()->minorUnits, $loan->committedVersion()])->toBe([60000, 30000, 4]);
+})->group('lumen');
+
+it('refuses a stale writer with a 409 ConcurrencyException', function () {
+ /** @var LumenEventSourcingTestCase $this */
+ $loans = $this->app()->make(LoanRepository::class);
+ $loans->save(Loan::grant('loan-r3', 'wlt-r3', new Money(90000, Currency::EUR)));
+ $first = $loans->get('loan-r3');
+ $second = $loans->get('loan-r3');
+
+ $first->repay(new Money(10000, Currency::EUR));
+ $loans->save($first);
+ $second->repay(new Money(10000, Currency::EUR));
+
+ expect(fn () => $loans->save($second))->toThrow(ConcurrencyException::class)
+ ->and($loans->get('loan-r3')->repaidMinor())->toBe(10000);
+})->group('lumen');
diff --git a/samples/lumen/tests/EventSourcing/LumenEventSourcedOrchestrationTestCase.php b/samples/lumen/tests/EventSourcing/LumenEventSourcedOrchestrationTestCase.php
new file mode 100644
index 00000000..25170565
--- /dev/null
+++ b/samples/lumen/tests/EventSourcing/LumenEventSourcedOrchestrationTestCase.php
@@ -0,0 +1,50 @@
+> */
+ protected function fireflyProviders(): array
+ {
+ return [...parent::fireflyProviders(), ...self::eventSourcingProviders()];
+ }
+
+ /** @return array */
+ protected function configOverrides(): array
+ {
+ return [
+ ...parent::configOverrides(),
+ ...self::eventSourcingConfig(),
+ 'firefly.orchestration.persistence.provider' => 'event-sourced',
+ ];
+ }
+
+ protected function defineFireflyEnvironment(Application $app): void
+ {
+ parent::defineFireflyEnvironment($app);
+
+ $this->bindEventSourcingManifest($app);
+ }
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->freshEventSourcingTables();
+ }
+}
diff --git a/samples/lumen/tests/EventSourcing/LumenEventSourcing.php b/samples/lumen/tests/EventSourcing/LumenEventSourcing.php
new file mode 100644
index 00000000..2f03b23f
--- /dev/null
+++ b/samples/lumen/tests/EventSourcing/LumenEventSourcing.php
@@ -0,0 +1,119 @@
+> */
+ protected static function eventSourcingProviders(): array
+ {
+ return [EventSourcingServiceProvider::class, EventSourcingWiringProvider::class];
+ }
+
+ /** @return array */
+ protected static function eventSourcingConfig(): array
+ {
+ return [
+ 'firefly.eventsourcing.store.provider' => 'database',
+ // 50 by default; 3 lets a short test cross the threshold.
+ 'firefly.eventsourcing.snapshot.threshold' => 3,
+ ];
+ }
+
+ protected function bindEventSourcingManifest(Application $app): void
+ {
+ $app->instance(EventSourcingManifest::class, (new EventSourcingScanner)->scan($this->appPsr4()));
+ }
+
+ /** Dropped first: on a real server (the integration subclasses) the last run's tables remain. */
+ protected function freshEventSourcingTables(): void
+ {
+ $schema = DB::connection()->getSchemaBuilder();
+ EventSourcingSchema::drop($schema);
+ EventSourcingSchema::create($schema);
+ }
+
+ public function commands(): CommandBus
+ {
+ $bus = $this->fireflyContext()->get(CommandBus::class);
+
+ return $bus instanceof CommandBus ? $bus : throw new LogicException('The CommandBus is not bound.');
+ }
+
+ public function queries(): QueryBus
+ {
+ $bus = $this->fireflyContext()->get(QueryBus::class);
+
+ return $bus instanceof QueryBus ? $bus : throw new LogicException('The QueryBus is not bound.');
+ }
+
+ /** A lender: ADMIN may grant a loan, and may withdraw or repay for any wallet. */
+ public function signInAdmin(): void
+ {
+ SecurityContextHolder::setContext(new SecurityContext(
+ Authentication::authenticated('lender', 'lender', [new SimpleGrantedAuthority('ROLE_ADMIN')])
+ ));
+ }
+
+ /** A borrower: WALLET_OWNER may repay a loan only from a wallet whose owner_id is $name. */
+ public function signInWalletOwner(string $name = 'owner-loans'): void
+ {
+ SecurityContextHolder::setContext(new SecurityContext(
+ Authentication::authenticated($name, $name, [new SimpleGrantedAuthority('ROLE_WALLET_OWNER')])
+ ));
+ }
+
+ /** Opens a EUR wallet owned by `owner-loans`, holding $balanceMinor, and returns its id. */
+ public function openWallet(int $balanceMinor = 0): string
+ {
+ $id = $this->commands()->send(new OpenWallet('owner-loans', Currency::EUR));
+ if (! is_string($id)) {
+ throw new LogicException('OpenWallet answers the new wallet id.');
+ }
+ if ($balanceMinor > 0) {
+ $this->commands()->send(new Deposit($id, $balanceMinor));
+ }
+
+ return $id;
+ }
+
+ /** Grants a loan of $principalMinor into $walletId through the bus and returns the loan id. */
+ public function grantLoan(string $walletId, int $principalMinor): string
+ {
+ $id = $this->commands()->send(new GrantLoan($walletId, $principalMinor));
+ if (! is_string($id)) {
+ throw new LogicException('GrantLoan answers the new loan id.');
+ }
+
+ return $id;
+ }
+}
diff --git a/samples/lumen/tests/EventSourcing/LumenEventSourcingOutboxTestCase.php b/samples/lumen/tests/EventSourcing/LumenEventSourcingOutboxTestCase.php
new file mode 100644
index 00000000..98230901
--- /dev/null
+++ b/samples/lumen/tests/EventSourcing/LumenEventSourcingOutboxTestCase.php
@@ -0,0 +1,65 @@
+> */
+ protected function fireflyProviders(): array
+ {
+ return [
+ ...parent::fireflyProviders(),
+ ResilienceServiceProvider::class,
+ SchedulingServiceProvider::class,
+ SchedulingWiringProvider::class,
+ ...self::eventSourcingProviders(),
+ ];
+ }
+
+ /** @return array */
+ protected function configOverrides(): array
+ {
+ return [
+ ...parent::configOverrides(),
+ 'cache.default' => 'array',
+ 'queue.default' => 'sync',
+ ...self::eventSourcingConfig(),
+ 'firefly.eventsourcing.publish.enabled' => true,
+ 'firefly.eventsourcing.publish.destination' => self::DESTINATION,
+ ];
+ }
+
+ protected function defineFireflyEnvironment(Application $app): void
+ {
+ parent::defineFireflyEnvironment($app);
+
+ $this->bindEventSourcingManifest($app);
+ }
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->freshEventSourcingTables();
+ }
+}
diff --git a/samples/lumen/tests/EventSourcing/LumenEventSourcingTestCase.php b/samples/lumen/tests/EventSourcing/LumenEventSourcingTestCase.php
new file mode 100644
index 00000000..33175d00
--- /dev/null
+++ b/samples/lumen/tests/EventSourcing/LumenEventSourcingTestCase.php
@@ -0,0 +1,60 @@
+> */
+ protected function fireflyProviders(): array
+ {
+ return [
+ ...parent::fireflyProviders(),
+ ResilienceServiceProvider::class,
+ SchedulingServiceProvider::class,
+ SchedulingWiringProvider::class,
+ ...self::eventSourcingProviders(),
+ ];
+ }
+
+ /** @return array */
+ protected function configOverrides(): array
+ {
+ return [
+ ...parent::configOverrides(),
+ 'cache.default' => 'array',
+ 'queue.default' => 'sync',
+ ...self::eventSourcingConfig(),
+ ];
+ }
+
+ protected function defineFireflyEnvironment(Application $app): void
+ {
+ parent::defineFireflyEnvironment($app);
+
+ $this->bindEventSourcingManifest($app);
+ }
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->freshEventSourcingTables();
+ }
+}
diff --git a/samples/lumen/tests/Integration/BootsLumenOnIntegrationServer.php b/samples/lumen/tests/Integration/BootsLumenOnIntegrationServer.php
index d60f9b5b..30d89297 100644
--- a/samples/lumen/tests/Integration/BootsLumenOnIntegrationServer.php
+++ b/samples/lumen/tests/Integration/BootsLumenOnIntegrationServer.php
@@ -4,6 +4,7 @@
namespace Lumen\Tests\Integration;
+use Firefly\Security\Core\SecurityContextHolder;
use Firefly\Testing\Integration\IntegrationDsn;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Support\Facades\Artisan;
@@ -41,6 +42,22 @@ protected function resolveApplicationConfiguration($app): void
$config->set('database.default', 'lumen-it');
}
+ /**
+ * Closes the lane connection before the application is flushed. On the sqlite lane the next test's migrate:fresh
+ * truncates the database FILE (SQLiteBuilder::refreshDatabaseFile); a WAL connection the previous application
+ * left open over it would then answer "database is locked" on the next boot's journal_mode pragma.
+ */
+ protected function tearDown(): void
+ {
+ if ($this->app !== null) {
+ // Only a booted application has a Context to clear; a skipped lane never booted one.
+ SecurityContextHolder::clearContext();
+ $this->app->make('db')->disconnect('lumen-it');
+ }
+
+ parent::tearDown();
+ }
+
/** A server database keeps the last run's rows, so every test starts from freshly migrated sample tables. */
protected function migrate(): void
{
diff --git a/samples/lumen/tests/Integration/BootsLumenOnIntegrationServerTest.php b/samples/lumen/tests/Integration/BootsLumenOnIntegrationServerTest.php
new file mode 100644
index 00000000..fc85e9ca
--- /dev/null
+++ b/samples/lumen/tests/Integration/BootsLumenOnIntegrationServerTest.php
@@ -0,0 +1,25 @@
+insert(['id' => 'wlt-reboot-'.uniqid(), 'owner_id' => 'owner-reboot', 'currency' => 'EUR', 'balance_minor' => 0]);
+
+ expect(DB::connection()->getName())->toBe('lumen-it')
+ ->and(Schema::hasTable('wallets'))->toBeTrue()
+ ->and(DB::table('wallets')->count())->toBe(1);
+})->with(['first boot' => ['sqlite'], 'second boot' => ['sqlite']])->group('integration');
diff --git a/samples/lumen/tests/Integration/EventSourcedPayoutIntegrationTestCase.php b/samples/lumen/tests/Integration/EventSourcedPayoutIntegrationTestCase.php
new file mode 100644
index 00000000..93e7c414
--- /dev/null
+++ b/samples/lumen/tests/Integration/EventSourcedPayoutIntegrationTestCase.php
@@ -0,0 +1,13 @@
+signInAdmin();
+ $walletId = $this->openWallet(50000);
+
+ $result = $this->app()->make(SagaEngine::class)->execute('payout', ['walletId' => $walletId, 'iban' => 'ES9121000418450200051332', 'amountMinor' => 20000]);
+
+ expect(DB::connection()->getName())->toBe('lumen-it')
+ ->and($result->success)->toBeTrue()
+ ->and($this->app()->make(EventStore::class)->load($result->correlationId)->aggregateType())->toBe('orchestration.execution')
+ ->and(DB::table(OrchestrationSchema::EXECUTIONS)->where('correlation_id', $result->correlationId)->value('status'))->toBe('COMPLETED')
+ ->and($this->queries()->ask(new GetBalance($walletId)))->toEqual(30000);
+})->with(IntegrationDsn::lanes())->group('integration');
diff --git a/samples/lumen/tests/Integration/LoanLedgerIntegrationTestCase.php b/samples/lumen/tests/Integration/LoanLedgerIntegrationTestCase.php
new file mode 100644
index 00000000..c2bff6dc
--- /dev/null
+++ b/samples/lumen/tests/Integration/LoanLedgerIntegrationTestCase.php
@@ -0,0 +1,17 @@
+signInAdmin();
+ $walletId = $this->openWallet();
+ $loanId = $this->grantLoan($walletId, 90000);
+ foreach ([30000, 30000, 30000] as $amountMinor) {
+ $this->commands()->send(new RepayLoan($loanId, $amountMinor));
+ }
+
+ $loan = $this->app()->make(LoanRepository::class)->get($loanId);
+ $exit = Artisan::call('firefly:eventsourcing:projections:run', ['projection' => LoanBookProjection::NAME]);
+
+ expect(DB::connection()->getName())->toBe('lumen-it')
+ ->and(DB::table(EventSourcingSchema::EVENTS)->where('aggregate_id', $loanId)->orderBy('aggregate_version')->pluck('event_type')->all())
+ ->toBe(['loan.granted', 'loan.repaid', 'loan.repaid', 'loan.repaid', 'loan.settled'])
+ ->and($this->app()->make(SnapshotStore::class)->latest($loanId)?->version)->toBe(3)
+ ->and([$loan->isSettled(), $loan->committedVersion()])->toBe([true, 5])
+ ->and(DB::table(OutboxSchema::TABLE)->where('destination', LumenEventSourcingOutboxTestCase::DESTINATION)->orderBy('id')->pluck('event_type')->all())
+ ->toBe(['loan.granted', 'loan.repaid', 'loan.repaid', 'loan.repaid', 'loan.settled'])
+ ->and($exit)->toBe(0)
+ ->and((array) DB::table(LoanBookProjection::TABLE)->where('loan_id', $loanId)->first())->toMatchArray([
+ 'repaid_minor' => 90000,
+ 'outstanding_minor' => 0,
+ 'repayments' => 3,
+ 'status' => 'settled',
+ ])
+ ->and($this->queries()->ask(new GetBalance($walletId)))->toEqual(0);
+})->with(IntegrationDsn::lanes())->group('integration');
+
+it('reads a version-1 repayment as version 2 on every server', function (string $lane) {
+ /** @var LoanLedgerIntegrationTestCase $this */
+ $loans = $this->app()->make(LoanRepository::class);
+ $loans->save(Loan::grant('loan-legacy-it', 'wlt-legacy-it', new Money(50000, Currency::EUR)));
+ $this->app()->make(EventStore::class)->append('loan-legacy-it', Loan::aggregateType(), [
+ new NewEvent('loan.repaid', ['loanId' => 'loan-legacy-it', 'amount' => 20000, 'outstandingMinor' => 30000], 1),
+ ], 1);
+
+ Artisan::call('firefly:eventsourcing:projections:run', ['projection' => LoanBookProjection::NAME]);
+
+ expect($loans->get('loan-legacy-it')->repaidMinor())->toBe(20000)
+ ->and(DB::table(LoanBookProjection::TABLE)->where('loan_id', 'loan-legacy-it')->value('repaid_minor'))->toEqual(20000);
+})->with(IntegrationDsn::lanes())->group('integration');
diff --git a/samples/lumen/tests/Integration/OutboxPostgresRoundTripTest.php b/samples/lumen/tests/Integration/OutboxPostgresRoundTripTest.php
index a4eaeb1f..08e191e3 100644
--- a/samples/lumen/tests/Integration/OutboxPostgresRoundTripTest.php
+++ b/samples/lumen/tests/Integration/OutboxPostgresRoundTripTest.php
@@ -7,6 +7,10 @@
use Firefly\Cqrs\Command\CommandBus;
use Firefly\Cqrs\Query\QueryBus;
use Firefly\Eda\Postgres\Outbox\OutboxSchema;
+use Firefly\Security\Core\Authentication;
+use Firefly\Security\Core\SecurityContext;
+use Firefly\Security\Core\SecurityContextHolder;
+use Firefly\Security\Core\SimpleGrantedAuthority;
use Firefly\Testing\Integration\IntegrationDsn;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
@@ -18,6 +22,8 @@
uses(OutboxPostgresIntegrationTestCase::class);
+afterEach(fn () => SecurityContextHolder::clearContext());
+
/**
* Real-Postgres outbox round-trip for the wallet sample. Env-gated exactly like
* packages/eda-postgres/tests/Integration/PostgresOutboxRoundTripTest — a manual skip on missing pdo_pgsql /
@@ -42,6 +48,10 @@
$dst = $commands->send(new OpenWallet('owner-B', Currency::EUR));
$commands->send(new Deposit($src, 10000));
+ // TransferHandler is guarded: the source wallet's owner may move money out of it.
+ SecurityContextHolder::setContext(new SecurityContext(
+ Authentication::authenticated('owner-A', 'owner-A', [new SimpleGrantedAuthority('ROLE_WALLET_OWNER')])
+ ));
$commands->send(new Transfer($src, $dst, 4000));
// The committed transfer's FundsWithdrawn event landed in the outbox same-tx (PENDING), and the money moved.
diff --git a/samples/lumen/tests/Outbox/OutboxSameTransactionTest.php b/samples/lumen/tests/Outbox/OutboxSameTransactionTest.php
index 83dc76ec..e94a0165 100644
--- a/samples/lumen/tests/Outbox/OutboxSameTransactionTest.php
+++ b/samples/lumen/tests/Outbox/OutboxSameTransactionTest.php
@@ -8,6 +8,11 @@
use Firefly\Cqrs\Exception\CommandProcessingException;
use Firefly\Cqrs\Query\QueryBus;
use Firefly\Eda\Postgres\Outbox\OutboxSchema;
+use Firefly\Kernel\Exception\Business\ConflictException;
+use Firefly\Security\Core\Authentication;
+use Firefly\Security\Core\SecurityContext;
+use Firefly\Security\Core\SecurityContextHolder;
+use Firefly\Security\Core\SimpleGrantedAuthority;
use Illuminate\Support\Facades\DB;
use Lumen\Application\Command\Deposit;
use Lumen\Application\Command\OpenWallet;
@@ -17,6 +22,8 @@
uses(OutboxPostgresTestCase::class);
+afterEach(fn () => SecurityContextHolder::clearContext());
+
// Gate on firefly/eda-postgres actually being installed (it is, via lumen's require-dev) — the adapter supplies the
// OutboxSchema/OutboxPreCommitHook the same-tx proof drives. Both tests skip cleanly if it is ever absent.
it('writes the FundsWithdrawn outbox row INSIDE the transfer transaction (present after commit)', function () {
@@ -32,6 +39,10 @@
$dst = $commands->send(new OpenWallet('owner-B', Currency::EUR));
$commands->send(new Deposit($src, 10000));
+ // TransferHandler is guarded: the source wallet's owner may move money out of it.
+ SecurityContextHolder::setContext(new SecurityContext(
+ Authentication::authenticated('owner-A', 'owner-A', [new SimpleGrantedAuthority('ROLE_WALLET_OWNER')])
+ ));
$commands->send(new Transfer($src, $dst, 4000));
// The transfer committed: its FundsWithdrawn domain event was written to the outbox IN the same transaction, so a
@@ -68,8 +79,19 @@
$rowsBefore = DB::table(OutboxSchema::TABLE)->count();
- expect(fn () => $commands->send(new Transfer($src, $dst, 4000)))
- ->toThrow(CommandProcessingException::class);
+ // Signed in as the source's owner, so the guard lets the transfer through and it is the CREDIT leg that fails.
+ SecurityContextHolder::setContext(new SecurityContext(
+ Authentication::authenticated('owner-A', 'owner-A', [new SimpleGrantedAuthority('ROLE_WALLET_OWNER')])
+ ));
+ $failed = null;
+ try {
+ $commands->send(new Transfer($src, $dst, 4000));
+ } catch (CommandProcessingException $e) {
+ $failed = $e;
+ }
+
+ expect($failed)->toBeInstanceOf(CommandProcessingException::class)
+ ->and($failed?->getPrevious())->toBeInstanceOf(ConflictException::class);
// Genuine same-tx atomicity: the failed transfer added NO outbox rows (its FundsWithdrawn INSERT rolled back with
// the aggregate — none survives), and NO balance moved. Money can neither vanish nor leak an outbox event.
diff --git a/samples/lumen/tests/Web/PayoutRestTest.php b/samples/lumen/tests/Web/PayoutRestTest.php
index 1d88e54e..24d76aee 100644
--- a/samples/lumen/tests/Web/PayoutRestTest.php
+++ b/samples/lumen/tests/Web/PayoutRestTest.php
@@ -112,6 +112,23 @@ public function send(object $command): mixed
expect($this->app()->make(SimulatedBankGateway::class)->calls)->toBe([]);
})->group('lumen');
+it('answers a payout from someone else\'s wallet with the withdraw\'s own 403, before the bank is called', function () {
+ /** @var LumenOrchestrationTestCase $this */
+ /** @var string $id */
+ $id = $this->postJson('/api/v1/wallets', ['owner_id' => 'owner-elsewhere', 'currency' => 'EUR'])->json('wallet_id');
+ $this->postJson("/api/v1/wallets/{$id}/deposit", ['amount_minor' => 50000]);
+
+ // owner-rest (signed in by beforeEach) holds ROLE_WALLET_OWNER but does not own this wallet: the saga's debit is
+ // a Withdraw, refused at the bus by hasPermission(#command, 'debit'), so the payout never reaches the bank.
+ $this->postJson("/api/v1/wallets/{$id}/payouts", ['iban' => 'ES9121000418450200051332', 'amount_minor' => 20000])
+ ->assertStatus(403)
+ ->assertHeader('Content-Type', 'application/problem+json')
+ ->assertJsonPath('code', 'ACCESS_DENIED');
+
+ expect($this->app()->make(SimulatedBankGateway::class)->calls)->toBe([]);
+ $this->getJson("/api/v1/wallets/{$id}/balance")->assertJson(['balance_minor' => 50000]);
+})->group('lumen');
+
it('validates the payout body before the saga starts', function () {
/** @var LumenOrchestrationTestCase $this */
/** @var string $id */
diff --git a/samples/lumen/tests/Web/WalletRestTest.php b/samples/lumen/tests/Web/WalletRestTest.php
index 0a1d550a..874be7c9 100644
--- a/samples/lumen/tests/Web/WalletRestTest.php
+++ b/samples/lumen/tests/Web/WalletRestTest.php
@@ -87,8 +87,8 @@
it('denies an unauthenticated withdraw as 401 problem+json (the endpoint IS secured)', function () {
/** @var LumenTestCase $this */
- // WithdrawHandler carries #[PreAuthorize("hasRole('ADMIN') or hasRole('WALLET_OWNER')")] (S6), enforced by
- // SecurityCommandAuthorizer at the bus. LumenTestCase's HTTP security filters (jwt/http/csrf) are OFF, so a
+ // WithdrawHandler carries #[PreAuthorize("hasRole('ADMIN') or (hasRole('WALLET_OWNER') and hasPermission(#command,
+ // 'debit'))")], enforced by SecurityCommandAuthorizer at the bus. LumenTestCase's HTTP security filters (jwt/http/csrf) are OFF, so a
// plain HTTP POST carries NO principal -> SecurityContextHolder::getContext() is anonymous -> the bus refuses
// the command with "authenticate first": MethodSecurityEvaluator answers an ANONYMOUS caller with an
// AuthenticationException (401 AUTHENTICATION_FAILED), the same answer the controller guard and the proxy
@@ -138,6 +138,24 @@
$this->getJson("/api/v1/wallets/{$id}/balance")->assertJson(['balance_minor' => 5000]);
})->group('lumen');
+it('names the authorities the rule asked for on a refused withdraw, as a refused controller method does', function () {
+ /** @var LumenTestCase $this */
+ // MethodSecurityRefusal puts the rule's authorities on the AuthorizationException as the `requiredAuthorities`
+ // extension member, and CommandProcessingException carries the cause's extension members, so the document a
+ // refused command renders names them too. hasPermission() is not an authority: only the two roles appear.
+ SecurityContextHolder::setContext(new SecurityContext(
+ Authentication::authenticated('mallory', 'mallory', [new SimpleGrantedAuthority('ROLE_USER')])
+ ));
+
+ /** @var string $id */
+ $id = $this->postJson('/api/v1/wallets', ['owner_id' => 'owner-3c', 'currency' => 'EUR'])->json('wallet_id');
+
+ $this->postJson("/api/v1/wallets/{$id}/withdraw", ['amount_minor' => 1000])
+ ->assertStatus(403)
+ ->assertJsonPath('code', 'ACCESS_DENIED')
+ ->assertJsonPath('requiredAuthorities', ['ROLE_ADMIN', 'ROLE_WALLET_OWNER']);
+})->group('lumen');
+
it('allows an authorized withdraw and renders an overdraw as 409 problem+json', function () {
/** @var LumenTestCase $this */
// SecurityContextHolder is a request-scoped holder backed by Laravel's Context facade (packages/security/src
@@ -172,6 +190,48 @@
$this->getJson("/api/v1/wallets/{$id}/balance")->assertJson(['balance_minor' => 2000]);
})->group('lumen');
+it('denies a withdraw from a WALLET_OWNER who does not own the wallet as 403 problem+json', function () {
+ /** @var LumenTestCase $this */
+ // The role is necessary, not sufficient: #[PreAuthorize("hasRole('ADMIN') or (hasRole('WALLET_OWNER') and
+ // hasPermission(#command, 'debit'))")] asks WalletPermissionEvaluator whether THIS principal owns THIS wallet.
+ // 'owner-7b' holds ROLE_WALLET_OWNER but the wallet is owner-7a's, so the bus refuses before the handler runs.
+ SecurityContextHolder::setContext(new SecurityContext(
+ Authentication::authenticated('owner-7b', 'owner-7b', [new SimpleGrantedAuthority('ROLE_WALLET_OWNER')])
+ ));
+
+ /** @var string $id */
+ $id = $this->postJson('/api/v1/wallets', ['owner_id' => 'owner-7a', 'currency' => 'EUR'])->json('wallet_id');
+ $this->postJson("/api/v1/wallets/{$id}/deposit", ['amount_minor' => 5000]);
+
+ $this->postJson("/api/v1/wallets/{$id}/withdraw", ['amount_minor' => 1000])
+ ->assertStatus(403)
+ ->assertHeader('Content-Type', 'application/problem+json')
+ ->assertJsonPath('status', 403)
+ ->assertJsonPath('code', 'ACCESS_DENIED')
+ ->assertJsonPath('category', 'security');
+
+ // Nothing was debited, and the ledger never saw a withdrawal.
+ $this->getJson("/api/v1/wallets/{$id}/balance")->assertJson(['balance_minor' => 5000]);
+ /** @var list> $entries */
+ $entries = $this->getJson("/api/v1/wallets/{$id}/ledger")->assertStatus(200)->json('entries');
+ expect(array_column($entries, 'event_type'))->not->toContain('FundsWithdrawn');
+})->group('lumen');
+
+it('allows an ADMIN to withdraw from a wallet it does not own', function () {
+ /** @var LumenTestCase $this */
+ SecurityContextHolder::setContext(new SecurityContext(
+ Authentication::authenticated('teller', 'teller', [new SimpleGrantedAuthority('ROLE_ADMIN')])
+ ));
+
+ /** @var string $id */
+ $id = $this->postJson('/api/v1/wallets', ['owner_id' => 'owner-8', 'currency' => 'EUR'])->json('wallet_id');
+ $this->postJson("/api/v1/wallets/{$id}/deposit", ['amount_minor' => 3000]);
+
+ $this->postJson("/api/v1/wallets/{$id}/withdraw", ['amount_minor' => 1000])
+ ->assertStatus(200)
+ ->assertJson(['wallet_id' => $id, 'balance_minor' => 2000]);
+})->group('lumen');
+
it('transfers between two wallets over REST and reflects both fresh balances', function () {
/** @var LumenTestCase $this */
/** @var string $src */
@@ -180,6 +240,11 @@
$dst = $this->postJson('/api/v1/wallets', ['owner_id' => 'owner-5b', 'currency' => 'EUR'])->json('wallet_id');
$this->postJson("/api/v1/wallets/{$src}/deposit", ['amount_minor' => 10000]);
+ // The transfer is guarded like the withdraw: signed in as the source wallet's owner, it goes through.
+ SecurityContextHolder::setContext(new SecurityContext(
+ Authentication::authenticated('owner-5a', 'owner-5a', [new SimpleGrantedAuthority('ROLE_WALLET_OWNER')])
+ ));
+
$this->postJson('/api/v1/wallets/transfers', [
'source_wallet_id' => $src,
'destination_wallet_id' => $dst,
@@ -195,6 +260,79 @@
$this->getJson("/api/v1/wallets/{$dst}/balance")->assertJson(['balance_minor' => 4000]);
})->group('lumen');
+it('denies an unauthenticated transfer as 401 problem+json and moves no money', function () {
+ /** @var LumenTestCase $this */
+ // TransferHandler carries the same #[PreAuthorize] as WithdrawHandler, with the Transfer bound to #command and
+ // WalletPermissionEvaluator asking who owns its SOURCE wallet. No principal -> "authenticate first".
+ /** @var string $src */
+ $src = $this->postJson('/api/v1/wallets', ['owner_id' => 'owner-9a', 'currency' => 'EUR'])->json('wallet_id');
+ /** @var string $dst */
+ $dst = $this->postJson('/api/v1/wallets', ['owner_id' => 'owner-9b', 'currency' => 'EUR'])->json('wallet_id');
+ $this->postJson("/api/v1/wallets/{$src}/deposit", ['amount_minor' => 5000]);
+
+ $this->postJson('/api/v1/wallets/transfers', [
+ 'source_wallet_id' => $src,
+ 'destination_wallet_id' => $dst,
+ 'amount_minor' => 1000,
+ ])->assertStatus(401)
+ ->assertHeader('Content-Type', 'application/problem+json')
+ ->assertJsonPath('status', 401)
+ ->assertJsonPath('code', 'AUTHENTICATION_FAILED')
+ ->assertJsonPath('category', 'security');
+
+ $this->getJson("/api/v1/wallets/{$src}/balance")->assertJson(['balance_minor' => 5000]);
+ $this->getJson("/api/v1/wallets/{$dst}/balance")->assertJson(['balance_minor' => 0]);
+})->group('lumen');
+
+it('denies a transfer out of a wallet the WALLET_OWNER does not own as 403 problem+json and moves no money', function () {
+ /** @var LumenTestCase $this */
+ // owner-10b owns the destination and holds the role; a transfer debits its source, which is owner-10a's.
+ SecurityContextHolder::setContext(new SecurityContext(
+ Authentication::authenticated('owner-10b', 'owner-10b', [new SimpleGrantedAuthority('ROLE_WALLET_OWNER')])
+ ));
+
+ /** @var string $src */
+ $src = $this->postJson('/api/v1/wallets', ['owner_id' => 'owner-10a', 'currency' => 'EUR'])->json('wallet_id');
+ /** @var string $dst */
+ $dst = $this->postJson('/api/v1/wallets', ['owner_id' => 'owner-10b', 'currency' => 'EUR'])->json('wallet_id');
+ $this->postJson("/api/v1/wallets/{$src}/deposit", ['amount_minor' => 5000]);
+
+ $this->postJson('/api/v1/wallets/transfers', [
+ 'source_wallet_id' => $src,
+ 'destination_wallet_id' => $dst,
+ 'amount_minor' => 1000,
+ ])->assertStatus(403)
+ ->assertHeader('Content-Type', 'application/problem+json')
+ ->assertJsonPath('status', 403)
+ ->assertJsonPath('code', 'ACCESS_DENIED')
+ ->assertJsonPath('category', 'security');
+
+ $this->getJson("/api/v1/wallets/{$src}/balance")->assertJson(['balance_minor' => 5000]);
+ $this->getJson("/api/v1/wallets/{$dst}/balance")->assertJson(['balance_minor' => 0]);
+})->group('lumen');
+
+it('allows an ADMIN to transfer between wallets it does not own', function () {
+ /** @var LumenTestCase $this */
+ SecurityContextHolder::setContext(new SecurityContext(
+ Authentication::authenticated('teller', 'teller', [new SimpleGrantedAuthority('ROLE_ADMIN')])
+ ));
+
+ /** @var string $src */
+ $src = $this->postJson('/api/v1/wallets', ['owner_id' => 'owner-11a', 'currency' => 'EUR'])->json('wallet_id');
+ /** @var string $dst */
+ $dst = $this->postJson('/api/v1/wallets', ['owner_id' => 'owner-11b', 'currency' => 'EUR'])->json('wallet_id');
+ $this->postJson("/api/v1/wallets/{$src}/deposit", ['amount_minor' => 5000]);
+
+ $this->postJson('/api/v1/wallets/transfers', [
+ 'source_wallet_id' => $src,
+ 'destination_wallet_id' => $dst,
+ 'amount_minor' => 1000,
+ ])->assertStatus(200)->assertJson([
+ 'source_balance_minor' => 4000,
+ 'destination_balance_minor' => 1000,
+ ]);
+})->group('lumen');
+
it('reads the projected ledger for a wallet over REST', function () {
/** @var LumenTestCase $this */
/** @var string $id */
diff --git a/tests/Fixtures/Tutorial/app/Greetings/GreetingCounts.php b/tests/Fixtures/Tutorial/app/Greetings/GreetingCounts.php
new file mode 100644
index 00000000..ed2c514e
--- /dev/null
+++ b/tests/Fixtures/Tutorial/app/Greetings/GreetingCounts.php
@@ -0,0 +1,60 @@
+payload['message'] ?? '';
+ $message = is_string($message) ? $message : '';
+
+ if (DB::table(self::TABLE)->where('name', $event->aggregateId)->exists()) {
+ DB::table(self::TABLE)->where('name', $event->aggregateId)->increment('times', 1, ['last_message' => $message]);
+
+ return;
+ }
+
+ DB::table(self::TABLE)->insert(['name' => $event->aggregateId, 'times' => 1, 'last_message' => $message]);
+ }
+
+ public function reset(): void
+ {
+ DB::table(self::TABLE)->delete();
+ }
+
+ /** @return array{name: string, times: int, lastMessage: string}|null */
+ public static function find(string $name): ?array
+ {
+ $row = DB::table(self::TABLE)->where('name', $name)->first();
+ if ($row === null) {
+ return null;
+ }
+
+ $values = (array) $row;
+ $times = $values['times'] ?? 0;
+ $message = $values['last_message'] ?? '';
+
+ return ['name' => $name, 'times' => is_numeric($times) ? (int) $times : 0, 'lastMessage' => is_string($message) ? $message : ''];
+ }
+}
diff --git a/tests/Fixtures/Tutorial/app/Greetings/GreetingJournal.php b/tests/Fixtures/Tutorial/app/Greetings/GreetingJournal.php
new file mode 100644
index 00000000..a083919b
--- /dev/null
+++ b/tests/Fixtures/Tutorial/app/Greetings/GreetingJournal.php
@@ -0,0 +1,48 @@
+recordThat(new GreetingSaid($name, $message));
+
+ return $journal;
+ }
+
+ public function say(string $message): void
+ {
+ $this->recordThat(new GreetingSaid($this->id(), $message));
+ }
+
+ public function times(): int
+ {
+ return $this->times;
+ }
+
+ public function lastMessage(): string
+ {
+ return $this->lastMessage;
+ }
+
+ protected function applyGreetingSaid(GreetingSaid $event): void
+ {
+ $this->times++;
+ $this->lastMessage = $event->message;
+ }
+}
diff --git a/tests/Fixtures/Tutorial/app/Greetings/GreetingJournals.php b/tests/Fixtures/Tutorial/app/Greetings/GreetingJournals.php
new file mode 100644
index 00000000..eb9ce19c
--- /dev/null
+++ b/tests/Fixtures/Tutorial/app/Greetings/GreetingJournals.php
@@ -0,0 +1,19 @@
+ */
+#[Component]
+final class GreetingJournals extends EventSourcedRepository
+{
+ public function __construct(EventSourcingSupport $support)
+ {
+ parent::__construct($support, GreetingJournal::class);
+ }
+}
diff --git a/tests/Fixtures/Tutorial/app/Greetings/GreetingSaid.php b/tests/Fixtures/Tutorial/app/Greetings/GreetingSaid.php
new file mode 100644
index 00000000..66b0fce3
--- /dev/null
+++ b/tests/Fixtures/Tutorial/app/Greetings/GreetingSaid.php
@@ -0,0 +1,22 @@
+journals->find($name);
+
+ if ($journal === null) {
+ $journal = GreetingJournal::begin($name, $message);
+ } else {
+ $journal->say($message);
+ }
+
+ $this->journals->save($journal);
+
+ return ['name' => $name, 'times' => $journal->times()];
+ }
+
+ /** @return array{name: string, times: int, lastMessage: string} */
+ #[GetMapping('/journal/{name}')]
+ public function show(#[PathVariable] string $name): array
+ {
+ return GreetingCounts::find($name)
+ ?? throw new ResourceNotFoundException("No greeting to {$name} has been projected yet.");
+ }
+}
diff --git a/tests/Fixtures/Tutorial/database/migrations/2026_10_13_000000_create_greeting_counts_table.php b/tests/Fixtures/Tutorial/database/migrations/2026_10_13_000000_create_greeting_counts_table.php
new file mode 100644
index 00000000..0addca18
--- /dev/null
+++ b/tests/Fixtures/Tutorial/database/migrations/2026_10_13_000000_create_greeting_counts_table.php
@@ -0,0 +1,24 @@
+string('name')->primary();
+ $table->unsignedInteger('times');
+ $table->string('last_message');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('greeting_counts');
+ }
+};
diff --git a/tests/Support/TutorialEventSourcingTestCase.php b/tests/Support/TutorialEventSourcingTestCase.php
new file mode 100644
index 00000000..14cdf43c
--- /dev/null
+++ b/tests/Support/TutorialEventSourcingTestCase.php
@@ -0,0 +1,46 @@
+defineSqliteMemory($app);
+ }
+
+ /** @return array */
+ protected function configOverrides(): array
+ {
+ return [...parent::configOverrides(), 'firefly.eventsourcing.store.provider' => 'database'];
+ }
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ Artisan::call('migrate', [
+ '--path' => [EventSourcingWiringProvider::MIGRATIONS.'/'.EventSourcingWiringProvider::TABLES_MIGRATION, self::migrationsPath()],
+ '--realpath' => true,
+ ]);
+ }
+}
diff --git a/tests/Support/TutorialTestCase.php b/tests/Support/TutorialTestCase.php
index 21f248ff..1b74f5b9 100644
--- a/tests/Support/TutorialTestCase.php
+++ b/tests/Support/TutorialTestCase.php
@@ -6,6 +6,10 @@
use Firefly\Eda\EdaServiceProvider;
use Firefly\Eda\EdaWiringProvider;
+use Firefly\EventSourcing\EventSourcingServiceProvider;
+use Firefly\EventSourcing\EventSourcingWiringProvider;
+use Firefly\EventSourcing\Manifest\EventSourcingManifest;
+use Firefly\EventSourcing\Scanner\EventSourcingScanner;
use Firefly\Orchestration\Manifest\OrchestrationManifest;
use Firefly\Orchestration\OrchestrationServiceProvider;
use Firefly\Orchestration\OrchestrationWiringProvider;
@@ -23,8 +27,9 @@
/**
* Boots the tutorial's own app/ tree — tests/Fixtures/Tutorial/app, in the reader's `App\` namespace — through the
* real pipeline a created application gets: the dev scan path (firefly.scan.paths) for its beans, the route scanner
- * for its #[RestController], the orchestration scanner for its #[Saga] (what firefly:cache would compile), and the
- * memory defaults for orchestration persistence and the event bus, exactly as the skeleton ships them.
+ * for its #[RestController], the orchestration scanner for its #[Saga] and the event-sourcing scanner for its
+ * #[EventType] events (what firefly:cache would compile), and the memory defaults for orchestration persistence, the
+ * event store and the event bus, exactly as the skeleton ships them.
*
* The fixture classes are not under any Composer PSR-4 root (they declare `App\…`, not `Firefly\Tests\…`), so this
* harness registers a test-only autoloader for them before the application boots.
@@ -57,6 +62,8 @@ protected function fireflyProviders(): array
EdaWiringProvider::class,
OrchestrationServiceProvider::class,
OrchestrationWiringProvider::class,
+ EventSourcingServiceProvider::class,
+ EventSourcingWiringProvider::class,
];
}
@@ -74,6 +81,7 @@ protected function defineFireflyEnvironment(Application $app): void
{
$app->instance(RouteManifest::class, new RouteManifest((new RouteScanner)->scan(self::psr4())));
$app->instance(OrchestrationManifest::class, (new OrchestrationScanner)->scan(self::psr4()));
+ $app->instance(EventSourcingManifest::class, (new EventSourcingScanner)->scan(self::psr4()));
}
protected function setUp(): void
diff --git a/tests/TutorialGreetingJournalTest.php b/tests/TutorialGreetingJournalTest.php
new file mode 100644
index 00000000..469453a5
--- /dev/null
+++ b/tests/TutorialGreetingJournalTest.php
@@ -0,0 +1,45 @@
+postJson('/journal/Ada')->assertStatus(201)->assertExactJson(['name' => 'Ada', 'times' => 1]);
+ $this->postJson('/journal/Ada')->assertStatus(201)->assertExactJson(['name' => 'Ada', 'times' => 2]);
+
+ // The events are stored; the read model has not been told yet.
+ $this->getJson('/journal/Ada')->assertStatus(404)->assertHeader('Content-Type', 'application/problem+json');
+
+ expect(Artisan::call('firefly:eventsourcing:projections:run'))->toBe(0)
+ ->and(Artisan::output())->toContain('greeting.counts: caught-up, 2 event(s) handled, at position 2')
+ ->and(DB::table(EventSourcingSchema::EVENTS)->where('aggregate_id', 'Ada')->orderBy('aggregate_version')->pluck('event_type')->all())
+ ->toBe(['greeting.said', 'greeting.said']);
+
+ $this->getJson('/journal/Ada')->assertOk()->assertExactJson(['name' => 'Ada', 'times' => 2, 'lastMessage' => 'Hello, Ada!']);
+});
+
+it('rebuilds the read model from the events alone', function () {
+ /** @var TutorialEventSourcingTestCase $this */
+ $this->postJson('/journal/Grace')->assertStatus(201);
+ Artisan::call('firefly:eventsourcing:projections:run');
+ DB::table(GreetingCounts::TABLE)->delete();
+
+ expect(Artisan::call('firefly:eventsourcing:projections:rebuild', ['projection' => 'greeting.counts']))->toBe(0)
+ ->and(Artisan::output())->toContain('greeting.counts rebuilt: 1 event(s) handled, at position 1');
+
+ $this->getJson('/journal/Grace')->assertOk()->assertJsonPath('times', 1);
+});