From f3617ac72d2c9fd3026c635f317a7eedb1d3ec25 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 07:46:41 -0700 Subject: [PATCH 01/19] test(lumen): prove the sample on the portable database outbox, delivered by firefly:eda:consume --- samples/lumen/config/firefly.php | 3 +- .../Outbox/OutboxDatabaseDeliveryTest.php | 45 ++++++++++++++++++ .../tests/Outbox/OutboxDatabaseTestCase.php | 47 +++++++++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 samples/lumen/tests/Outbox/OutboxDatabaseDeliveryTest.php create mode 100644 samples/lumen/tests/Outbox/OutboxDatabaseTestCase.php diff --git a/samples/lumen/config/firefly.php b/samples/lumen/config/firefly.php index 315d1f0c..d3eee39d 100644 --- a/samples/lumen/config/firefly.php +++ b/samples/lumen/config/firefly.php @@ -9,5 +9,6 @@ ], ], // firefly.eda.provider is unset here -> the in-memory EventPublisher (zero external infra). - // Set 'eda' => ['provider' => 'postgres'] to switch on the genuine same-transaction outbox. + // 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. ]; diff --git a/samples/lumen/tests/Outbox/OutboxDatabaseDeliveryTest.php b/samples/lumen/tests/Outbox/OutboxDatabaseDeliveryTest.php new file mode 100644 index 00000000..ad1bf3fa --- /dev/null +++ b/samples/lumen/tests/Outbox/OutboxDatabaseDeliveryTest.php @@ -0,0 +1,45 @@ +app()->make(EventPublisher::class))->toBeInstanceOf(DatabaseEventPublisher::class); +})->group('lumen'); + +it('writes each domain event to the outbox in its transaction and delivers it only when a consumer runs', function () { + /** @var OutboxDatabaseTestCase $this */ + /** @var CommandBus $commands */ + $commands = $this->fireflyContext()->get(CommandBus::class); + + /** @var string $id */ + $id = $commands->send(new OpenWallet('owner-db', Currency::EUR)); + $commands->send(new Deposit($id, 2500)); + + // Committed with the wallet and waiting for a consumer: the projector has not run yet. + expect(DB::table(OutboxSchema::TABLE)->where('status', OutboxSchema::STATUS_PENDING)->orderBy('id')->pluck('event_type')->all()) + ->toBe(['WalletOpened', 'FundsDeposited']) + ->and(LedgerEntry::query()->where('wallet_id', $id)->count())->toBe(0); + + $exit = Artisan::call('firefly:eda:consume', ['--max-messages' => 2, '--time-limit' => 10, '--poll-timeout' => 100]); + + expect($exit)->toBe(0) + ->and(DB::table(OutboxSchema::TABLE)->where('status', OutboxSchema::STATUS_PUBLISHED)->count())->toBe(2) + ->and(LedgerEntry::query()->where('wallet_id', $id)->orderBy('id')->pluck('event_type')->all()) + ->toBe(['WalletOpened', 'FundsDeposited']); +})->group('lumen'); diff --git a/samples/lumen/tests/Outbox/OutboxDatabaseTestCase.php b/samples/lumen/tests/Outbox/OutboxDatabaseTestCase.php new file mode 100644 index 00000000..d93b5fc8 --- /dev/null +++ b/samples/lumen/tests/Outbox/OutboxDatabaseTestCase.php @@ -0,0 +1,47 @@ +> */ + protected function fireflyProviders(): array + { + return [...parent::fireflyProviders(), EdaDatabaseServiceProvider::class, EdaConsumerServiceProvider::class]; + } + + /** @return array */ + protected function configOverrides(): array + { + return [...parent::configOverrides(), 'firefly.eda.provider' => 'database']; + } + + protected function setUp(): void + { + parent::setUp(); + + Schema::dropIfExists(OutboxSchema::TABLE); + Schema::create(OutboxSchema::TABLE, fn (Blueprint $table) => OutboxSchema::blueprint($table)); + } +} From 36a968d808b85a368d684780621c29cd6f10d501 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 07:48:27 -0700 Subject: [PATCH 02/19] test(lumen): run the sample's portable outbox on every integration lane --- .../BootsLumenOnIntegrationServer.php | 59 +++++++++++++++++++ .../OutboxDatabaseIntegrationTestCase.php | 16 +++++ .../OutboxDatabaseRoundTripTest.php | 48 +++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 samples/lumen/tests/Integration/BootsLumenOnIntegrationServer.php create mode 100644 samples/lumen/tests/Integration/OutboxDatabaseIntegrationTestCase.php create mode 100644 samples/lumen/tests/Integration/OutboxDatabaseRoundTripTest.php diff --git a/samples/lumen/tests/Integration/BootsLumenOnIntegrationServer.php b/samples/lumen/tests/Integration/BootsLumenOnIntegrationServer.php new file mode 100644 index 00000000..d60f9b5b --- /dev/null +++ b/samples/lumen/tests/Integration/BootsLumenOnIntegrationServer.php @@ -0,0 +1,59 @@ +lane()); + if ($reason !== null) { + $this->markTestSkipped($reason); + } + + parent::setUp(); + } + + protected function resolveApplicationConfiguration($app): void + { + parent::resolveApplicationConfiguration($app); + + $lane = $this->lane(); + $overrides = $lane === 'sqlite' ? ['database' => IntegrationDsn::sqlitePath('lumen-outbox')] : []; + + /** @var Repository $config */ + $config = $app->make('config'); + $config->set('database.connections.lumen-it', IntegrationDsn::connectionConfig($lane, $overrides)); + $config->set('database.default', 'lumen-it'); + } + + /** A server database keeps the last run's rows, so every test starts from freshly migrated sample tables. */ + protected function migrate(): void + { + Artisan::call('migrate:fresh', ['--path' => dirname(__DIR__, 2).'/src/Infrastructure/Migration', '--realpath' => true]); + } + + private function lane(): string + { + $lane = $this->providedData()[0] ?? null; + if (! is_string($lane) || ! in_array($lane, IntegrationDsn::lanes(), true)) { + throw new LogicException('A Lumen integration test needs a dataset naming one of IntegrationDsn::lanes().'); + } + + return $lane; + } +} diff --git a/samples/lumen/tests/Integration/OutboxDatabaseIntegrationTestCase.php b/samples/lumen/tests/Integration/OutboxDatabaseIntegrationTestCase.php new file mode 100644 index 00000000..e1868337 --- /dev/null +++ b/samples/lumen/tests/Integration/OutboxDatabaseIntegrationTestCase.php @@ -0,0 +1,16 @@ +fireflyContext()->get(CommandBus::class); + /** @var QueryBus $queries */ + $queries = $this->fireflyContext()->get(QueryBus::class); + + /** @var string $id */ + $id = $commands->send(new OpenWallet('owner-it', Currency::EUR)); + $commands->send(new Deposit($id, 2500)); + + expect(DB::connection()->getName())->toBe('lumen-it') + ->and(DB::table(OutboxSchema::TABLE)->where('status', OutboxSchema::STATUS_PENDING)->orderBy('id')->pluck('event_type')->all()) + ->toBe(['WalletOpened', 'FundsDeposited']) + ->and($queries->ask(new GetBalance($id)))->toBe(2500); + + $exit = Artisan::call('firefly:eda:consume', ['--max-messages' => 2, '--time-limit' => 20, '--poll-timeout' => 500]); + + expect($exit)->toBe(0) + ->and(DB::table(OutboxSchema::TABLE)->where('status', OutboxSchema::STATUS_PENDING)->count())->toBe(0) + ->and(LedgerEntry::query()->where('wallet_id', $id)->orderBy('id')->pluck('event_type')->all()) + ->toBe(['WalletOpened', 'FundsDeposited']); +})->with(IntegrationDsn::lanes())->group('integration'); From ec797b563e331064df11eb0ebd2b912dee2219e6 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 07:53:58 -0700 Subject: [PATCH 03/19] feat(lumen): cache wallet balances with #[CachedQuery] and evict them per wallet from the funds events --- book/src-es/06-domain-driven-design.md | 15 +++- book/src-es/07-cqrs.md | 8 ++ book/src/06-domain-driven-design.md | 15 +++- book/src/07-cqrs.md | 8 ++ .../src/Application/Query/GetBalance.php | 8 ++ .../lumen/src/Domain/Event/FundsDeposited.php | 7 ++ .../lumen/src/Domain/Event/FundsWithdrawn.php | 6 ++ .../Application/LumenQueryCacheTestCase.php | 26 ++++++ .../tests/Application/QueryCacheTest.php | 87 +++++++++++++++++++ samples/lumen/tests/LumenTestCase.php | 9 +- 10 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 samples/lumen/tests/Application/LumenQueryCacheTestCase.php create mode 100644 samples/lumen/tests/Application/QueryCacheTest.php diff --git a/book/src-es/06-domain-driven-design.md b/book/src-es/06-domain-driven-design.md index a4723045..b0996f41 100644 --- a/book/src-es/06-domain-driven-design.md +++ b/book/src-es/06-domain-driven-design.md @@ -461,10 +461,17 @@ declare(strict_types=1); namespace Lumen\Domain\Event; +use Firefly\Cqrs\Attributes\InvalidatesQuery; use Firefly\Cqrs\Attributes\PublishDomainEvent; use Firefly\Domain\DomainEvent; +use Lumen\Application\Query\GetBalance; +/** + * Raised by Wallet::deposit(). It also says what it makes stale: the cached GetBalance of the same wallet, matched + * property for property (`walletId` of the query = `walletId` of the event), evicted once the deposit commits. + */ #[PublishDomainEvent('wallet.events')] +#[InvalidatesQuery(GetBalance::class, with: ['walletId' => 'walletId'])] final readonly class FundsDeposited extends DomainEvent { public function __construct( @@ -486,10 +493,16 @@ declare(strict_types=1); namespace Lumen\Domain\Event; +use Firefly\Cqrs\Attributes\InvalidatesQuery; use Firefly\Cqrs\Attributes\PublishDomainEvent; use Firefly\Domain\DomainEvent; +use Lumen\Application\Query\GetBalance; +/** + * Raised by Wallet::withdraw(). Evicts the cached GetBalance of the same wallet once the withdrawal commits. + */ #[PublishDomainEvent('wallet.events')] +#[InvalidatesQuery(GetBalance::class, with: ['walletId' => 'walletId'])] final readonly class FundsWithdrawn extends DomainEvent { public function __construct( @@ -503,7 +516,7 @@ final readonly class FundsWithdrawn extends DomainEvent } ``` -Un cuarto evento, `TransferCompleted`, no lo lanza el propio `Wallet` sino la capa de aplicación una vez que ambas piernas de una transferencia tienen éxito — `TransferHandler`, más adelante en este capítulo, muestra exactamente dónde. Cada uno lleva `#[PublishDomainEvent('wallet.events')]` — un atributo de `firefly/cqrs`, no de `firefly/domain`, que nombra el **destino** al que un evento se republica una vez que cruza al mundo de los eventos de integración; `firefly/domain` en sí no tiene ninguna opinión sobre destinos en absoluto, solo sobre lanzar y drenar. +Un cuarto evento, `TransferCompleted`, no lo lanza el propio `Wallet` sino la capa de aplicación una vez que ambas piernas de una transferencia tienen éxito — `TransferHandler`, más adelante en este capítulo, muestra exactamente dónde. Cada uno lleva `#[PublishDomainEvent('wallet.events')]` — un atributo de `firefly/cqrs`, no de `firefly/domain`, que nombra el **destino** al que un evento se republica una vez que cruza al mundo de los eventos de integración; `firefly/domain` en sí no tiene ninguna opinión sobre destinos en absoluto, solo sobre lanzar y drenar. Los dos eventos de fondos llevan además `#[InvalidatesQuery(GetBalance::class, …)]`, un atributo de CQRS que explica el Capítulo 7: en cuanto cualquiera de los dos se confirma, retira de la caché el saldo de ese monedero. Fíjate en lo que cada evento lleva y no lleva. `FundsDeposited` y `FundsWithdrawn` llevan ambos el saldo **posterior a la operación** (`$balanceMinor`), no solo el importe que se movió — un suscriptor que actualiza un modelo de lectura nunca tiene que recargar el monedero para conocer su nuevo saldo; todo lo que necesita ya está en el hecho que recibió. Ese diseño rinde frutos directamente en `LedgerProjector`, más adelante en este capítulo. diff --git a/book/src-es/07-cqrs.md b/book/src-es/07-cqrs.md index 9a5d30af..da83021f 100644 --- a/book/src-es/07-cqrs.md +++ b/book/src-es/07-cqrs.md @@ -191,9 +191,17 @@ declare(strict_types=1); namespace Lumen\Application\Query; +use Firefly\Cqrs\Attributes\CachedQuery; + /** * Query: read a wallet's current balance in minor units. GetBalanceHandler returns null for an unknown wallet. + * + * Cached for 60 seconds once firefly.cqrs.query.cache.provider=cache, keyed by its one public property: two + * GetBalance('w-1') share an entry, GetBalance('w-2') has its own. A null answer is never cached. FundsDeposited and + * FundsWithdrawn evict the entry of the wallet they changed, so the TTL only bounds staleness from writes that bypass + * the bus. */ +#[CachedQuery(ttl: 60)] final readonly class GetBalance { public function __construct(public string $walletId) {} diff --git a/book/src/06-domain-driven-design.md b/book/src/06-domain-driven-design.md index b92068aa..460d0e83 100644 --- a/book/src/06-domain-driven-design.md +++ b/book/src/06-domain-driven-design.md @@ -461,10 +461,17 @@ declare(strict_types=1); namespace Lumen\Domain\Event; +use Firefly\Cqrs\Attributes\InvalidatesQuery; use Firefly\Cqrs\Attributes\PublishDomainEvent; use Firefly\Domain\DomainEvent; +use Lumen\Application\Query\GetBalance; +/** + * Raised by Wallet::deposit(). It also says what it makes stale: the cached GetBalance of the same wallet, matched + * property for property (`walletId` of the query = `walletId` of the event), evicted once the deposit commits. + */ #[PublishDomainEvent('wallet.events')] +#[InvalidatesQuery(GetBalance::class, with: ['walletId' => 'walletId'])] final readonly class FundsDeposited extends DomainEvent { public function __construct( @@ -486,10 +493,16 @@ declare(strict_types=1); namespace Lumen\Domain\Event; +use Firefly\Cqrs\Attributes\InvalidatesQuery; use Firefly\Cqrs\Attributes\PublishDomainEvent; use Firefly\Domain\DomainEvent; +use Lumen\Application\Query\GetBalance; +/** + * Raised by Wallet::withdraw(). Evicts the cached GetBalance of the same wallet once the withdrawal commits. + */ #[PublishDomainEvent('wallet.events')] +#[InvalidatesQuery(GetBalance::class, with: ['walletId' => 'walletId'])] final readonly class FundsWithdrawn extends DomainEvent { public function __construct( @@ -503,7 +516,7 @@ final readonly class FundsWithdrawn extends DomainEvent } ``` -A fourth event, `TransferCompleted`, is raised not by `Wallet` itself but by the application layer once both legs of a transfer succeed — `TransferHandler`, later in this chapter, shows exactly where. Every one carries `#[PublishDomainEvent('wallet.events')]` — an attribute from `firefly/cqrs`, not `firefly/domain`, which names the **destination** an event republishes to once it crosses into the integration-event world; `firefly/domain` itself has no opinion about destinations at all, only about raising and draining. +A fourth event, `TransferCompleted`, is raised not by `Wallet` itself but by the application layer once both legs of a transfer succeed — `TransferHandler`, later in this chapter, shows exactly where. Every one carries `#[PublishDomainEvent('wallet.events')]` — an attribute from `firefly/cqrs`, not `firefly/domain`, which names the **destination** an event republishes to once it crosses into the integration-event world; `firefly/domain` itself has no opinion about destinations at all, only about raising and draining. The two funds events also carry `#[InvalidatesQuery(GetBalance::class, …)]`, a CQRS attribute Chapter 7 explains: once either one commits, it evicts that wallet's cached balance. Notice what each event does and does not carry. `FundsDeposited` and `FundsWithdrawn` both carry the **post-operation** balance (`$balanceMinor`), not just the amount that moved — a subscriber updating a read-model never has to reload the wallet to learn its new balance; everything it needs is already in the fact it received. That design pays off directly in `LedgerProjector`, later in this chapter. diff --git a/book/src/07-cqrs.md b/book/src/07-cqrs.md index 9d166f3c..f2c87e8e 100644 --- a/book/src/07-cqrs.md +++ b/book/src/07-cqrs.md @@ -191,9 +191,17 @@ declare(strict_types=1); namespace Lumen\Application\Query; +use Firefly\Cqrs\Attributes\CachedQuery; + /** * Query: read a wallet's current balance in minor units. GetBalanceHandler returns null for an unknown wallet. + * + * Cached for 60 seconds once firefly.cqrs.query.cache.provider=cache, keyed by its one public property: two + * GetBalance('w-1') share an entry, GetBalance('w-2') has its own. A null answer is never cached. FundsDeposited and + * FundsWithdrawn evict the entry of the wallet they changed, so the TTL only bounds staleness from writes that bypass + * the bus. */ +#[CachedQuery(ttl: 60)] final readonly class GetBalance { public function __construct(public string $walletId) {} diff --git a/samples/lumen/src/Application/Query/GetBalance.php b/samples/lumen/src/Application/Query/GetBalance.php index 6272771b..50b0f3ec 100644 --- a/samples/lumen/src/Application/Query/GetBalance.php +++ b/samples/lumen/src/Application/Query/GetBalance.php @@ -4,9 +4,17 @@ namespace Lumen\Application\Query; +use Firefly\Cqrs\Attributes\CachedQuery; + /** * Query: read a wallet's current balance in minor units. GetBalanceHandler returns null for an unknown wallet. + * + * Cached for 60 seconds once firefly.cqrs.query.cache.provider=cache, keyed by its one public property: two + * GetBalance('w-1') share an entry, GetBalance('w-2') has its own. A null answer is never cached. FundsDeposited and + * FundsWithdrawn evict the entry of the wallet they changed, so the TTL only bounds staleness from writes that bypass + * the bus. */ +#[CachedQuery(ttl: 60)] final readonly class GetBalance { public function __construct(public string $walletId) {} diff --git a/samples/lumen/src/Domain/Event/FundsDeposited.php b/samples/lumen/src/Domain/Event/FundsDeposited.php index 28915621..d0f3cd9b 100644 --- a/samples/lumen/src/Domain/Event/FundsDeposited.php +++ b/samples/lumen/src/Domain/Event/FundsDeposited.php @@ -4,10 +4,17 @@ namespace Lumen\Domain\Event; +use Firefly\Cqrs\Attributes\InvalidatesQuery; use Firefly\Cqrs\Attributes\PublishDomainEvent; use Firefly\Domain\DomainEvent; +use Lumen\Application\Query\GetBalance; +/** + * Raised by Wallet::deposit(). It also says what it makes stale: the cached GetBalance of the same wallet, matched + * property for property (`walletId` of the query = `walletId` of the event), evicted once the deposit commits. + */ #[PublishDomainEvent('wallet.events')] +#[InvalidatesQuery(GetBalance::class, with: ['walletId' => 'walletId'])] final readonly class FundsDeposited extends DomainEvent { public function __construct( diff --git a/samples/lumen/src/Domain/Event/FundsWithdrawn.php b/samples/lumen/src/Domain/Event/FundsWithdrawn.php index e1a4f223..845cb4a5 100644 --- a/samples/lumen/src/Domain/Event/FundsWithdrawn.php +++ b/samples/lumen/src/Domain/Event/FundsWithdrawn.php @@ -4,10 +4,16 @@ namespace Lumen\Domain\Event; +use Firefly\Cqrs\Attributes\InvalidatesQuery; use Firefly\Cqrs\Attributes\PublishDomainEvent; use Firefly\Domain\DomainEvent; +use Lumen\Application\Query\GetBalance; +/** + * Raised by Wallet::withdraw(). Evicts the cached GetBalance of the same wallet once the withdrawal commits. + */ #[PublishDomainEvent('wallet.events')] +#[InvalidatesQuery(GetBalance::class, with: ['walletId' => 'walletId'])] final readonly class FundsWithdrawn extends DomainEvent { public function __construct( diff --git a/samples/lumen/tests/Application/LumenQueryCacheTestCase.php b/samples/lumen/tests/Application/LumenQueryCacheTestCase.php new file mode 100644 index 00000000..eca37656 --- /dev/null +++ b/samples/lumen/tests/Application/LumenQueryCacheTestCase.php @@ -0,0 +1,26 @@ + */ + protected function configOverrides(): array + { + return [ + ...parent::configOverrides(), + 'cache.default' => 'array', + 'firefly.cqrs.query.cache.provider' => 'cache', + ]; + } +} diff --git a/samples/lumen/tests/Application/QueryCacheTest.php b/samples/lumen/tests/Application/QueryCacheTest.php new file mode 100644 index 00000000..bec17c3d --- /dev/null +++ b/samples/lumen/tests/Application/QueryCacheTest.php @@ -0,0 +1,87 @@ + SecurityContextHolder::clearContext()); + +it('answers a repeated balance query from the cache until the next deposit evicts it', function () { + /** @var LumenQueryCacheTestCase $this */ + /** @var CommandBus $commands */ + $commands = $this->fireflyContext()->get(CommandBus::class); + /** @var QueryBus $queries */ + $queries = $this->fireflyContext()->get(QueryBus::class); + + /** @var string $id */ + $id = $commands->send(new OpenWallet('owner-cache', Currency::EUR)); + $commands->send(new Deposit($id, 1000)); + + expect($queries->ask(new GetBalance($id)))->toBe(1000); + + // Changed behind the bus's back: the cached answer does not see it. + DB::table('wallets')->where('id', $id)->update(['balance_minor' => 5000]); + expect($queries->ask(new GetBalance($id)))->toBe(1000); + + // A committed deposit raises FundsDeposited, whose #[InvalidatesQuery] evicts exactly this wallet's balance. + $commands->send(new Deposit($id, 500)); + expect($queries->ask(new GetBalance($id)))->toBe(5500); +})->group('lumen'); + +it('evicts only the balance of the wallet that changed', function () { + /** @var LumenQueryCacheTestCase $this */ + /** @var CommandBus $commands */ + $commands = $this->fireflyContext()->get(CommandBus::class); + /** @var QueryBus $queries */ + $queries = $this->fireflyContext()->get(QueryBus::class); + + /** @var string $a */ + $a = $commands->send(new OpenWallet('owner-a', Currency::EUR)); + /** @var string $b */ + $b = $commands->send(new OpenWallet('owner-b', Currency::EUR)); + $commands->send(new Deposit($a, 100)); + $commands->send(new Deposit($b, 200)); + expect($queries->ask(new GetBalance($a)))->toBe(100) + ->and($queries->ask(new GetBalance($b)))->toBe(200); + + DB::table('wallets')->whereIn('id', [$a, $b])->update(['balance_minor' => 9000]); + $commands->send(new Deposit($a, 1)); + + expect($queries->ask(new GetBalance($a)))->toBe(9001) + ->and($queries->ask(new GetBalance($b)))->toBe(200); +})->group('lumen'); + +it('evicts the balance when funds are withdrawn', function () { + /** @var LumenQueryCacheTestCase $this */ + SecurityContextHolder::setContext(new SecurityContext( + Authentication::authenticated('owner-w', 'owner-w', [new SimpleGrantedAuthority('ROLE_WALLET_OWNER')]) + )); + /** @var CommandBus $commands */ + $commands = $this->fireflyContext()->get(CommandBus::class); + /** @var QueryBus $queries */ + $queries = $this->fireflyContext()->get(QueryBus::class); + + /** @var string $id */ + $id = $commands->send(new OpenWallet('owner-w', Currency::EUR)); + $commands->send(new Deposit($id, 3000)); + expect($queries->ask(new GetBalance($id)))->toBe(3000); + + $commands->send(new Withdraw($id, 1000)); + + expect($queries->ask(new GetBalance($id)))->toBe(2000); +})->group('lumen'); diff --git a/samples/lumen/tests/LumenTestCase.php b/samples/lumen/tests/LumenTestCase.php index 0b058bbe..658dce77 100644 --- a/samples/lumen/tests/LumenTestCase.php +++ b/samples/lumen/tests/LumenTestCase.php @@ -128,7 +128,14 @@ protected function defineFireflyEnvironment(Application $app): void // #[QueryHandler] route to the real CommandBus/QueryBus. CqrsWiringProvider::passes() also contributes // DomainEventBridgeWiringPass — the committed-DomainEvent -> EventPublisher bridge — with no extra glue. $handlers = (new HandlerScanner)->scan($appPsr4); - $app->instance(HandlerManifest::class, new HandlerManifest($handlers['handlers'], $handlers['destinations'])); + // The cached-query policies and the #[InvalidatesQuery] rules travel in the same manifest — leaving them out + // would make every #[CachedQuery] run uncached and every eviction a no-op once the query cache is on. + $app->instance(HandlerManifest::class, new HandlerManifest( + $handlers['handlers'], + $handlers['destinations'], + $handlers['cachedQueries'], + $handlers['invalidations'], + )); // (3) EDA listeners (mirror EdaCapstoneTestCase). Bind the compiled EventListenerManifest so #[EventListener]s // subscribe. The EventPublisher stays the REAL InMemoryEventBus from EdaAutoConfiguration (NOT bound here). From 5ffeb1bcde0c477ae4c4c0d172b68cc2d51e078f Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 07:56:58 -0700 Subject: [PATCH 04/19] feat(lumen): deposit card top-ups from a signed firefly/webhooks endpoint, once per delivery --- samples/lumen/composer.json | 5 ++ .../Listener/CardTopUpListener.php | 43 ++++++++++ .../lumen/tests/Web/CardTopUpWebhookTest.php | 78 +++++++++++++++++++ .../lumen/tests/Web/LumenWebhooksTestCase.php | 53 +++++++++++++ 4 files changed, 179 insertions(+) create mode 100644 samples/lumen/src/Application/Listener/CardTopUpListener.php create mode 100644 samples/lumen/tests/Web/CardTopUpWebhookTest.php create mode 100644 samples/lumen/tests/Web/LumenWebhooksTestCase.php diff --git a/samples/lumen/composer.json b/samples/lumen/composer.json index 96a036a0..9015ce72 100644 --- a/samples/lumen/composer.json +++ b/samples/lumen/composer.json @@ -21,6 +21,7 @@ "require": { "php": "^8.3", "firefly/actuator": "*@dev", + "firefly/callbacks": "*@dev", "firefly/config": "*@dev", "firefly/container": "*@dev", "firefly/context": "*@dev", @@ -30,9 +31,13 @@ "firefly/eda": "*@dev", "firefly/feature-flags": "*@dev", "firefly/kernel": "*@dev", + "firefly/orchestration": "*@dev", + "firefly/resilience": "*@dev", + "firefly/scheduling": "*@dev", "firefly/security": "*@dev", "firefly/validation": "*@dev", "firefly/web": "*@dev", + "firefly/webhooks": "*@dev", "fireflyframework/larafly": "*@dev", "illuminate/database": "^13.0", "illuminate/support": "^13.0" diff --git a/samples/lumen/src/Application/Listener/CardTopUpListener.php b/samples/lumen/src/Application/Listener/CardTopUpListener.php new file mode 100644 index 00000000..a4a32387 --- /dev/null +++ b/samples/lumen/src/Application/Listener/CardTopUpListener.php @@ -0,0 +1,43 @@ +payload['body'] ?? null; + $data = is_array($body) ? ($body['data'] ?? null) : null; + $walletId = is_array($data) ? ($data['wallet_id'] ?? null) : null; + $amountMinor = is_array($data) ? ($data['amount_minor'] ?? null) : null; + + if (! is_string($walletId) || ! is_int($amountMinor) || $amountMinor <= 0) { + return; + } + + $this->commands->send(new Deposit($walletId, $amountMinor)); + } +} diff --git a/samples/lumen/tests/Web/CardTopUpWebhookTest.php b/samples/lumen/tests/Web/CardTopUpWebhookTest.php new file mode 100644 index 00000000..3bd6c383 --- /dev/null +++ b/samples/lumen/tests/Web/CardTopUpWebhookTest.php @@ -0,0 +1,78 @@ + $eventId, + 'type' => 'top_up.succeeded', + 'data' => ['wallet_id' => $walletId, 'amount_minor' => $amountMinor], + ], JSON_THROW_ON_ERROR); +} + +/** + * The two headers the processor sends with a delivery: the hmac validator's default signature over the exact bytes, + * and the delivery id that makes a redelivery recognisable. + * + * @return array + */ +function lumenTopUpHeaders(string $body, string $eventId): array +{ + return [ + 'X-Signature' => 'sha256='.hash_hmac('sha256', $body, LumenWebhooksTestCase::SECRET), + 'X-Idempotency-Key' => $eventId, + ]; +} + +it('deposits a signed card top-up once, however often the processor delivers it', function () { + /** @var LumenWebhooksTestCase $this */ + /** @var string $id */ + $id = $this->postJson('/api/v1/wallets', ['owner_id' => 'owner-card', 'currency' => 'EUR'])->json('wallet_id'); + $body = lumenTopUp($id, 2500, 'evt_1'); + + $this->postWebhook('/webhooks/card-processor', $body, lumenTopUpHeaders($body, 'evt_1')) + ->assertStatus(202) + ->assertJson(['status' => 'accepted', 'provider' => 'card-processor', 'eventType' => 'webhook.card-processor.top_up.succeeded']); + + // The processor never saw the 202 and delivers the same event again: answered, not deposited twice. + $this->postWebhook('/webhooks/card-processor', $body, lumenTopUpHeaders($body, 'evt_1')) + ->assertStatus(200) + ->assertJson(['status' => 'duplicate']); + + $this->getJson("/api/v1/wallets/{$id}/balance")->assertOk()->assertJson(['balance_minor' => 2500]); +})->group('lumen'); + +it('refuses a delivery whose signature does not cover the body it came with', function () { + /** @var LumenWebhooksTestCase $this */ + /** @var string $id */ + $id = $this->postJson('/api/v1/wallets', ['owner_id' => 'owner-forged', 'currency' => 'EUR'])->json('wallet_id'); + $body = lumenTopUp($id, 2500, 'evt_2'); + + $this->postWebhook('/webhooks/card-processor', $body, lumenTopUpHeaders(lumenTopUp($id, 990000, 'evt_2'), 'evt_2')) + ->assertStatus(401) + ->assertHeader('Content-Type', 'application/problem+json') + ->assertJsonPath('code', 'WEBHOOK_SIGNATURE_INVALID'); + + $this->getJson("/api/v1/wallets/{$id}/balance")->assertOk()->assertJson(['balance_minor' => 0]); +})->group('lumen'); + +it('dead-letters a verified top-up for an unknown wallet instead of failing the delivery', function () { + /** @var LumenWebhooksTestCase $this */ + $body = lumenTopUp('no-such-wallet', 2500, 'evt_3'); + + $this->postWebhook('/webhooks/card-processor', $body, lumenTopUpHeaders($body, 'evt_3'))->assertStatus(202); + + $entries = $this->app()->make(DeadLetterStore::class)->all(); + + expect($entries)->toHaveCount(1) + ->and($entries[0]->envelope->eventType)->toBe('webhook.card-processor.top_up.succeeded') + ->and($entries[0]->exceptionMessage)->toContain('no-such-wallet'); +})->group('lumen'); diff --git a/samples/lumen/tests/Web/LumenWebhooksTestCase.php b/samples/lumen/tests/Web/LumenWebhooksTestCase.php new file mode 100644 index 00000000..dfba0c89 --- /dev/null +++ b/samples/lumen/tests/Web/LumenWebhooksTestCase.php @@ -0,0 +1,53 @@ +` and `X-Idempotency-Key`. Accepted deliveries are + * published to the sample's real InMemoryEventBus, where CardTopUpListener turns them into deposits. The idempotency + * store is `memory` because one test is one process; a deployment uses `cache` or `database`. + */ +abstract class LumenWebhooksTestCase extends LumenTestCase +{ + public const string SECRET = 'lumen-card-secret'; + + /** @return list> */ + protected function fireflyProviders(): array + { + return [...parent::fireflyProviders(), WebhooksServiceProvider::class, WebhooksWiringProvider::class]; + } + + /** @return array */ + protected function configOverrides(): array + { + return [ + ...parent::configOverrides(), + 'firefly.webhooks.enabled' => true, + 'firefly.webhooks.idempotency.store' => 'memory', + 'firefly.webhooks.providers' => [ + 'card-processor' => ['secret' => self::SECRET], + ], + ]; + } + + /** + * POSTs a raw body with exact headers. A signature covers bytes, so postJson() — which re-encodes — cannot be used. + * + * @param array $headers + * @return TestResponse + */ + public function postWebhook(string $uri, string $body, array $headers = []): TestResponse + { + return $this->call('POST', $uri, [], [], [], $this->transformHeadersToServerVars(['Content-Type' => 'application/json', ...$headers]), $body); + } +} From e284fe3ac6fb531153f273de6afd7c5e90028e85 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 07:58:35 -0700 Subject: [PATCH 05/19] test(lumen): deliver signed partner callbacks for the funds events, verified by the webhooks hmac validator --- .../Application/LumenCallbacksTestCase.php | 48 ++++++++++++ .../Application/PartnerCallbacksTest.php | 74 +++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 samples/lumen/tests/Application/LumenCallbacksTestCase.php create mode 100644 samples/lumen/tests/Application/PartnerCallbacksTest.php diff --git a/samples/lumen/tests/Application/LumenCallbacksTestCase.php b/samples/lumen/tests/Application/LumenCallbacksTestCase.php new file mode 100644 index 00000000..a9b1f07f --- /dev/null +++ b/samples/lumen/tests/Application/LumenCallbacksTestCase.php @@ -0,0 +1,48 @@ +."). firefly/resilience supplies the per-endpoint circuit breaker, on the + * array cache store; the first retry is immediate so a failing partner does not slow the suite. + */ +abstract class LumenCallbacksTestCase extends LumenTestCase +{ + public const string SECRET = 'lumen-partner-secret'; + + /** @return list> */ + protected function fireflyProviders(): array + { + return [...parent::fireflyProviders(), ResilienceServiceProvider::class, CallbacksServiceProvider::class, CallbacksWiringProvider::class]; + } + + /** @return array */ + protected function configOverrides(): array + { + return [ + ...parent::configOverrides(), + 'cache.default' => 'array', + 'firefly.resilience.method.enabled' => false, + 'firefly.callbacks.enabled' => true, + 'firefly.callbacks.event-types' => ['Funds*'], + 'firefly.callbacks.retry.initial-delay' => 0, + 'firefly.callbacks.subscriptions' => [[ + 'id' => 'partner-ledger', + 'name' => 'partner ledger', + 'url' => 'https://partner.example.com/hooks/wallet', + 'event-types' => ['FundsDeposited', 'FundsWithdrawn'], + 'secret' => self::SECRET, + ]], + ]; + } +} diff --git a/samples/lumen/tests/Application/PartnerCallbacksTest.php b/samples/lumen/tests/Application/PartnerCallbacksTest.php new file mode 100644 index 00000000..eadde4a8 --- /dev/null +++ b/samples/lumen/tests/Application/PartnerCallbacksTest.php @@ -0,0 +1,74 @@ + Http::response('', 204)]); + /** @var CommandBus $commands */ + $commands = $this->fireflyContext()->get(CommandBus::class); + + /** @var string $id */ + $id = $commands->send(new OpenWallet('owner-partner', Currency::EUR)); + $commands->send(new Deposit($id, 700)); + + Http::assertSentCount(1); + Http::assertSent(function (Request $request) use ($id): bool { + $headers = []; + foreach ($request->headers() as $name => $values) { + $first = is_array($values) ? ($values[0] ?? '') : $values; + $headers[strtolower($name)] = is_string($first) ? $first : ''; + } + + // The partner's own check, as a LaraFly receiver on firefly/webhooks would run it: throws on a bad signature + // or a timestamp outside the five-minute window. + (new HmacSignatureValidator(LumenCallbacksTestCase::SECRET, 'X-Firefly-Signature', 'sha256=', 'sha256', 'hex', 'X-Firefly-Timestamp', 300.0)) + ->verify(new WebhookRequest('lumen', 'POST', $request->url(), $headers, $request->body(), [], 'application/json', time())); + + $data = lumenCallbackData($request->body()); + + return $request->url() === 'https://partner.example.com/hooks/wallet' + && $request->header('X-Firefly-Event') === ['FundsDeposited'] + && is_array($data) + && ($data['walletId'] ?? null) === $id + && ($data['amountMinor'] ?? null) === 700; + }); + + $logged = $this->app()->make(ExecutionLog::class)->recent(); + expect($logged)->toHaveCount(1) + ->and($logged[0]->status)->toBe(CallbackStatus::Delivered); +})->group('lumen'); + +it('calls the partner for nothing it did not subscribe to', function () { + /** @var LumenCallbacksTestCase $this */ + Http::fake(); + /** @var CommandBus $commands */ + $commands = $this->fireflyContext()->get(CommandBus::class); + + $commands->send(new OpenWallet('owner-quiet', Currency::EUR)); + + Http::assertNothingSent(); +})->group('lumen'); From 6581f51fc0f40cef82480086109a95417f175453 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 08:00:47 -0700 Subject: [PATCH 06/19] feat(lumen): pay wallets out to a bank through a compensating saga with database checkpoints and dead letters --- .../src/Application/Payout/BankGateway.php | 21 ++++ .../src/Application/Payout/PayoutRejected.php | 16 +++ .../src/Application/Payout/PayoutSaga.php | 67 +++++++++++ .../Payout/SimulatedBankGateway.php | 60 ++++++++++ .../LumenOrchestrationTestCase.php | 66 +++++++++++ .../tests/Application/PayoutSagaTest.php | 104 ++++++++++++++++++ 6 files changed, 334 insertions(+) create mode 100644 samples/lumen/src/Application/Payout/BankGateway.php create mode 100644 samples/lumen/src/Application/Payout/PayoutRejected.php create mode 100644 samples/lumen/src/Application/Payout/PayoutSaga.php create mode 100644 samples/lumen/src/Infrastructure/Payout/SimulatedBankGateway.php create mode 100644 samples/lumen/tests/Application/LumenOrchestrationTestCase.php create mode 100644 samples/lumen/tests/Application/PayoutSagaTest.php diff --git a/samples/lumen/src/Application/Payout/BankGateway.php b/samples/lumen/src/Application/Payout/BankGateway.php new file mode 100644 index 00000000..1d41cbc7 --- /dev/null +++ b/samples/lumen/src/Application/Payout/BankGateway.php @@ -0,0 +1,21 @@ + Withdraw through the bus (its own transaction, its own #[PreAuthorize]); undone by refund (a Deposit) + * submit -> the bank's reference for the transfer; undone by cancel + * confirm -> the bank executes it; the last step, so nothing after it needs undoing + * + * Input: walletId, iban, amountMinor. + */ +#[Service] +#[Saga(name: 'payout')] +final class PayoutSaga +{ + public function __construct( + private readonly CommandBus $commands, + private readonly BankGateway $bank, + ) {} + + #[SagaStep(id: 'debit', compensate: 'refund')] + public function debit(#[Input('walletId')] string $walletId, #[Input('amountMinor')] int $amountMinor): int + { + $balance = $this->commands->send(new Withdraw($walletId, $amountMinor)); + + return is_int($balance) ? $balance : 0; + } + + public function refund(#[Input('walletId')] string $walletId, #[Input('amountMinor')] int $amountMinor): void + { + $this->commands->send(new Deposit($walletId, $amountMinor)); + } + + #[SagaStep(id: 'submit', dependsOn: ['debit'], compensate: 'cancel')] + public function submit(#[Input('iban')] string $iban, #[Input('amountMinor')] int $amountMinor): string + { + return $this->bank->submit($iban, $amountMinor); + } + + public function cancel(string $reference): void + { + $this->bank->cancel($reference); + } + + #[SagaStep(id: 'confirm', dependsOn: ['submit'])] + public function confirm(#[FromStep('submit')] string $reference): string + { + $this->bank->confirm($reference); + + return $reference; + } +} diff --git a/samples/lumen/src/Infrastructure/Payout/SimulatedBankGateway.php b/samples/lumen/src/Infrastructure/Payout/SimulatedBankGateway.php new file mode 100644 index 00000000..efca42b0 --- /dev/null +++ b/samples/lumen/src/Infrastructure/Payout/SimulatedBankGateway.php @@ -0,0 +1,60 @@ + */ + public array $calls = []; + + /** @var array reference => amount in minor units */ + private array $transfers = []; + + private int $issued = 0; + + public function submit(string $iban, int $amountMinor): string + { + if (str_starts_with($iban, 'XX')) { + $this->calls[] = "refused {$iban}"; + + throw new PayoutRejected("The bank refused the account {$iban}."); + } + + $reference = 'bank-'.++$this->issued; + $this->transfers[$reference] = $amountMinor; + $this->calls[] = "submit {$reference}"; + + return $reference; + } + + public function confirm(string $reference): void + { + if (($this->transfers[$reference] ?? 0) > self::DAILY_LIMIT_MINOR) { + $this->calls[] = "limit {$reference}"; + + throw new PayoutRejected("Transfer {$reference} is over the bank's daily limit."); + } + + $this->calls[] = "confirm {$reference}"; + } + + public function cancel(string $reference): void + { + unset($this->transfers[$reference]); + $this->calls[] = "cancel {$reference}"; + } +} diff --git a/samples/lumen/tests/Application/LumenOrchestrationTestCase.php b/samples/lumen/tests/Application/LumenOrchestrationTestCase.php new file mode 100644 index 00000000..e45b8c91 --- /dev/null +++ b/samples/lumen/tests/Application/LumenOrchestrationTestCase.php @@ -0,0 +1,66 @@ +> */ + protected function fireflyProviders(): array + { + return [ + ...parent::fireflyProviders(), + ResilienceServiceProvider::class, + SchedulingServiceProvider::class, + SchedulingWiringProvider::class, + OrchestrationServiceProvider::class, + OrchestrationWiringProvider::class, + ]; + } + + /** @return array */ + protected function configOverrides(): array + { + return [ + ...parent::configOverrides(), + 'cache.default' => 'array', + 'queue.default' => 'sync', + 'firefly.orchestration.persistence.provider' => 'database', + 'firefly.orchestration.dlq.store' => 'database', + ]; + } + + protected function defineFireflyEnvironment(Application $app): void + { + parent::defineFireflyEnvironment($app); + + $app->instance(OrchestrationManifest::class, (new OrchestrationScanner)->scan($this->appPsr4())); + } + + protected function setUp(): void + { + parent::setUp(); + + OrchestrationSchema::create(DB::connection()->getSchemaBuilder()); + } +} diff --git a/samples/lumen/tests/Application/PayoutSagaTest.php b/samples/lumen/tests/Application/PayoutSagaTest.php new file mode 100644 index 00000000..fb8e5038 --- /dev/null +++ b/samples/lumen/tests/Application/PayoutSagaTest.php @@ -0,0 +1,104 @@ + SecurityContextHolder::clearContext()); + +/** Signs a wallet owner in: the saga's debit step is a Withdraw, which #[PreAuthorize] guards at the bus. */ +function lumenSignInPayoutOwner(): void +{ + SecurityContextHolder::setContext(new SecurityContext( + Authentication::authenticated('owner-payout', 'owner-payout', [new SimpleGrantedAuthority('ROLE_WALLET_OWNER')]) + )); +} + +/** Opens a EUR wallet holding $balanceMinor and returns its id. */ +function lumenFundedWallet(CommandBus $commands, int $balanceMinor): string +{ + $id = $commands->send(new OpenWallet('owner-payout', Currency::EUR)); + if (! is_string($id)) { + throw new LogicException('OpenWallet answers the new wallet id.'); + } + $commands->send(new Deposit($id, $balanceMinor)); + + return $id; +} + +it('pays out: debits the wallet, then submits and confirms the bank transfer', function () { + /** @var LumenOrchestrationTestCase $this */ + lumenSignInPayoutOwner(); + /** @var CommandBus $commands */ + $commands = $this->fireflyContext()->get(CommandBus::class); + /** @var QueryBus $queries */ + $queries = $this->fireflyContext()->get(QueryBus::class); + $id = lumenFundedWallet($commands, 50000); + + $result = $this->app()->make(SagaEngine::class)->execute('payout', ['walletId' => $id, 'iban' => 'ES9121000418450200051332', 'amountMinor' => 20000]); + + expect($result->success)->toBeTrue() + ->and($result->status)->toBe(ExecutionStatus::Completed) + ->and($this->app()->make(SimulatedBankGateway::class)->calls)->toBe(['submit bank-1', 'confirm bank-1']) + ->and($queries->ask(new GetBalance($id)))->toBe(30000) + ->and(DB::table(OrchestrationSchema::EXECUTIONS)->where('correlation_id', $result->correlationId)->value('status'))->toBe('COMPLETED'); +})->group('lumen'); + +it('refunds the wallet when the bank refuses the account', function () { + /** @var LumenOrchestrationTestCase $this */ + lumenSignInPayoutOwner(); + /** @var CommandBus $commands */ + $commands = $this->fireflyContext()->get(CommandBus::class); + /** @var QueryBus $queries */ + $queries = $this->fireflyContext()->get(QueryBus::class); + $id = lumenFundedWallet($commands, 50000); + + $result = $this->app()->make(SagaEngine::class)->execute('payout', ['walletId' => $id, 'iban' => 'XX0012345678', 'amountMinor' => 20000]); + + expect($result->success)->toBeFalse() + ->and($result->firstErrorStepId())->toBe('submit') + ->and($result->compensatedSteps())->toBe(['debit']) + ->and($this->app()->make(SimulatedBankGateway::class)->calls)->toBe(['refused XX0012345678']) + ->and($queries->ask(new GetBalance($id)))->toBe(50000) + ->and(DB::table(OrchestrationSchema::DEAD_LETTERS)->where('correlation_id', $result->correlationId)->value('step_id'))->toBe('submit'); +})->group('lumen'); + +it('cancels the bank transfer, then refunds the wallet, when the confirmation fails', function () { + /** @var LumenOrchestrationTestCase $this */ + lumenSignInPayoutOwner(); + /** @var CommandBus $commands */ + $commands = $this->fireflyContext()->get(CommandBus::class); + /** @var QueryBus $queries */ + $queries = $this->fireflyContext()->get(QueryBus::class); + $id = lumenFundedWallet($commands, 200000); + + // 1,500.00 is over the simulated bank's daily limit: submitted, then refused at confirmation. + $result = $this->app()->make(SagaEngine::class)->execute('payout', ['walletId' => $id, 'iban' => 'ES9121000418450200051332', 'amountMinor' => 150000]); + + expect($result->success)->toBeFalse() + ->and($result->status)->toBe(ExecutionStatus::Failed) + ->and($result->firstErrorStepId())->toBe('confirm') + ->and($result->compensatedSteps())->toBe(['debit', 'submit']) + ->and($this->app()->make(SimulatedBankGateway::class)->calls)->toBe(['submit bank-1', 'limit bank-1', 'cancel bank-1']) + ->and($queries->ask(new GetBalance($id)))->toBe(200000) + ->and(DB::table(OrchestrationSchema::EXECUTIONS)->where('correlation_id', $result->correlationId)->value('status'))->toBe('FAILED'); +})->group('lumen'); From 5a5484b10fe09eec45e9ccfe1b4966352eae9040 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 08:05:08 -0700 Subject: [PATCH 07/19] feat(lumen): expose the payout saga at POST /api/v1/wallets/{id}/payouts and describe the new capabilities --- samples/lumen/README.md | 25 ++++++- samples/lumen/src/Web/Dto/PayoutRequest.php | 19 ++++++ samples/lumen/src/Web/PayoutController.php | 62 +++++++++++++++++ samples/lumen/tests/Web/PayoutRestTest.php | 75 +++++++++++++++++++++ 4 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 samples/lumen/src/Web/Dto/PayoutRequest.php create mode 100644 samples/lumen/src/Web/PayoutController.php create mode 100644 samples/lumen/tests/Web/PayoutRestTest.php diff --git a/samples/lumen/README.md b/samples/lumen/README.md index 9adf6fa0..a8fed65b 100644 --- a/samples/lumen/README.md +++ b/samples/lumen/README.md @@ -17,9 +17,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, the LedgerProjector listener -│ ├── Infrastructure/ # WalletRepository port + EloquentWalletRepository adapter + migrations -│ └── Web/ # WalletController (#[RestController]) + validated request DTOs +│ ├── Application/ # CQRS: Command/Query + Handler pairs, listeners, the payout saga + bank port +│ ├── Infrastructure/ # WalletRepository + EloquentWalletRepository, 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) ``` @@ -57,6 +57,22 @@ HTTP endpoints — none of which contain business logic of their own. fault (not-found, overdraw, denied) renders as an RFC-7807 `application/problem+json` response via the framework's own `ProblemDetailsRenderer` — no bespoke error handling in the sample itself. +- **A compensating saga** — `PayoutSaga` (`#[Saga(name: 'payout')]`) debits the + wallet, submits a transfer to the `BankGateway` port and confirms it; when the + bank refuses, the completed steps are undone newest first (`cancel`, then + `refund`), the execution is checkpointed and dead-lettered by + `firefly/orchestration`. `SimulatedBankGateway` is the adapter. +- **Webhooks in, callbacks out** — `CardTopUpListener` deposits the card + processor's signed `top_up.succeeded` webhook (`firefly/webhooks`, verified, + deduplicated); `firefly/callbacks` posts signed `FundsDeposited` and + `FundsWithdrawn` callbacks to a subscribed partner. +- **A cached balance** — `GetBalance` is a `#[CachedQuery]`; `FundsDeposited` + and `FundsWithdrawn` evict exactly the wallet they changed + (`#[InvalidatesQuery]`). +- **The portable outbox** — under `firefly.eda.provider=database` the same + 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. ## REST API @@ -66,6 +82,7 @@ HTTP endpoints — none of which contain business logic of their own. | 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/{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 | @@ -78,6 +95,8 @@ principal at all it renders a 401 `application/problem+json` response signed in but lacks the role a 403 (`ACCESS_DENIED`). The endpoint is 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, and a refusal by the bank afterwards is a 409 PAYOUT_FAILED with the money back in the wallet. + The optional feature-flag example keeps `wallet-balance-v2` off and `wallet-checkout-view` at `legacy` in the test harness. `WalletRollout` reads both through the real `FeatureFlags` client; a separate route carries `#[FeatureFlag('wallet-rollout-route')]` and fails closed; `WalletRolloutGate` demonstrates a proxied diff --git a/samples/lumen/src/Web/Dto/PayoutRequest.php b/samples/lumen/src/Web/Dto/PayoutRequest.php new file mode 100644 index 00000000..f2595359 --- /dev/null +++ b/samples/lumen/src/Web/Dto/PayoutRequest.php @@ -0,0 +1,19 @@ +sagas->execute('payout', ['walletId' => $id, 'iban' => $body->iban, 'amountMinor' => $body->amount_minor]); + + if (! $result->success) { + // The debit never happened, so nothing was undone: the bus's own refusal — 401, 403, 404, or the 409 of + // an overdraw — is the honest answer, exactly as POST /withdraw would have given it. + if ($result->firstErrorStepId() === 'debit' && $result->error !== null) { + throw $result->error; + } + + throw new ConflictException( + sprintf('Payout %s failed at step "%s" and ended %s: %s', $result->correlationId, (string) $result->firstErrorStepId(), $result->status->value, $result->error?->getMessage() ?? 'no error was recorded'), + 'PAYOUT_FAILED', + $result->error, + ); + } + + $reference = ($result->steps['confirm'] ?? null)?->result; + + return [ + 'payout_id' => $result->correlationId, + 'wallet_id' => $id, + 'status' => $result->status->value, + 'bank_reference' => is_string($reference) ? $reference : '', + ]; + } +} diff --git a/samples/lumen/tests/Web/PayoutRestTest.php b/samples/lumen/tests/Web/PayoutRestTest.php new file mode 100644 index 00000000..9a57926c --- /dev/null +++ b/samples/lumen/tests/Web/PayoutRestTest.php @@ -0,0 +1,75 @@ + SecurityContextHolder::clearContext()); + +beforeEach(function () { + // The saga's debit is a Withdraw, guarded by #[PreAuthorize] at the bus; set before the request, the context + // survives into the in-process dispatch exactly as WalletRestTest's authorized withdraw relies on. + SecurityContextHolder::setContext(new SecurityContext( + Authentication::authenticated('owner-rest', 'owner-rest', [new SimpleGrantedAuthority('ROLE_WALLET_OWNER')]) + )); +}); + +it('pays out over REST and answers 201 with the bank reference', function () { + /** @var LumenOrchestrationTestCase $this */ + /** @var string $id */ + $id = $this->postJson('/api/v1/wallets', ['owner_id' => 'owner-rest', 'currency' => 'EUR'])->json('wallet_id'); + $this->postJson("/api/v1/wallets/{$id}/deposit", ['amount_minor' => 50000]); + + $this->postJson("/api/v1/wallets/{$id}/payouts", ['iban' => 'ES9121000418450200051332', 'amount_minor' => 20000]) + ->assertStatus(201) + ->assertJson(['wallet_id' => $id, 'status' => 'COMPLETED', 'bank_reference' => 'bank-1']); + + $this->getJson("/api/v1/wallets/{$id}/balance")->assertJson(['balance_minor' => 30000]); +})->group('lumen'); + +it('answers 409 PAYOUT_FAILED, with the money back in the wallet, when the bank refuses', function () { + /** @var LumenOrchestrationTestCase $this */ + /** @var string $id */ + $id = $this->postJson('/api/v1/wallets', ['owner_id' => 'owner-rest', 'currency' => 'EUR'])->json('wallet_id'); + $this->postJson("/api/v1/wallets/{$id}/deposit", ['amount_minor' => 50000]); + + $this->postJson("/api/v1/wallets/{$id}/payouts", ['iban' => 'XX0012345678', 'amount_minor' => 20000]) + ->assertStatus(409) + ->assertHeader('Content-Type', 'application/problem+json') + ->assertJsonPath('code', 'PAYOUT_FAILED'); + + $this->getJson("/api/v1/wallets/{$id}/balance")->assertJson(['balance_minor' => 50000]); +})->group('lumen'); + +it('answers an overdrawing payout with the withdraw\'s own 409, before the bank is called', function () { + /** @var LumenOrchestrationTestCase $this */ + /** @var string $id */ + $id = $this->postJson('/api/v1/wallets', ['owner_id' => 'owner-rest', 'currency' => 'EUR'])->json('wallet_id'); + $this->postJson("/api/v1/wallets/{$id}/deposit", ['amount_minor' => 1000]); + + $this->postJson("/api/v1/wallets/{$id}/payouts", ['iban' => 'ES9121000418450200051332', 'amount_minor' => 5000]) + ->assertStatus(409) + ->assertHeader('Content-Type', 'application/problem+json') + ->assertJsonPath('category', 'business'); + + expect($this->app()->make(SimulatedBankGateway::class)->calls)->toBe([]); +})->group('lumen'); + +it('validates the payout body before the saga starts', function () { + /** @var LumenOrchestrationTestCase $this */ + /** @var string $id */ + $id = $this->postJson('/api/v1/wallets', ['owner_id' => 'owner-rest', 'currency' => 'EUR'])->json('wallet_id'); + + $this->postJson("/api/v1/wallets/{$id}/payouts", ['iban' => '', 'amount_minor' => -1]) + ->assertStatus(422) + ->assertHeader('Content-Type', 'application/problem+json'); +})->group('lumen'); From b9fa8af90af5bb0d58a230cbc7231d02017424fe Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 08:10:14 -0700 Subject: [PATCH 08/19] fix(lumen): answer a payout whose refund failed with 500 PAYOUT_COMPENSATION_FAILED instead of the bank's refusal --- samples/lumen/README.md | 2 +- samples/lumen/src/Web/PayoutController.php | 64 ++++++++++++++++++++-- samples/lumen/tests/Web/PayoutRestTest.php | 48 ++++++++++++++++ 3 files changed, 109 insertions(+), 5 deletions(-) diff --git a/samples/lumen/README.md b/samples/lumen/README.md index a8fed65b..ae3ae504 100644 --- a/samples/lumen/README.md +++ b/samples/lumen/README.md @@ -95,7 +95,7 @@ principal at all it renders a 401 `application/problem+json` response signed in but lacks the role a 403 (`ACCESS_DENIED`). The endpoint is 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, and a refusal by the bank afterwards is a 409 PAYOUT_FAILED with the money back in the wallet. +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. The optional feature-flag example keeps `wallet-balance-v2` off and `wallet-checkout-view` at `legacy` in the test harness. `WalletRollout` reads both through the real `FeatureFlags` client; a separate route carries diff --git a/samples/lumen/src/Web/PayoutController.php b/samples/lumen/src/Web/PayoutController.php index 58257284..36786c5e 100644 --- a/samples/lumen/src/Web/PayoutController.php +++ b/samples/lumen/src/Web/PayoutController.php @@ -5,8 +5,14 @@ namespace Lumen\Web; use Firefly\Container\Attributes\Lazy; +use Firefly\Kernel\Error\ErrorCategory; +use Firefly\Kernel\Error\ErrorSeverity; use Firefly\Kernel\Exception\Business\ConflictException; +use Firefly\Kernel\Exception\FireflyException; +use Firefly\Orchestration\Core\ExecutionStatus; +use Firefly\Orchestration\Core\StepStatus; use Firefly\Orchestration\Saga\Engine\SagaEngine; +use Firefly\Orchestration\Saga\Engine\SagaResult; use Firefly\Validation\Valid; use Firefly\Web\Attributes\PathVariable; use Firefly\Web\Attributes\PostMapping; @@ -37,14 +43,34 @@ public function payout(#[PathVariable] string $id, #[Valid] #[RequestBody] Payou $result = $this->sagas->execute('payout', ['walletId' => $id, 'iban' => $body->iban, 'amountMinor' => $body->amount_minor]); if (! $result->success) { - // The debit never happened, so nothing was undone: the bus's own refusal — 401, 403, 404, or the 409 of - // an overdraw — is the honest answer, exactly as POST /withdraw would have given it. - if ($result->firstErrorStepId() === 'debit' && $result->error !== null) { + // A compensation that did not finish: money may have left the wallet and not come back. That is never a + // business refusal; it is the one outcome an operator must act on, so it gets its own server-side answer + // naming the steps left undone and the payout id the execution is checkpointed and dead-lettered under. + // Asked of the steps as well as the status: under the default strict-sequential policy a compensation + // that throws marks its step COMPENSATION_FAILED and the error handler continues, so the saga still + // ends FAILED, while a policy that stops the sweep ends it COMPENSATION_FAILED. + $undone = $this->undoneSteps($result); + if ($undone !== [] || $result->status === ExecutionStatus::CompensationFailed) { + throw (new FireflyException( + sprintf('Payout %s failed at step "%s" and was not fully undone; steps left uncompensated: %s.', $result->correlationId, (string) $this->failingStep($result), implode(', ', $undone)), + 'PAYOUT_COMPENSATION_FAILED', + 500, + ErrorCategory::Internal, + ErrorSeverity::Critical, + $result->error, + ))->withExtensions(['payout_id' => $result->correlationId, 'uncompensated_steps' => $undone]); + } + + // The debit itself was refused, so nothing was undone: the bus's own refusal — 401, 403, 404, or the 409 + // of an overdraw — is the honest answer, exactly as POST /withdraw would have given it. Asked of the debit + // step's own status, not firstErrorStepId(), which also counts a step whose compensation failed. + $debit = $result->steps['debit'] ?? null; + if ($debit !== null && $debit->status === StepStatus::Failed && $result->error !== null) { throw $result->error; } throw new ConflictException( - sprintf('Payout %s failed at step "%s" and ended %s: %s', $result->correlationId, (string) $result->firstErrorStepId(), $result->status->value, $result->error?->getMessage() ?? 'no error was recorded'), + sprintf('Payout %s failed at step "%s" and ended %s: %s', $result->correlationId, (string) $this->failingStep($result), $result->status->value, $result->error?->getMessage() ?? 'no error was recorded'), 'PAYOUT_FAILED', $result->error, ); @@ -59,4 +85,34 @@ public function payout(#[PathVariable] string $id, #[Valid] #[RequestBody] Payou 'bank_reference' => is_string($reference) ? $reference : '', ]; } + + /** + * The steps a failed payout left changed: the engine's own list for a COMPENSATION_FAILED saga, plus any step + * whose compensation threw, in saga order. + * + * @return list + */ + private function undoneSteps(SagaResult $result): array + { + $undone = []; + foreach ($result->steps as $stepId => $step) { + if ($step->status === StepStatus::CompensationFailed || in_array($stepId, $result->uncompensatedSteps, true)) { + $undone[] = (string) $stepId; + } + } + + return $undone; + } + + /** The step whose own action failed (FAILED or TIMED_OUT), as opposed to one whose compensation did. */ + private function failingStep(SagaResult $result): ?string + { + foreach ($result->steps as $stepId => $step) { + if ($step->status === StepStatus::Failed || $step->status === StepStatus::TimedOut) { + return $stepId; + } + } + + return $result->firstErrorStepId(); + } } diff --git a/samples/lumen/tests/Web/PayoutRestTest.php b/samples/lumen/tests/Web/PayoutRestTest.php index 9a57926c..1d88e54e 100644 --- a/samples/lumen/tests/Web/PayoutRestTest.php +++ b/samples/lumen/tests/Web/PayoutRestTest.php @@ -4,12 +4,18 @@ namespace Lumen\Tests\Web; +use Firefly\Cqrs\Command\CommandBus; +use Firefly\Orchestration\Persistence\OrchestrationSchema; 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\Payout\PayoutSaga; use Lumen\Infrastructure\Payout\SimulatedBankGateway; use Lumen\Tests\Application\LumenOrchestrationTestCase; +use RuntimeException; uses(LumenOrchestrationTestCase::class); @@ -50,6 +56,48 @@ $this->getJson("/api/v1/wallets/{$id}/balance")->assertJson(['balance_minor' => 50000]); })->group('lumen'); +it('answers 500 PAYOUT_COMPENSATION_FAILED, never the bank\'s 409, when the refund after a refusal fails', function () { + /** @var LumenOrchestrationTestCase $this */ + /** @var string $id */ + $id = $this->postJson('/api/v1/wallets', ['owner_id' => 'owner-rest', 'currency' => 'EUR'])->json('wallet_id'); + $this->postJson("/api/v1/wallets/{$id}/deposit", ['amount_minor' => 50000]); + + // The bank refuses the account at submit, and the refund (a Deposit through the bus) then fails too: the debit is + // done, the money is neither at the bank nor back in the wallet. PayoutSaga is a singleton built at boot, so the + // failing bus goes in through a fresh saga bean; the debit still runs through the real bus. + /** @var CommandBus $real */ + $real = $this->fireflyContext()->get(CommandBus::class); + $commands = new class($real) implements CommandBus + { + public function __construct(private readonly CommandBus $inner) {} + + public function send(object $command): mixed + { + if ($command instanceof Deposit) { + throw new RuntimeException('The ledger is unavailable.'); + } + + return $this->inner->send($command); + } + }; + $this->app()->instance(PayoutSaga::class, new PayoutSaga($commands, $this->app()->make(SimulatedBankGateway::class))); + + $response = $this->postJson("/api/v1/wallets/{$id}/payouts", ['iban' => 'XX0012345678', 'amount_minor' => 20000]) + ->assertStatus(500) + ->assertHeader('Content-Type', 'application/problem+json') + ->assertJsonPath('code', 'PAYOUT_COMPENSATION_FAILED') + ->assertJsonPath('uncompensated_steps', ['debit']); + + // Under the default strict-sequential policy the error handler continues past the failed refund, so the execution + // ends FAILED with debit marked COMPENSATION_FAILED; the answer is read from the step, not the execution status. + $payoutId = $response->json('payout_id'); + expect($payoutId)->toBeString() + ->and(DB::table(OrchestrationSchema::EXECUTIONS)->where('correlation_id', $payoutId)->value('status'))->toBe('FAILED') + ->and(DB::table(OrchestrationSchema::DEAD_LETTERS)->where('correlation_id', $payoutId)->exists())->toBeTrue(); + + $this->getJson("/api/v1/wallets/{$id}/balance")->assertJson(['balance_minor' => 30000]); +})->group('lumen'); + it('answers an overdrawing payout with the withdraw\'s own 409, before the bank is called', function () { /** @var LumenOrchestrationTestCase $this */ /** @var string $id */ From 9d108bd828b7c1469a4bc1ff61cc6f22f5cec149 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 08:13:17 -0700 Subject: [PATCH 09/19] test(docs): boot the tutorial's greeting-card saga as the reader's own app files through the real pipeline --- .../Fixtures/Tutorial/app/Cards/CardDesk.php | 61 ++++++++++ .../Tutorial/app/Cards/GreetingCardSaga.php | 48 ++++++++ .../app/Http/GreetingCardController.php | 30 +++++ tests/Support/TutorialTestCase.php | 105 ++++++++++++++++++ tests/TutorialGreetingCardSagaTest.php | 34 ++++++ 5 files changed, 278 insertions(+) create mode 100644 tests/Fixtures/Tutorial/app/Cards/CardDesk.php create mode 100644 tests/Fixtures/Tutorial/app/Cards/GreetingCardSaga.php create mode 100644 tests/Fixtures/Tutorial/app/Http/GreetingCardController.php create mode 100644 tests/Support/TutorialTestCase.php create mode 100644 tests/TutorialGreetingCardSagaTest.php diff --git a/tests/Fixtures/Tutorial/app/Cards/CardDesk.php b/tests/Fixtures/Tutorial/app/Cards/CardDesk.php new file mode 100644 index 00000000..1532259d --- /dev/null +++ b/tests/Fixtures/Tutorial/app/Cards/CardDesk.php @@ -0,0 +1,61 @@ + */ + public array $journal = []; + + private int $cards = 0; + + private int $payments = 0; + + public function reserve(): string + { + $card = 'card-'.++$this->cards; + $this->journal[] = "reserve {$card}"; + + return $card; + } + + public function release(string $card): void + { + $this->journal[] = "release {$card}"; + } + + public function charge(int $amountMinor): string + { + $payment = 'payment-'.++$this->payments; + $this->journal[] = "charge {$payment} {$amountMinor}"; + + return $payment; + } + + public function refund(string $payment): void + { + $this->journal[] = "refund {$payment}"; + } + + public function send(string $card, string $to): string + { + if ($to === 'Nobody') { + throw new RuntimeException("The mailer has no address for {$to}."); + } + + $this->journal[] = "send {$card} to {$to}"; + + return "sent {$card} to {$to}"; + } +} diff --git a/tests/Fixtures/Tutorial/app/Cards/GreetingCardSaga.php b/tests/Fixtures/Tutorial/app/Cards/GreetingCardSaga.php new file mode 100644 index 00000000..f95ca0af --- /dev/null +++ b/tests/Fixtures/Tutorial/app/Cards/GreetingCardSaga.php @@ -0,0 +1,48 @@ +desk->reserve(); + } + + public function release(string $card): void + { + $this->desk->release($card); + } + + #[SagaStep(id: 'charge', dependsOn: ['reserve'], compensate: 'refund')] + public function charge(): string + { + return $this->desk->charge(self::PRICE_MINOR); + } + + public function refund(string $payment): void + { + $this->desk->refund($payment); + } + + #[SagaStep(id: 'send', dependsOn: ['charge'])] + public function send(#[FromStep('reserve')] string $card, #[Input('to')] string $to): string + { + return $this->desk->send($card, $to); + } +} diff --git a/tests/Fixtures/Tutorial/app/Http/GreetingCardController.php b/tests/Fixtures/Tutorial/app/Http/GreetingCardController.php new file mode 100644 index 00000000..a5aaaf4e --- /dev/null +++ b/tests/Fixtures/Tutorial/app/Http/GreetingCardController.php @@ -0,0 +1,30 @@ +sagas->execute('greeting-card', ['to' => $to]); + + if (! $result->success) { + throw new ConflictException("The card for {$to} was not sent; every completed step was undone.", 'CARD_NOT_SENT', $result->error); + } + + return ['order' => $result->correlationId, 'status' => $result->status->value]; + } +} diff --git a/tests/Support/TutorialTestCase.php b/tests/Support/TutorialTestCase.php new file mode 100644 index 00000000..21f248ff --- /dev/null +++ b/tests/Support/TutorialTestCase.php @@ -0,0 +1,105 @@ + */ + public static function psr4(): array + { + return ['App\\' => self::appPath()]; + } + + /** @return list */ + protected function fireflyProviders(): array + { + return [ + ValidationServiceProvider::class, + WebServiceProvider::class, + ResilienceServiceProvider::class, + SchedulingServiceProvider::class, + SchedulingWiringProvider::class, + EdaServiceProvider::class, + EdaWiringProvider::class, + OrchestrationServiceProvider::class, + OrchestrationWiringProvider::class, + ]; + } + + /** @return array */ + protected function configOverrides(): array + { + return [ + 'firefly.scan.paths' => self::psr4(), + 'cache.default' => 'array', + 'queue.default' => 'sync', + ]; + } + + 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())); + } + + protected function setUp(): void + { + self::registerAutoloader(); + + parent::setUp(); + } + + private static function registerAutoloader(): void + { + if (self::$autoloaderRegistered) { + return; + } + + self::$autoloaderRegistered = true; + + spl_autoload_register(static function (string $class): void { + if (! str_starts_with($class, 'App\\')) { + return; + } + + $file = self::appPath().'/'.str_replace('\\', '/', substr($class, 4)).'.php'; + if (is_file($file)) { + require_once $file; + } + }); + } +} diff --git a/tests/TutorialGreetingCardSagaTest.php b/tests/TutorialGreetingCardSagaTest.php new file mode 100644 index 00000000..58aea097 --- /dev/null +++ b/tests/TutorialGreetingCardSagaTest.php @@ -0,0 +1,34 @@ +postJson('/cards/Ada') + ->assertStatus(201) + ->assertJsonPath('status', 'COMPLETED'); + + expect($this->app()->make(CardDesk::class)->journal) + ->toBe(['reserve card-1', 'charge payment-1 300', 'send card-1 to Ada']); +}); + +it('undoes the charge, then the reservation, when the card cannot be sent', function () { + /** @var TutorialTestCase $this */ + $this->postJson('/cards/Nobody') + ->assertStatus(409) + ->assertHeader('Content-Type', 'application/problem+json') + ->assertJsonPath('code', 'CARD_NOT_SENT'); + + expect($this->app()->make(CardDesk::class)->journal) + ->toBe(['reserve card-1', 'charge payment-1 300', 'refund payment-1', 'release card-1']); +}); From 5f00186387191ce06e734fa7fda34529cb1f3db3 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 08:20:16 -0700 Subject: [PATCH 10/19] docs(book): Chapter 7 caches Lumen's balance with #[CachedQuery] and evicts it per wallet with #[InvalidatesQuery] --- book/src-es/07-cqrs.md | 82 +++++++++++++++++++++++++++++++++++++++++- book/src/07-cqrs.md | 82 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 162 insertions(+), 2 deletions(-) diff --git a/book/src-es/07-cqrs.md b/book/src-es/07-cqrs.md index da83021f..69dbd678 100644 --- a/book/src-es/07-cqrs.md +++ b/book/src-es/07-cqrs.md @@ -2,7 +2,7 @@ # CQRS: Comandos y Consultas {.chtitle} -Al terminar este capítulo conocerás la forma de los dos buses de `firefly/cqrs` (`CommandBus::send` / `QueryBus::ask`), cómo `#[CommandHandler]`/`#[QueryHandler]` registran un manejador como bean de DI y como destino de despacho a la vez — con el tipo de mensaje manejado *inferido* del propio parámetro `handle()` del manejador —, exactamente qué aspecto tiene la tubería acotada que ejecuta cada bus, cómo la excepción propia de un manejador llega hasta quien lo llama envuelta en una `CommandProcessingException`/`QueryProcessingException`, y cómo `HandlerScanner` compila el descubrimiento de manejadores en un manifiesto que `firefly:cache` escribe justo al lado de los manifiestos de componentes y rutas que ya conociste en los Capítulos 2 y 4. +Al terminar este capítulo conocerás la forma de los dos buses de `firefly/cqrs` (`CommandBus::send` / `QueryBus::ask`), cómo `#[CommandHandler]`/`#[QueryHandler]` registran un manejador como bean de DI y como destino de despacho a la vez — con el tipo de mensaje manejado *inferido* del propio parámetro `handle()` del manejador —, exactamente qué aspecto tiene la tubería acotada que ejecuta cada bus, cómo la excepción propia de un manejador llega hasta quien lo llama envuelta en una `CommandProcessingException`/`QueryProcessingException`, cómo `#[CachedQuery]` guarda en caché la respuesta de una consulta y `#[InvalidatesQuery]` la retira cuando se confirma el evento que la deja obsoleta, y cómo `HandlerScanner` compila el descubrimiento de manejadores en un manifiesto que `firefly:cache` escribe justo al lado de los manifiestos de componentes y rutas que ya conociste en los Capítulos 2 y 4. !!! note "Término nuevo: CQRS" **Command Query Responsibility Segregation** (segregación de responsabilidad entre comandos y consultas) trata las escrituras y las lecturas como dos tipos distintos de mensaje con dos caminos de despacho distintos — un **comando** es una intención de cambiar el modelo de escritura y devuelve como mucho un id o un recuento; una **consulta** es una lectura pura que nunca muta nada. `firefly/cqrs` le da a cada uno su propio bus, su propio atributo de manejador y su propia tubería, así que los dos nunca se mezclan por accidente en un mismo punto de llamada. @@ -473,6 +473,84 @@ El tipo exterior te dice *qué operación del bus* falló; el tipo interior — --- +## Cachear consultas: `#[CachedQuery]` y `#[InvalidatesQuery]` + +La tabla de la tubería de arriba nombra una etapa de caché de consultas que, por defecto, siempre falla. Fijar `firefly.cqrs.query.cache.provider=cache` cambia `NoOpQueryCache` por `CacheQueryCache`, que guarda la respuesta de una consulta en un almacén de caché de Laravel, y dos atributos deciden qué se guarda en caché y qué lo deja obsoleto. La lectura del saldo de Lumen es la candidata natural — todas las pantallas del monedero la piden, y solo cambia cuando se mueve dinero: + + +```php +#[CachedQuery(ttl: 60)] +final readonly class GetBalance +{ + public function __construct(public string $walletId) {} +} +``` + +La clave es el propio valor de la consulta. `CacheQueryCache` la deriva de la clase de la consulta y de sus propiedades públicas, así que dos objetos `GetBalance('w-1')` comparten una entrada y `GetBalance('w-2')` tiene la suya. Una respuesta `null` — un monedero desconocido — nunca se guarda; una consulta con estado privado o protegido no se puede convertir en clave y simplemente se ejecuta sin caché; y una caché que falla se registra en el log y se esquiva, porque una caché puede hacer más rápida una consulta, pero nunca debe hacerla fallar. `ttl: 60` acota cuánto vive una entrada y, si falta, se usa `firefly.cqrs.query.cache_ttl`; `group:` (por defecto, la clase de la consulta) nombra el conjunto que una sola expulsión puede vaciar de golpe. + +La otra mitad le corresponde al evento. Un saldo en caché es incorrecto en el momento en que se confirma un depósito, y el evento es lo único que lo sabe, así que la regla se escribe en la clase del evento: + + +```php +#[PublishDomainEvent('wallet.events')] +#[InvalidatesQuery(GetBalance::class, with: ['walletId' => 'walletId'])] +final readonly class FundsDeposited extends DomainEvent +{ + public function __construct( + public string $walletId, + public int $amountMinor, + public string $currency, + public int $balanceMinor, + ) { + parent::__construct(); + } +} +``` + +`with:` asigna cada propiedad pública de la consulta a una propiedad del evento, de modo que la expulsión retira exactamente una entrada: la del monedero que cambió. `HandlerScanner` rechaza un mapa que nombre una propiedad desconocida o que se deje una fuera — en tiempo de `firefly:cache`, no en producción, porque un mapa erróneo derivaría una clave que ninguna entrada tiene y fallaría en silencio. La expulsión se ejecuta cuando el evento se despacha tras confirmarse su transacción, así que un depósito revertido no expulsa nada, y se ejecuta de nuevo, emparejada por tipo de evento, cuando el mismo evento llega por el bus de eventos desde otro proceso. Si omites `with:`, se va el grupo entero; si escribes `key: 'statement:{account}'`, se expulsa en su lugar la plantilla de clave propia de una consulta `Cacheable`. `FundsWithdrawn` lleva el mismo atributo. + +El ejemplo demuestra el comportamiento que importa, con la caché activada en `LumenQueryCacheTestCase` (`cache.default=array`, `provider=cache`) y en ningún otro sitio: + + +```php + expect($queries->ask(new GetBalance($id)))->toBe(1000); + + // Changed behind the bus's back: the cached answer does not see it. + DB::table('wallets')->where('id', $id)->update(['balance_minor' => 5000]); + expect($queries->ask(new GetBalance($id)))->toBe(1000); + + // A committed deposit raises FundsDeposited, whose #[InvalidatesQuery] evicts exactly this wallet's balance. + $commands->send(new Deposit($id, 500)); + expect($queries->ask(new GetBalance($id)))->toBe(5500); +``` + +La segunda respuesta es la de la caché — la fila cambió por debajo y no se enteró, que es todo el sentido de una caché y todo su riesgo. El depósito expulsa la entrada, y la siguiente lectura vuelve a ser la de la base de datos: 5000 más 500. El mismo fichero demuestra que un depósito en el monedero A deja en paz el saldo en caché del monedero B, y que una retirada expulsa igual que un depósito. + +Los grupos se expulsan por **generaciones**, no por etiquetas de caché, así que la expulsión funciona en todos los almacenes, `file` y `database` incluidos: una entrada expulsada deja de ser alcanzable al instante y caduca con su TTL, y por eso merece la pena fijar uno. La configuración de referencia trae el bloque comentado: + + +```php +// 'query' => [ +// 'cache_ttl' => 60, +// 'cache' => [ +// 'provider' => env('FIREFLY_CQRS_QUERY_CACHE', 'none'), +// 'store' => env('FIREFLY_CQRS_QUERY_CACHE_STORE', ''), +// 'prefix' => 'firefly:cqrs:', +// 'invalidation' => [ +// 'ledger.closed' => [App\Ledger\LedgerTotals::class], +// ], +// ], +// ], +``` + +!!! warning "La clave es el objeto valor" + Una consulta cuya respuesta depende del inquilino o del usuario autenticado debe llevarlos como propiedades públicas. `GetBalance` es segura porque el id del monedero es la pregunta entera; una consulta `MyWallets` sin propiedad `ownerId` le daría a cada usuario la lista del primero. + +!!! note "Por qué un evento de dominio nombra una consulta" + `FundsDeposited` ahora importa `GetBalance`, una clase de la capa de aplicación, del mismo modo que ya lleva `#[PublishDomainEvent]`. El framework pone la regla ahí a propósito — el evento es quien sabe lo que deja obsoleto — y con ello se gana la expulsión precisa, de un solo monedero. La alternativa, `firefly.cqrs.query.cache.invalidation` (tipo de evento ⇒ clases de consulta, para eventos de los que este proceso no tiene clase), mantiene limpio el dominio y expulsa el saldo de todos los monederos en cada depósito. + +--- + ## Compilado, no reflexionado: `HandlerScanner` → `HandlerManifest` → `firefly:cache` `HandlerScanner` es el **único** punto de reflexión en `packages/cqrs/src` (la misma invariante forzada por grep que el Capítulo 6 describió para `firefly/domain`). Recorre una vez las raíces PSR-4 de la aplicación, resuelve cada `#[CommandHandler]`/`#[QueryHandler]` (ejecutando exactamente la inferencia de `messageType()` que acabas de leer) en un `HandlerDescriptor`, y entrega la lista completa a `HandlerManifestCompiler`, que la `var_export()`a en un archivo PHP de array plano — la misma técnica sin reflexión que el Capítulo 2 introdujo para el manifiesto de componentes y el Capítulo 3 para `#[ConfigProperties]`. @@ -498,6 +576,7 @@ Equivócate en esto en una aplicación — añade un manejador, olvida volver a | `#[CommandHandler]` / `#[QueryHandler]` | Especializan `#[Component]` — un solo atributo registra el manejador como bean de DI **y** como destino de despacho | | Inferencia del tipo de mensaje | `HandlerScanner` lee el único parámetro de `handle()` a menos que se dé una cadena de clase explícita | | La tubería acotada | correlacionar → validar → autorizar → (obtener de caché, solo consultas) → resolver e invocar → (guardar en caché, solo consultas) → métricas | +| `#[CachedQuery]` / `#[InvalidatesQuery]` | Guarda la respuesta de una consulta por valor en un almacén de caché de Laravel (`firefly.cqrs.query.cache.provider=cache`); el evento que la deja obsoleta expulsa una entrada (`with:`) o un grupo tras confirmarse | | `CommandProcessingException` / `QueryProcessingException` | Envoltorios que preservan la categoría; el fallo original siempre es alcanzable vía `getPrevious()` | | `HandlerRegistry` | Una clase de comando/consulta se mapea a exactamente un manejador — un registro duplicado falla ruidosamente en tiempo de cableado | | `HandlerScanner` → `HandlerManifest` | El único punto de reflexión; compilado por `firefly:cache` (uno de trece pares escáner/compilador) en un manifiesto sin reflexión | @@ -509,3 +588,4 @@ Equivócate en esto en una aplicación — añade un manejador, olvida volver a 1. **Provoca `CommandHandlerNotFoundException`.** En una copia de trabajo del proyecto (no en el paquete distribuido `samples/lumen`), define una nueva clase de comando sin ningún `#[CommandHandler]` en toda la aplicación, despáchala a través de `CommandBus::send()`, y confirma que obtienes una `CommandHandlerNotFoundException` sin envolver en nada (la lanza directamente el registro, antes de que el propio bloque `catch` del bus pudiera envolver el fallo de un manejador). 2. **Fuerza un `handle()` ambiguo.** Dale a una clase `#[QueryHandler]` un método `handle()` con dos parámetros (o un parámetro tipado `object`) y ejecuta `php artisan firefly:cache`. Confirma que falla ruidosamente con `CqrsConfigurationException` nombrando la clase y el método exactos, en vez de omitir el manejador silenciosamente. 3. **Lee el manifiesto compilado.** Tras ejecutar `firefly:cache` en un proyecto con `OpenWalletHandler` y `GetBalanceHandler` presentes, abre `bootstrap/cache/firefly/handlers.php` y encuentra ambas entradas. Empareja el campo `kind` de cada array (`'command'` o `'query'`) con el enum `HandlerKind` que describió este capítulo. +4. **Rompe una expulsión a propósito.** En una copia de trabajo del proyecto, borra la línea `#[InvalidatesQuery]` de `FundsWithdrawn` y ejecuta `samples/lumen/tests/Application/QueryCacheTest.php`. Confirma que solo falla la prueba de la retirada, con el saldo antiguo, y restaura la línea. diff --git a/book/src/07-cqrs.md b/book/src/07-cqrs.md index f2c87e8e..69078dca 100644 --- a/book/src/07-cqrs.md +++ b/book/src/07-cqrs.md @@ -2,7 +2,7 @@ # CQRS: Commands and Queries {.chtitle} -By the end of this chapter you will know the shape of `firefly/cqrs`'s two buses (`CommandBus::send` / `QueryBus::ask`), how `#[CommandHandler]`/`#[QueryHandler]` register a handler as both a DI bean and a dispatch target — with the handled message type *inferred* from the handler's own `handle()` parameter — exactly what the bounded pipeline each bus runs looks like, how a handler's own exception surfaces to a caller wrapped in a `CommandProcessingException`/`QueryProcessingException`, and how `HandlerScanner` compiles handler discovery into a manifest `firefly:cache` writes right alongside the component and route manifests you already met in Chapters 2 and 4. +By the end of this chapter you will know the shape of `firefly/cqrs`'s two buses (`CommandBus::send` / `QueryBus::ask`), how `#[CommandHandler]`/`#[QueryHandler]` register a handler as both a DI bean and a dispatch target — with the handled message type *inferred* from the handler's own `handle()` parameter — exactly what the bounded pipeline each bus runs looks like, how a handler's own exception surfaces to a caller wrapped in a `CommandProcessingException`/`QueryProcessingException`, how `#[CachedQuery]` puts a query's answer in a cache and `#[InvalidatesQuery]` takes it out again when the event that makes it stale commits, and how `HandlerScanner` compiles handler discovery into a manifest `firefly:cache` writes right alongside the component and route manifests you already met in Chapters 2 and 4. !!! note "New term: CQRS" **Command Query Responsibility Segregation** treats writes and reads as two distinct kinds of message with two distinct dispatch paths — a **command** is an intent to change the write model and returns at most an id or a count; a **query** is a pure read that never mutates anything. `firefly/cqrs` gives each its own bus, its own handler attribute, and its own pipeline, so the two are never accidentally mixed at a single call site. @@ -473,6 +473,84 @@ The outer type tells you *which bus operation* failed; the inner type — reache --- +## Caching queries: `#[CachedQuery]` and `#[InvalidatesQuery]` + +The pipeline table above names a query-cache stage that misses by default. Setting `firefly.cqrs.query.cache.provider=cache` swaps `NoOpQueryCache` for `CacheQueryCache`, which keeps a query's answer on a Laravel cache store, and two attributes decide what is cached and what makes it stale. Lumen's balance read is the natural candidate — every wallet screen asks for it, and it changes only when money moves: + + +```php +#[CachedQuery(ttl: 60)] +final readonly class GetBalance +{ + public function __construct(public string $walletId) {} +} +``` + +The key is the query value itself. `CacheQueryCache` derives it from the query's class and its public properties, so two `GetBalance('w-1')` objects share one entry and `GetBalance('w-2')` gets its own. A `null` answer — an unknown wallet — is never stored; a query holding private or protected state cannot be keyed and simply runs uncached; and a cache that fails is logged and bypassed, because a cache may make a query faster but must never make it fail. `ttl: 60` bounds how long an entry lives, falling back to `firefly.cqrs.query.cache_ttl`; `group:` (by default the query class) names the set one eviction can clear at once. + +The other half belongs to the event. A cached balance is wrong the moment a deposit commits, and the event is the one thing that knows it, so the rule is written on the event class: + + +```php +#[PublishDomainEvent('wallet.events')] +#[InvalidatesQuery(GetBalance::class, with: ['walletId' => 'walletId'])] +final readonly class FundsDeposited extends DomainEvent +{ + public function __construct( + public string $walletId, + public int $amountMinor, + public string $currency, + public int $balanceMinor, + ) { + parent::__construct(); + } +} +``` + +`with:` maps each public property of the query to a property of the event, so the eviction removes exactly one entry: the wallet that changed. `HandlerScanner` refuses a map that names an unknown property or leaves one out — at `firefly:cache` time, not in production, because a wrong map would derive a key no entry has and miss silently. The eviction runs when the event is dispatched after its transaction commits, so a rolled-back deposit evicts nothing, and it runs again, matched by event type, when the same event arrives over the event bus from another process. Leave `with:` out and the whole group goes; write `key: 'statement:{account}'` and a `Cacheable` query's own key template is evicted instead. `FundsWithdrawn` carries the same attribute. + +The sample proves the behavior that matters, with the cache switched on in `LumenQueryCacheTestCase` (`cache.default=array`, `provider=cache`) and nowhere else: + + +```php + expect($queries->ask(new GetBalance($id)))->toBe(1000); + + // Changed behind the bus's back: the cached answer does not see it. + DB::table('wallets')->where('id', $id)->update(['balance_minor' => 5000]); + expect($queries->ask(new GetBalance($id)))->toBe(1000); + + // A committed deposit raises FundsDeposited, whose #[InvalidatesQuery] evicts exactly this wallet's balance. + $commands->send(new Deposit($id, 500)); + expect($queries->ask(new GetBalance($id)))->toBe(5500); +``` + +The second answer is the cached one — the row changed under it and it did not notice, which is the whole point of a cache and the whole risk of one. The deposit evicts the entry, and the next read is the database's again: 5000 plus 500. The same file proves that a deposit into wallet A leaves wallet B's cached balance alone, and that a withdrawal evicts like a deposit does. + +Groups are evicted by **generations**, not cache tags, so eviction works on every store, `file` and `database` included: an evicted entry becomes unreachable at once and expires on its TTL, which is why setting one is worth it. The reference configuration ships the block commented out: + + +```php +// 'query' => [ +// 'cache_ttl' => 60, +// 'cache' => [ +// 'provider' => env('FIREFLY_CQRS_QUERY_CACHE', 'none'), +// 'store' => env('FIREFLY_CQRS_QUERY_CACHE_STORE', ''), +// 'prefix' => 'firefly:cqrs:', +// 'invalidation' => [ +// 'ledger.closed' => [App\Ledger\LedgerTotals::class], +// ], +// ], +// ], +``` + +!!! warning "The key is the value object" + A query whose answer depends on the tenant or the signed-in user must carry them as public properties. `GetBalance` is safe because the wallet id is the whole question; a `MyWallets` query with no `ownerId` property would hand every user the first user's list. + +!!! note "Why a domain event names a query" + `FundsDeposited` now imports `GetBalance`, an application-layer class, the same way it already carries `#[PublishDomainEvent]`. The framework puts the rule there on purpose — the event is what knows what it makes stale — and it buys the precise, one-wallet eviction. The alternative, `firefly.cqrs.query.cache.invalidation` (event type ⇒ query classes, for events this process has no class for), keeps the domain clean and evicts every wallet's balance on every deposit. + +--- + ## Compiled, not reflected: `HandlerScanner` → `HandlerManifest` → `firefly:cache` `HandlerScanner` is the **sole** reflection site in `packages/cqrs/src` (the same grep-enforced invariant Chapter 6 described for `firefly/domain`). It walks the app's PSR-4 roots once, resolves every `#[CommandHandler]`/`#[QueryHandler]` (running exactly the `messageType()` inference you just read) into a `HandlerDescriptor`, and hands the whole list to `HandlerManifestCompiler`, which `var_export()`s it to a plain PHP array file — the same zero-reflection technique Chapter 2 introduced for the component manifest and Chapter 3 for `#[ConfigProperties]`. @@ -498,6 +576,7 @@ Get this wrong in an application — add a handler, forget to re-run `firefly:ca | `#[CommandHandler]` / `#[QueryHandler]` | Specialise `#[Component]` — one attribute registers the handler as a DI bean **and** a dispatch target | | Message-type inference | `HandlerScanner` reads the sole `handle()` parameter unless an explicit class-string is given | | The bounded pipeline | correlate → validate → authorize → (cache-get, query only) → resolve + invoke → (cache-put, query only) → metrics | +| `#[CachedQuery]` / `#[InvalidatesQuery]` | Caches a query's answer by value on a Laravel store (`firefly.cqrs.query.cache.provider=cache`); the event that makes it stale evicts one entry (`with:`) or a group after it commits | | `CommandProcessingException` / `QueryProcessingException` | Category-preserving wrappers; the original fault is always reachable via `getPrevious()` | | `HandlerRegistry` | One command/query class maps to exactly one handler — a duplicate registration fails loud at wiring time | | `HandlerScanner` → `HandlerManifest` | The sole reflection site; compiled by `firefly:cache` (one of thirteen scanner/compiler pairs) into a zero-reflection manifest | @@ -509,3 +588,4 @@ Get this wrong in an application — add a handler, forget to re-run `firefly:ca 1. **Trigger `CommandHandlerNotFoundException`.** In a scratch copy of the project (not the shipped `samples/lumen` package), define a new command class with no `#[CommandHandler]` anywhere in the app, dispatch it through `CommandBus::send()`, and confirm you get a `CommandHandlerNotFoundException` wrapped in nothing (it is thrown directly by the registry, before the bus's own `catch` block would wrap a handler's fault). 2. **Force an ambiguous `handle()`.** Give a `#[QueryHandler]` class a `handle()` method with two parameters (or a parameter typed `object`) and run `php artisan firefly:cache`. Confirm it fails loud with `CqrsConfigurationException` naming the exact class and method, rather than silently skipping the handler. 3. **Read the compiled manifest.** After running `firefly:cache` in a project with `OpenWalletHandler` and `GetBalanceHandler` present, open `bootstrap/cache/firefly/handlers.php` and find both entries. Match each array's `kind` field (`'command'` or `'query'`) back to the `HandlerKind` enum this chapter described. +4. **Break an eviction on purpose.** In a scratch copy of the project, delete the `#[InvalidatesQuery]` line from `FundsWithdrawn` and run `samples/lumen/tests/Application/QueryCacheTest.php`. Confirm that only the withdrawal test fails, with the old balance, then restore the line. From 8c5133e0165791803b5fef7b14d3d3cd7404b016 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 08:30:12 -0700 Subject: [PATCH 11/19] =?UTF-8?q?docs(book):=20Chapter=208=20teaches=20the?= =?UTF-8?q?=20outbox=20on=20every=20database=20=E2=80=94=20claims,=20any?= =?UTF-8?q?=20number=20of=20consumers,=20the=20relay,=20durable=20dead=20l?= =?UTF-8?q?etters=20and=20Kafka/RabbitMQ=20messaging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- book/src-es/08-eda-outbox.md | 138 +++++++++++++++++++++++++++++++++-- book/src/08-eda-outbox.md | 138 +++++++++++++++++++++++++++++++++-- 2 files changed, 260 insertions(+), 16 deletions(-) diff --git a/book/src-es/08-eda-outbox.md b/book/src-es/08-eda-outbox.md index a641e2bc..40ab5a02 100644 --- a/book/src-es/08-eda-outbox.md +++ b/book/src-es/08-eda-outbox.md @@ -2,7 +2,7 @@ # Arquitectura Guiada por Eventos y el Outbox Transaccional {.chtitle} -Al terminar este capítulo conocerás las dos superficies de eventos, deliberadamente distintas, que distribuye LaraFly y por qué confundirlas es el error más común de todos; el puerto `EventPublisher` y el `EventEnvelope` que transporta; cómo `#[EventListener]` compara con el **nombre de tipo** de un evento — no con su destino — un tropiezo real que sufrió la construcción de `samples/lumen` al conectar `LedgerProjector` en el Capítulo 6; los adaptadores en memoria y de cola; el modelo de reintento/carta-muerta en el que se envuelve todo listener; y — cerrando la brecha de durabilidad que deja abierta el puente dominio→integración — el genuino **outbox de la misma transacción** que `firefly/eda-postgres` escribe de forma atómica junto con el agregado que lanzó el evento. +Al terminar este capítulo conocerás las dos superficies de eventos, deliberadamente distintas, que distribuye LaraFly y por qué confundirlas es el error más común de todos; el puerto `EventPublisher` y el `EventEnvelope` que transporta; cómo `#[EventListener]` compara con el **nombre de tipo** de un evento — no con su destino — un tropiezo real que sufrió la construcción de `samples/lumen` al conectar `LedgerProjector` en el Capítulo 6; los adaptadores en memoria y de cola; el modelo de reintento/carta-muerta en el que se envuelve todo listener; y — cerrando la brecha de durabilidad que deja abierta el puente dominio→integración — el genuino **outbox de la misma transacción** que `firefly/eda-database` escribe de forma atómica junto con el agregado que lanzó el evento, en cualquiera de las cinco bases de datos; cómo sus consumidores arriendan filas para que puedan correr tantos como quieras a la vez; el `firefly:outbox:relay` opcional; qué añade encima `firefly/eda-postgres`; adónde va una carta muerta cuando tiene que sobrevivir al proceso (`firefly:eda:dead-letters:replay`); y cómo `firefly/messaging` consume de Kafka y RabbitMQ (`firefly:messaging:consume`). !!! note "Término nuevo: evento de integración" El Capítulo 6 construyó **eventos de dominio** — `WalletOpened`, `FundsDeposited` — hechos que un agregado lanza para consumidores dentro del *mismo* proceso y el *mismo* desplegable. Un **evento de integración** es el mismo hecho reemitido a través de una frontera de proceso o de servicio: un envoltorio con forma de JSON con un nombre de tipo, una carga útil y encabezados de enrutamiento, transportado por un broker (o, en proceso, por ese mismo bus). Este capítulo trata de la maquinaria al otro lado de esa frontera, y del puente que lleva un evento de dominio hasta ella. @@ -18,7 +18,7 @@ LaraFly distribuye dos mecanismos de eventos sin relación entre sí que resulta | Eventos de aplicación en proceso | `#[AsEventListener]` | una **clase** de evento PHP | síncrona, mismo proceso, sin broker | `firefly/context` | | Bus broker de EDA | `#[EventListener]` | un **patrón** de tipo de evento (glob al estilo fnmatch, p. ej. `'user.*'`) | en memoria (síncrona) o en cola (asíncrona) | `firefly/eda` | -`#[AsEventListener]` se dispara cuando el código de la aplicación llama a `ApplicationEventPublisher::publish(object $event)` con un objeto PHP tipado — el mismo mecanismo sobre el que cabalga el despacho post-confirmación del Capítulo 6 (`DomainEventDispatcher` llama exactamente a esto para publicar un `DomainEvent` drenado). `#[EventListener]`, el tema de este capítulo, se dispara cuando llega al bus de **eda** un `EventEnvelope` cuya cadena `eventType` coincide con tu patrón — en memoria, a través de un worker de cola, o (la segunda mitad de este capítulo) a través de una fila del outbox de Postgres. Ningún paquete depende del otro; nada en `firefly/eda` importa siquiera el `Dispatcher` de Illuminate. +`#[AsEventListener]` se dispara cuando el código de la aplicación llama a `ApplicationEventPublisher::publish(object $event)` con un objeto PHP tipado — el mismo mecanismo sobre el que cabalga el despacho post-confirmación del Capítulo 6 (`DomainEventDispatcher` llama exactamente a esto para publicar un `DomainEvent` drenado). `#[EventListener]`, el tema de este capítulo, se dispara cuando llega al bus de **eda** un `EventEnvelope` cuya cadena `eventType` coincide con tu patrón — en memoria, a través de un worker de cola, o (la segunda mitad de este capítulo) a través de una fila del outbox. Ningún paquete depende del otro; nada en `firefly/eda` importa siquiera el `Dispatcher` de Illuminate. --- @@ -64,7 +64,7 @@ final readonly class EventEnvelope } ``` -`eventType` y `destination` son dos cosas genuinamente distintas, y el resto de este capítulo gira sobre esa distinción: `destination` es *hacia dónde* se enruta el envoltorio en el cable (un nombre de cola, la columna `destination` de una fila del outbox de Postgres); `eventType` es *qué hecho transporta*, y es el **único** campo contra el que se compara jamás un patrón de `#[EventListener]`. +`eventType` y `destination` son dos cosas genuinamente distintas, y el resto de este capítulo gira sobre esa distinción: `destination` es *hacia dónde* se enruta el envoltorio en el cable (un nombre de cola, la columna `destination` de una fila del outbox); `eventType` es *qué hecho transporta*, y es el **único** campo contra el que se compara jamás un patrón de `#[EventListener]`. `withHeaders(array $extra): self` devuelve una copia con los encabezados fusionados — usado internamente para estampar `x-correlation-id`, y (lo verás de nuevo más abajo) `x-original-topic`/`x-exception` en un envoltorio enviado a la carta muerta. `start()`/`stop()` son no-ops en los adaptadores en memoria y de cola; el propio consumidor del outbox (más adelante en este capítulo) es donde empiezan a hacer trabajo real. @@ -374,12 +374,51 @@ Cuando no hay ningún `EventPublisher` de `firefly/eda` vinculado en absoluto, ` --- -## El genuino outbox de la misma transacción (`firefly/eda-postgres`) +## El genuino outbox de la misma transacción (`firefly/eda-database`) -`firefly/eda-postgres` cierra la brecha escribiendo la fila del outbox **dentro** de la propia transacción abierta del agregado, en la propia conexión del agregado — así que confirma o revierte de forma atómica junto con el agregado, sin doble escritura y sin nada que "esperar" después del hecho. +`firefly/eda-database` cierra la brecha escribiendo la fila del outbox **dentro** de la propia transacción abierta del agregado, en la propia conexión del agregado — así que confirma o revierte de forma atómica junto con el agregado, sin doble escritura y sin nada que "esperar" después del hecho — y lo hace igual en PostgreSQL, MySQL, MariaDB, SQL Server y SQLite. `firefly/eda-postgres` es el mismo outbox con dos optimizaciones de PostgreSQL, y el final de esta sección muestra exactamente cuáles son. ::: figure art/figures/outbox-flow.svg | Figura 8.2 — El outbox de la misma transacción: la propia confirmación del agregado y el INSERT del outbox son una unidad atómica; un consumidor reclama filas PENDING de forma independiente. +### Activarlo + +El outbox es un ajuste y una tabla. `firefly.eda.provider=database` lo selecciona; la configuración de referencia trae su bloque comentado con los valores por defecto: + + +```php +// 'database' => [ +// 'connection' => 'mysql', +// 'channel' => 'firefly_eda_events', +// 'max-attempts' => 3, +// 'claim-strategy' => env('FIREFLY_EDA_CLAIM_STRATEGY', 'auto'), +// 'lease-seconds' => 300, +// 'poll-interval-ms' => 500, +// 'relay' => [ +// 'downstream-provider' => 'rabbitmq', +// ], +// ], +``` + + +```bash +php artisan migrate # crea firefly_eda_outbox (y añade las columnas de arrendamiento a una antigua) +php artisan firefly:eda:consume # la entrega terminal en proceso — ejecuta uno o más como workers de larga duración +``` + +Nada cambia en el código de Lumen. El `OutboxDatabaseTestCase` de la muestra añade el proveedor de `firefly/eda-database` y fija la clave, y los mismos comandos `OpenWallet` y `Deposit` dejan ahora sus eventos en la tabla hasta que un consumidor viene a por ellos: + + +```php + // Committed with the wallet and waiting for a consumer: the projector has not run yet. + expect(DB::table(OutboxSchema::TABLE)->where('status', OutboxSchema::STATUS_PENDING)->orderBy('id')->pluck('event_type')->all()) + ->toBe(['WalletOpened', 'FundsDeposited']) + ->and(LedgerEntry::query()->where('wallet_id', $id)->count())->toBe(0); + + $exit = Artisan::call('firefly:eda:consume', ['--max-messages' => 2, '--time-limit' => 10, '--poll-timeout' => 100]); +``` + +Ese es el trato que hace el outbox, dicho en dos aserciones: los eventos son duraderos en el instante en que la cartera confirma, y `LedgerProjector` se ejecuta un momento después, en un worker, en vez de dentro de la petición. El resto de esta sección explica cómo se garantizan las dos mitades. + ### El mecanismo: `PreCommitEventHook` El Capítulo 6 mostró a `DomainEventDispatcher::dispatchAfterCommit()` drenando el `AggregateTracker` y planificando cada evento vía `DB::afterCommit()`. Lleva un colaborador más, opcional — un hook que se ejecuta **antes** de esa planificación, mientras la transacción sigue abierta: @@ -572,8 +611,87 @@ Con `provider=postgres`, `PostgresEventConsumer` es esta clase más el despertar Esta es una ventana de estado `PENDING`→`PUBLISHED` **duradera**, no una marca de agua alta en memoria: un consumidor reiniciado retoma en la fila reclamable más antigua y nunca reproduce una ya `PUBLISHED`. Pueden correr a la vez tantos workers consumidores como quieras, en cualquiera de las cinco bases de datos — cada fila se entrega una vez mientras su consumidor vive dentro de su arrendamiento (`firefly.eda.database.lease-seconds`, 300 por defecto); un consumidor que cae a mitad de un manejador deja su fila para reclamarla de nuevo cuando acaba el arrendamiento, así que a través de una caída la entrega es al-menos-una-vez y los listeners deberían seguir siendo idempotentes. +### Cómo se elige una reclamación, servidor a servidor + +`claimOne()` nunca fija su SQL en el código. Una `OutboxClaimStrategy` es la que elige, y `firefly.eda.database.claim-strategy=auto` (el valor por defecto) escoge una a partir de lo que el servidor informa de sí mismo — `DatabaseCapabilities` de `firefly/data` lee la versión, nunca el nombre del driver, así que una conexión `mysql` cuyo servidor es MariaDB se reconoce como MariaDB: + +| Estrategia | Cómo elige una fila | `auto` la elige en | +|---|---|---| +| `skip-locked` | `SELECT … FOR UPDATE SKIP LOCKED` en una transacción de reclamación corta (MySQL y MariaDB la ejecutan en `READ COMMITTED`, así que los publicadores nunca esperan a sus bloqueos de hueco) | PostgreSQL; MySQL 8.0.1+; MariaDB 10.6+ | +| `read-past` | `SELECT TOP n … WITH (UPDLOCK, READPAST, ROWLOCK)` en una transacción de reclamación corta | SQL Server | +| `optimistic-lease` | una lectura sin bloqueo y luego un `UPDATE … WHERE status = 'PENDING' AND (claimed_until IS NULL OR claimed_until < now)` condicional por fila; la fila se gana cuando cambia exactamente una fila | SQLite; cualquier servidor que la sonda no reconozca; MySQL y MariaDB antiguos | + +Una estrategia forzada por nombre que el servidor no puede ejecutar falla en la primera reclamación con un error que nombra el servidor y su versión — nunca se degrada en silencio — y la elección se hace en esa primera reclamación, así que arrancar una aplicación no abre ninguna conexión para averiguarlo. Los instantes del arrendamiento son el reloj de la aplicación en UTC con precisión de milisegundos, así que mantén los relojes de los nodos sincronizados muy por dentro de `firefly.eda.database.lease-seconds`. + +### Tantos consumidores como quieras + +Como toda estrategia termina con la fila arrendada a exactamente un reclamante, escalar la entrega es arrancar otro `firefly:eda:consume`. Cada fila se entrega una vez mientras su worker vive dentro de su arrendamiento; un worker que muere a mitad de una entrega deja su fila arrendada hasta que acaba el arrendamiento, y entonces otro worker la entrega — a través de una caída, la entrega es al-menos-una-vez. `packages/eda-database/tests/Integration/OutboxConcurrencyIntegrationTest.php` hace competir a dos procesos sobre un mismo outbox en cada servidor, y la muestra ejecuta su propio viaje de ida y vuelta en cada uno de ellos, una entrada del dataset por carril: + + +```php + $exit = Artisan::call('firefly:eda:consume', ['--max-messages' => 2, '--time-limit' => 20, '--poll-timeout' => 500]); + + expect($exit)->toBe(0) + ->and(DB::table(OutboxSchema::TABLE)->where('status', OutboxSchema::STATUS_PENDING)->count())->toBe(0) + ->and(LedgerEntry::query()->where('wallet_id', $id)->orderBy('id')->pluck('event_type')->all()) + ->toBe(['WalletOpened', 'FundsDeposited']); +})->with(IntegrationDsn::lanes())->group('integration'); +``` + +Con `provider=database` el informe de salud del actuator gana un componente `outbox` — el driver, la estrategia en uso y el atasco de filas `PENDING`, o `DOWN` con el error — así que una flota de consumidores que ha dejado de dar abasto se ve antes de que nadie pregunte por qué llega tarde el libro mayor. + +### Reenviar a un broker: `firefly:outbox:relay` + +`firefly:eda:consume` entrega las filas a los propios listeners de esta aplicación. Cuando otros servicios también tienen que oír los eventos, el relay reenvía las filas confirmadas a un segundo broker y marca cada una como `PUBLISHED` — el mismo arrendamiento, el mismo `max-attempts`, la traza que lleva cada fila continuada a través del salto. `firefly.eda.database.relay.downstream-provider` nombra el broker (`rabbitmq`, `kafka`, un class-string de `EventPublisher` o un id del contenedor), y el relay rechaza como destino al propio escritor del outbox: + + +```bash +php artisan firefly:outbox:relay --batch-size=50 --sleep=1 +``` + +### Qué añade `firefly/eda-postgres` + +Todo lo anterior funciona en PostgreSQL tal cual. `firefly.eda.provider=postgres` es ese mismo outbox — la misma tabla, las mismas reclamaciones, el mismo consumidor — más las dos cosas que solo ofrece PostgreSQL: `PostgresEventPublisher` lanza `pg_notify` dentro de la transacción que escribe, y `PostgresEventConsumer` hace `LISTEN` y espera a la notificación en vez de dormir `poll-interval-ms` entre reclamaciones vacías. Toda clave `firefly.eda.postgres.*` sigue funcionando como alias de su gemela `firefly.eda.database.*`, y la clave de database gana cuando las dos están fijadas, así que moverse entre los dos proveedores no pierde ningún ajuste. + !!! laravel "Paridad con Laravel" - Laravel puro no tiene ningún patrón de outbox transaccional de primera parte en absoluto — el idioma que tendrías que construir a mano es una tabla `outbox` más un comando planificado que la sondea, sin ninguna ayuda del framework para mantener el `INSERT` dentro de la misma transacción que tu escritura de dominio. `firefly/eda-postgres` es exactamente ese idioma, pero con la atomicidad garantizada por construcción (`PreCommitEventHook` se ejecuta mientras la transacción sigue abierta) en vez de dejada a la disciplina del desarrollador. + Laravel puro no tiene ningún patrón de outbox transaccional de primera parte en absoluto — el idioma que tendrías que construir a mano es una tabla `outbox` más un comando planificado que la sondea, sin ninguna ayuda del framework para mantener el `INSERT` dentro de la misma transacción que tu escritura de dominio. `firefly/eda-database` es exactamente ese idioma, en cualquier base de datos que hable Laravel, pero con la atomicidad garantizada por construcción (`PreCommitEventHook` se ejecuta mientras la transacción sigue abierta) en vez de dejada a la disciplina del desarrollador. + +--- + +## Cartas muertas que sobreviven al proceso + +El modelo de reintento del principio de este capítulo termina en una carta muerta, y por defecto esa carta muerta vive en `InMemoryDeadLetterStore` — desaparece cuando el proceso termina. Eso vale para una prueba y está mal para un worker, así que `firefly.eda.dead-letter.store` elige el almacén bajo cualquier proveedor: + + +```php +'dead-letter' => [ + 'store' => env('FIREFLY_EDA_DEAD_LETTER_STORE', 'memory'), + // 'connection' => 'mysql', +], +``` + +`database` escribe una fila de `firefly_eda_dead_letters` por cada envoltorio agotado, en `firefly.eda.dead-letter.connection` (la conexión por defecto si no se fija). Los dos almacenes implementan `ReplayableDeadLetterStore`, que es lo que necesita el comando de repetición: republica las entradas, de la más antigua a la más reciente, a través del `EventPublisher` vinculado hacia su destino original, y elimina cada una que se republicó: + + +```bash +php artisan firefly:eda:dead-letters:replay --event-type=FundsDeposited --dry-run +php artisan firefly:eda:dead-letters:replay --event-type=FundsDeposited --limit=100 +``` + +`--id`, `--destination` y `--keep` acotan y conservan; `--dry-run` lista y no cambia nada. `firefly/messaging` tiene el mismo par — `firefly.messaging.dead-letter.store=memory|database` y `php artisan firefly:messaging:dead-letters:replay` — para su `MessageBrokerPort` en bruto. Una fila del outbox que agota `max-attempts` es distinta: se marca como `FAILED` en su sitio, porque la fila *es* la carta muerta del outbox, y las dos pruebas corren en cada servidor de la matriz (consulta la matriz de soporte de bases de datos en la documentación). + +--- + +## Mensajería sobre Kafka y RabbitMQ + +`firefly/messaging` es el hermano de más bajo nivel del bus de eventos: cargas `Message` en bruto sobre un `MessageBrokerPort`, consumidas por métodos `#[MessageListener]`. `firefly.messaging.provider=kafka` (con `ext-rdkafka`) y `=rabbitmq` vinculan un contenedor de listeners sobre las mismas factorías de clientes que usan los adaptadores del bus de eventos, y un worker consume de él: + + +```bash +php artisan firefly:messaging:consume --max-messages=1000 --time-limit=3600 +``` + +El contenedor es el dueño de los reintentos: una entrega se intenta `firefly.messaging.listener.retry.max-attempts` veces (cinco por defecto) con un retroceso exponencial desde un segundo, duplicándose, con tope en treinta; un listener con `retries: N` obtiene N + 1 intentos con su propio `retryDelay` lineal en su lugar. Después, una carta muerta — Kafka produce el registro en el `deadLetterTopic` del listener o en `.DLT`, RabbitMQ lo republica en `deadLetterTopic` o deja que el exchange de cartas muertas de la cola lo mueva a `.dlq` — que lleva `x-original-topic`, `x-exception`, `x-exception-message` y `x-delivery-attempts`. Un registro se confirma, o un mensaje se reconoce, solo después de que sus listeners lo manejaran o de que pasara a carta muerta, así que la entrega es al-menos-una-vez; los grupos de consumidores son reales (un grupo de consumidores de Kafka por grupo de listeners, una cola duradera de RabbitMQ por grupo). Con `memory` y `queue` el comando se niega con el código de salida 1, porque esos proveedores no tienen bucle de consumo. --- @@ -587,8 +705,12 @@ Esta es una ventana de estado `PENDING`→`PUBLISHED` **duradera**, no una marca | `RetryingEventHandler` | Envuelve siempre a todo listener: reintento con retroceso lineal, luego carta muerta (o relanzamiento sin almacén vinculado) | | `InMemoryEventBus` / `QueueEventBus` | Adaptadores `EventPublisher` síncrono frente a respaldado-por-cola (disparar-y-olvidar), puerto idéntico | | `DomainEventBridge` | El disparador post-confirmación que republica un `DomainEvent` confirmado como evento de integración — de mejor esfuerzo, no atómico | -| `OutboxPreCommitHook` / `PostgresEventPublisher` | Escribe la fila del outbox **dentro** de la propia transacción abierta del agregado — genuinamente atómico | +| `OutboxPreCommitHook` / `DatabaseEventPublisher` | Escribe la fila del outbox **dentro** de la propia transacción abierta del agregado — genuinamente atómico, en cualquiera de las cinco bases de datos; `PostgresEventPublisher` añade `pg_notify` | | `DatabaseEventConsumer` / `PostgresEventConsumer` | Arrienda filas `PENDING` (SKIP LOCKED, READPAST o un UPDATE condicional — cualquier número de workers), conduce directamente los manejadores de `#[EventListener]`, sin `INSERT` de vuelta; Postgres añade `LISTEN`/`NOTIFY` | +| `OutboxClaimStrategy` | `skip-locked`, `read-past` u `optimistic-lease`, elegida por `auto` a partir de la versión del servidor; todas arriendan una fila a exactamente un reclamante | +| `firefly:outbox:relay` | Opcional: reenvía las filas confirmadas a un segundo broker (`firefly.eda.database.relay.downstream-provider`) | +| `firefly.eda.dead-letter.store=database` | Cartas muertas en `firefly_eda_dead_letters`, reproducidas por `firefly:eda:dead-letters:replay` (la mensajería tiene el mismo par) | +| `firefly:messaging:consume` | El worker del contenedor de listeners de Kafka/RabbitMQ: reintentos del contenedor, topics o colas de cartas muertas, confirmaciones al-menos-una-vez | --- @@ -596,4 +718,4 @@ Esta es una ventana de estado `PENDING`→`PUBLISHED` **duradera**, no una marca 1. **Reproduce el tropiezo, deliberadamente.** En una copia de trabajo del proyecto, cambia los patrones de `#[EventListener]` de `LedgerProjector` a `['wallet.*']` y vuelve a ejecutar su suite de pruebas. Confirma que el proyector jamás se dispara en silencio y que nunca se escribe ninguna fila en `ledger_entries` — luego restaura los patrones originales de nombre de tipo y confirma que vuelven a pasar. 2. **Fuerza una carta muerta.** Escribe un pequeño manejador `#[EventListener]` que siempre lance una excepción, configura `firefly.eda.retries=2` y vincula un `InMemoryDeadLetterStore`, publica un evento que coincida, e inspecciona `DeadLetterStore::all()` después. Confirma que el `exceptionMessage` de la entrada coincide con lo que lanzó tu manejador. -3. **Demuestra tú mismo el rollback del outbox.** Usando una conexión Postgres (`@group('integration')`, según las propias convenciones de prueba del framework), reproduce los dos casos de `OutboxSameTransactionTest` que citó este capítulo — publicar-y-luego-confirmar y publicar-y-luego-lanzar — y confirma que la presencia o ausencia de la fila coincide con la descripción de este capítulo. +3. **Demuestra el outbox en un servidor real.** Exporta el DSN de un carril (el Capítulo 12 enseña cómo) y ejecuta `composer test:integration -- samples/lumen/tests/Integration/OutboxDatabaseRoundTripTest.php`. Confirma que las dos filas están en `PENDING` antes de que corra el consumidor y en `PUBLISHED` después, y lee luego `OutboxSameTransactionTest` para la mitad del rollback: la fila de una unidad de trabajo fallida no llega a existir. diff --git a/book/src/08-eda-outbox.md b/book/src/08-eda-outbox.md index f36649b1..1d98c938 100644 --- a/book/src/08-eda-outbox.md +++ b/book/src/08-eda-outbox.md @@ -2,7 +2,7 @@ # Event-Driven Architecture and the Transactional Outbox {.chtitle} -By the end of this chapter you will know the two, deliberately distinct event surfaces LaraFly ships and why confusing them is the single most common mistake; the `EventPublisher` port and the `EventEnvelope` it carries; how `#[EventListener]` matches an event's **type name** — not its destination — a real gotcha the `samples/lumen` build hit while wiring `LedgerProjector` in Chapter 6; the in-memory and queue adapters; the retry/dead-letter model every listener is wrapped in; and — closing the durability gap the domain→integration bridge leaves open — the genuine **same-transaction outbox** `firefly/eda-postgres` writes atomically with the aggregate that raised the event. +By the end of this chapter you will know the two, deliberately distinct event surfaces LaraFly ships and why confusing them is the single most common mistake; the `EventPublisher` port and the `EventEnvelope` it carries; how `#[EventListener]` matches an event's **type name** — not its destination — a real gotcha the `samples/lumen` build hit while wiring `LedgerProjector` in Chapter 6; the in-memory and queue adapters; the retry/dead-letter model every listener is wrapped in; and — closing the durability gap the domain→integration bridge leaves open — the genuine **same-transaction outbox** `firefly/eda-database` writes atomically with the aggregate that raised the event, on any of the five databases; how its consumers lease rows so that any number of them can run at once; the optional `firefly:outbox:relay`; what `firefly/eda-postgres` adds on top; where a dead letter goes when it must outlive the process (`firefly:eda:dead-letters:replay`); and how `firefly/messaging` consumes from Kafka and RabbitMQ (`firefly:messaging:consume`). !!! note "New term: integration event" Chapter 6 built **domain events** — `WalletOpened`, `FundsDeposited` — facts an aggregate raises for consumers inside the *same* process and the *same* deployable. An **integration event** is the same fact re-emitted across a process or service boundary: a JSON-ish envelope with a type name, a payload, and routing headers, carried by a broker (or, in-process, by the very same bus). This chapter is about the machinery on the far side of that boundary, and the bridge that gets a domain event onto it. @@ -18,7 +18,7 @@ LaraFly ships two unrelated event mechanisms that happen to share a similar-look | In-process application events | `#[AsEventListener]` | a PHP event **class** | synchronous, same process, no broker | `firefly/context` | | EDA broker bus | `#[EventListener]` | an event-type **pattern** (fnmatch glob, e.g. `'user.*'`) | in-memory (sync) or queue (async) | `firefly/eda` | -`#[AsEventListener]` fires when application code calls `ApplicationEventPublisher::publish(object $event)` with a typed PHP object — the seam Chapter 6's after-commit dispatch itself rides on (`DomainEventDispatcher` calls exactly this to publish a drained `DomainEvent`). `#[EventListener]`, this chapter's subject, fires when an `EventEnvelope` whose `eventType` string matches your pattern arrives on the **eda** bus — in-memory, via a queue worker, or (this chapter's second half) via a Postgres outbox row. Neither package depends on the other; nothing in `firefly/eda` even imports Illuminate's `Dispatcher`. +`#[AsEventListener]` fires when application code calls `ApplicationEventPublisher::publish(object $event)` with a typed PHP object — the seam Chapter 6's after-commit dispatch itself rides on (`DomainEventDispatcher` calls exactly this to publish a drained `DomainEvent`). `#[EventListener]`, this chapter's subject, fires when an `EventEnvelope` whose `eventType` string matches your pattern arrives on the **eda** bus — in-memory, via a queue worker, or (this chapter's second half) via an outbox row. Neither package depends on the other; nothing in `firefly/eda` even imports Illuminate's `Dispatcher`. --- @@ -64,7 +64,7 @@ final readonly class EventEnvelope } ``` -`eventType` and `destination` are two genuinely different things, and the rest of this chapter hinges on that distinction: `destination` is *where* the envelope is routed on the wire (a queue name, a Postgres outbox row's `destination` column); `eventType` is *what fact it carries*, and it is the **only** field a `#[EventListener]` pattern is ever matched against. +`eventType` and `destination` are two genuinely different things, and the rest of this chapter hinges on that distinction: `destination` is *where* the envelope is routed on the wire (a queue name, an outbox row's `destination` column); `eventType` is *what fact it carries*, and it is the **only** field a `#[EventListener]` pattern is ever matched against. `withHeaders(array $extra): self` returns a copy with merged headers — used internally to stamp `x-correlation-id`, and (you'll meet this again below) `x-original-topic`/`x-exception` on a dead-lettered envelope. `start()`/`stop()` are no-ops on the in-memory and queue adapters; the outbox's own consumer (later in this chapter) is where they start doing real work. @@ -374,12 +374,51 @@ When no `firefly/eda` `EventPublisher` is bound at all, `CqrsAutoConfiguration` --- -## The genuine same-transaction outbox (`firefly/eda-postgres`) +## The genuine same-transaction outbox (`firefly/eda-database`) -`firefly/eda-postgres` closes the gap by writing the outbox row **inside** the aggregate's own open transaction, on the aggregate's own connection — so it commits or rolls back atomically with the aggregate, with no dual-write and nothing to "hope" about after the fact. +`firefly/eda-database` closes the gap by writing the outbox row **inside** the aggregate's own open transaction, on the aggregate's own connection — so it commits or rolls back atomically with the aggregate, with no dual-write and nothing to "hope" about after the fact — and it does so on PostgreSQL, MySQL, MariaDB, SQL Server and SQLite alike. `firefly/eda-postgres` is the same outbox with two PostgreSQL optimizations, and the end of this section shows exactly what they are. ::: figure art/figures/outbox-flow.svg | Figure 8.2 — The same-transaction outbox: the aggregate's own commit and the outbox INSERT are one atomic unit; a consumer claims PENDING rows independently. +### Switching it on + +The outbox is one setting and one table. `firefly.eda.provider=database` selects it; the reference configuration ships its block commented out at the defaults: + + +```php +// 'database' => [ +// 'connection' => 'mysql', +// 'channel' => 'firefly_eda_events', +// 'max-attempts' => 3, +// 'claim-strategy' => env('FIREFLY_EDA_CLAIM_STRATEGY', 'auto'), +// 'lease-seconds' => 300, +// 'poll-interval-ms' => 500, +// 'relay' => [ +// 'downstream-provider' => 'rabbitmq', +// ], +// ], +``` + + +```bash +php artisan migrate # creates firefly_eda_outbox (and adds the lease columns to an older one) +php artisan firefly:eda:consume # the terminal in-process delivery — run one or more as long-lived workers +``` + +Nothing in Lumen's code changes. The sample's `OutboxDatabaseTestCase` adds `firefly/eda-database`'s provider and sets the key, and the same `OpenWallet` and `Deposit` commands now leave their events in the table until a consumer comes for them: + + +```php + // Committed with the wallet and waiting for a consumer: the projector has not run yet. + expect(DB::table(OutboxSchema::TABLE)->where('status', OutboxSchema::STATUS_PENDING)->orderBy('id')->pluck('event_type')->all()) + ->toBe(['WalletOpened', 'FundsDeposited']) + ->and(LedgerEntry::query()->where('wallet_id', $id)->count())->toBe(0); + + $exit = Artisan::call('firefly:eda:consume', ['--max-messages' => 2, '--time-limit' => 10, '--poll-timeout' => 100]); +``` + +That is the trade the outbox makes, stated as two assertions: the events are durable the instant the wallet commits, and `LedgerProjector` runs a moment later, in a worker, instead of inside the request. The rest of this section is how both halves are guaranteed. + ### The seam: `PreCommitEventHook` Chapter 6 showed `DomainEventDispatcher::dispatchAfterCommit()` draining the `AggregateTracker` and scheduling each event via `DB::afterCommit()`. It carries one more, optional collaborator — a hook that runs **before** that scheduling, while the transaction is still open: @@ -572,8 +611,87 @@ Under `provider=postgres`, `PostgresEventConsumer` is this class plus Postgres's This is a **durable** `PENDING`→`PUBLISHED` status window, not an in-memory high-water mark: a restarted consumer resumes at the oldest claimable row and never replays a `PUBLISHED` one. Any number of consumer workers may run at once, on any of the five databases — each row is delivered once while its consumer lives within its lease (`firefly.eda.database.lease-seconds`, 300 by default); a consumer that crashes mid-handler leaves its row to be claimed again when the lease ends, so across a crash delivery is at-least-once and listeners should still be idempotent. +### How a claim is chosen, server by server + +`claimOne()` never hard-codes its SQL. An `OutboxClaimStrategy` does the picking, and `firefly.eda.database.claim-strategy=auto` (the default) chooses one from what the server reports about itself — `DatabaseCapabilities` in `firefly/data` reads the version, never the driver name, so a `mysql` connection whose server is MariaDB is recognized as MariaDB: + +| Strategy | How it picks a row | `auto` chooses it on | +|---|---|---| +| `skip-locked` | `SELECT … FOR UPDATE SKIP LOCKED` in a short claim transaction (MySQL and MariaDB run it `READ COMMITTED`, so publishers never wait on its gap locks) | PostgreSQL; MySQL 8.0.1+; MariaDB 10.6+ | +| `read-past` | `SELECT TOP n … WITH (UPDLOCK, READPAST, ROWLOCK)` in a short claim transaction | SQL Server | +| `optimistic-lease` | an unlocked read, then one conditional `UPDATE … WHERE status = 'PENDING' AND (claimed_until IS NULL OR claimed_until < now)` per row; the row is won when exactly one row changed | SQLite; any server the probe does not recognise; older MySQL and MariaDB | + +A strategy forced by name that the server cannot run fails at the first claim with an error naming the server and its version — it is never downgraded in silence — and the choice is made at that first claim, so booting an application opens no connection to find out. Lease instants are the application's clock in UTC at millisecond precision, so keep the nodes' clocks synchronized well within `firefly.eda.database.lease-seconds`. + +### Any number of consumers + +Because every strategy ends with the row leased to exactly one claimant, scaling delivery is starting another `firefly:eda:consume`. Each row is delivered once while its worker lives within its lease; a worker that dies mid-delivery leaves its row leased until the lease ends, and then another worker delivers it — across a crash, delivery is at least once. `packages/eda-database/tests/Integration/OutboxConcurrencyIntegrationTest.php` races two processes over one outbox on every server, and the sample runs its own round trip on each of them, one dataset entry per lane: + + +```php + $exit = Artisan::call('firefly:eda:consume', ['--max-messages' => 2, '--time-limit' => 20, '--poll-timeout' => 500]); + + expect($exit)->toBe(0) + ->and(DB::table(OutboxSchema::TABLE)->where('status', OutboxSchema::STATUS_PENDING)->count())->toBe(0) + ->and(LedgerEntry::query()->where('wallet_id', $id)->orderBy('id')->pluck('event_type')->all()) + ->toBe(['WalletOpened', 'FundsDeposited']); +})->with(IntegrationDsn::lanes())->group('integration'); +``` + +Under `provider=database` the actuator's health report gains an `outbox` component — the driver, the strategy in use and the `PENDING` backlog, or `DOWN` with the error — so a consumer fleet that has stopped keeping up is visible before anyone asks why the ledger is late. + +### Forwarding to a broker: `firefly:outbox:relay` + +`firefly:eda:consume` delivers rows to this application's own listeners. When other services must hear the events too, the relay forwards committed rows to a second broker and marks each one `PUBLISHED` — the same lease, the same `max-attempts`, the trace each row carries continued across the hop. `firefly.eda.database.relay.downstream-provider` names the broker (`rabbitmq`, `kafka`, an `EventPublisher` class-string or a container id), and the relay refuses the outbox writer itself as its downstream: + + +```bash +php artisan firefly:outbox:relay --batch-size=50 --sleep=1 +``` + +### What `firefly/eda-postgres` adds + +Everything above works on PostgreSQL as it is. `firefly.eda.provider=postgres` is that same outbox — the same table, the same claims, the same consumer — plus the two things only PostgreSQL offers: `PostgresEventPublisher` raises `pg_notify` inside the writing transaction, and `PostgresEventConsumer` `LISTEN`s and waits on the notification instead of sleeping `poll-interval-ms` between empty claims. Every `firefly.eda.postgres.*` key keeps working as an alias of its `firefly.eda.database.*` twin, and the database key wins when both are set, so moving between the two providers loses no setting. + !!! laravel "Laravel parity" - Plain Laravel has no first-party transactional-outbox pattern at all — the idiom you'd hand-roll is an `outbox` table plus a scheduled command that polls it, with no framework help keeping the `INSERT` inside the same transaction as your domain write. `firefly/eda-postgres` is exactly that idiom, but with the atomicity guaranteed by construction (`PreCommitEventHook` runs while the transaction is still open) rather than left to developer discipline. + Plain Laravel has no first-party transactional-outbox pattern at all — the idiom you'd hand-roll is an `outbox` table plus a scheduled command that polls it, with no framework help keeping the `INSERT` inside the same transaction as your domain write. `firefly/eda-database` is exactly that idiom, on any database Laravel speaks, but with the atomicity guaranteed by construction (`PreCommitEventHook` runs while the transaction is still open) rather than left to developer discipline. + +--- + +## Dead letters that survive the process + +The retry model at the start of this chapter ends with a dead letter, and by default that dead letter lives in `InMemoryDeadLetterStore` — gone when the process exits. That is fine for a test and wrong for a worker, so `firefly.eda.dead-letter.store` picks the store under every provider: + + +```php +'dead-letter' => [ + 'store' => env('FIREFLY_EDA_DEAD_LETTER_STORE', 'memory'), + // 'connection' => 'mysql', +], +``` + +`database` writes one `firefly_eda_dead_letters` row per exhausted envelope, on `firefly.eda.dead-letter.connection` (the default connection when unset). Both stores implement `ReplayableDeadLetterStore`, which is what the replay command needs: it republishes entries, oldest first, through the bound `EventPublisher` to their original destination, and removes each one that republished: + + +```bash +php artisan firefly:eda:dead-letters:replay --event-type=FundsDeposited --dry-run +php artisan firefly:eda:dead-letters:replay --event-type=FundsDeposited --limit=100 +``` + +`--id`, `--destination` and `--keep` narrow and preserve; `--dry-run` lists and changes nothing. `firefly/messaging` has the same pair — `firefly.messaging.dead-letter.store=memory|database` and `php artisan firefly:messaging:dead-letters:replay` — for its raw `MessageBrokerPort`. An outbox row that exhausts `max-attempts` is different: it is marked `FAILED` in place, because the row *is* the outbox's dead letter, and both proofs are on every server in the matrix (see the database support matrix in the documentation). + +--- + +## Messaging on Kafka and RabbitMQ + +`firefly/messaging` is the lower-level sibling of the event bus: raw `Message` payloads on a `MessageBrokerPort`, consumed by `#[MessageListener]` methods. `firefly.messaging.provider=kafka` (with `ext-rdkafka`) and `=rabbitmq` bind a listener container on the same client factories the event bus's adapters use, and one worker consumes from it: + + +```bash +php artisan firefly:messaging:consume --max-messages=1000 --time-limit=3600 +``` + +The container owns retries: a delivery is attempted `firefly.messaging.listener.retry.max-attempts` times (five by default) with exponential back-off from one second, doubling, capped at thirty; a listener with `retries: N` gets N + 1 attempts with its own linear `retryDelay` instead. Then a dead letter — Kafka produces the record to the listener's `deadLetterTopic` or `.DLT`, RabbitMQ republishes it to `deadLetterTopic` or lets the queue's dead-letter exchange move it to `.dlq` — carrying `x-original-topic`, `x-exception`, `x-exception-message` and `x-delivery-attempts`. A record is committed, or a message acknowledged, only after its listeners handled it or it was dead-lettered, so delivery is at least once; consumer groups are real (one Kafka consumer group per listener group, one durable RabbitMQ queue per group). On `memory` and `queue` the command refuses with exit code 1, because those providers have no consumer loop. --- @@ -587,8 +705,12 @@ This is a **durable** `PENDING`→`PUBLISHED` status window, not an in-memory hi | `RetryingEventHandler` | Always wraps every listener: linear-backoff retry, then dead-letter (or re-throw with no store bound) | | `InMemoryEventBus` / `QueueEventBus` | Synchronous vs. queue-backed (fire-and-forget) `EventPublisher` adapters, identical port | | `DomainEventBridge` | The after-commit trigger republishing a committed `DomainEvent` as an integration event — best-effort, non-atomic | -| `OutboxPreCommitHook` / `PostgresEventPublisher` | Writes the outbox row **inside** the aggregate's own open transaction — genuinely atomic | +| `OutboxPreCommitHook` / `DatabaseEventPublisher` | Writes the outbox row **inside** the aggregate's own open transaction — genuinely atomic, on any of the five databases; `PostgresEventPublisher` adds `pg_notify` | | `DatabaseEventConsumer` / `PostgresEventConsumer` | Leases `PENDING` rows (SKIP LOCKED, READPAST or a conditional UPDATE — any number of workers), drives `#[EventListener]` handlers directly, no re-`INSERT`; Postgres adds `LISTEN`/`NOTIFY` | +| `OutboxClaimStrategy` | `skip-locked`, `read-past` or `optimistic-lease`, chosen by `auto` from the server's version; every one leases a row to exactly one claimant | +| `firefly:outbox:relay` | Optional: forwards committed rows to a second broker (`firefly.eda.database.relay.downstream-provider`) | +| `firefly.eda.dead-letter.store=database` | Dead letters in `firefly_eda_dead_letters`, replayed by `firefly:eda:dead-letters:replay` (messaging has the same pair) | +| `firefly:messaging:consume` | The Kafka/RabbitMQ listener-container worker: container retries, dead-letter topics or queues, at-least-once commits | --- @@ -596,4 +718,4 @@ This is a **durable** `PENDING`→`PUBLISHED` status window, not an in-memory hi 1. **Reproduce the gotcha, deliberately.** In a scratch copy of the project, change `LedgerProjector`'s `#[EventListener]` patterns to `['wallet.*']` and re-run its test suite. Confirm the projector silently never fires and no `ledger_entries` rows are ever written — then restore the original type-name patterns and confirm they pass again. 2. **Force a dead letter.** Write a small `#[EventListener]` handler that always throws, configure `firefly.eda.retries=2` and bind an `InMemoryDeadLetterStore`, publish a matching event, and inspect `DeadLetterStore::all()` afterward. Confirm the entry's `exceptionMessage` matches what your handler threw. -3. **Prove the outbox rollback yourself.** Using a Postgres connection (`@group('integration')`, per the framework's own test conventions), reproduce the two `OutboxSameTransactionTest` cases this chapter quoted — publish-then-commit and publish-then-throw — and confirm the row's presence or absence matches this chapter's description. +3. **Prove the outbox on a real server.** Export one lane's DSN (Chapter 12 shows how) and run `composer test:integration -- samples/lumen/tests/Integration/OutboxDatabaseRoundTripTest.php`. Confirm the two rows are `PENDING` before the consumer runs and `PUBLISHED` after it, then read `OutboxSameTransactionTest` for the rollback half: the row from a failed unit of work never exists at all. From 064af3714a67908057678d7fe6e652d92adee859 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 08:41:29 -0700 Subject: [PATCH 12/19] docs(book): add Chapter 8A, webhooks in and callbacks out on Lumen, in both editions --- README.md | 8 +- book/README.md | 6 +- book/book.es.yaml | 7 + book/book.yaml | 7 + book/src-es/08a-webhooks-callbacks.md | 294 ++++++++++++++++++++++++++ book/src/08a-webhooks-callbacks.md | 294 ++++++++++++++++++++++++++ docs/book.md | 2 +- docs/index.md | 2 +- 8 files changed, 611 insertions(+), 9 deletions(-) create mode 100644 book/src-es/08a-webhooks-callbacks.md create mode 100644 book/src/08a-webhooks-callbacks.md diff --git a/README.md b/README.md index 29675516..82f44053 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, **sixteen chapters** +The book is **structurally complete and bilingual (English + Spanish)**: a quick start, **seventeen chapters** across four parts — Foundations (DI, config, HTTP), Modelling & Persisting the Domain (repositories, DDD), -Coordinating & Securing the App (CQRS, EDA + transactional outbox, `#[Transactional]`, security, OAuth2 and +Coordinating & Securing the App (CQRS, EDA + transactional outbox, webhooks and callbacks, `#[Transactional]`, 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 @@ -1133,7 +1133,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 (16 chapters + appendices, PDF + EPUB). +- [*larafly by example*](book/README.md) — the complete bilingual book (17 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`. @@ -1170,7 +1170,7 @@ accurately: today via a plain `#[EventListener]`; a dedicated `firefly/eventsourcing`-style package for event sourcing/snapshots/projections is future work, as it is in PyFly. - **Documentation.** The end-to-end [tutorial](docs/tutorial.md) (EN + ES), the *larafly by example* - [book](book/README.md) (16 chapters + appendices, EN + ES, PDF + EPUB), and a + [book](book/README.md) (17 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 94216212..9a51880d 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 sixteen chapters, 4A, 10A and 13A included (Parts I-IV) + 01..13a-*.md # the seventeen chapters, 4A, 8A, 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, sixteen chapters across four +matter, a "Build Lumen step by step" quick start, seventeen chapters across four parts — - **Part I — Foundations**: Why LaraFly, Dependency Injection & Auto-Configuration, @@ -147,7 +147,7 @@ parts — - **Part II — Modelling & Persisting the Domain**: Persistence & Repositories, Domain-Driven Design - **Part III — Coordinating & Securing the Application**: CQRS, Event-Driven - Architecture & the Transactional Outbox, Transactions & the `#[Transactional]` + Architecture & the Transactional Outbox, Webhooks and Callbacks, Transactions & the `#[Transactional]` proxy, Security, OAuth2 and OpenID Connect - **Part IV — Observability, Testing & Delivery**: Observability/Actuator, Testing, the CLI & the Zero-Reflection Cache, Feature Flags diff --git a/book/book.es.yaml b/book/book.es.yaml index 84f54c33..e181e0dd 100644 --- a/book/book.es.yaml +++ b/book/book.es.yaml @@ -49,6 +49,12 @@ labels: # manuscritos y de book/README.md. Un capítulo y no dos porque los dos paquetes son las dos direcciones de un # mismo protocolo, y quien instale cualquiera de ellos necesita el viaje completo delante para saber cuál tiene # entre manos. +# Tarea D1: firefly/webhooks y firefly/callbacks añadidos como Capítulo 8A, justo después del capítulo de eventos en +# cuyo bus terminan los dos, y firefly/orchestration como Capítulo 9A, justo después del capítulo de transacciones cuyo +# límite (una transacción) existe para superar. Intercalados igual que 4A y 10A: `num` es una etiqueta libre y el orden +# 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`. front: - {id: title, file: 00-front/00-title.md, nav: false} - {id: copyright, file: 00-front/00-copyright.md, nav: false} @@ -74,6 +80,7 @@ parts: chapters: - {id: ch07, file: 07-cqrs.md, num: 7, title: "CQRS: Comandos y Consultas"} - {id: ch08, file: 08-eda-outbox.md, num: 8, title: "Arquitectura Guiada por Eventos y el Outbox Transaccional"} + - {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: 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"} diff --git a/book/book.yaml b/book/book.yaml index bddb8243..db4129cd 100644 --- a/book/book.yaml +++ b/book/book.yaml @@ -47,6 +47,12 @@ labels: # renumbering Chapters 11-13 would have invalidated every "Chapter N" cross-reference in both manuscripts and in # book/README.md. One chapter rather than two because the two packages are the two directions of one protocol, # and a reader who installs either needs the round trip in front of them to know which one they are holding. +# Task D1: firefly/webhooks and firefly/callbacks added as Chapter 8A, immediately after the event-driven chapter whose +# bus both of them end on, and firefly/orchestration as Chapter 9A, immediately after the transactions chapter whose +# limit (one transaction) it exists to go past. Slotted exactly the way 4A and 10A were: `num` is a free-form label and +# 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. front: - {id: title, file: 00-front/00-title.md, nav: false} - {id: copyright, file: 00-front/00-copyright.md, nav: false} @@ -72,6 +78,7 @@ parts: chapters: - {id: ch07, file: 07-cqrs.md, num: 7, title: "CQRS: Commands and Queries"} - {id: ch08, file: 08-eda-outbox.md, num: 8, title: "Event-Driven Architecture and the Transactional Outbox"} + - {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: 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"} diff --git a/book/src-es/08a-webhooks-callbacks.md b/book/src-es/08a-webhooks-callbacks.md new file mode 100644 index 00000000..57341252 --- /dev/null +++ b/book/src-es/08a-webhooks-callbacks.md @@ -0,0 +1,294 @@ +Parte III — Coordinar y Asegurar la Aplicación · Capítulo 8A + +# Webhooks y Callbacks: Recibir y Enviar Eventos Firmados {.chtitle} + +Al terminar este capítulo sabrás cómo una aplicación LaraFly recibe un webhook y cómo lo envía, y por qué las dos direcciones terminan en el bus de eventos que construyó el Capítulo 8. A la entrada, `firefly/webhooks` da a cada proveedor configurado un endpoint, `POST /webhooks/{provider}`, que rechaza un cuerpo demasiado grande, verifica la firma (`hmac`, `stripe`, `github`, `twilio` o `none`), somete una marca de tiempo firmada a una ventana de repetición, responde a una reentrega como `duplicate` y publica lo que acepta como `webhook.{provider}.{type}` — así que la recarga con tarjeta de Lumen es un `#[EventListener]` corriente. A la salida, `firefly/callbacks` envía por POST cada evento del bus al que se suscribió un socio, firmado con `X-Firefly-Timestamp` y `X-Firefly-Signature`, reintentado con espera creciente tras un circuit breaker por endpoint, y anotado en un registro de ejecuciones que puedes listar y reproducir con `firefly:callbacks:list` y `firefly:callbacks:replay`. + +!!! note "Término nuevo: webhook" + Un **webhook** es una petición HTTP que un sistema hace a otro para decirle que algo ha ocurrido — un pago con tarjeta se ha completado, se ha fusionado una pull request. El receptor no lo pidió en ese momento; registró una URL una vez, y el emisor la llama. De ahí se siguen dos cosas, y este capítulo trata sobre todo de ellas: el receptor tiene que demostrar que la petición viene de verdad del emisor (una firma sobre los bytes exactos), y tiene que contar con recibir el mismo evento más de una vez (los emisores reintentan hasta que ven un 2xx). + +--- + +## Dos paquetes, un bus + +`firefly/webhooks` es la mitad de entrada y `firefly/callbacks` la de salida, y ninguno sabe del otro. Lo que comparten es el bus: un webhook aceptado se convierte en un `EventEnvelope` publicado a través del puerto `EventPublisher`, y un callback se dispara cuando llega un envoltorio a él. Todo lo que el Capítulo 8 dijo de ese bus — la comparación por `eventType`, los reintentos, las cartas muertas, el outbox, los brokers — se aplica por tanto a las dos mitades sin una línea de código adicional. Los dos están apagados hasta que se fija su clave maestra, `firefly.webhooks.enabled` y `firefly.callbacks.enabled`, y los dos vienen en la biblioteca raíz. + +--- + +## Recibir: un endpoint firmado por proveedor + +La configuración de referencia muestra el bloque de entrada con cada clave opcional comentada en su valor por defecto: + + +```php +'webhooks' => [ + 'enabled' => env('FIREFLY_WEBHOOKS_ENABLED', false), + + // 'path' => 'webhooks', + // 'max-body-bytes' => 1048576, + // 'destination' => 'webhooks.{provider}', + // 'event-type' => 'webhook.{provider}.{type}', + // 'replay' => ['tolerance' => '5m'], + // 'idempotency' => [ + // 'store' => env('FIREFLY_WEBHOOKS_IDEMPOTENCY_STORE', 'cache'), + // 'ttl' => '24h', + // 'cache-store' => '', + // 'connection' => null, + // ], + // 'providers' => [ + // 'stripe' => ['validator' => 'stripe', 'secret' => env('STRIPE_WEBHOOK_SECRET')], + // 'github' => ['validator' => 'github', 'secret' => env('GITHUB_WEBHOOK_SECRET')], + // 'twilio' => ['validator' => 'twilio', 'secret' => env('TWILIO_AUTH_TOKEN'), 'public-url' => 'https://api.example.com/webhooks/twilio'], + // 'partner' => ['secret' => env('PARTNER_WEBHOOK_SECRET'), 'timestamp-header' => 'X-Firefly-Timestamp', 'signature-header' => 'X-Firefly-Signature', 'idempotency-header' => 'X-Firefly-Delivery'], + // ], +], +``` + +La clave del array bajo `providers` es el id del proveedor y el último segmento de su URL: `stripe` se responde en `/webhooks/stripe`. Un proveedor que firma y no tiene secreto impide el arranque y nombra la clave. Lumen tiene un proveedor, el procesador de tarjetas que recarga los monederos, y usa el validador `hmac` por defecto, así que toda la configuración es un secreto: + + +```php + 'firefly.webhooks.enabled' => true, + 'firefly.webhooks.idempotency.store' => 'memory', + 'firefly.webhooks.providers' => [ + 'card-processor' => ['secret' => self::SECRET], + ], +``` + +Cada validador sabe dónde pone su proveedor la firma, qué firma, qué campo nombra el evento y qué valor identifica una entrega: + +| `validator` | Firma | Protección frente a repeticiones | Clave de idempotencia | Tipo de evento | +|---|---|---|---|---| +| `hmac` (por defecto) | `X-Signature: sha256=`; con `timestamp-header` la carga firmada es `"."` | la marca de tiempo, cuando se fija `timestamp-header` | `X-Idempotency-Key` | campo `type` del cuerpo | +| `stripe` | `Stripe-Signature: t=…,v1=…` (vale cualquier `v1` mientras se rota un secreto) | `t`, dentro de `tolerance` | campo `id` del cuerpo | campo `type` del cuerpo | +| `github` | `X-Hub-Signature-256: sha256=…` | nada firmado — la clave de idempotencia | `X-GitHub-Delivery` | `X-GitHub-Event` | +| `twilio` | `X-Twilio-Signature`, HMAC-SHA1 en base64 sobre la URL y los parámetros del formulario ordenados | nada firmado — la clave de idempotencia | `I-Twilio-Idempotency-Token` | campo `EventType` del formulario | +| `none` | sin verificar — para un emisor en una red de confianza | — | `X-Idempotency-Key` | campo `type` del cuerpo | + +Cada columna es un valor por defecto que la entrada de un proveedor puede sobrescribir (`signature-header`, `signature-prefix`, `algorithm`, `encoding`, `timestamp-header`, `tolerance`, `idempotency-header`, `idempotency-field`, `event-type-header`, `event-type-field`, `destination`, `public-url`). La ruta se monta sin grupo de middleware — sin sesión y sin un token CSRF que un proveedor nunca podría enviar — y cuando `HttpSecurity` está encendido permites `webhooks/**`, porque la firma es la autenticación. + +--- + +## Qué responde el endpoint + +| Estado | Cuándo | +|---|---| +| 202 `accepted` | verificada y publicada | +| 200 `duplicate` | una entrega ya aceptada (la misma clave de idempotencia dentro de `idempotency.ttl`) | +| 400 `WEBHOOK_PAYLOAD_INVALID` | el cuerpo dice ser JSON y no es un objeto JSON | +| 401 `WEBHOOK_SIGNATURE_INVALID` | una firma ausente o incorrecta | +| 401 `WEBHOOK_TIMESTAMP_OUT_OF_TOLERANCE` | una marca de tiempo firmada fuera de la ventana de repetición | +| 404 `WEBHOOK_PROVIDER_NOT_FOUND` | no existe ese proveedor | +| 413 `WEBHOOK_PAYLOAD_TOO_LARGE` | por encima de `max-body-bytes`, rechazada antes de calcular ninguna firma | +| 503 `WEBHOOK_PUBLISH_FAILED` | verificada, pero el bus rechazó la publicación — la reclamación de idempotencia se libera, así que el reintento del proveedor se procesa | + +Todo rechazo es problem+json diga lo que diga la cabecera `Accept` del emisor, y todo 4xx nombra al proveedor en un miembro `provider`, así que un secreto mal configurado aparece en tus logs con el proveedor al lado. + +--- + +## La recarga con tarjeta de Lumen + +Una entrega aceptada se publica como `webhook.{provider}.{type}` en `webhooks.{provider}`, con el cuerpo decodificado bajo la clave `body` de la carga. Para el `top_up.succeeded` del procesador de tarjetas eso es `webhook.card-processor.top_up.succeeded`, y el código que reacciona a él es un listener como `LedgerProjector`: + + +```php +#[Component] +final class CardTopUpListener +{ + public function __construct(private readonly CommandBus $commands) {} + + #[EventListener('webhook.card-processor.top_up.succeeded')] + public function onTopUp(EventEnvelope $envelope): void + { + $body = $envelope->payload['body'] ?? null; + $data = is_array($body) ? ($body['data'] ?? null) : null; + $walletId = is_array($data) ? ($data['wallet_id'] ?? null) : null; + $amountMinor = is_array($data) ? ($data['amount_minor'] ?? null) : null; + + if (! is_string($walletId) || ! is_int($amountMinor) || $amountMinor <= 0) { + return; + } + + $this->commands->send(new Deposit($walletId, $amountMinor)); + } +} +``` + +El listener nunca comprueba una firma, una marca de tiempo ni un duplicado: cuando un envoltorio llega al bus, las tres cosas ya están resueltas. Lo que hace es el trabajo del dominio — un `Deposit`, por el mismo bus, el mismo handler `#[Transactional]` y los mismos eventos de dominio que un ingreso hecho por REST. La prueba del ejemplo envía la entrega dos veces, como lo haría un procesador que no vio el primer 202: + + +```php + $this->postWebhook('/webhooks/card-processor', $body, lumenTopUpHeaders($body, 'evt_1')) + ->assertStatus(202) + ->assertJson(['status' => 'accepted', 'provider' => 'card-processor', 'eventType' => 'webhook.card-processor.top_up.succeeded']); + + // The processor never saw the 202 and delivers the same event again: answered, not deposited twice. + $this->postWebhook('/webhooks/card-processor', $body, lumenTopUpHeaders($body, 'evt_1')) + ->assertStatus(200) + ->assertJson(['status' => 'duplicate']); + + $this->getJson("/api/v1/wallets/{$id}/balance")->assertOk()->assertJson(['balance_minor' => 2500]); +``` + +`postWebhook()` es el POST en crudo del arnés: una firma cubre bytes, así que un helper que volviera a codificar el JSON firmaría algo que el endpoint nunca recibe. La segunda prueba del fichero cambia un dígito del importe después de firmar y obtiene un 401 `WEBHOOK_SIGNATURE_INVALID` con el saldo intacto. + +!!! tip "Un listener que falla es una carta muerta, no un 5xx" + La tercera prueba envía una recarga verificada para un monedero que no existe. El `Deposit` lanza, el listener lanza — y el endpoint sigue respondiendo 202, porque en el bus en memoria todo listener corre dentro de `RetryingEventHandler`, y siempre hay un `DeadLetterStore` enlazado: el envoltorio se guarda ahí, no se pierde. Es la respuesta correcta para el procesador (su entrega era válida; reintentarla fallaría igual), y el sitio correcto donde dejar el problema: corrige la causa y luego `php artisan firefly:eda:dead-letters:replay --event-type=webhook.card-processor.top_up.succeeded` la entrega de nuevo. Con `firefly.eda.dead-letter.store=database` (Capítulo 8) la carta muerta sobrevive a un reinicio. + + +```php + $entries = $this->app()->make(DeadLetterStore::class)->all(); + + expect($entries)->toHaveCount(1) + ->and($entries[0]->envelope->eventType)->toBe('webhook.card-processor.top_up.succeeded') + ->and($entries[0]->exceptionMessage)->toContain('no-such-wallet'); +``` + +--- + +## Recordar una entrega: idempotencia + +`firefly.webhooks.idempotency.store` elige dónde se recuerdan los ids de las entregas aceptadas durante `idempotency.ttl` (24 horas por defecto). La reclamación es atómica, así que dos entregas simultáneas de un mismo webhook producen un 202 y un 200, nunca dos publicaciones: + +| Almacén | Respaldo | Notas | +|---|---|---| +| `cache` (por defecto) | el almacén de caché de Laravel que nombra `idempotency.cache-store`, `add()` con un TTL | atómico en los almacenes redis, memcached, database y array | +| `database` | `firefly_webhook_receipts` en `idempotency.connection` | tabla portable, clave primaria (provider, key), sensible a mayúsculas en todos los drivers; púrgala a diario | +| `memory` | el proceso | un solo proceso — las pruebas de Lumen lo usan porque una prueba es un proceso | + + +```bash +php artisan vendor:publish --tag=firefly-webhooks-migrations +php artisan migrate +php artisan firefly:webhooks:prune +``` + +Detrás de un proxy, fija el `public-url` del proveedor: una firma de Twilio cubre la URL exacta a la que llamó Twilio, y un balanceador que reescriba el host o el esquema haría parecer falsificadas todas las firmas. + +--- + +## Enviar: callbacks a los socios que los pidieron + +La mitad de salida parte del mismo fichero de referencia: + + +```php +'callbacks' => [ + 'enabled' => env('FIREFLY_CALLBACKS_ENABLED', false), + + // 'event-types' => ['order.*', 'invoice.paid'], + // 'registry' => ['store' => env('FIREFLY_CALLBACKS_REGISTRY', 'memory')], + // 'log' => ['store' => env('FIREFLY_CALLBACKS_LOG', 'memory')], + // 'connection' => null, + // 'subscriptions' => [ + // ['id' => 'partner-orders', 'url' => 'https://partner.example.com/hooks', 'event-types' => ['order.*'], 'secret' => env('PARTNER_CALLBACK_SECRET')], + // ], + // … +], +``` + +`event-types` es a lo que se suscribe el dispatcher en el bus — una suscripción por patrón, ninguna mientras la lista esté vacía — y cada suscripción lo estrecha después con sus propios patrones. El socio de Lumen, un servicio de conciliación, quiere enterarse de cada movimiento de dinero y de nada más: + + +```php + 'firefly.callbacks.enabled' => true, + 'firefly.callbacks.event-types' => ['Funds*'], + 'firefly.callbacks.retry.initial-delay' => 0, + 'firefly.callbacks.subscriptions' => [[ + 'id' => 'partner-ledger', + 'name' => 'partner ledger', + 'url' => 'https://partner.example.com/hooks/wallet', + 'event-types' => ['FundsDeposited', 'FundsWithdrawn'], + 'secret' => self::SECRET, + ]], +``` + +Los patrones comparan con el `eventType` del envoltorio, exactamente como hace `#[EventListener]`, y los eventos de dominio de Lumen llegan al bus con su nombre corto de clase — la lección que `LedgerProjector` enseñó en el Capítulo 8 se aplica sin cambios. Con `registry.store=database`, las suscripciones son filas de `firefly_callback_subscriptions`, gestionadas en tiempo de ejecución a través del puerto `SubscriptionRegistry` en lugar de la configuración. + +--- + +## Cómo es una entrega + +Una entrega es un `POST` JSON cuyo cuerpo queda fijado al crear la ejecución, así que cada intento y cada repetición envían los mismos bytes: + + +```json +{"id": "9f2c…", "eventId": "4b1e…", "eventType": "FundsDeposited", "occurredAt": "2026-10-13T10:00:00+00:00", "data": {"walletId": "w-1", "amountMinor": 700, "currency": "EUR", "balanceMinor": 700}} +``` + +Las cabeceras son `Content-Type: application/json`, `User-Agent: LaraFly-Callbacks`, `X-Firefly-Event`, `X-Firefly-Event-Id`, `X-Firefly-Delivery` (el id de la ejecución — la clave de idempotencia del receptor), las cabeceras propias de la suscripción y, cuando tiene secreto, `X-Firefly-Timestamp: ` y `X-Firefly-Signature: sha256=.")>`. La marca de tiempo está dentro de la firma, así que una entrega capturada no puede repetirse más tarde. La prueba de Lumen comprueba la firma como lo haría el socio, con el propio validador `hmac` del paquete de webhooks: + + +```php + // The partner's own check, as a LaraFly receiver on firefly/webhooks would run it: throws on a bad signature + // or a timestamp outside the five-minute window. + (new HmacSignatureValidator(LumenCallbacksTestCase::SECRET, 'X-Firefly-Signature', 'sha256=', 'sha256', 'hex', 'X-Firefly-Timestamp', 300.0)) + ->verify(new WebhookRequest('lumen', 'POST', $request->url(), $headers, $request->body(), [], 'application/json', time())); +``` + +Así que un socio LaraFly recibe un callback de LaraFly solo con configuración: + + +```php +// config/firefly.php +return [ + 'webhooks' => [ + 'enabled' => true, + 'providers' => [ + 'lumen' => [ + 'secret' => env('LUMEN_CALLBACK_SECRET'), + 'signature-header' => 'X-Firefly-Signature', + 'timestamp-header' => 'X-Firefly-Timestamp', + 'idempotency-header' => 'X-Firefly-Delivery', + 'event-type-header' => 'X-Firefly-Event', + ], + ], + ], +]; +``` + +--- + +## Reintentos, el circuit breaker y el registro de ejecuciones + +- **Qué cuenta como entregado.** Un 2xx. Un 408, 429, 5xx o error de transporte se intenta de nuevo tras `retry.initial-delay` × `retry.multiplier`^(n−1), con tope en `retry.max-delay`, hasta `retry.max-attempts` (gana el `max-attempts` propio de una suscripción). Cualquier otro estado es la respuesta definitiva del receptor: fallida, sin reintento. Las redirecciones no se siguen. +- **Un breaker por endpoint.** Cada `scheme://host:port` tiene un circuit breaker de `firefly/resilience`: tras `circuit-breaker.failure-threshold` fallos se abre durante `circuit-breaker.wait-duration-in-open`, y las entregas a ese endpoint fallan en el acto con `circuit open` en lugar de esperar a un socio caído. +- **Adónde pueden apuntar las URLs.** Solo https salvo que `http.require-https` sea false, y solo a un host de `http.allowed-hosts` (o a un subdominio suyo) cuando esa lista está fijada. La URL que se comprueba y a la que se envía es siempre la actual de la suscripción. +- **Dónde corren los intentos.** Allí donde se consume el evento. En el bus en memoria es la petición que publicó el evento — la prueba de Lumen ve el POST antes de que `send()` vuelva — así que elige `queue`, un broker o el outbox (Capítulo 8) para llevar las entregas a workers. Un evento reentregado corresponde a la misma ejecución: una ya entregada no se envía otra vez. + +`log.store=memory` guarda las entregas del proceso; `database` las guarda en `firefly_callback_executions`, y los dos comandos lo leen: + + +```bash +php artisan firefly:callbacks:list --executions --status=failed --limit=50 +php artisan firefly:callbacks:replay --failed +php artisan firefly:callbacks:replay 9f2c4e7a-1b2c-4d5e-8f90-a1b2c3d4e5f6 +``` + +Una repetición reenvía el cuerpo registrado, firmado de nuevo con una marca de tiempo fresca, a la URL **actual** de la suscripción, pasando por las mismas comprobaciones de https y de hosts permitidos; rechaza una suscripción inactiva salvo que se pase `--include-inactive`, y termina con código 1 si alguna repetición no acabó entregada, así que una repetición programada informa de sus propios fallos. + +!!! laravel "Paridad con Laravel" + Laravel no tiene una primitiva para webhooks de entrada: el idioma es un controlador por proveedor, una comprobación de firma escrita a mano y una tabla `processed_webhooks` que te acuerdas de consultar — Cashier trae uno de esos controladores, solo para Stripe. De salida, el idioma es un job en cola por evento con `Http::post()` y tu propio código de reintento y firma. `firefly/webhooks` y `firefly/callbacks` son esos idiomas convertidos en configuración, con las partes difíciles — comprobaciones de firma en tiempo constante sobre bytes en crudo, una reclamación de idempotencia atómica, un breaker por endpoint, un registro reproducible — escritas una sola vez. + +--- + +## Lo que aprendiste {.recap} + +| Concepto | Qué hace | +|---|---| +| `POST /webhooks/{provider}` | Un endpoint por proveedor configurado: límite de tamaño, firma, ventana de repetición, idempotencia y después una publicación en el bus | +| Validadores | `hmac` (por defecto), `stripe`, `github`, `twilio`, `none` — cada uno conoce la firma, la clave de idempotencia y el tipo de evento de su proveedor | +| `webhook.{provider}.{type}` | El tipo de evento con el que se publica una entrega aceptada; el código que reacciona es un `#[EventListener]` corriente | +| Almacén de idempotencia | `cache` (por defecto), `database` (`firefly_webhook_receipts`) o `memory`; una reclamación atómica, así que una reentrega es un 200 `duplicate` | +| Un listener que falla | El bus lo convierte en carta muerta y la entrega sigue siendo un 202; `firefly:eda:dead-letters:replay` la entrega de nuevo | +| `firefly.callbacks.subscriptions` / `SubscriptionRegistry` | Quién recibe qué tipos de evento, desde configuración (`memory`) o desde filas (`database`) | +| `X-Firefly-Timestamp` / `X-Firefly-Signature` | HMAC-SHA256 sobre `"."` — exactamente lo que comprueba el validador `hmac` | +| Reintentos, breaker, registro | Espera creciente ante 408/429/5xx, un circuit breaker por endpoint, un registro de ejecuciones que leen `firefly:callbacks:list` y `firefly:callbacks:replay` | + +--- + +## Ponlo en práctica {.exercises} + +1. **Falsifica una entrega.** En `CardTopUpWebhookTest`, firma el cuerpo con otro secreto y comprueba que el 401 nombra al proveedor en su miembro `provider`. +2. **Añade un segundo proveedor.** Da a `LumenWebhooksTestCase` un proveedor `stripe` (`'validator' => 'stripe'`) y escribe una prueba que envíe una cabecera `Stripe-Signature: t=…,v1=…` construida con `hash_hmac('sha256', "{$t}.{$body}", $secret)`. Después mueve `t` diez minutos al pasado y comprueba que el 401 es `WEBHOOK_TIMESTAMP_OUT_OF_TOLERANCE`. +3. **Mira cómo se abre el breaker.** En `PartnerCallbacksTest`, simula al socio con `Http::response('', 503)`, fija `firefly.callbacks.circuit-breaker.failure-threshold` a `1`, haz dos ingresos y lee el error de la segunda ejecución con `ExecutionLog::recent()`. diff --git a/book/src/08a-webhooks-callbacks.md b/book/src/08a-webhooks-callbacks.md new file mode 100644 index 00000000..21d76462 --- /dev/null +++ b/book/src/08a-webhooks-callbacks.md @@ -0,0 +1,294 @@ +Part III — Coordinating and Securing the Application · Chapter 8A + +# Webhooks and Callbacks: Receiving and Sending Signed Events {.chtitle} + +By the end of this chapter you will know how a LaraFly application receives a webhook and how it sends one, and why both directions end on the event bus Chapter 8 built. On the way in, `firefly/webhooks` gives every configured provider an endpoint, `POST /webhooks/{provider}`, that refuses an oversized body, verifies the signature (`hmac`, `stripe`, `github`, `twilio` or `none`), holds a signed timestamp to a replay window, answers a redelivery as a `duplicate`, and publishes what it accepts as `webhook.{provider}.{type}` — so Lumen's card top-up is an ordinary `#[EventListener]`. On the way out, `firefly/callbacks` POSTs every bus event a partner subscribed to, signed with `X-Firefly-Timestamp` and `X-Firefly-Signature`, retried with back-off behind a circuit breaker per endpoint, and recorded in an execution log you can list and replay with `firefly:callbacks:list` and `firefly:callbacks:replay`. + +!!! note "New term: webhook" + A **webhook** is an HTTP request one system makes to another to say that something happened — a card payment succeeded, a pull request merged. The receiver did not ask at that moment; it registered a URL once, and the sender calls it. Two things follow from that, and this chapter is mostly about them: the receiver must prove the request really came from the sender (a signature over the exact bytes), and it must expect the same event more than once (senders retry until they see a 2xx). + +--- + +## Two packages, one bus + +`firefly/webhooks` is the inbound half and `firefly/callbacks` the outbound one, and neither knows about the other. What they share is the bus: an accepted webhook becomes an `EventEnvelope` published through the `EventPublisher` port, and a callback is triggered by an envelope arriving on it. Everything Chapter 8 said about that bus — `eventType` matching, retries, dead letters, the outbox, brokers — therefore applies to both halves without a line of extra code. Both are off until their master key is set, `firefly.webhooks.enabled` and `firefly.callbacks.enabled`, and both ship in the root library. + +--- + +## Receiving: a signed endpoint per provider + +The reference configuration shows the inbound block with every optional key commented out at its default: + + +```php +'webhooks' => [ + 'enabled' => env('FIREFLY_WEBHOOKS_ENABLED', false), + + // 'path' => 'webhooks', + // 'max-body-bytes' => 1048576, + // 'destination' => 'webhooks.{provider}', + // 'event-type' => 'webhook.{provider}.{type}', + // 'replay' => ['tolerance' => '5m'], + // 'idempotency' => [ + // 'store' => env('FIREFLY_WEBHOOKS_IDEMPOTENCY_STORE', 'cache'), + // 'ttl' => '24h', + // 'cache-store' => '', + // 'connection' => null, + // ], + // 'providers' => [ + // 'stripe' => ['validator' => 'stripe', 'secret' => env('STRIPE_WEBHOOK_SECRET')], + // 'github' => ['validator' => 'github', 'secret' => env('GITHUB_WEBHOOK_SECRET')], + // 'twilio' => ['validator' => 'twilio', 'secret' => env('TWILIO_AUTH_TOKEN'), 'public-url' => 'https://api.example.com/webhooks/twilio'], + // 'partner' => ['secret' => env('PARTNER_WEBHOOK_SECRET'), 'timestamp-header' => 'X-Firefly-Timestamp', 'signature-header' => 'X-Firefly-Signature', 'idempotency-header' => 'X-Firefly-Delivery'], + // ], +], +``` + +The array key under `providers` is the provider id and the last segment of its URL: `stripe` is answered at `/webhooks/stripe`. A signing provider without a secret refuses the boot and names the key. Lumen has one provider, the card processor that tops wallets up, and it uses the default `hmac` validator, so the whole configuration is a secret: + + +```php + 'firefly.webhooks.enabled' => true, + 'firefly.webhooks.idempotency.store' => 'memory', + 'firefly.webhooks.providers' => [ + 'card-processor' => ['secret' => self::SECRET], + ], +``` + +Each validator knows where its provider puts the signature, what it signs, which field names the event and which value identifies a delivery: + +| `validator` | Signature | Replay protection | Idempotency key | Event type | +|---|---|---|---|---| +| `hmac` (default) | `X-Signature: sha256=`; with `timestamp-header` the signed payload is `"."` | the timestamp, when `timestamp-header` is set | `X-Idempotency-Key` | body field `type` | +| `stripe` | `Stripe-Signature: t=…,v1=…` (any `v1` may match while a secret is rolled) | `t`, inside `tolerance` | body field `id` | body field `type` | +| `github` | `X-Hub-Signature-256: sha256=…` | none signed — the idempotency key | `X-GitHub-Delivery` | `X-GitHub-Event` | +| `twilio` | `X-Twilio-Signature`, base64 HMAC-SHA1 over the URL and the sorted form parameters | none signed — the idempotency key | `I-Twilio-Idempotency-Token` | form field `EventType` | +| `none` | not verified — for a sender on a network you trust | — | `X-Idempotency-Key` | body field `type` | + +Every column is a default a provider entry can override (`signature-header`, `signature-prefix`, `algorithm`, `encoding`, `timestamp-header`, `tolerance`, `idempotency-header`, `idempotency-field`, `event-type-header`, `event-type-field`, `destination`, `public-url`). The route is mounted without a middleware group — no session and no CSRF token a provider could never send — and when `HttpSecurity` is on you permit `webhooks/**`, because the signature is the authentication. + +--- + +## What the endpoint answers + +| Status | When | +|---|---| +| 202 `accepted` | verified and published | +| 200 `duplicate` | a delivery already accepted (same idempotency key within `idempotency.ttl`) | +| 400 `WEBHOOK_PAYLOAD_INVALID` | the body says it is JSON and is not a JSON object | +| 401 `WEBHOOK_SIGNATURE_INVALID` | a missing or wrong signature | +| 401 `WEBHOOK_TIMESTAMP_OUT_OF_TOLERANCE` | a signed timestamp outside the replay window | +| 404 `WEBHOOK_PROVIDER_NOT_FOUND` | no such provider | +| 413 `WEBHOOK_PAYLOAD_TOO_LARGE` | above `max-body-bytes`, refused before any signature work | +| 503 `WEBHOOK_PUBLISH_FAILED` | verified, but the bus refused the publish — the idempotency claim is released, so the provider's retry is processed | + +Every refusal is problem+json whatever the sender's `Accept` header says, and every 4xx names the provider in a `provider` member, so a misconfigured secret shows up in your logs with the provider attached. + +--- + +## Lumen's card top-up + +An accepted delivery is published as `webhook.{provider}.{type}` on `webhooks.{provider}`, with the decoded body under the payload's `body` key. For the card processor's `top_up.succeeded` that is `webhook.card-processor.top_up.succeeded`, and the code that reacts to it is a listener like `LedgerProjector`: + + +```php +#[Component] +final class CardTopUpListener +{ + public function __construct(private readonly CommandBus $commands) {} + + #[EventListener('webhook.card-processor.top_up.succeeded')] + public function onTopUp(EventEnvelope $envelope): void + { + $body = $envelope->payload['body'] ?? null; + $data = is_array($body) ? ($body['data'] ?? null) : null; + $walletId = is_array($data) ? ($data['wallet_id'] ?? null) : null; + $amountMinor = is_array($data) ? ($data['amount_minor'] ?? null) : null; + + if (! is_string($walletId) || ! is_int($amountMinor) || $amountMinor <= 0) { + return; + } + + $this->commands->send(new Deposit($walletId, $amountMinor)); + } +} +``` + +The listener never checks a signature, a timestamp or a duplicate: by the time an envelope reaches the bus, all three have been settled. What it does is the domain's work — one `Deposit`, through the same bus, the same `#[Transactional]` handler and the same domain events as a deposit made over REST. The sample's test sends the delivery twice, the way a processor that missed the first 202 would: + + +```php + $this->postWebhook('/webhooks/card-processor', $body, lumenTopUpHeaders($body, 'evt_1')) + ->assertStatus(202) + ->assertJson(['status' => 'accepted', 'provider' => 'card-processor', 'eventType' => 'webhook.card-processor.top_up.succeeded']); + + // The processor never saw the 202 and delivers the same event again: answered, not deposited twice. + $this->postWebhook('/webhooks/card-processor', $body, lumenTopUpHeaders($body, 'evt_1')) + ->assertStatus(200) + ->assertJson(['status' => 'duplicate']); + + $this->getJson("/api/v1/wallets/{$id}/balance")->assertOk()->assertJson(['balance_minor' => 2500]); +``` + +`postWebhook()` is the harness's raw POST: a signature covers bytes, so a helper that re-encodes the JSON would sign something the endpoint never receives. The second test in the file changes one digit of the amount after signing and gets a 401 `WEBHOOK_SIGNATURE_INVALID` with the balance untouched. + +!!! tip "A failing listener is a dead letter, not a 5xx" + The third test sends a verified top-up for a wallet that does not exist. The `Deposit` throws, the listener throws — and the endpoint still answers 202, because on the in-memory bus every listener runs inside `RetryingEventHandler`, and a `DeadLetterStore` is always bound: the envelope is stored there, not lost. That is the right answer to give the processor (its delivery was valid; retrying it would fail the same way), and the right place to leave the problem: fix the cause, then `php artisan firefly:eda:dead-letters:replay --event-type=webhook.card-processor.top_up.succeeded` delivers it again. With `firefly.eda.dead-letter.store=database` (Chapter 8) the dead letter survives a restart. + + +```php + $entries = $this->app()->make(DeadLetterStore::class)->all(); + + expect($entries)->toHaveCount(1) + ->and($entries[0]->envelope->eventType)->toBe('webhook.card-processor.top_up.succeeded') + ->and($entries[0]->exceptionMessage)->toContain('no-such-wallet'); +``` + +--- + +## Remembering a delivery: idempotency + +`firefly.webhooks.idempotency.store` picks where accepted delivery ids are remembered for `idempotency.ttl` (24 hours by default). The claim is atomic, so two simultaneous deliveries of one webhook produce one 202 and one 200, never two publishes: + +| Store | Backing | Notes | +|---|---|---| +| `cache` (default) | the Laravel cache store `idempotency.cache-store` names, `add()` with a TTL | atomic on redis, memcached, database and array stores | +| `database` | `firefly_webhook_receipts` on `idempotency.connection` | portable table, primary key (provider, key), case-sensitive on every driver; prune it daily | +| `memory` | the process | one process only — Lumen's tests use it because one test is one process | + + +```bash +php artisan vendor:publish --tag=firefly-webhooks-migrations +php artisan migrate +php artisan firefly:webhooks:prune +``` + +Behind a proxy, set the provider's `public-url`: a Twilio signature covers the exact URL Twilio called, and a load balancer that rewrites the host or the scheme would otherwise make every signature look forged. + +--- + +## Sending: callbacks to the partners who asked + +The outbound half starts from the same reference file: + + +```php +'callbacks' => [ + 'enabled' => env('FIREFLY_CALLBACKS_ENABLED', false), + + // 'event-types' => ['order.*', 'invoice.paid'], + // 'registry' => ['store' => env('FIREFLY_CALLBACKS_REGISTRY', 'memory')], + // 'log' => ['store' => env('FIREFLY_CALLBACKS_LOG', 'memory')], + // 'connection' => null, + // 'subscriptions' => [ + // ['id' => 'partner-orders', 'url' => 'https://partner.example.com/hooks', 'event-types' => ['order.*'], 'secret' => env('PARTNER_CALLBACK_SECRET')], + // ], + // … +], +``` + +`event-types` is what the dispatcher subscribes to on the bus — one subscription per pattern, nothing at all while the list is empty — and each subscription then narrows it with its own patterns. Lumen's partner, a reconciliation service, wants to hear about every movement of money and nothing else: + + +```php + 'firefly.callbacks.enabled' => true, + 'firefly.callbacks.event-types' => ['Funds*'], + 'firefly.callbacks.retry.initial-delay' => 0, + 'firefly.callbacks.subscriptions' => [[ + 'id' => 'partner-ledger', + 'name' => 'partner ledger', + 'url' => 'https://partner.example.com/hooks/wallet', + 'event-types' => ['FundsDeposited', 'FundsWithdrawn'], + 'secret' => self::SECRET, + ]], +``` + +The patterns match the envelope's `eventType`, exactly as `#[EventListener]` does, and Lumen's domain events reach the bus as their short class names — the lesson `LedgerProjector` taught in Chapter 8 applies unchanged. With `registry.store=database`, subscriptions are rows of `firefly_callback_subscriptions`, managed at runtime through the `SubscriptionRegistry` port instead of configuration. + +--- + +## What a delivery looks like + +A delivery is a JSON `POST` whose body is fixed when the execution is created, so every attempt and every replay sends the same bytes: + + +```json +{"id": "9f2c…", "eventId": "4b1e…", "eventType": "FundsDeposited", "occurredAt": "2026-10-13T10:00:00+00:00", "data": {"walletId": "w-1", "amountMinor": 700, "currency": "EUR", "balanceMinor": 700}} +``` + +The headers are `Content-Type: application/json`, `User-Agent: LaraFly-Callbacks`, `X-Firefly-Event`, `X-Firefly-Event-Id`, `X-Firefly-Delivery` (the execution id — the receiver's idempotency key), the subscription's own headers, and, when it has a secret, `X-Firefly-Timestamp: ` and `X-Firefly-Signature: sha256=.")>`. The timestamp is inside the signature, so a captured delivery cannot be replayed later. Lumen's test checks the signature the way the partner would, with the webhooks package's own `hmac` validator: + + +```php + // The partner's own check, as a LaraFly receiver on firefly/webhooks would run it: throws on a bad signature + // or a timestamp outside the five-minute window. + (new HmacSignatureValidator(LumenCallbacksTestCase::SECRET, 'X-Firefly-Signature', 'sha256=', 'sha256', 'hex', 'X-Firefly-Timestamp', 300.0)) + ->verify(new WebhookRequest('lumen', 'POST', $request->url(), $headers, $request->body(), [], 'application/json', time())); +``` + +So a LaraFly partner receives a LaraFly callback with configuration only: + + +```php +// config/firefly.php +return [ + 'webhooks' => [ + 'enabled' => true, + 'providers' => [ + 'lumen' => [ + 'secret' => env('LUMEN_CALLBACK_SECRET'), + 'signature-header' => 'X-Firefly-Signature', + 'timestamp-header' => 'X-Firefly-Timestamp', + 'idempotency-header' => 'X-Firefly-Delivery', + 'event-type-header' => 'X-Firefly-Event', + ], + ], + ], +]; +``` + +--- + +## Retries, the circuit breaker and the execution log + +- **What counts as delivered.** A 2xx. A 408, 429, 5xx or transport error is attempted again after `retry.initial-delay` × `retry.multiplier`^(n−1), capped at `retry.max-delay`, up to `retry.max-attempts` (a subscription's own `max-attempts` wins). Any other status is the receiver's final answer: failed, not retried. Redirects are not followed. +- **A breaker per endpoint.** Each `scheme://host:port` has a `firefly/resilience` circuit breaker: after `circuit-breaker.failure-threshold` failures it opens for `circuit-breaker.wait-duration-in-open`, and deliveries to that endpoint fail at once with `circuit open` instead of waiting on a partner that is down. +- **Where URLs may point.** https only unless `http.require-https` is false, and only to a host in `http.allowed-hosts` (or a subdomain of one) when that list is set. The URL checked and sent to is always the subscription's current one. +- **Where attempts run.** Wherever the event is consumed. On the in-memory bus that is the request that published the event — Lumen's test sees the POST before `send()` returns — so choose `queue`, a broker or the outbox (Chapter 8) to move deliveries into workers. A redelivered event maps to the same execution: one already delivered is not sent again. + +`log.store=memory` keeps the deliveries of the process; `database` keeps them in `firefly_callback_executions`, and the two commands read it: + + +```bash +php artisan firefly:callbacks:list --executions --status=failed --limit=50 +php artisan firefly:callbacks:replay --failed +php artisan firefly:callbacks:replay 9f2c4e7a-1b2c-4d5e-8f90-a1b2c3d4e5f6 +``` + +A replay re-sends the logged body re-signed with a fresh timestamp, to the subscription's **current** URL, through the same https and allowed-host checks; it refuses an inactive subscription unless `--include-inactive` is passed, and it exits 1 if any replay did not end delivered, so a scheduled replay reports its own failures. + +!!! laravel "Laravel parity" + Laravel has no inbound-webhook primitive: the idiom is a controller per provider, a hand-written signature check, and a `processed_webhooks` table you remember to consult — Cashier ships one such controller for Stripe alone. Outbound, the idiom is a queued job per event with `Http::post()` and your own retry and signing code. `firefly/webhooks` and `firefly/callbacks` are those idioms made into configuration, with the hard parts — constant-time signature checks over raw bytes, an atomic idempotency claim, a breaker per endpoint, a replayable log — written once. + +--- + +## What you learned {.recap} + +| Concept | What it does | +|---|---| +| `POST /webhooks/{provider}` | One endpoint per configured provider: size limit, signature, replay window, idempotency, then a publish to the bus | +| Validators | `hmac` (default), `stripe`, `github`, `twilio`, `none` — each knows its provider's signature, idempotency key and event type | +| `webhook.{provider}.{type}` | The event type an accepted delivery is published as; the reacting code is an ordinary `#[EventListener]` | +| Idempotency store | `cache` (default), `database` (`firefly_webhook_receipts`) or `memory`; an atomic claim, so a redelivery is a 200 `duplicate` | +| A failing listener | Dead-lettered by the bus, the delivery still 202; `firefly:eda:dead-letters:replay` delivers it again | +| `firefly.callbacks.subscriptions` / `SubscriptionRegistry` | Who receives which event types, from configuration (`memory`) or rows (`database`) | +| `X-Firefly-Timestamp` / `X-Firefly-Signature` | HMAC-SHA256 over `"."` — exactly what the `hmac` validator checks | +| Retries, breaker, log | Back-off on 408/429/5xx, a circuit breaker per endpoint, an execution log read by `firefly:callbacks:list` and `firefly:callbacks:replay` | + +--- + +## Try it yourself {.exercises} + +1. **Forge a delivery.** In `CardTopUpWebhookTest`, sign the body with a different secret and confirm the 401 names the provider in its `provider` member. +2. **Add a second provider.** Give `LumenWebhooksTestCase` a `stripe` provider (`'validator' => 'stripe'`) and write a test that sends a `Stripe-Signature: t=…,v1=…` header built with `hash_hmac('sha256', "{$t}.{$body}", $secret)`. Then move `t` ten minutes into the past and confirm the 401 is `WEBHOOK_TIMESTAMP_OUT_OF_TOLERANCE`. +3. **Watch the breaker open.** In `PartnerCallbacksTest`, fake the partner with `Http::response('', 503)`, set `firefly.callbacks.circuit-breaker.failure-threshold` to `1`, make two deposits, and read the second execution's error from `ExecutionLog::recent()`. diff --git a/docs/book.md b/docs/book.md index 7af127ed..cdedaaac 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, sixteen chapters, a Laravel cheat-sheet, +Both editions cover the same application: a quick start, seventeen 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/index.md b/docs/index.md index 3d555893..d74169b1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -126,7 +126,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 — 16 chapters plus appendices, bilingual (English + Spanish), building this exact sample — is available +book — 17 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 From 3f2d8db2f9dfe83bc3f76a8a6394565c79c216c3 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 08:55:54 -0700 Subject: [PATCH 13/19] docs(book): add Chapter 9A, Lumen's compensating payout saga with TCC, durable workflows and the scheduler's lock, in both editions --- README.md | 8 +- book/README.md | 6 +- book/book.es.yaml | 1 + book/book.yaml | 1 + book/src-es/09a-orchestration.md | 393 +++++++++++++++++++++++++++++++ book/src/09a-orchestration.md | 393 +++++++++++++++++++++++++++++++ docs/book.md | 2 +- docs/index.md | 2 +- 8 files changed, 797 insertions(+), 9 deletions(-) create mode 100644 book/src-es/09a-orchestration.md create mode 100644 book/src/09a-orchestration.md diff --git a/README.md b/README.md index 82f44053..09ffad81 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, **seventeen chapters** +The book is **structurally complete and bilingual (English + Spanish)**: a quick start, **eighteen 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]`, security, OAuth2 and +Coordinating & Securing the App (CQRS, EDA + transactional outbox, webhooks and callbacks, `#[Transactional]`, sagas/TCC/durable workflows, 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 @@ -1133,7 +1133,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 (17 chapters + appendices, PDF + EPUB). +- [*larafly by example*](book/README.md) — the complete bilingual book (18 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`. @@ -1170,7 +1170,7 @@ accurately: today via a plain `#[EventListener]`; a dedicated `firefly/eventsourcing`-style package for event sourcing/snapshots/projections is future work, as it is in PyFly. - **Documentation.** The end-to-end [tutorial](docs/tutorial.md) (EN + ES), the *larafly by example* - [book](book/README.md) (17 chapters + appendices, EN + ES, PDF + EPUB), and a + [book](book/README.md) (18 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 9a51880d..92503163 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 seventeen chapters, 4A, 8A, 10A and 13A included (Parts I-IV) + 01..13a-*.md # the eighteen chapters, 4A, 8A, 9A, 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, seventeen chapters across four +matter, a "Build Lumen step by step" quick start, eighteen 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, Security, OAuth2 and OpenID Connect + proxy, Sagas, TCC and Durable Workflows, Security, OAuth2 and OpenID Connect - **Part IV — Observability, Testing & Delivery**: Observability/Actuator, Testing, the CLI & the Zero-Reflection Cache, Feature Flags diff --git a/book/book.es.yaml b/book/book.es.yaml index e181e0dd..71de88c4 100644 --- a/book/book.es.yaml +++ b/book/book.es.yaml @@ -82,6 +82,7 @@ parts: - {id: ch08, file: 08-eda-outbox.md, num: 8, title: "Arquitectura Guiada por Eventos y el Outbox Transaccional"} - {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: 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 db4129cd..5a344c06 100644 --- a/book/book.yaml +++ b/book/book.yaml @@ -80,6 +80,7 @@ parts: - {id: ch08, file: 08-eda-outbox.md, num: 8, title: "Event-Driven Architecture and the Transactional Outbox"} - {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: 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/09a-orchestration.md b/book/src-es/09a-orchestration.md new file mode 100644 index 00000000..3b6a7919 --- /dev/null +++ b/book/src-es/09a-orchestration.md @@ -0,0 +1,393 @@ +Parte III — Coordinar y Asegurar la Aplicación · Capítulo 9A + +# Sagas, TCC y Workflows Duraderos {.chtitle} + +Al terminar este capítulo sabrás qué hacer cuando una operación de negocio abarca más de una transacción — la línea que el `#[Transactional]` del Capítulo 9 no puede cruzar. Conocerás los tres patrones que distribuye `firefly/orchestration` y cuándo encaja cada uno: una **saga** (`#[Saga]`, `#[SagaStep]`) ejecuta pasos hacia delante en el orden de `dependsOn` y deshace los ya completados con sus compensaciones; un **TCC** (`#[Tcc]`, `#[TccParticipant]`, `#[TryMethod]`, `#[ConfirmMethod]`, `#[CancelMethod]`) reserva con cada participante antes de confirmar a ninguno; un **workflow duradero** (`#[Workflow]`, `#[WorkflowStep]`) espera señales (`#[WaitForSignal]`) y temporizadores (`#[WaitForTimer]`) a través de procesos. Ejecutarás de principio a fin la saga `payout` de Lumen y la verás compensar en orden inverso, y sabrás dónde viven sus puntos de control, su barrido de recuperación y sus cartas muertas (`firefly.orchestration.persistence.provider`, `firefly:orchestration:dlq`), cómo el cerrojo distribuido del planificador (`firefly.scheduling.lock.provider`) mantiene el tic de un temporizador en un solo nodo, y qué significa el modelo síncrono de PHP para los pasos de una misma capa. + +!!! note "Término nuevo: saga" + Una **saga** es una secuencia de transacciones locales, cada una de las cuales confirma por su cuenta, acompañada de una **compensación** para cada paso que cambió algo — una acción que lo deshace semánticamente. Si el paso tres falla, nada revierte por ti los pasos uno y dos; la saga ejecuta sus compensaciones, empezando por la más reciente. Una compensación no es un rollback: un pago reembolsado es un segundo pago en sentido contrario, y los dos quedan en el libro mayor. + +--- + +## Cuando una transacción no basta + +El Capítulo 9 terminó con `Transfer`: dos monederos, una base de datos, una frontera `#[Transactional]`, y un dinero que no puede desaparecer porque el cargo y el abono se confirman o se revierten juntos. Un **pago saliente** es distinto. El dinero sale del monedero en la base de datos de Lumen y llega a un banco, que tiene su propia base de datos, sus propias transacciones y su propia idea de cuándo algo es definitivo. Ninguna transacción puede abarcar las dos, así que el diseño honesto es una secuencia de pasos que confirman cada uno, y una forma explícita de deshacer cada uno de los que el fallo del paso siguiente dejaría colgados. Eso es una saga, y la sección siguiente es la que ejecuta Lumen. + +--- + +## La saga de pagos salientes de Lumen + +El banco es un puerto, porque es otro sistema: + + +```php +interface BankGateway +{ + public function submit(string $iban, int $amountMinor): string; + + public function confirm(string $reference): void; + + public function cancel(string $reference): void; +} +``` + +`SimulatedBankGateway` es el adaptador del sample: rechaza un IBAN que empieza por `XX` cuando se envía la transferencia, y se niega a confirmar una que supere su límite diario de 1.000,00 — los dos rechazos que da un banco real, que es justo lo que la saga necesita para tener algo que compensar. La saga en sí es un bean con un `#[SagaStep]` por cada paso hacia delante: + + +```php +#[Service] +#[Saga(name: 'payout')] +final class PayoutSaga +{ + public function __construct( + private readonly CommandBus $commands, + private readonly BankGateway $bank, + ) {} + + #[SagaStep(id: 'debit', compensate: 'refund')] + public function debit(#[Input('walletId')] string $walletId, #[Input('amountMinor')] int $amountMinor): int + { + $balance = $this->commands->send(new Withdraw($walletId, $amountMinor)); + + return is_int($balance) ? $balance : 0; + } + + public function refund(#[Input('walletId')] string $walletId, #[Input('amountMinor')] int $amountMinor): void + { + $this->commands->send(new Deposit($walletId, $amountMinor)); + } + + #[SagaStep(id: 'submit', dependsOn: ['debit'], compensate: 'cancel')] + public function submit(#[Input('iban')] string $iban, #[Input('amountMinor')] int $amountMinor): string + { + return $this->bank->submit($iban, $amountMinor); + } + + public function cancel(string $reference): void + { + $this->bank->cancel($reference); + } + + #[SagaStep(id: 'confirm', dependsOn: ['submit'])] + public function confirm(#[FromStep('submit')] string $reference): string + { + $this->bank->confirm($reference); + + return $reference; + } +} +``` + +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. + +--- + +## Ejecutarla, y verla deshacerse + +`SagaEngine::execute('payout', $input)` ejecuta la saga y devuelve un `SagaResult`. El test del sample recorre el caso que más importa — el banco acepta la transferencia y luego se niega a ejecutarla: + + +```php + // 1,500.00 is over the simulated bank's daily limit: submitted, then refused at confirmation. + $result = $this->app()->make(SagaEngine::class)->execute('payout', ['walletId' => $id, 'iban' => 'ES9121000418450200051332', 'amountMinor' => 150000]); + + expect($result->success)->toBeFalse() + ->and($result->status)->toBe(ExecutionStatus::Failed) + ->and($result->firstErrorStepId())->toBe('confirm') + ->and($result->compensatedSteps())->toBe(['debit', 'submit']) + ->and($this->app()->make(SimulatedBankGateway::class)->calls)->toBe(['submit bank-1', 'limit bank-1', 'cancel bank-1']) + ->and($queries->ask(new GetBalance($id)))->toBe(200000) + ->and(DB::table(OrchestrationSchema::EXECUTIONS)->where('correlation_id', $result->correlationId)->value('status'))->toBe('FAILED'); +``` + +El propio registro de llamadas del banco cuenta la historia en orden: la transferencia se envió, se rechazó en la confirmación y se canceló; después se reembolsó el monedero, y su saldo es exactamente el que era. Los tres desenlaces que cubre el fichero de test: + +| Entrada | Falla en | Compensado | Llamadas al banco | Monedero después | +|---|---|---|---|---| +| un IBAN válido, 200,00 | — | — | `submit bank-1`, `confirm bank-1` | debitado en 200,00 | +| `XX0012345678`, 200,00 | `submit` | `debit` | `refused XX0012345678` | sin cambios | +| un IBAN válido, 1.500,00 | `confirm` | `submit`, luego `debit` | `submit bank-1`, `limit bank-1`, `cancel bank-1` | sin cambios | + +`compensatedSteps()` enumera los pasos deshechos en orden de declaración; el orden en que se **ejecutaron** es el inverso — la política por defecto deshace primero el paso completado más reciente, y por eso al banco se le pidió cancelar antes de que se reembolsara el monedero. La saga de tarjetas de felicitación del tutorial, cuyo diario registra cada llamada, comprueba ese orden llamada a llamada en `tests/TutorialGreetingCardSagaTest.php`: primero se reembolsa el cobro y después se libera la tarjeta. + +--- + +## Sobre HTTP + +`PayoutController` expone la saga en `POST /api/v1/wallets/{id}/payouts`: + + +```php + $result = $this->sagas->execute('payout', ['walletId' => $id, 'iban' => $body->iban, 'amountMinor' => $body->amount_minor]); + + if (! $result->success) { + // A compensation that did not finish: money may have left the wallet and not come back. That is never a + // business refusal; it is the one outcome an operator must act on, so it gets its own server-side answer + // naming the steps left undone and the payout id the execution is checkpointed and dead-lettered under. + // Asked of the steps as well as the status: under the default strict-sequential policy a compensation + // that throws marks its step COMPENSATION_FAILED and the error handler continues, so the saga still + // ends FAILED, while a policy that stops the sweep ends it COMPENSATION_FAILED. + $undone = $this->undoneSteps($result); + if ($undone !== [] || $result->status === ExecutionStatus::CompensationFailed) { + throw (new FireflyException( + sprintf('Payout %s failed at step "%s" and was not fully undone; steps left uncompensated: %s.', $result->correlationId, (string) $this->failingStep($result), implode(', ', $undone)), + 'PAYOUT_COMPENSATION_FAILED', + 500, + ErrorCategory::Internal, + ErrorSeverity::Critical, + $result->error, + ))->withExtensions(['payout_id' => $result->correlationId, 'uncompensated_steps' => $undone]); + } + + // The debit itself was refused, so nothing was undone: the bus's own refusal — 401, 403, 404, or the 409 + // of an overdraw — is the honest answer, exactly as POST /withdraw would have given it. Asked of the debit + // step's own status, not firstErrorStepId(), which also counts a step whose compensation failed. + $debit = $result->steps['debit'] ?? null; + if ($debit !== null && $debit->status === StepStatus::Failed && $result->error !== null) { + throw $result->error; + } + + throw new ConflictException( + sprintf('Payout %s failed at step "%s" and ended %s: %s', $result->correlationId, (string) $this->failingStep($result), $result->status->value, $result->error?->getMessage() ?? 'no error was recorded'), + 'PAYOUT_FAILED', + $result->error, + ); + } +``` + +Tres tipos de fallo, tres respuestas. Cuando una compensación no terminó — el propio reembolso lanzó una excepción, o una política detuvo el barrido — puede que el dinero haya salido del monedero y no haya vuelto, así que eso nunca se informa como un rechazo de negocio: el cliente recibe un 500 `PAYOUT_COMPENSATION_FAILED` cuyos `payout_id` y `uncompensated_steps` le dicen a un operador qué ejecución buscar y qué queda por deshacer. La comprobación mira los pasos además del estado, porque con el manejador por defecto una compensación que lanza marca su paso `COMPENSATION_FAILED` y la saga sigue terminando `FAILED`. Cuando el propio cargo se rechazó, no pasó nada y no se deshizo nada, así que el controlador relanza la excepción del propio bus y la capa web la presenta exactamente igual que presenta un withdraw fallido — un descubierto es el mismo 409 problem+json, un rol que falta el mismo 403. Cuando falló un paso posterior y se deshicieron todos los pasos completados, el cliente recibe un 409 `PAYOUT_FAILED` cuyo mensaje nombra el paso y el estado final. `PayoutRestTest` los cubre todos, incluido el reembolso fallido, y un pago saliente que sale bien responde 201 con la referencia del banco. + +--- + +## Políticas de compensación + +`firefly.orchestration.saga.compensation-policy` decide cómo se deshacen los pasos completados: + +| Política | Comportamiento | +|---|---| +| `strict-sequential` (por defecto) | Deshace en orden inverso de finalización, de uno en uno. | +| `grouped-parallel` | Deshace capa a capa, en orden inverso de capas (secuencialmente dentro de una capa — PHP es síncrono). | +| `retry-with-backoff` | Como `strict-sequential`, reintentando cada compensación con su `compensationRetry` y su `compensationBackoffMs`. | +| `circuit-breaker` | Deja de compensar en cuanto falla un paso `compensationCritical` o se abre el breaker; la saga termina `COMPENSATION_FAILED`, pasa a carta muerta y enumera lo que dejó en `SagaResult::$uncompensatedSteps`. | +| `best-effort` | Ejecuta todas las compensaciones y registra cada fallo sin detenerse. | + +`firefly.orchestration.saga.compensation-error-handler` decide qué le hace a la ejecución una compensación fallida — `default`, `fail-fast`, `log-and-continue`, `retry`, `robust`, o el nombre de clase de tu propio bean `CompensationErrorHandler`. Un paso también puede llevar `retry`, `backoffMs`, `timeoutMs` y una `idempotencyKey` en su `#[SagaStep]`; los reintentos de un paso se ejecutan antes de que la saga lo dé por perdido. + +--- + +## 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`: + + +```php +'persistence' => [ + 'provider' => env('FIREFLY_ORCHESTRATION_PERSISTENCE', 'memory'), + 'connection' => env('FIREFLY_ORCHESTRATION_DB_CONNECTION'), + 'cache-store' => env('FIREFLY_ORCHESTRATION_CACHE_STORE'), + 'key-prefix' => 'orchestration:', + 'key-ttl' => null, + 'retention-period' => env('FIREFLY_ORCHESTRATION_RETENTION', 'P7D'), + 'cleanup-interval' => '1h', +], +// … +'recovery' => [ + 'enabled' => env('FIREFLY_ORCHESTRATION_RECOVERY_ENABLED', true), + 'stale-threshold' => env('FIREFLY_ORCHESTRATION_STALE_THRESHOLD', '1h'), + 'action' => env('FIREFLY_ORCHESTRATION_RECOVERY_ACTION', 'report'), +], +// … +'dlq' => [ + 'enabled' => true, + 'store' => env('FIREFLY_ORCHESTRATION_DLQ_STORE', 'memory'), +], +``` + +Los puntos de control son lo que hace sobrevivible una caída. Un barrido de recuperación programado encuentra las ejecuciones que no se han actualizado en `recovery.stale-threshold` y, según `recovery.action`, las informa (`report`), las continúa desde su último punto de control (`resume`) o las deshace (`compensate`); el mismo barrido borra las ejecuciones terminadas más antiguas que `persistence.retention-period`. Una ejecución fallida se convierte en una carta muerta — una entrada por paso fallido, en `memory` o en `database` — y reintentar una arranca otra vez la misma definición con la misma entrada. Los operadores llegan a todo ello desde la línea de comandos: + + +```bash +php artisan vendor:publish --tag=firefly-orchestration-migrations +php artisan migrate +php artisan firefly:orchestration:list --name=payout --status=failed +php artisan firefly:orchestration:show 7c1d0e8a-5b4f-4c3e-9a21-0f6e2d4b8c13 +php artisan firefly:orchestration:dlq --name=payout +php artisan firefly:orchestration:dlq:retry 3f2a9c4e-8d1b-4e6f-a0c7-5b9e2d1f4a86 +``` + +--- + +## TCC: reservar en todas partes y luego confirmar + +Una saga confirma cada paso y lo deshace después. **TCC** — try, confirm, cancel — es la forma más estricta para cuando nada puede ser visible hasta que todos estén de acuerdo: cada participante primero *intenta* (reserva, sin comprometerse con el resultado), y solo cuando todos los intentos salieron bien se *confirman* todos; si falla algún intento, se *cancela* a cada participante que lo intentó. Los participantes son beans separados nombrados por `#[TccParticipant]`, en `order`; este es el lado del cargo de una transferencia en el capstone de orquestación: + + +```php +#[Tcc(name: 'transfer', maxRetries: 0, backoffMs: 1)] +final class TransferTcc {} +``` + + +```php +#[Service] +#[TccParticipant(tcc: 'transfer', id: 'debit', order: 1)] +final class DebitAccount +{ + public function __construct(private readonly Ledger $ledger) {} + + #[TryMethod] + public function hold(#[Input('amount')] int $amount): string + { + $this->ledger->record("hold {$amount}"); + + return "hold-{$amount}"; + } + + #[ConfirmMethod] + public function commit(#[FromTry] string $hold): void + { + $this->ledger->record("commit {$hold}"); + } + + #[CancelMethod] + public function release(#[FromTry] string $hold): void + { + $this->ledger->record("release {$hold}"); + } +} +``` + +`#[FromTry]` les pasa a confirm y a cancel el valor que devolvió el try — aquí el id de la retención — así que un participante nunca tiene que volver a buscar su propia reserva. Un TCC confirmado termina `CONFIRMED`, uno cancelado `CANCELED`; con `recovery.action=resume`, un proceso que murió en `CONFIRMING` se lleva hasta `CONFIRMED`, confirmando solo a los participantes que aún no habían confirmado. + +--- + +## Workflows duraderos: esperar al mundo + +Las sagas y los TCC se ejecutan de principio a fin dentro de una llamada. Un **workflow** puede esperar — a que una persona pulse un enlace, a que pase un día — y sobrevivir al proceso que lo arrancó. `#[WaitForSignal]` aparca la ejecución en `WAITING` hasta que se entrega una señal; `#[WaitForTimer]` la aparca hasta que vence el temporizador. El workflow de alta del capstone hace las dos cosas: + + +```php +#[Service] +#[Workflow(id: 'onboarding')] +final class OnboardingWorkflow +{ + public function __construct(private readonly Ledger $ledger) {} + + #[WorkflowStep(id: 'register')] + public function register(#[Input('email')] string $email, ExecutionContext $ctx): string + { + $ctx->setSearchAttribute('email', $email); + $this->ledger->record("register {$email}"); + + return "user-{$email}"; + } + + #[WorkflowStep(id: 'verify', dependsOn: ['register'])] + #[WaitForSignal('email-verified')] + public function verify(#[Variable('signals.email-verified')] mixed $proof): string + { + $this->ledger->record('verified'); + + return 'verified'; + } + + #[WorkflowStep(id: 'cool-off', dependsOn: ['verify'])] + #[WaitForTimer(delayMs: 1, timerId: 'cool-off')] + public function coolOff(): string + { + $this->ledger->record('cooled off'); + + return 'cooled'; + } + + #[WorkflowQuery('progress')] + public function progress(ExecutionContext $ctx): int + { + return count($ctx->results()); + } +} +``` + +`WorkflowEngine::start()` ejecuta `register` y se aparca en `verify`; se escribe el punto de control y la petición termina. Más tarde — otra petición, otro proceso — `WorkflowEngine::signal($correlationId, 'email-verified', $payload)` entrega la señal, que el paso lee como la variable `signals.email-verified`, y el workflow sigue hasta que el temporizador vuelve a aparcarlo. El tic programado (`firefly.orchestration.workflow.tick.*`, cada minuto por defecto) hace avanzar los temporizadores vencidos; un workflow vencido que el tic no puede mover se pospone, duplicando desde un minuto, y se aparca en `FAILED` y pasa a carta muerta tras `workflow.max-resume-attempts` tics fallidos. `#[WorkflowQuery]` responde a una pregunta de solo lectura sobre una ejecución viva, y `setSearchAttribute()` hace que se pueda encontrar. `#[WaitForAll]`, `#[WaitForAny]`, `#[ChildWorkflow]`, las condiciones de paso, `ContinueAsNew` y los workflows encolados (`triggerMode: TriggerMode::Async`) están en la guía del módulo. + + +```bash +php artisan firefly:orchestration:signal 7c1d0e8a-5b4f-4c3e-9a21-0f6e2d4b8c13 email-verified --payload='{"token":"t-1"}' +php artisan firefly:orchestration:tick +``` + +--- + +## Un nodo cada vez: el cerrojo del planificador + +El tic, el barrido de recuperación y cada `#[ScheduledSaga]`, `#[ScheduledTcc]` y `#[ScheduledWorkflow]` se ejecutan en la planificación del framework exactamente como lo hace una tarea `#[Scheduled]` tuya, cada uno bajo un nombre de cerrojo propio; y todo escritor de una ejecución toma antes un cerrojo por ejecución. En un servidor no hace falta nada más. En tres, tres planificadores disparan el mismo tic en el mismo minuto — así que una tarea protegida con `#[Scheduled(lock: …)]` pide primero un `DistributedLock`, y solo el nodo que gana la ejecuta. `firefly.scheduling.lock.provider` elige el backend: + + +```php +'scheduling' => [ + 'lock' => [ + 'provider' => env('FIREFLY_SCHEDULING_LOCK', 'none'), + // … + ], + // … +], +``` + +| `firefly.scheduling.lock.provider` | Backend | Funciona en | Notas | +|---|---|---|---| +| `none` (por defecto) | `NoneLock` | cualquier cosa | toda adquisición tiene éxito — correcto para un nodo, sin exclusión entre varios | +| `cache` | `CacheLock` | un store de caché con cerrojos | `database`, `redis` o `memcached` entre nodos; `array` es por proceso | +| `database` | `DatabaseLeaseLock` | cualquier conexión de base de datos de Laravel | una fila de arrendamiento por nombre de cerrojo en `firefly_scheduling_locks`; el TTL se respeta en todos los drivers | +| `mysql` | `MySqlNamedLock` | MySQL, MariaDB | `GET_LOCK`/`RELEASE_LOCK`, retenido por la sesión, sin TTL | +| `postgres` | `PgAdvisoryLock` | PostgreSQL | advisory lock con ámbito de sesión | + +`DatabaseLeaseLock` es el que no necesita nada más allá de la base de datos que ya tienes: adquirir es un `UPDATE` condicional que se queda con un arrendamiento vencido, o un `INSERT` cuando la fila no existe (dentro de un savepoint cuando hay una transacción abierta, para que una carrera perdida nunca aborte tu unidad de trabajo); liberar borra la fila solo mientras siga llevando el token de este nodo, así que una tarea que se pasó de su TTL no puede liberar el arrendamiento que otro nodo ha tomado desde entonces. El cerrojo de la orquestación va sobre el mismo proveedor, retenido como mucho `firefly.orchestration.lock.ttl` y esperado como mucho `firefly.orchestration.lock.wait`: + + +```php +'lock' => [ + 'ttl' => '5m', + 'wait' => '5s', +], +``` + +--- + +## Eventos, disparadores y operación + +- **Eventos del ciclo de vida.** Con `firefly.orchestration.events.enabled`, cada ejecución publica `EXECUTION_STARTED`, `EXECUTION_COMPLETED`, `STEP_COMPLETED` y `STEP_FAILED` a través del `EventPublisher` (Capítulo 8) — un workflow publica sus eventos de paso solo con `publishEvents: true`. Un `#[StepEvent]` en un paso publica la finalización de ese paso en el topic y el tipo que nombra, estén o no activos los eventos del ciclo de vida. +- **Disparadores.** El `triggerEventType` de una definición la arranca desde un evento del bus con el payload del evento como entrada; el correlation id de la ejecución se deriva del id del evento, así que un evento reentregado encuentra su ejecución ya creada y se reconoce como una operación nula. +- **Planificaciones.** `#[ScheduledSaga]`, `#[ScheduledTcc]` y `#[ScheduledWorkflow]` arrancan una definición con una cadencia cron, de ritmo fijo o de retardo fijo, bajo el cerrojo de arriba. +- **Verlo.** `GET /actuator/orchestration` (una vez expuesto) enumera las definiciones con sus capas de la topología, el número de ejecuciones, las cartas muertas y los disparadores; el componente de salud `orchestration` informa del store, de las ejecuciones abiertas y obsoletas y de las cartas muertas; con `firefly/observability`, las ejecuciones y los pasos se miden y se trazan. `firefly:cache` compila cada `#[Saga]`, `#[Tcc]` y `#[Workflow]` en `orchestration.php`, y la topología se valida al arrancar — un ciclo, un `dependsOn` desconocido o una compensación que falta impiden el arranque. + +--- + +## Qué significa el modelo síncrono de PHP + +Los pasos de una capa de la topología se ejecutan uno tras otro, en orden de declaración: una capa es la garantía de que cada dependencia terminó antes de que empiece un dependiente, no una promesa de paralelismo. `layerConcurrency`, el `cpuBound` de un paso y el `async` de un paso de workflow se aceptan para que las definiciones se porten sin cambios desde el motor de Java, y no tienen efecto; el endpoint del actuator lo dice en sus `notes`. Para el trabajo que deba salir de la petición, usa un workflow encolado, o arranca la saga desde un evento que consuma un worker. + +!!! laravel "Paridad con Laravel" + `Bus::chain()` de Laravel ejecuta trabajos en orden y se detiene en el primer fallo; `Bus::batch()` los ejecuta juntos y te avisa al final. Ninguno sabe deshacer los trabajos que ya salieron bien, ninguno persiste un punto de control paso a paso desde el que reanudar, y ninguno puede esperar tres días a una señal. `firefly/orchestration` deja la cola de Laravel para lo que hace bien — el trabajo de un workflow ASYNC es un trabajo encolado normal — y añade la compensación, los puntos de control y las esperas duraderas. + +--- + +## Lo que aprendiste {.recap} + +| Concepto | Qué hace | +|---|---| +| `#[Saga]` / `#[SagaStep]` | Pasos hacia delante en capas de `dependsOn`, cada uno su propia unidad de trabajo, con `compensate:` nombrando cómo deshacerlo | +| Compensación | Deshace los pasos completados empezando por el más reciente (`strict-sequential`), o bajo otras cuatro políticas; un reembolso, no un rollback | +| `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 | +| 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 | + +--- + +## Ponlo en práctica {.exercises} + +1. **Cambia la política.** Ejecuta `PayoutSagaTest` con `firefly.orchestration.saga.compensation-policy=best-effort` añadido a `LumenOrchestrationTestCase` y confirma que los tres desenlaces no cambian — luego explica por qué: ninguna compensación de la saga de pagos salientes falla nunca. +2. **Haz que falle una compensación.** En una copia de prueba, haz que `SimulatedBankGateway::cancel()` lance una excepción, vuelve a ejecutar el test del fallo en la confirmación, y lee el estado final, el estado de cada paso en `$result->steps` y `$result->uncompensatedSteps` — una vez con el manejador de errores de compensación por defecto y otra con `firefly.orchestration.saga.compensation-error-handler=fail-fast`. ¿Cuál de los dos reembolsó el monedero, y qué respuesta daría `PayoutController` en cada caso? +3. **Sobrevive a un reinicio.** Copia `OnboardingWorkflow` en una aplicación tuya, apunta `firefly.orchestration.persistence.provider=database` a un servidor real, arranca el workflow en un proceso PHP y entrega `email-verified` desde un segundo proceso con `php artisan firefly:orchestration:signal`. diff --git a/book/src/09a-orchestration.md b/book/src/09a-orchestration.md new file mode 100644 index 00000000..0774c38f --- /dev/null +++ b/book/src/09a-orchestration.md @@ -0,0 +1,393 @@ +Part III — Coordinating and Securing the Application · Chapter 9A + +# Sagas, TCC and Durable Workflows {.chtitle} + +By the end of this chapter you will know what to do when one business operation spans more than one transaction — the line Chapter 9's `#[Transactional]` cannot cross. You will know the three patterns `firefly/orchestration` ships and when each one fits: a **saga** (`#[Saga]`, `#[SagaStep]`) runs forward steps in `dependsOn` order and undoes the completed ones with their compensations; a **TCC** (`#[Tcc]`, `#[TccParticipant]`, `#[TryMethod]`, `#[ConfirmMethod]`, `#[CancelMethod]`) reserves with every participant before it confirms any; a **durable workflow** (`#[Workflow]`, `#[WorkflowStep]`) waits for signals (`#[WaitForSignal]`) and timers (`#[WaitForTimer]`) across processes. You will run Lumen's `payout` saga end to end and watch it compensate in reverse, and you will know where its checkpoints, its recovery sweep and its dead letters live (`firefly.orchestration.persistence.provider`, `firefly:orchestration:dlq`), how the scheduler's distributed lock (`firefly.scheduling.lock.provider`) keeps a timer tick on one node, and what PHP's synchronous model means for steps in the same layer. + +!!! note "New term: saga" + A **saga** is a sequence of local transactions, each of which commits on its own, paired with a **compensation** for every step that changed something — an action that semantically undoes it. If step three fails, nothing rolls steps one and two back for you; the saga runs their compensations, newest first. A compensation is not a rollback: a refunded payment is a second payment in the other direction, and both are in the ledger. + +--- + +## When one transaction is not enough + +Chapter 9 ended on `Transfer`: two wallets, one database, one `#[Transactional]` boundary, and money that cannot vanish because the debit and the credit commit or roll back together. A **payout** is different. The money leaves the wallet in Lumen's database and arrives at a bank, which has its own database, its own transactions and its own idea of when something is final. No transaction can enlist both, so the honest design is a sequence of steps that each commit, and an explicit undo for each one that the next step's failure would otherwise strand. That is a saga, and the next section is the one Lumen runs. + +--- + +## Lumen's payout saga + +The bank is a port, because it is another system: + + +```php +interface BankGateway +{ + public function submit(string $iban, int $amountMinor): string; + + public function confirm(string $reference): void; + + public function cancel(string $reference): void; +} +``` + +`SimulatedBankGateway` is the sample's adapter: it refuses an IBAN that starts with `XX` when the transfer is submitted, and it refuses to confirm one above its daily limit of 1,000.00 — the two refusals a real bank gives, which is what the saga needs something to compensate. The saga itself is a bean with one `#[SagaStep]` per forward step: + + +```php +#[Service] +#[Saga(name: 'payout')] +final class PayoutSaga +{ + public function __construct( + private readonly CommandBus $commands, + private readonly BankGateway $bank, + ) {} + + #[SagaStep(id: 'debit', compensate: 'refund')] + public function debit(#[Input('walletId')] string $walletId, #[Input('amountMinor')] int $amountMinor): int + { + $balance = $this->commands->send(new Withdraw($walletId, $amountMinor)); + + return is_int($balance) ? $balance : 0; + } + + public function refund(#[Input('walletId')] string $walletId, #[Input('amountMinor')] int $amountMinor): void + { + $this->commands->send(new Deposit($walletId, $amountMinor)); + } + + #[SagaStep(id: 'submit', dependsOn: ['debit'], compensate: 'cancel')] + public function submit(#[Input('iban')] string $iban, #[Input('amountMinor')] int $amountMinor): string + { + return $this->bank->submit($iban, $amountMinor); + } + + public function cancel(string $reference): void + { + $this->bank->cancel($reference); + } + + #[SagaStep(id: 'confirm', dependsOn: ['submit'])] + public function confirm(#[FromStep('submit')] string $reference): string + { + $this->bank->confirm($reference); + + return $reference; + } +} +``` + +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. + +--- + +## Running it, and watching it undo itself + +`SagaEngine::execute('payout', $input)` runs the saga and returns a `SagaResult`. The sample's test drives the case that matters most — the bank accepts the transfer and then refuses to execute it: + + +```php + // 1,500.00 is over the simulated bank's daily limit: submitted, then refused at confirmation. + $result = $this->app()->make(SagaEngine::class)->execute('payout', ['walletId' => $id, 'iban' => 'ES9121000418450200051332', 'amountMinor' => 150000]); + + expect($result->success)->toBeFalse() + ->and($result->status)->toBe(ExecutionStatus::Failed) + ->and($result->firstErrorStepId())->toBe('confirm') + ->and($result->compensatedSteps())->toBe(['debit', 'submit']) + ->and($this->app()->make(SimulatedBankGateway::class)->calls)->toBe(['submit bank-1', 'limit bank-1', 'cancel bank-1']) + ->and($queries->ask(new GetBalance($id)))->toBe(200000) + ->and(DB::table(OrchestrationSchema::EXECUTIONS)->where('correlation_id', $result->correlationId)->value('status'))->toBe('FAILED'); +``` + +The bank's own call log tells the story in order: the transfer was submitted, refused at confirmation, and canceled; then the wallet was refunded, and its balance is exactly what it was. The three outcomes the test file covers: + +| Input | Fails at | Compensated | Bank calls | Wallet afterwards | +|---|---|---|---|---| +| a valid IBAN, 200.00 | — | — | `submit bank-1`, `confirm bank-1` | debited by 200.00 | +| `XX0012345678`, 200.00 | `submit` | `debit` | `refused XX0012345678` | unchanged | +| a valid IBAN, 1,500.00 | `confirm` | `submit`, then `debit` | `submit bank-1`, `limit bank-1`, `cancel bank-1` | unchanged | + +`compensatedSteps()` lists the undone steps in declaration order; the order they **ran** in is the reverse — the default policy undoes the newest completed step first, which is why the bank was told to cancel before the wallet was refunded. The tutorial's greeting-card saga, whose journal records every call, asserts that order call by call in `tests/TutorialGreetingCardSagaTest.php`: the charge is refunded, then the card is released. + +--- + +## Over HTTP + +`PayoutController` exposes the saga at `POST /api/v1/wallets/{id}/payouts`: + + +```php + $result = $this->sagas->execute('payout', ['walletId' => $id, 'iban' => $body->iban, 'amountMinor' => $body->amount_minor]); + + if (! $result->success) { + // A compensation that did not finish: money may have left the wallet and not come back. That is never a + // business refusal; it is the one outcome an operator must act on, so it gets its own server-side answer + // naming the steps left undone and the payout id the execution is checkpointed and dead-lettered under. + // Asked of the steps as well as the status: under the default strict-sequential policy a compensation + // that throws marks its step COMPENSATION_FAILED and the error handler continues, so the saga still + // ends FAILED, while a policy that stops the sweep ends it COMPENSATION_FAILED. + $undone = $this->undoneSteps($result); + if ($undone !== [] || $result->status === ExecutionStatus::CompensationFailed) { + throw (new FireflyException( + sprintf('Payout %s failed at step "%s" and was not fully undone; steps left uncompensated: %s.', $result->correlationId, (string) $this->failingStep($result), implode(', ', $undone)), + 'PAYOUT_COMPENSATION_FAILED', + 500, + ErrorCategory::Internal, + ErrorSeverity::Critical, + $result->error, + ))->withExtensions(['payout_id' => $result->correlationId, 'uncompensated_steps' => $undone]); + } + + // The debit itself was refused, so nothing was undone: the bus's own refusal — 401, 403, 404, or the 409 + // of an overdraw — is the honest answer, exactly as POST /withdraw would have given it. Asked of the debit + // step's own status, not firstErrorStepId(), which also counts a step whose compensation failed. + $debit = $result->steps['debit'] ?? null; + if ($debit !== null && $debit->status === StepStatus::Failed && $result->error !== null) { + throw $result->error; + } + + throw new ConflictException( + sprintf('Payout %s failed at step "%s" and ended %s: %s', $result->correlationId, (string) $this->failingStep($result), $result->status->value, $result->error?->getMessage() ?? 'no error was recorded'), + 'PAYOUT_FAILED', + $result->error, + ); + } +``` + +Three kinds of failure, three answers. When a compensation did not finish — the refund itself threw, or a policy stopped the sweep — money may have left the wallet and not come back, so that is never reported as a business refusal: the client gets a 500 `PAYOUT_COMPENSATION_FAILED` whose `payout_id` and `uncompensated_steps` tell an operator which execution to look up and what is left to undo. The check reads the steps as well as the status, because under the default handler a compensation that throws marks its step `COMPENSATION_FAILED` and the saga still ends `FAILED`. When the debit itself was refused, nothing happened and nothing was undone, so the controller rethrows the bus's own exception and the web layer renders it exactly as it renders a failed withdraw — an overdraw is the same 409 problem+json, a missing role the same 403. When a later step failed and every completed step was undone, the client gets a 409 `PAYOUT_FAILED` whose message names the step and the final status. `PayoutRestTest` covers all of them, the failed refund included, and a successful payout answers 201 with the bank's reference. + +--- + +## Compensation policies + +`firefly.orchestration.saga.compensation-policy` decides how completed steps are undone: + +| Policy | Behavior | +|---|---| +| `strict-sequential` (default) | Undo in reverse completion order, one at a time. | +| `grouped-parallel` | Undo layer by layer, in reverse layer order (sequentially inside a layer — PHP is synchronous). | +| `retry-with-backoff` | Like `strict-sequential`, retrying each compensation with its `compensationRetry` and `compensationBackoffMs`. | +| `circuit-breaker` | Stop compensating once a `compensationCritical` step fails or the breaker opens; the saga ends `COMPENSATION_FAILED`, is dead-lettered, and lists what it left in `SagaResult::$uncompensatedSteps`. | +| `best-effort` | Run every compensation and record each failure without stopping. | + +`firefly.orchestration.saga.compensation-error-handler` decides what one failed compensation does to the run — `default`, `fail-fast`, `log-and-continue`, `retry`, `robust`, or the class name of your own `CompensationErrorHandler` bean. A step can also carry `retry`, `backoffMs`, `timeoutMs` and an `idempotencyKey` on its `#[SagaStep]`; a step's retries run before the saga gives up on it. + +--- + +## 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`: + + +```php +'persistence' => [ + 'provider' => env('FIREFLY_ORCHESTRATION_PERSISTENCE', 'memory'), + 'connection' => env('FIREFLY_ORCHESTRATION_DB_CONNECTION'), + 'cache-store' => env('FIREFLY_ORCHESTRATION_CACHE_STORE'), + 'key-prefix' => 'orchestration:', + 'key-ttl' => null, + 'retention-period' => env('FIREFLY_ORCHESTRATION_RETENTION', 'P7D'), + 'cleanup-interval' => '1h', +], +// … +'recovery' => [ + 'enabled' => env('FIREFLY_ORCHESTRATION_RECOVERY_ENABLED', true), + 'stale-threshold' => env('FIREFLY_ORCHESTRATION_STALE_THRESHOLD', '1h'), + 'action' => env('FIREFLY_ORCHESTRATION_RECOVERY_ACTION', 'report'), +], +// … +'dlq' => [ + 'enabled' => true, + 'store' => env('FIREFLY_ORCHESTRATION_DLQ_STORE', 'memory'), +], +``` + +The checkpoints are what make a crash survivable. A scheduled recovery sweep finds executions not updated for `recovery.stale-threshold` and, by `recovery.action`, reports them (`report`), runs them on from their last checkpoint (`resume`) or undoes them (`compensate`); the same sweep deletes terminal executions older than `persistence.retention-period`. A failed execution becomes a dead letter — one entry per failed step, `memory` or `database` — and retrying one starts the same definition again with the same input. Operators reach all of it from the command line: + + +```bash +php artisan vendor:publish --tag=firefly-orchestration-migrations +php artisan migrate +php artisan firefly:orchestration:list --name=payout --status=failed +php artisan firefly:orchestration:show 7c1d0e8a-5b4f-4c3e-9a21-0f6e2d4b8c13 +php artisan firefly:orchestration:dlq --name=payout +php artisan firefly:orchestration:dlq:retry 3f2a9c4e-8d1b-4e6f-a0c7-5b9e2d1f4a86 +``` + +--- + +## TCC: reserve everywhere, then confirm + +A saga commits each step and undoes it later. **TCC** — try, confirm, cancel — is the stricter shape for when nothing may be visible until everyone agreed: each participant first *tries* (reserves, without committing to the outcome), and only when every try succeeded are they all *confirmed*; if any try fails, every participant that tried is *canceled*. The participants are separate beans named by `#[TccParticipant]`, in `order`; this is the orchestration capstone's debit side of a transfer: + + +```php +#[Tcc(name: 'transfer', maxRetries: 0, backoffMs: 1)] +final class TransferTcc {} +``` + + +```php +#[Service] +#[TccParticipant(tcc: 'transfer', id: 'debit', order: 1)] +final class DebitAccount +{ + public function __construct(private readonly Ledger $ledger) {} + + #[TryMethod] + public function hold(#[Input('amount')] int $amount): string + { + $this->ledger->record("hold {$amount}"); + + return "hold-{$amount}"; + } + + #[ConfirmMethod] + public function commit(#[FromTry] string $hold): void + { + $this->ledger->record("commit {$hold}"); + } + + #[CancelMethod] + public function release(#[FromTry] string $hold): void + { + $this->ledger->record("release {$hold}"); + } +} +``` + +`#[FromTry]` hands confirm and cancel the value the try returned — here the hold's id — so a participant never has to look its own reservation up again. A confirmed TCC ends `CONFIRMED`, a canceled one `CANCELED`; with `recovery.action=resume`, a process that died in `CONFIRMING` is driven on to `CONFIRMED`, confirming only the participants that had not confirmed yet. + +--- + +## Durable workflows: waiting for the world + +Sagas and TCCs run start to finish inside one call. A **workflow** may wait — for a person to click a link, for a day to pass — and survive the process that started it. `#[WaitForSignal]` parks the execution `WAITING` until a signal is delivered; `#[WaitForTimer]` parks it until the timer is due. The capstone's onboarding workflow does both: + + +```php +#[Service] +#[Workflow(id: 'onboarding')] +final class OnboardingWorkflow +{ + public function __construct(private readonly Ledger $ledger) {} + + #[WorkflowStep(id: 'register')] + public function register(#[Input('email')] string $email, ExecutionContext $ctx): string + { + $ctx->setSearchAttribute('email', $email); + $this->ledger->record("register {$email}"); + + return "user-{$email}"; + } + + #[WorkflowStep(id: 'verify', dependsOn: ['register'])] + #[WaitForSignal('email-verified')] + public function verify(#[Variable('signals.email-verified')] mixed $proof): string + { + $this->ledger->record('verified'); + + return 'verified'; + } + + #[WorkflowStep(id: 'cool-off', dependsOn: ['verify'])] + #[WaitForTimer(delayMs: 1, timerId: 'cool-off')] + public function coolOff(): string + { + $this->ledger->record('cooled off'); + + return 'cooled'; + } + + #[WorkflowQuery('progress')] + public function progress(ExecutionContext $ctx): int + { + return count($ctx->results()); + } +} +``` + +`WorkflowEngine::start()` runs `register` and parks at `verify`; the checkpoint is written and the request ends. Later — another request, another process — `WorkflowEngine::signal($correlationId, 'email-verified', $payload)` delivers the signal, which the step reads as the variable `signals.email-verified`, and the workflow runs on until the timer parks it again. The scheduled tick (`firefly.orchestration.workflow.tick.*`, every minute by default) advances due timers; a due workflow the tick cannot drive is backed off, doubling from one minute, and parked `FAILED` and dead-lettered after `workflow.max-resume-attempts` failed ticks. `#[WorkflowQuery]` answers a read-only question about a live execution, and `setSearchAttribute()` makes it findable. `#[WaitForAll]`, `#[WaitForAny]`, `#[ChildWorkflow]`, step conditions, `ContinueAsNew` and queued (`triggerMode: TriggerMode::Async`) workflows are in the module guide. + + +```bash +php artisan firefly:orchestration:signal 7c1d0e8a-5b4f-4c3e-9a21-0f6e2d4b8c13 email-verified --payload='{"token":"t-1"}' +php artisan firefly:orchestration:tick +``` + +--- + +## One node at a time: the scheduler's lock + +The tick, the recovery sweep and every `#[ScheduledSaga]`, `#[ScheduledTcc]` and `#[ScheduledWorkflow]` run on the framework's schedule exactly as a `#[Scheduled]` task of your own does, each under a lock name of its own; and every writer of one execution takes a per-execution lock first. On one server, nothing more is needed. On three, three schedulers fire the same tick in the same minute — so a task guarded with `#[Scheduled(lock: …)]` asks a `DistributedLock` first, and only the node that wins runs it. `firefly.scheduling.lock.provider` picks the backend: + + +```php +'scheduling' => [ + 'lock' => [ + 'provider' => env('FIREFLY_SCHEDULING_LOCK', 'none'), + // … + ], + // … +], +``` + +| `firefly.scheduling.lock.provider` | Backend | Works on | Notes | +|---|---|---|---| +| `none` (default) | `NoneLock` | anything | every acquire succeeds — correct for one node, no exclusion across several | +| `cache` | `CacheLock` | a lock-capable cache store | `database`, `redis` or `memcached` across nodes; `array` is per process | +| `database` | `DatabaseLeaseLock` | any Laravel database connection | a lease row per lock name in `firefly_scheduling_locks`; the TTL is honored on every driver | +| `mysql` | `MySqlNamedLock` | MySQL, MariaDB | `GET_LOCK`/`RELEASE_LOCK`, held by the session, no TTL | +| `postgres` | `PgAdvisoryLock` | PostgreSQL | session-scoped advisory lock | + +`DatabaseLeaseLock` is the one that needs nothing beyond the database you already have: acquiring is a conditional `UPDATE` that takes over an expired lease, or an `INSERT` when the row does not exist (inside a savepoint when a transaction is open, so a lost race never aborts your unit of work); releasing deletes the row only while it still carries this node's token, so a task that overran its TTL cannot release the lease another node has taken since. The orchestration lock rides on the same provider, held at most `firefly.orchestration.lock.ttl` and waited for at most `firefly.orchestration.lock.wait`: + + +```php +'lock' => [ + 'ttl' => '5m', + 'wait' => '5s', +], +``` + +--- + +## Events, triggers and operating it + +- **Lifecycle events.** With `firefly.orchestration.events.enabled`, every execution publishes `EXECUTION_STARTED`, `EXECUTION_COMPLETED`, `STEP_COMPLETED` and `STEP_FAILED` through the `EventPublisher` (Chapter 8) — a workflow publishes its step events only with `publishEvents: true`. A `#[StepEvent]` on a step publishes that step's completion to the topic and type it names, whether or not the lifecycle events are on. +- **Triggers.** A definition's `triggerEventType` starts it from an event-bus event with the event's payload as input; the execution's correlation id is derived from the event id, so a redelivered event finds its execution already there and is acknowledged as a no-op. +- **Schedules.** `#[ScheduledSaga]`, `#[ScheduledTcc]` and `#[ScheduledWorkflow]` start a definition on a cron, fixed-rate or fixed-delay cadence, under the lock above. +- **Seeing it.** `GET /actuator/orchestration` (once exposed) lists definitions with their topology layers, execution counts, dead letters and triggers; the `orchestration` health component reports the store, open and stale executions and dead letters; with `firefly/observability`, executions and steps are timed and traced. `firefly:cache` compiles every `#[Saga]`, `#[Tcc]` and `#[Workflow]` into `orchestration.php`, and the topology is validated at boot — a cycle, an unknown `dependsOn` or a missing compensation refuses to start. + +--- + +## What PHP's synchronous model means + +Steps of one topology layer run one after another, in declaration order: a layer is a guarantee that every dependency finished before a dependent starts, not a promise of parallelism. `layerConcurrency`, a step's `cpuBound` and a workflow step's `async` are accepted so definitions port unchanged from the Java engine, and have no effect; the actuator endpoint says so in its `notes`. For work that should leave the request, use a queued workflow, or start the saga from an event a worker consumes. + +!!! laravel "Laravel parity" + Laravel's `Bus::chain()` runs jobs in order and stops at the first failure; `Bus::batch()` runs them together and calls you back. Neither knows how to undo the jobs that already succeeded, neither persists a step-by-step checkpoint you can resume from, and neither can wait three days for a signal. `firefly/orchestration` keeps Laravel's queue for what it is good at — the ASYNC workflow job is an ordinary queued job — and adds the compensation, the checkpoints and the durable waits. + +--- + +## What you learned {.recap} + +| Concept | What it does | +|---|---| +| `#[Saga]` / `#[SagaStep]` | Forward steps in `dependsOn` layers, each its own unit of work, with `compensate:` naming its undo | +| Compensation | Undoes completed steps newest first (`strict-sequential`), or under four other policies; a refund, not a rollback | +| `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 | +| 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 | + +--- + +## Try it yourself {.exercises} + +1. **Change the policy.** Run `PayoutSagaTest` with `firefly.orchestration.saga.compensation-policy=best-effort` added to `LumenOrchestrationTestCase` and confirm the three outcomes do not change — then explain why: no compensation in the payout saga ever fails. +2. **Make a compensation fail.** In a scratch copy, make `SimulatedBankGateway::cancel()` throw, rerun the confirmation-failure test, and read the final status, each step's status in `$result->steps` and `$result->uncompensatedSteps` — once with the default compensation error handler and once with `firefly.orchestration.saga.compensation-error-handler=fail-fast`. Which of the two refunded the wallet, and which answer would `PayoutController` give for each? +3. **Survive a restart.** Copy `OnboardingWorkflow` into an application of your own, point `firefly.orchestration.persistence.provider=database` at a real server, start the workflow in one PHP process, and deliver `email-verified` from a second one with `php artisan firefly:orchestration:signal`. diff --git a/docs/book.md b/docs/book.md index cdedaaac..be38284a 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, seventeen chapters, a Laravel cheat-sheet, +Both editions cover the same application: a quick start, eighteen 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/index.md b/docs/index.md index d74169b1..fcf7793d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -126,7 +126,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 — 17 chapters plus appendices, bilingual (English + Spanish), building this exact sample — is available +book — 18 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 From 8bbc12907be4ca6dd769a23e765dd8d986976f2e Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 09:05:44 -0700 Subject: [PATCH 14/19] =?UTF-8?q?docs(book):=20Chapter=2012=20runs=20the?= =?UTF-8?q?=20same=20tests=20on=20every=20real=20server=20=E2=80=94=20Inte?= =?UTF-8?q?grationDsn,=20the=20CI=20lanes=20and=20what=20SQL=20Server=20te?= =?UTF-8?q?aches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- book/src-es/12-testing.md | 131 +++++++++++++++++++++++++++++++++++++- book/src/12-testing.md | 131 +++++++++++++++++++++++++++++++++++++- 2 files changed, 260 insertions(+), 2 deletions(-) diff --git a/book/src-es/12-testing.md b/book/src-es/12-testing.md index 487d3f03..88f69fc0 100644 --- a/book/src-es/12-testing.md +++ b/book/src-es/12-testing.md @@ -2,7 +2,7 @@ # Probar Aplicaciones LaraFly {.chtitle} -Al terminar este capítulo conocerás las dos familias de arnés de arranque de `firefly/testing` — `FireflyTestCase`/`FireflyDatabaseTestCase` para pruebas de clase al estilo de Testbench, y `bootFireflyApp()`/`fireflyApplication()` para arranques desnudos sin ninguna cola de framework de pruebas — los once dobles de grabación que permiten a una prueba afirmar sobre lo que un puerto de Firefly realmente vio (algo que los propios `Event::fake()`/`Bus::fake()` de Laravel no pueden hacer, porque nunca han oído hablar de los puertos de Firefly), las cinco expectativas de Pest con sabor a Firefly, el par `WebSliceTestCase`/`DataSliceTestCase` que arranca solo los beans que una prueba necesita, la suite de navegador de Pest 4 que conduce el **esqueleto que se entrega** en el mismo proceso — mismo contenedor, misma base de datos SQLite, sin un segundo servidor — y por qué `assertNoJavaScriptErrors()` es la aserción que se lo gana, los ayudantes que autentican un test (`actingAsAuthentication()` y el par `actingAsPrincipal()`/`actingAsOidcUser()` construido sobre él, el declarativo `#[WithMockUser]`, y `withoutSecurity()` para la dirección contraria), y — porque este libro solo enseña lo que realmente se entrega — una limitación real y honesta con la que se toparon las propias pruebas de `samples/lumen` y cómo la sortearon. +Al terminar este capítulo conocerás las dos familias de arnés de arranque de `firefly/testing` — `FireflyTestCase`/`FireflyDatabaseTestCase` para pruebas de clase al estilo de Testbench, y `bootFireflyApp()`/`fireflyApplication()` para arranques desnudos sin ninguna cola de framework de pruebas — los once dobles de grabación que permiten a una prueba afirmar sobre lo que un puerto de Firefly realmente vio (algo que los propios `Event::fake()`/`Bus::fake()` de Laravel no pueden hacer, porque nunca han oído hablar de los puertos de Firefly), las cinco expectativas de Pest con sabor a Firefly, el par `WebSliceTestCase`/`DataSliceTestCase` que arranca solo los beans que una prueba necesita, la suite de navegador de Pest 4 que conduce el **esqueleto que se entrega** en el mismo proceso — mismo contenedor, misma base de datos SQLite, sin un segundo servidor — y por qué `assertNoJavaScriptErrors()` es la aserción que se lo gana, los ayudantes que autentican un test (`actingAsAuthentication()` y el par `actingAsPrincipal()`/`actingAsOidcUser()` construido sobre él, el declarativo `#[WithMockUser]`, y `withoutSecurity()` para la dirección contraria), cómo las mismas pruebas de integración corren en CI contra servidores reales de PostgreSQL, MySQL, MariaDB, SQL Server y SQLite (`IntegrationDsn`, `composer test:integration`, `docker-compose.integration.yml`), y — porque este libro solo enseña lo que realmente se entrega — una limitación real y honesta con la que se toparon las propias pruebas de `samples/lumen` y cómo la sortearon. !!! note "Término nuevo: prueba de rebanada (slice test)" Una **prueba de rebanada** arranca solo la estrecha vertical del framework que una prueba realmente ejercita — el pipeline web sobre un controlador, o el pipeline de datos sobre un repositorio — en lugar de toda la aplicación. Es la idea de `@WebMvcTest`/`@DataJpaTest` de Spring Boot: arranques más rápidos, y una rebanada mal cableada falla de inmediato en lugar de funcionar silenciosamente por accidente porque algún bean no relacionado resultó estar presente también. @@ -512,6 +512,133 @@ Alimenta el resultado directamente al `configOverrides()` de una subclase de `Fi --- +## Servidores reales en CI: los carriles de integración + +`RequiresDocker` responde a una sola pregunta: ¿hay un demonio de Docker? El trabajo que hizo portables el outbox, los bloqueos del planificador y el almacén de orquestación necesitaba una respuesta más fuerte: la misma prueba, ejecutada contra cada servidor real que el framework dice soportar. Eso son los **carriles de integración**, y toda prueba de este repositorio cuyo SQL no puede ejercitar una prueba sqlite `:memory:` pertenece a uno. Cada carril se nombra con una variable de entorno: + +| Variable | Backend | Carril | +|---|---|---| +| `FIREFLY_PG_DSN` | PostgreSQL 16 | `pgsql` | +| `FIREFLY_MYSQL_DSN` | MySQL 8.4 | `mysql` | +| `FIREFLY_MARIADB_DSN` | MariaDB 11 | `mariadb` | +| `FIREFLY_SQLSRV_DSN` | SQL Server 2022 | `sqlsrv` | +| *(ninguna)* | un fichero SQLite en modo WAL | `sqlite` | +| `FIREFLY_RABBITMQ_DSN`, `FIREFLY_KAFKA_BROKERS`, `FIREFLY_REDIS_URL` | los brokers | `brokers` | + +Un único helper las lee todas. `Firefly\Testing\Integration\IntegrationDsn` acepta DSN con la forma `host=…;port=…;dbname=…;user=…;password=…` o en forma de URL, trata una variable vacía como si no estuviera definida y dice por qué un carril no puede correr aquí — o nada, cuando sí puede: + + +```php +/** Why $lane cannot run here, or null when it can. */ +public static function skipReason(string $lane): ?string +{ + $database = self::DATABASES[$lane] ?? throw new InvalidArgumentException(sprintf('Unknown integration lane [%s]; known: %s.', $lane, implode(', ', self::lanes()))); + + if (! extension_loaded($database['extension'])) { + return sprintf('requires ext-%s for the %s integration lane', $database['extension'], $lane); + } + + if ($database['variable'] !== null && self::missing($database['variable'])) { + return sprintf('Set %s (host=…;port=…;dbname=…;user=…;password=…) to run the %s integration lane.', $database['variable'], $lane); + } + + return null; +} +``` + +Una variable que falta es un salto, nunca un fallo, así que un portátil sin servidores sigue ejecutando el grupo limpiamente. `connectionConfig($lane)` convierte un carril en la configuración de una conexión de Laravel, y `connect($lane)` abre una conexión independiente — la que usa un segundo proceso cuando una prueba pone a competir a dos consumidores. El viaje de ida y vuelta del outbox de Lumen muestra la forma que toma una prueba arrancada: un trait lee el carril del dataset de la prueba, salta **antes** de que la aplicación arranque cuando el carril no puede correr, y hace de la base de datos de ese carril la conexión por defecto, de modo que los repositorios, el outbox y el consumidor la usan todos: + + +```php + protected function setUp(): void + { + $reason = IntegrationDsn::skipReason($this->lane()); + if ($reason !== null) { + $this->markTestSkipped($reason); + } + + parent::setUp(); + } + + protected function resolveApplicationConfiguration($app): void + { + parent::resolveApplicationConfiguration($app); + + $lane = $this->lane(); + $overrides = $lane === 'sqlite' ? ['database' => IntegrationDsn::sqlitePath('lumen-outbox')] : []; + + /** @var Repository $config */ + $config = $app->make('config'); + $config->set('database.connections.lumen-it', IntegrationDsn::connectionConfig($lane, $overrides)); + $config->set('database.default', 'lumen-it'); + } +``` + +Un `it()` se convierte entonces en una prueba por carril con `->with(IntegrationDsn::lanes())`, y `->group('integration')` las mantiene todas fuera de `composer test`. En local, el fichero de compose del repositorio arranca los mismos servidores que usa CI: + + +```bash +docker compose -f docker-compose.integration.yml up -d # PostgreSQL, MySQL, MariaDB y los brokers +export FIREFLY_PG_DSN='host=127.0.0.1;port=5432;dbname=firefly;user=firefly;password=firefly' +composer test:integration # pest --group integration +``` + +SQL Server está detrás del perfil `sqlsrv` del fichero de compose, porque su imagen solo existe para x86-64. En CI el job `integration` es una matriz sobre los cuatro servidores, cada uno un contenedor de `services:` que exporta solo su propio DSN, junto a un job `brokers` para RabbitMQ, Kafka (con `ext-rdkafka`) y Redis: + + +```yaml + integration: + name: Integration (${{ matrix.lane }}) + runs-on: ubuntu-latest + # A lock wait that never times out must fail the lane, not hold a runner for six hours. + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + include: + - lane: pgsql + image: postgres:16 + # … + - lane: sqlsrv + image: mcr.microsoft.com/mssql/server:2022-latest + # … + - name: Pest (integration, ${{ matrix.lane }}) + env: + FIREFLY_INTEGRATION_LANE: ${{ matrix.lane }} + run: | + export ${{ matrix.dsn-variable }}='${{ matrix.dsn }}' + composer test:integration +``` + +Un carril no debe quedar en verde saltándolo todo, y saltar es exactamente lo que haría una exportación de DSN rota. Por eso CI define `FIREFLY_INTEGRATION_LANE`, y `tests/Integration/IntegrationLaneTest.php` **falla** entonces salvo que las variables de ese carril estén definidas, su extensión de PHP esté cargada y su servidor responda. En un portátil la variable no está definida y esa prueba salta como las demás. + +### Lo que enseña SQL Server + +SQL Server es el carril que encuentra lo que los otros cuatro perdonan, y conviene conocer tres de sus lecciones antes de escribir una prueba contra él. La primera: sin `READ_COMMITTED_SNAPSHOT`, que viene desactivado por defecto, un `SELECT` normal desde una segunda conexión **espera** a una fila que este proceso insertó y no ha confirmado — en una prueba, para siempre, porque quien escribe es la propia prueba. Para afirmar que una fila todavía no es visible, lee saltándote las filas bloqueadas: + + +```php + // SQL Server without READ_COMMITTED_SNAPSHOT makes a plain read WAIT on the uncommitted row (here, forever: + // the writer is this process); READPAST reads what is committed, which is the claim being made. + $visible = $reader->table(OutboxSchema::TABLE); + expect(($reader->getDriverName() === 'sqlsrv' ? $visible->lock('with(readpast)') : $visible)->count())->toBe(0); +``` + +La segunda: un `SET` de sesión hecho mediante una sentencia preparada no se mantiene. `pdo_sqlsrv` ejecuta cada sentencia preparada a través de `sp_prepexec`, y SQL Server deshace un `SET` hecho dentro de ella cuando la llamada termina — por eso `#[Transactional(timeout:)]` aplica su `SET LOCK_TIMEOUT` con `unprepared()`, nunca con `statement()`: + + +```php + // unprepared(), never statement(): pdo_sqlsrv runs a prepared statement through sp_prepexec, and SQL Server + // restores a SET made inside it when that call returns, so the session would keep waiting forever. + /** @var literal-string $set an integer formatted by %d: nothing but digits reaches the batch */ + $set = sprintf('set lock_timeout %d', $seconds * 1000); + $connection->unprepared($set); +``` + +La tercera: los recorridos por rango y `MERGE` se bloquean ante las filas sin confirmar de otros workers, así que todo lo que corre en concurrencia debería actualizar e insertar por clave primaria exacta — la reclamación `read-past` del outbox del Capítulo 8 es la forma que funciona. Y como una espera de bloqueo que nunca termina es el fallo que SQL Server produce en lugar de un error, el `timeout-minutes: 25` del job de CI lo convierte en un carril fallido en vez de en un runner retenido durante seis horas. + +--- + ## Prueba desde Lumen: cómo la muestra hace dogfooding de todo `samples/lumen` construye su propia base de prueba, `LumenTestCase`, encima de `FireflyDatabaseTestCase` — el patrón sobre el que cada capítulo de este libro ha estado descansando en última instancia: @@ -773,6 +900,7 @@ El Capítulo 13A prueba el `WalletRollout` incluido en Lumen con `withFeatureFla | `#[FireflyTest]` / `#[WebSlice]` / `#[DataSlice]` | Análogo de atributo de clase de las mismas tres formas, para clases de prueba escritas a mano | | `FixtureRegistry` / `AggregateSeeder` / `ListenerSpy` | Fixtures con nombre; reproducción de eventos de dominio; un spy compartido de orden de llamada | | `RequiresDocker` / `fireflyConfigFor()` | Opt-in de `@group integration`; salta-no-falla sin Docker; mapeo de testcontainer → config | +| `IntegrationDsn` / `composer test:integration` | Un único helper lee el DSN de cada carril y salta el carril que no puede correr; CI ejecuta el grupo `integration` por servidor más un job `brokers`, e `IntegrationLaneTest` hace fallar al carril que lo saltó todo | | La suite `browser` | Pest 4 + `pest-plugin-browser` sirviendo el **esqueleto** arrancado con Testbench en proceso — mismo contenedor, mismo SQLite, sin un segundo servidor | | Las dos suites de `phpunit.xml.dist` | `tests/Browser` excluida de `unit`, así que `composer check` se mantiene libre de Node; `composer test:browser` la ejecuta a solas | | `assertNoJavaScriptErrors()` | La afirmación que paga la suite: una página que se renderiza y lanza en la consola se ve bien en una captura | @@ -793,3 +921,4 @@ El Capítulo 13A prueba el `WalletRollout` incluido en Lumen con `withFeatureFla 5. **Añade una prueba de integración protegida por `RequiresDocker`.** Elige un repositorio respaldado por Eloquent, escribe una prueba `@group integration` contra un testcontainer Postgres real usando `fireflyConfigFor()`, y confirma que `vendor/bin/pest` (sin banderas) la salta por completo mientras que `vendor/bin/pest --group=integration` la ejecuta de verdad. 6. **Comprueba que `$forwardTo` importa.** Enlaza un `RecordingAuthenticationEvents` sin `$forwardTo` desde `defineFireflyEnvironment()`, registra un `#[AsEventListener]` sobre `InteractiveAuthenticationSuccessEvent` que escriba una fila, e inicia sesión: confirma que el espía graba el evento y que el listener nunca se ejecutó. Después vuelve a enlazarlo envolviendo el publicador propio del framework y confirma que ocurren las dos cosas. Por último mueve la llamada a `instance()` a *después* del arranque y confirma que el espía no graba nada en absoluto — el momento del enlace es todo el truco. 7. **Pasa una puerta de dos formas.** Toma una prueba de controlador que reciba un `401`, y haz que pase dos veces: una con `#[WithMockUser(roles: ['ADMIN'])]` sobre el método, otra con `withoutSecurity()`. Después pon `#[WithMockUser]` a nivel de clase y sobrescríbelo en un solo método, y confirma que gana el de nivel de método. +8. **Ejecuta tú un carril.** Arranca los servicios de compose, exporta `FIREFLY_PG_DSN` y ejecuta `composer test:integration -- samples/lumen/tests/Integration`. Confirma que pasan las entradas `pgsql` y `sqlite` y que cada uno de los demás carriles indica la variable o la extensión que le falta. diff --git a/book/src/12-testing.md b/book/src/12-testing.md index a823e0ad..41db0461 100644 --- a/book/src/12-testing.md +++ b/book/src/12-testing.md @@ -2,7 +2,7 @@ # Testing LaraFly Applications {.chtitle} -By the end of this chapter you will know `firefly/testing`'s two boot-harness families — `FireflyTestCase`/`FireflyDatabaseTestCase` for Testbench-style class tests, and `bootFireflyApp()`/`fireflyApplication()` for bare boots with no test framework glue at all — the eleven recording doubles that let a test assert on what a Firefly port actually saw (something Laravel's own `Event::fake()`/`Bus::fake()` cannot do, because they have never heard of Firefly's ports), the five Firefly-flavored Pest expectations, the `WebSliceTestCase`/`DataSliceTestCase` pair that boots only the beans a test needs, the Pest 4 browser suite that drives the **shipped skeleton** in the same process — same container, same SQLite database, no second server — and why `assertNoJavaScriptErrors()` is the assertion that earns it, the helpers that sign a test in (`actingAsAuthentication()` and the `actingAsPrincipal()`/`actingAsOidcUser()` pair built on it, the declarative `#[WithMockUser]`, and `withoutSecurity()` for the other direction), and — because this book only teaches what is actually shipped — a real, honest limitation `samples/lumen`'s own tests ran into and how they worked around it. +By the end of this chapter you will know `firefly/testing`'s two boot-harness families — `FireflyTestCase`/`FireflyDatabaseTestCase` for Testbench-style class tests, and `bootFireflyApp()`/`fireflyApplication()` for bare boots with no test framework glue at all — the eleven recording doubles that let a test assert on what a Firefly port actually saw (something Laravel's own `Event::fake()`/`Bus::fake()` cannot do, because they have never heard of Firefly's ports), the five Firefly-flavored Pest expectations, the `WebSliceTestCase`/`DataSliceTestCase` pair that boots only the beans a test needs, the Pest 4 browser suite that drives the **shipped skeleton** in the same process — same container, same SQLite database, no second server — and why `assertNoJavaScriptErrors()` is the assertion that earns it, the helpers that sign a test in (`actingAsAuthentication()` and the `actingAsPrincipal()`/`actingAsOidcUser()` pair built on it, the declarative `#[WithMockUser]`, and `withoutSecurity()` for the other direction), how the same integration tests run in CI against real PostgreSQL, MySQL, MariaDB, SQL Server and SQLite servers (`IntegrationDsn`, `composer test:integration`, `docker-compose.integration.yml`), and — because this book only teaches what is actually shipped — a real, honest limitation `samples/lumen`'s own tests ran into and how they worked around it. !!! note "New term: slice test" A **slice test** boots only the narrow vertical of the framework a test actually exercises — the web pipeline over one controller, or the data pipeline over one repository — rather than the whole application. It is Spring Boot's `@WebMvcTest`/`@DataJpaTest` idea: faster boots, and a mis-wired slice fails immediately instead of quietly working by accident because some unrelated bean happened to be present too. @@ -512,6 +512,133 @@ Feed the result straight into a `FireflyTestCase` subclass's `configOverrides()` --- +## Real servers in CI: the integration lanes + +`RequiresDocker` answers one question — is there a Docker daemon? The work that made the outbox, the scheduling locks and the orchestration store portable needed a stronger answer: the same test, run against every real server the framework claims to support. That is what the **integration lanes** are, and every test in this repository whose SQL a sqlite `:memory:` test cannot exercise belongs to one. Each lane is named by an environment variable: + +| Variable | Backend | Lane | +|---|---|---| +| `FIREFLY_PG_DSN` | PostgreSQL 16 | `pgsql` | +| `FIREFLY_MYSQL_DSN` | MySQL 8.4 | `mysql` | +| `FIREFLY_MARIADB_DSN` | MariaDB 11 | `mariadb` | +| `FIREFLY_SQLSRV_DSN` | SQL Server 2022 | `sqlsrv` | +| *(none)* | a SQLite file in WAL mode | `sqlite` | +| `FIREFLY_RABBITMQ_DSN`, `FIREFLY_KAFKA_BROKERS`, `FIREFLY_REDIS_URL` | the brokers | `brokers` | + +One helper reads them all. `Firefly\Testing\Integration\IntegrationDsn` accepts `host=…;port=…;dbname=…;user=…;password=…` or URL-form DSNs, treats a blank variable as unset, and says why a lane cannot run here — or nothing, when it can: + + +```php +/** Why $lane cannot run here, or null when it can. */ +public static function skipReason(string $lane): ?string +{ + $database = self::DATABASES[$lane] ?? throw new InvalidArgumentException(sprintf('Unknown integration lane [%s]; known: %s.', $lane, implode(', ', self::lanes()))); + + if (! extension_loaded($database['extension'])) { + return sprintf('requires ext-%s for the %s integration lane', $database['extension'], $lane); + } + + if ($database['variable'] !== null && self::missing($database['variable'])) { + return sprintf('Set %s (host=…;port=…;dbname=…;user=…;password=…) to run the %s integration lane.', $database['variable'], $lane); + } + + return null; +} +``` + +A missing variable is a skip, never a failure, so a laptop with no servers still runs the group cleanly. `connectionConfig($lane)` turns a lane into a Laravel connection config, and `connect($lane)` opens a standalone connection — what a second process uses when a test races two consumers. Lumen's outbox round trip shows the shape a booted test takes: a trait reads the lane from the test's dataset, skips **before** the application boots when the lane cannot run, and makes that lane's database the default connection, so the repositories, the outbox and the consumer all use it: + + +```php + protected function setUp(): void + { + $reason = IntegrationDsn::skipReason($this->lane()); + if ($reason !== null) { + $this->markTestSkipped($reason); + } + + parent::setUp(); + } + + protected function resolveApplicationConfiguration($app): void + { + parent::resolveApplicationConfiguration($app); + + $lane = $this->lane(); + $overrides = $lane === 'sqlite' ? ['database' => IntegrationDsn::sqlitePath('lumen-outbox')] : []; + + /** @var Repository $config */ + $config = $app->make('config'); + $config->set('database.connections.lumen-it', IntegrationDsn::connectionConfig($lane, $overrides)); + $config->set('database.default', 'lumen-it'); + } +``` + +One `it()` then becomes one test per lane with `->with(IntegrationDsn::lanes())`, and `->group('integration')` keeps all of them out of `composer test`. Locally, the repository's compose file starts the same servers CI uses: + + +```bash +docker compose -f docker-compose.integration.yml up -d # PostgreSQL, MySQL, MariaDB and the brokers +export FIREFLY_PG_DSN='host=127.0.0.1;port=5432;dbname=firefly;user=firefly;password=firefly' +composer test:integration # pest --group integration +``` + +SQL Server sits behind the compose file's `sqlsrv` profile, because its image is x86-64 only. In CI the `integration` job is a matrix over the four servers, each a `services:` container exporting only its own DSN, beside a `brokers` job for RabbitMQ, Kafka (with `ext-rdkafka`) and Redis: + + +```yaml + integration: + name: Integration (${{ matrix.lane }}) + runs-on: ubuntu-latest + # A lock wait that never times out must fail the lane, not hold a runner for six hours. + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + include: + - lane: pgsql + image: postgres:16 + # … + - lane: sqlsrv + image: mcr.microsoft.com/mssql/server:2022-latest + # … + - name: Pest (integration, ${{ matrix.lane }}) + env: + FIREFLY_INTEGRATION_LANE: ${{ matrix.lane }} + run: | + export ${{ matrix.dsn-variable }}='${{ matrix.dsn }}' + composer test:integration +``` + +A lane must not go green by skipping everything, and skipping is exactly what a broken DSN export would do. So CI sets `FIREFLY_INTEGRATION_LANE`, and `tests/Integration/IntegrationLaneTest.php` then **fails** unless that lane's variables are set, its PHP extension is loaded and its server answers. On a laptop the variable is unset and that test skips like the rest. + +### What SQL Server teaches + +SQL Server is the lane that finds what the other four forgive, and three of its lessons are worth knowing before you write a test against it. The first: without `READ_COMMITTED_SNAPSHOT`, which is off by default, a plain `SELECT` from a second connection **waits** on a row this process inserted and has not committed — in a test, forever, because the writer is the test. To assert that a row is not visible yet, read past the locked rows: + + +```php + // SQL Server without READ_COMMITTED_SNAPSHOT makes a plain read WAIT on the uncommitted row (here, forever: + // the writer is this process); READPAST reads what is committed, which is the claim being made. + $visible = $reader->table(OutboxSchema::TABLE); + expect(($reader->getDriverName() === 'sqlsrv' ? $visible->lock('with(readpast)') : $visible)->count())->toBe(0); +``` + +The second: a session `SET` made through a prepared statement does not stick. `pdo_sqlsrv` runs every prepared statement through `sp_prepexec`, and SQL Server restores a `SET` made inside it when the call returns — which is why `#[Transactional(timeout:)]` applies its `SET LOCK_TIMEOUT` with `unprepared()`, never `statement()`: + + +```php + // unprepared(), never statement(): pdo_sqlsrv runs a prepared statement through sp_prepexec, and SQL Server + // restores a SET made inside it when that call returns, so the session would keep waiting forever. + /** @var literal-string $set an integer formatted by %d: nothing but digits reaches the batch */ + $set = sprintf('set lock_timeout %d', $seconds * 1000); + $connection->unprepared($set); +``` + +The third: range scans and `MERGE` block on other workers' uncommitted rows, so anything that runs concurrently should update and insert by exact primary key — the outbox's `read-past` claim from Chapter 8 is the shape that works. And because a lock wait that never ends is the failure SQL Server produces instead of an error, the CI job's `timeout-minutes: 25` turns it into a failed lane rather than a runner held for six hours. + +--- + ## Proof from Lumen: how the sample dogfoods it all `samples/lumen` builds its own test base, `LumenTestCase`, on top of `FireflyDatabaseTestCase` — the pattern every chapter of this book has ultimately been resting on: @@ -773,6 +900,7 @@ Chapter 13A exercises Lumen’s checked-in `WalletRollout` with `withFeatureFlag | `#[FireflyTest]` / `#[WebSlice]` / `#[DataSlice]` | Class-attribute analog of the same three shapes, for hand-written test classes | | `FixtureRegistry` / `AggregateSeeder` / `ListenerSpy` | Named fixtures; domain-event replay; a shared call-order spy | | `RequiresDocker` / `fireflyConfigFor()` | `@group integration` opt-in; skip-not-fail without Docker; testcontainer → config mapping | +| `IntegrationDsn` / `composer test:integration` | One helper reads every lane's DSN and skips a lane that cannot run; CI runs the `integration` group per server plus a `brokers` job, and `IntegrationLaneTest` fails a lane that skipped everything | | The `browser` suite | Pest 4 + `pest-plugin-browser` serving the Testbench-booted **skeleton** in process — same container, same SQLite, no second server | | `phpunit.xml.dist`'s two suites | `tests/Browser` excluded from `unit`, so `composer check` stays Node-free; `composer test:browser` runs it alone | | `assertNoJavaScriptErrors()` | The assertion that earns the suite: a page that renders and throws in the console looks fine in a screenshot | @@ -793,3 +921,4 @@ Chapter 13A exercises Lumen’s checked-in `WalletRollout` with `withFeatureFlag 5. **Add a `RequiresDocker`-gated integration test.** Pick a repository backed by Eloquent, write an `@group integration` test against a real Postgres testcontainer using `fireflyConfigFor()`, and confirm `vendor/bin/pest` (no flags) skips it entirely while `vendor/bin/pest --group=integration` runs it for real. 6. **Watch `$forwardTo` matter.** Bind a `RecordingAuthenticationEvents` with no `$forwardTo` from `defineFireflyEnvironment()`, register an `#[AsEventListener]` on `InteractiveAuthenticationSuccessEvent` that writes a row, and sign in: confirm the spy records the event and the listener never ran. Then re-bind it wrapping the framework's own publisher and confirm both happen. Finally move the `instance()` call to *after* boot and confirm the spy records nothing at all — the binding moment is the whole trick. 7. **Reach past a gate two ways.** Take a controller test that gets a `401`, and make it pass twice: once with `#[WithMockUser(roles: ['ADMIN'])]` on the method, once with `withoutSecurity()`. Then put `#[WithMockUser]` on the class and a different one on a single method, and confirm which of the two the framework honours. +8. **Run one lane yourself.** Start the compose services, export `FIREFLY_PG_DSN`, and run `composer test:integration -- samples/lumen/tests/Integration`. Confirm the `pgsql` and `sqlite` entries pass and every other lane reports the variable or the extension it is missing. From 06e6385d7b0f36347066b714f0885bb66938fc59 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 09:14:57 -0700 Subject: [PATCH 15/19] docs(book): the preface, Chapter 1, Appendix A and the glossary name what W1, W2 and W4 shipped, in both editions --- book/README.md | 5 ++++- book/src-es/00-front/00-preface.md | 2 ++ book/src-es/01-why-larafly.md | 2 +- book/src-es/11-observability-actuator.md | 2 +- book/src-es/90-appendix-a-laravel.md | 5 +++++ book/src-es/94-glossary.md | 22 +++++++++++++++++++++- book/src/00-front/00-preface.md | 2 ++ book/src/01-why-larafly.md | 2 +- book/src/11-observability-actuator.md | 2 +- book/src/90-appendix-a-laravel.md | 5 +++++ book/src/94-glossary.md | 22 +++++++++++++++++++++- 11 files changed, 64 insertions(+), 7 deletions(-) diff --git a/book/README.md b/book/README.md index 92503163..a9320320 100644 --- a/book/README.md +++ b/book/README.md @@ -158,7 +158,10 @@ in `src/` **and** in `src-es/` carries either a `source:` marker naming the repository file it was excerpted from — compared line for line by the repository's own documentation guard — or an `illustrative:` marker saying it is the reader's own code, which is the kind `php -l` checks. Both editions build to -`book/dist/` as PDF + EPUB. +`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. **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/src-es/00-front/00-preface.md b/book/src-es/00-front/00-preface.md index 733a0946..d2cd6bb0 100644 --- a/book/src-es/00-front/00-preface.md +++ b/book/src-es/00-front/00-preface.md @@ -18,6 +18,8 @@ Cada capítulo hace avanzar **Lumen**, un servicio de monedero digital y libro m El recorrido empieza con suavidad. El **Inicio rápido** te lleva desde el instalador incluido `firefly new` hasta un endpoint en ejecución y consultable con curl, previendo en miniatura los estereotipos, el contenedor y la ruta de arranque compilada antes de que ningún capítulo te pida razonar sobre ellos. El **Capítulo 1** da un paso atrás y argumenta el enfoque completo — qué problema resuelve LaraFly y sobre qué pilares se sostiene. El **Capítulo 2** abre la sala de máquinas: el contenedor de inyección de dependencias, los atributos de estereotipo y el escaneo de componentes que compila tus clases anotadas en un manifiesto de arranque en caché, sin reflexión. Las partes posteriores de este libro — que llegarán en los capítulos siguientes — construyen hacia afuera desde esa base hacia la configuración, HTTP, la persistencia, el modelado de dominio, CQRS, la arquitectura orientada a eventos, la seguridad y la observabilidad, siempre a través de la misma base de código de `Lumen`, siempre con código que puedes ejecutar. +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 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/01-why-larafly.md b/book/src-es/01-why-larafly.md index 281de893..f45bafba 100644 --- a/book/src-es/01-why-larafly.md +++ b/book/src-es/01-why-larafly.md @@ -49,7 +49,7 @@ Las escrituras y las lecturas viajan por dos caminos separados. Un `#[CommandHan ### 4. Arquitectura orientada a eventos, con un outbox genuino -Un agregado emite eventos de dominio que describen lo que ocurrió; tras una confirmación, esos eventos pueden proyectarse en el mismo proceso o conectarse hacia afuera al `EventPublisher`, respaldado por bróker, de `firefly/eda` — en memoria para las pruebas, una cola de Laravel para entrega asíncrona, o un bróker de mensajes real (Kafka, RabbitMQ, o `LISTEN`/`NOTIFY` de Postgres) para entrega entre servicios. El adaptador de Postgres en particular escribe la fila del outbox en la *misma* transacción de base de datos que el propio agregado, de modo que un evento nunca puede registrarse sin que la escritura que describe se haya confirmado realmente. +Un agregado emite eventos de dominio que describen lo que ocurrió; tras una confirmación, esos eventos pueden proyectarse en el mismo proceso o conectarse hacia afuera al `EventPublisher`, respaldado por bróker, de `firefly/eda` — en memoria para las pruebas, una cola de Laravel para entrega asíncrona, o un broker de mensajería real (Kafka o RabbitMQ) para la entrega entre servicios. El outbox de base de datos escribe la fila del evento en la *misma* transacción de base de datos que el propio agregado — en PostgreSQL, MySQL, MariaDB, SQL Server o SQLite, con `LISTEN`/`NOTIFY` como extra de PostgreSQL —, así que un evento nunca puede quedar registrado sin que la escritura que describe se haya confirmado de verdad. Cuando una operación abarca más de un sistema, las sagas, el TCC y los workflows duraderos toman el relevo donde termina una sola transacción. ### 5. Seguridad diff --git a/book/src-es/11-observability-actuator.md b/book/src-es/11-observability-actuator.md index 21ccee25..00a44313 100644 --- a/book/src-es/11-observability-actuator.md +++ b/book/src-es/11-observability-actuator.md @@ -754,7 +754,7 @@ Por último, un puerto `Tracer` remata el paquete — y ya no es el marcador de Lo que compra el puerto es un identificador que sobrevive a todas las fronteras que cruza una petición. `W3CTraceContextPropagator` habla [W3C Trace Context](https://www.w3.org/TR/trace-context/) sobre un mapa de cabeceras llano, sin SDK de por medio — `extract(array $carrier): ?SpanContext` a la entrada, `inject(SpanContext): array` a la salida —, así que la propagación funciona igual bajo un tracer respaldado por el SDK que bajo uno de prueba. -Hay cinco fronteras instrumentadas, y la figura las muestra las cinco a la vez. `TracingFilter` (`#[Order(-110)]`, el más externo de los filtros descubiertos) continúa un `traceparent` entrante como padre remoto y abre un span `SERVER`, y después publica sus identificadores en el `Context` de Laravel y en `Request::$attributes` como `firefly.trace_id` y `firefly.span_id`. `TracerCqrsTracing` abre un span `INTERNAL` alrededor de cada comando y cada consulta — no hay nada que transportar, porque el bus es en proceso, así que el span simplemente se anida bajo el que esté vigente. `TracerEdaTracing` abre un span `PRODUCER` llamado `publish ` a la salida, escribiendo el `traceparent` en las cabeceras del sobre, y un span `CONSUMER` llamado `process ` en la entrega, leyendo el mismo valor de vuelta — que es lo que une el span de un worker de colas con la petición que publicó. Hoy seis llamadores alcanzan esa costura: `InMemoryEventBus`, `QueueEventBus`, el `SubscriberRegistrySink` compartido a través del cual entrega todo consumidor de broker y —desde 26.09.4— los tres publicadores de broker, `RabbitMqEventPublisher`, `KafkaEventPublisher` y `PostgresEventPublisher`, cada uno de los cuales pasa `publish()` por `tracePublish()` en lugar de construir su sobre por su cuenta. Un *publish* de broker abre por tanto un span `PRODUCER` y estampa un `traceparent` puesto por el propio framework. `firefly.eda.tracing.brokers.enabled` degrada esos tres al no-op sin tocar los buses en memoria y de colas, que es lo único que puede dejar todavía sin estampar un registro de broker. Y `HttpClientTracingMiddleware`, que `HttpClientTracingPass` instala en la factoría `Http` de Laravel en el arranque, abre un span `CLIENT` en cada llamada saliente e inyecta la cabecera en la petición PSR-7. +Hay cinco fronteras instrumentadas, y la figura las muestra las cinco a la vez. `TracingFilter` (`#[Order(-110)]`, el más externo de los filtros descubiertos) continúa un `traceparent` entrante como padre remoto y abre un span `SERVER`, y después publica sus identificadores en el `Context` de Laravel y en `Request::$attributes` como `firefly.trace_id` y `firefly.span_id`. `TracerCqrsTracing` abre un span `INTERNAL` alrededor de cada comando y cada consulta — no hay nada que transportar, porque el bus es en proceso, así que el span simplemente se anida bajo el que esté vigente. `TracerEdaTracing` abre un span `PRODUCER` llamado `publish ` a la salida, escribiendo el `traceparent` en las cabeceras del sobre, y un span `CONSUMER` llamado `process ` en la entrega, leyendo el mismo valor de vuelta — que es lo que une el span de un worker de colas con la petición que publicó. Hoy seis llamadores alcanzan esa costura: `InMemoryEventBus`, `QueueEventBus`, el `SubscriberRegistrySink` compartido a través del cual entrega todo consumidor de broker y —desde 26.09.4— los tres publicadores de broker, `RabbitMqEventPublisher`, `KafkaEventPublisher` y el `DatabaseEventPublisher` del outbox (que `PostgresEventPublisher` extiende), cada uno de los cuales pasa `publish()` por `tracePublish()` en lugar de construir su sobre por su cuenta. Un *publish* de broker abre por tanto un span `PRODUCER` y estampa un `traceparent` puesto por el propio framework. `firefly.eda.tracing.brokers.enabled` degrada esos tres al no-op sin tocar los buses en memoria y de colas, que es lo único que puede dejar todavía sin estampar un registro de broker. Y `HttpClientTracingMiddleware`, que `HttpClientTracingPass` instala en la factoría `Http` de Laravel en el arranque, abre un span `CLIENT` en cada llamada saliente e inyecta la cabecera en la petición PSR-7. Ambas costuras son la forma de `CqrsMetrics` que acabas de leer: `CqrsTracing` vive en `firefly/cqrs` y `EdaTracing` en `firefly/eda`, cada una con un valor por defecto no-op detrás de `#[ConditionalOnMissingBean]`, y la auto-configuración `#[Order(500)]` de observabilidad registra primero la implementación real. Ni `firefly/cqrs` ni `firefly/eda` dependen de `firefly/observability` — mira sus `composer.json` si quieres comprobarlo. diff --git a/book/src-es/90-appendix-a-laravel.md b/book/src-es/90-appendix-a-laravel.md index adaa5206..37879a5e 100644 --- a/book/src-es/90-appendix-a-laravel.md +++ b/book/src-es/90-appendix-a-laravel.md @@ -23,6 +23,11 @@ LaraFly es **aditivo**: superpone un modelo de aplicación con forma de Spring B | Pruebas | `RefreshDatabase`, `Event::fake()`, `Bus::fake()` — solo ven las propias primitivas de Laravel | `FireflyTestCase`/`FireflyDatabaseTestCase`, dobles de grabación para los propios puertos de Firefly, expectativas de Pest con sabor a Firefly | 12 | | Andamiaje | `php artisan make:controller`/`make:model` | `php artisan make:firefly-controller`/`make:firefly-handler`/… — un generador por estereotipo | 13 | | Rendimiento de arranque | La propia caché de config/rutas de Laravel (`config:cache`, `route:cache`) | `php artisan firefly:cache` — un comando compila cada manifiesto de Firefly **y** genera proxies `#[Transactional]` para un arranque sin reflexión | 13 | +| Caché de consultas | `Cache::remember()` alrededor de la lectura, invalidada a mano | `#[CachedQuery]` en la consulta, `#[InvalidatesQuery]` en el evento que la deja obsoleta | 7 | +| Webhooks entrantes | Un controlador por proveedor con una comprobación de firma escrita a mano | `firefly/webhooks`: `POST /webhooks/{provider}`, verificado, deduplicado, publicado en el bus de eventos | 8A | +| 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 | --- diff --git a/book/src-es/94-glossary.md b/book/src-es/94-glossary.md index b8ca65db..f15d7a13 100644 --- a/book/src-es/94-glossary.md +++ b/book/src-es/94-glossary.md @@ -24,22 +24,32 @@ **BootPass** — Una única unidad ordenada de trabajo en tiempo de arranque — escaneo, evaluación de condiciones, registro de beans, montaje de rutas — ejecutada por el `FireflyKernel` en una `BootPhase` específica. `ActuatorRouteRegistrar` y `MeterBindingsPass` son ambos `BootPass` (Capítulos 2, 11). +**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). + **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). + **Command (CQRS)** — Un objeto que expresa una única intención de escritura — "retirar fondos", "abrir una wallet" — despachado a través del `CommandBus` a exactamente un `#[CommandHandler]`. Los comandos pueden ser denegados antes de llegar siquiera a su handler por un `CommandAuthorizer` (Capítulos 7, 10). **CommandBus** — El pipeline que recibe un `Command`, lo pasa por una secuencia acotada de etapas (correlacionar → validar → autorizar → invocar → métricas), y lo enruta a su único `#[CommandHandler]` registrado (Capítulo 7). +**Compensation** — La acción que deshace semánticamente un paso completado de una saga — un reembolso para un cobro, una cancelación para una transferencia enviada. Las compensaciones se ejecutan de la más reciente a la más antigua cuando falla un paso posterior; no revierten nada, añaden el efecto contrario (Capítulo 9A). + **Component** — El atributo de estereotipo genérico para un bean gestionado que no encaja en los roles más específicos `#[Service]`/`#[Repository]`/`#[Configuration]`. Todos los estereotipos son equivalentes para el contenedor; la distinción es para los lectores humanos y las herramientas (Capítulo 2). **Condition (`#[ConditionalOn*]`)** — Un atributo (`#[ConditionalOnProperty]`, `#[ConditionalOnBean]`, `#[ConditionalOnMissingBean]`) evaluado durante la pasada de condiciones del pipeline de arranque para decidir si una definición de bean sobrevive. El `#[ConditionalOnProperty(matchIfMissing: true)]` de `DbHealthIndicator` es lo que lo mantiene activo por defecto; lo que evita un `DOWN` sorpresa en una aplicación sin base de datos es otra interfaz, `ConditionalHealthIndicator::available()` (Capítulos 2, 11). +**Dead letter** — Un evento o mensaje que agotó sus reintentos, conservado en vez de perdido para poder inspeccionarlo y reproducirlo: en memoria o en `firefly_eda_dead_letters` para el bus de eventos (`firefly:eda:dead-letters:replay`), en sus propias tablas para la mensajería y la orquestación; una fila `FAILED` del outbox es la carta muerta del outbox (Capítulos 8, 8A, 9A). + **Deptrac** — El linter estático de fronteras de arquitectura (`deptrac/deptrac`) que hace cumplir qué paquete puede depender de cuál, como capas declaradas en `deptrac.yaml`. Un recuento de `0` violaciones es parte de la propia definición de terminado de este libro para el código de cada capítulo (Capítulos 2, 11, 13). **Domain event** — Un registro inmutable de un hecho de negocio que ya ha ocurrido — "wallet abierta", "fondos retirados" — registrado por una raíz de agregado y publicado, tras un commit exitoso, a través de un `ApplicationEventPublisher` (Capítulos 6, 8, 9). **DTO (Data Transfer Object)** — Un objeto sencillo que transporta datos a través de una frontera de capa — un cuerpo de petición, un subárbol de configuración enlazado con `#[ConfigProperties]` — sin exponer tipos de dominio internos (Capítulos 3, 4). +**Durable workflow** — Una orquestación que puede esperar — una señal, un temporizador — a través de peticiones y procesos, porque cada frontera de paso escribe un punto de control desde el que se reanuda (`#[Workflow]`, `#[WaitForSignal]`, `#[WaitForTimer]`) (Capítulo 9A). + **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). **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). @@ -56,6 +66,8 @@ **Integration event** — Un evento de dominio que ha cruzado la frontera desde el modelo de dominio en-proceso hacia el bus de EDA respaldado por broker, mediante el puente protegido de después-del-commit presentado junto a CQRS. No es el mismo objeto, ni la misma superficie, que el evento de dominio que lo produjo (Capítulos 7, 8, 9). +**Integration lane** — Un backend real contra el que corre el grupo de pruebas `integration` — PostgreSQL, MySQL, MariaDB, SQL Server, un fichero SQLite o los brokers — seleccionado por su variable de DSN a través de `IntegrationDsn`; CI ejecuta un job por carril (Capítulo 12). + **JWT (JSON Web Token)** — Un token portador firmado y caducable que `JwtService` emite y valida. El `JwtService` de LaraFly se niega a arrancar en absoluto con un secreto de firma débil o de marcador de posición, y se niega a aceptar cualquier token que carezca de su claim `exp`, por muy válida que sea su firma (Capítulo 10). **Manifest** — Una instantánea compilada y cacheable de todo lo que un escáner encontró — rutas, handlers de CQRS, listeners de eventos, reglas de seguridad, métodos transaccionales — escrita en `bootstrap/cache/firefly/` por `firefly:cache` y vuelta a cargar en el arranque sin ninguna reflexión (Capítulos 2, 13). @@ -64,7 +76,9 @@ **Method security** — Autorización declarada por atributos (`#[PreAuthorize]`, `#[Secured]`, `#[RolesAllowed]`) hecha cumplir en el bus de CQRS y en el despachador de controladores por un evaluador de expresiones de lista blanca cerrada que nunca llama a `eval()` (Capítulo 10). -**Outbox pattern** — Una técnica para publicar un evento de dominio de forma fiable junto a la escritura de base de datos que lo produjo: ambos se escriben en la misma transacción local, eliminando la necesidad de un commit de dos fases entre la base de datos y el broker de mensajes (Capítulos 8, 9). +**Outbox lease** — La reclamación que un consumidor del outbox mantiene sobre una fila: `claimed_by` y `claimed_until`, confirmados antes de que corra ningún listener. Mientras vive el arrendamiento ningún otro consumidor toma la fila; cuando termina, la fila de un consumidor caído se entrega de nuevo (Capítulo 8). + +**Outbox pattern** — Una técnica para publicar un evento de dominio de forma fiable junto a la escritura de base de datos que lo produjo: ambos se escriben en la misma transacción local, eliminando la necesidad de un commit de dos fases entre la base de datos y el broker de mensajes (Capítulos 8, 9; `firefly.eda.provider=database` en cualquier base de datos). **Pest expectation** — Un matcher personalizado `expect()->extend(...)` que `firefly/testing` instala — `toHavePublished`, `toHaveHandledCommand`, `toBeUp`, `toHaveRecordedMetric`, `toBeProblemDetails` — registrado una vez desde el `tests/Pest.php` raíz del monorepo (Capítulo 12). @@ -86,6 +100,8 @@ **Repository** — Una abstracción similar a una colección sobre la persistencia, que permite al código de aplicación cargar y guardar agregados o entidades sin ningún SQL propio. `CrudRepository` es la base tipada que extiende cada repositorio de dominio (Capítulo 5). +**Saga** — Una operación de negocio hecha de transacciones locales que se confirman cada una por su cuenta, con una compensación para cada paso que cambió algo; un fallo deshace los pasos completados en vez de revertirlos (`#[Saga]`, `#[SagaStep]`) (Capítulo 9A). + **Secure by default** — La postura operativa de LaraFly allí donde aplica: un endpoint de actuator es inalcanzable (404) hasta que se expone explícitamente, una regla de URL de `HttpSecurity` deniega cualquier cosa sin coincidencia, y un secreto JWT que parece débil se niega a arrancar en absoluto — el sistema falla cerrado, no abierto (Capítulos 10, 11). **SecurityContext** / **SecurityContextHolder** — Una instantánea inmutable del `Authentication` actual, mantenida por petición por un accesor estático respaldado por la propia fachada `Context` de Laravel. Cada filtro de autenticación la limpia en un bloque `finally` de modo que el principal de una petición nunca pueda filtrarse a la siguiente (Capítulo 10). @@ -96,10 +112,14 @@ **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). + **Testcontainers** — Una biblioteca que arranca contenedores Docker reales para pruebas de integración y los desmonta después. El trait `RequiresDocker` y el helper `fireflyConfigFor()` de `firefly/testing` hacen que una prueba respaldada por testcontainers sea opcional y segura para CI incluso sin Docker presente (Capítulo 12). **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). **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). + **Zero-reflection boot** — El estado que una aplicación alcanza una vez que `firefly:cache` ha ejecutado: cada manifiesto se carga desde un archivo PHP precompilado bajo `bootstrap/cache/firefly/`, de modo que ningún atributo, clase o método vuelve a someterse a reflexión en tiempo de petición (Capítulo 13). diff --git a/book/src/00-front/00-preface.md b/book/src/00-front/00-preface.md index 43bf6b42..b2d00c6e 100644 --- a/book/src/00-front/00-preface.md +++ b/book/src/00-front/00-preface.md @@ -18,6 +18,8 @@ Every chapter advances **Lumen**, a digital-wallet-and-ledger service: a `Wallet The journey starts gently. The **Quick Start** takes you from the bundled `firefly new` installer to a running, curl-able endpoint, previewing the stereotypes, the container, and the compiled boot path in miniature before any chapter asks you to reason about them. **Chapter 1** steps back and makes the case for the whole approach — what problem LaraFly solves and the pillars it stands on. **Chapter 2** opens the engine room: the dependency-injection container, the stereotype attributes, and the component scan that compiles your annotated classes into a cached, zero-reflection boot manifest. Later parts of this book — arriving in the chapters that follow — build outward from that foundation into configuration, HTTP, persistence, domain modelling, CQRS, event-driven architecture, security, and observability, always through the same `Lumen` codebase, always with code you can run. +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 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/01-why-larafly.md b/book/src/01-why-larafly.md index 96cd3a9b..bacc3b37 100644 --- a/book/src/01-why-larafly.md +++ b/book/src/01-why-larafly.md @@ -49,7 +49,7 @@ Writes and reads travel two separate paths. A `#[CommandHandler]` handles one wr ### 4. Event-driven architecture, with a genuine outbox -An aggregate raises domain events describing what happened; after a commit, those events can be projected in-process or bridged out to `firefly/eda`'s broker-backed `EventPublisher` — in-memory for tests, a Laravel queue for async delivery, or a real message broker (Kafka, RabbitMQ, or Postgres `LISTEN`/`NOTIFY`) for cross-service delivery. The Postgres adapter in particular writes the outbox row in the *same* database transaction as the aggregate itself, so an event can never be recorded without the write it describes actually having committed. +An aggregate raises domain events describing what happened; after a commit, those events can be projected in-process or bridged out to `firefly/eda`'s broker-backed `EventPublisher` — in-memory for tests, a Laravel queue for async delivery, or a real message broker (Kafka or RabbitMQ) for cross-service delivery. The database outbox writes the event's row in the *same* database transaction as the aggregate itself — on PostgreSQL, MySQL, MariaDB, SQL Server or SQLite, with `LISTEN`/`NOTIFY` as a PostgreSQL bonus — so an event can never be recorded without the write it describes actually having committed. When one operation spans more than one system, sagas, TCC and durable workflows take over where a single transaction ends. ### 5. Security diff --git a/book/src/11-observability-actuator.md b/book/src/11-observability-actuator.md index 230e050c..fdf86caa 100644 --- a/book/src/11-observability-actuator.md +++ b/book/src/11-observability-actuator.md @@ -754,7 +754,7 @@ Finally, a `Tracer` port rounds out the package — and it is no longer the plac What the port buys is one id that survives every boundary a request crosses. `W3CTraceContextPropagator` speaks [W3C Trace Context](https://www.w3.org/TR/trace-context/) over a plain header map, with no SDK involved — `extract(array $carrier): ?SpanContext` on the way in, `inject(SpanContext): array` on the way out — so propagation works identically under an SDK-backed tracer and under a test one. -Five boundaries are instrumented, and the figure is all five at once. `TracingFilter` (`#[Order(-110)]`, outermost of the discovered filters) continues an inbound `traceparent` as a remote parent and opens a `SERVER` span, then publishes its ids to Laravel `Context` and to `Request::$attributes` as `firefly.trace_id` and `firefly.span_id`. `TracerCqrsTracing` opens an `INTERNAL` span around every command and every query — nothing to carry, since the bus is in-process, so the span simply nests under whatever is current. `TracerEdaTracing` opens a `PRODUCER` span named `publish ` on the way out, writing the `traceparent` into the envelope headers, and a `CONSUMER` span named `process ` on delivery, reading it back — which is what joins a queue worker's span to the request that published. Six callers reach that seam today: `InMemoryEventBus`, `QueueEventBus`, the shared `SubscriberRegistrySink` every broker consumer delivers through, and — since 26.09.4 — the three broker publishers themselves, `RabbitMqEventPublisher`, `KafkaEventPublisher` and `PostgresEventPublisher`, each of which routes `publish()` through `tracePublish()` rather than building its envelope alone. A broker *publish* therefore opens a `PRODUCER` span and stamps a `traceparent` of the framework's own making. `firefly.eda.tracing.brokers.enabled` downgrades those three to the no-op without touching the in-memory and queue buses, which is the one thing that can still leave a broker record unstamped. And `HttpClientTracingMiddleware`, which `HttpClientTracingPass` installs on Laravel's `Http` factory at boot, opens a `CLIENT` span on every outbound call and injects the header onto the PSR-7 request. +Five boundaries are instrumented, and the figure is all five at once. `TracingFilter` (`#[Order(-110)]`, outermost of the discovered filters) continues an inbound `traceparent` as a remote parent and opens a `SERVER` span, then publishes its ids to Laravel `Context` and to `Request::$attributes` as `firefly.trace_id` and `firefly.span_id`. `TracerCqrsTracing` opens an `INTERNAL` span around every command and every query — nothing to carry, since the bus is in-process, so the span simply nests under whatever is current. `TracerEdaTracing` opens a `PRODUCER` span named `publish ` on the way out, writing the `traceparent` into the envelope headers, and a `CONSUMER` span named `process ` on delivery, reading it back — which is what joins a queue worker's span to the request that published. Six callers reach that seam today: `InMemoryEventBus`, `QueueEventBus`, the shared `SubscriberRegistrySink` every broker consumer delivers through, and — since 26.09.4 — the three broker publishers themselves, `RabbitMqEventPublisher`, `KafkaEventPublisher` and the outbox's `DatabaseEventPublisher` (which `PostgresEventPublisher` extends), each of which routes `publish()` through `tracePublish()` rather than building its envelope alone. A broker *publish* therefore opens a `PRODUCER` span and stamps a `traceparent` of the framework's own making. `firefly.eda.tracing.brokers.enabled` downgrades those three to the no-op without touching the in-memory and queue buses, which is the one thing that can still leave a broker record unstamped. And `HttpClientTracingMiddleware`, which `HttpClientTracingPass` installs on Laravel's `Http` factory at boot, opens a `CLIENT` span on every outbound call and injects the header onto the PSR-7 request. Both seams are the `CqrsMetrics` shape you have just read: `CqrsTracing` lives in `firefly/cqrs` and `EdaTracing` in `firefly/eda`, each with a no-op default behind `#[ConditionalOnMissingBean]`, and observability's `#[Order(500)]` auto-configuration registers the real implementation first. Neither `firefly/cqrs` nor `firefly/eda` depends on `firefly/observability` — check their `composer.json` files if you want to see it. diff --git a/book/src/90-appendix-a-laravel.md b/book/src/90-appendix-a-laravel.md index fcf2cde4..3b9e9791 100644 --- a/book/src/90-appendix-a-laravel.md +++ b/book/src/90-appendix-a-laravel.md @@ -23,6 +23,11 @@ LaraFly is **additive**: it layers a Spring-Boot-shaped application model on top | Testing | `RefreshDatabase`, `Event::fake()`, `Bus::fake()` — see Laravel's own primitives only | `FireflyTestCase`/`FireflyDatabaseTestCase`, recording doubles for Firefly's own ports, Firefly-flavored Pest expectations | 12 | | Scaffolding | `php artisan make:controller`/`make:model` | `php artisan make:firefly-controller`/`make:firefly-handler`/… — one generator per stereotype | 13 | | Boot performance | Laravel's own config/route caching (`config:cache`, `route:cache`) | `php artisan firefly:cache` — one command compiles every Firefly manifest **and** generates `#[Transactional]` proxies for a zero-reflection boot | 13 | +| Query caching | `Cache::remember()` around the read, invalidated by hand | `#[CachedQuery]` on the query, `#[InvalidatesQuery]` on the event that makes it stale | 7 | +| Inbound webhooks | A controller per provider with a hand-written signature check | `firefly/webhooks`: `POST /webhooks/{provider}`, verified, deduplicated, published to the event bus | 8A | +| 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 | --- diff --git a/book/src/94-glossary.md b/book/src/94-glossary.md index 8a498a53..5a1dd2c0 100644 --- a/book/src/94-glossary.md +++ b/book/src/94-glossary.md @@ -24,22 +24,32 @@ **BootPass** — A single, ordered unit of boot-time work — scanning, condition evaluation, bean registration, route mounting — run by the `FireflyKernel` at a specific `BootPhase`. `ActuatorRouteRegistrar` and `MeterBindingsPass` are both `BootPass`es (Chapters 2, 11). +**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). + **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). + **Command (CQRS)** — An object expressing a single write intent — "withdraw funds", "open a wallet" — dispatched through the `CommandBus` to exactly one `#[CommandHandler]`. Commands may be denied before they ever reach their handler by a `CommandAuthorizer` (Chapters 7, 10). **CommandBus** — The pipeline that receives a `Command`, runs it through a bounded sequence of stages (correlate → validate → authorize → invoke → metrics), and routes it to its one registered `#[CommandHandler]` (Chapter 7). +**Compensation** — The action that semantically undoes a completed saga step — a refund for a charge, a cancellation for a submitted transfer. Compensations run newest first when a later step fails; they roll nothing back, they add the opposite effect (Chapter 9A). + **Component** — The generic stereotype attribute for a managed bean that does not fit the more specific `#[Service]`/`#[Repository]`/`#[Configuration]` roles. All stereotypes are equivalent to the container; the distinction is for human readers and tooling (Chapter 2). **Condition (`#[ConditionalOn*]`)** — An attribute (`#[ConditionalOnProperty]`, `#[ConditionalOnBean]`, `#[ConditionalOnMissingBean]`) evaluated during the boot pipeline's condition pass to decide whether a bean definition survives. `DbHealthIndicator`'s `#[ConditionalOnProperty(matchIfMissing: true)]` is what keeps it on by default; a separate interface, `ConditionalHealthIndicator::available()`, is what keeps a database-less application from a surprise `DOWN` (Chapters 2, 11). +**Dead letter** — An event or message that exhausted its retries, kept rather than lost so it can be inspected and replayed: in memory or in `firefly_eda_dead_letters` for the event bus (`firefly:eda:dead-letters:replay`), in their own tables for messaging and orchestration; a `FAILED` outbox row is the outbox's dead letter (Chapters 8, 8A, 9A). + **Deptrac** — The static architecture-boundary linter (`deptrac/deptrac`) that enforces which package may depend on which, as declared layers in `deptrac.yaml`. A `0` violation count is part of this book's own definition of done for every chapter's code (Chapters 2, 11, 13). **Domain event** — An immutable record of a business fact that has already happened — "wallet opened", "funds withdrawn" — recorded by an aggregate root and published, after a successful commit, through an `ApplicationEventPublisher` (Chapters 6, 8, 9). **DTO (Data Transfer Object)** — A plain object carrying data across a layer boundary — a request body, a `#[ConfigProperties]`-bound configuration subtree — without exposing internal domain types (Chapters 3, 4). +**Durable workflow** — An orchestration that can wait — for a signal, for a timer — across requests and processes, because every step boundary writes a checkpoint it resumes from (`#[Workflow]`, `#[WaitForSignal]`, `#[WaitForTimer]`) (Chapter 9A). + **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). **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). @@ -56,6 +66,8 @@ **Integration event** — A domain event that has crossed the boundary from the in-process domain model onto the broker-backed EDA bus, via the guarded after-commit bridge introduced alongside CQRS. Not the same object, and not the same surface, as the domain event that produced it (Chapters 7, 8, 9). +**Integration lane** — One real backend the `integration` test group runs against — PostgreSQL, MySQL, MariaDB, SQL Server, a SQLite file, or the brokers — selected by its DSN variable through `IntegrationDsn`; CI runs one job per lane (Chapter 12). + **JWT (JSON Web Token)** — A signed, expiring bearer token `JwtService` issues and validates. LaraFly's `JwtService` refuses to boot at all with a weak or placeholder signing secret, and refuses to accept any token missing its `exp` claim, however valid its signature (Chapter 10). **Manifest** — A compiled, cacheable snapshot of everything a scanner found — routes, CQRS handlers, event listeners, security rules, transactional methods — written to `bootstrap/cache/firefly/` by `firefly:cache` and loaded back in at boot with no reflection at all (Chapters 2, 13). @@ -64,7 +76,9 @@ **Method security** — Attribute-declared authorization (`#[PreAuthorize]`, `#[Secured]`, `#[RolesAllowed]`) enforced at the CQRS bus and the controller dispatcher by a closed-whitelist expression evaluator that never calls `eval()` (Chapter 10). -**Outbox pattern** — A technique for publishing a domain event reliably alongside the database write that produced it: both are written in the same local transaction, eliminating the need for a two-phase commit between the database and the message broker (Chapters 8, 9). +**Outbox lease** — The claim an outbox consumer holds on a row: `claimed_by` and `claimed_until`, committed before any listener runs. While the lease lives no other consumer takes the row; when it ends, a crashed consumer's row is delivered again (Chapter 8). + +**Outbox pattern** — A technique for publishing a domain event reliably alongside the database write that produced it: both are written in the same local transaction, eliminating the need for a two-phase commit between the database and the message broker (Chapters 8, 9; `firefly.eda.provider=database` on any database). **Pest expectation** — A custom `expect()->extend(...)` matcher `firefly/testing` installs — `toHavePublished`, `toHaveHandledCommand`, `toBeUp`, `toHaveRecordedMetric`, `toBeProblemDetails` — registered once from the monorepo's root `tests/Pest.php` (Chapter 12). @@ -86,6 +100,8 @@ **Repository** — A collection-like abstraction over persistence, letting application code load and save aggregates or entities with no SQL of its own. `CrudRepository` is the typed base every domain repository extends (Chapter 5). +**Saga** — A business operation made of local transactions that each commit on their own, with a compensation for every step that changed something; a failure undoes the completed steps instead of rolling them back (`#[Saga]`, `#[SagaStep]`) (Chapter 9A). + **Secure by default** — LaraFly's operational posture wherever it applies: an actuator endpoint is unreachable (404) until explicitly exposed, an `HttpSecurity` URL rule denies anything unmatched, and a JWT secret that looks weak refuses to boot at all — the system fails closed, not open (Chapters 10, 11). **SecurityContext** / **SecurityContextHolder** — An immutable snapshot of the current `Authentication`, held per-request by a static accessor backed by Laravel's own `Context` facade. Every authentication filter clears it in a `finally` block so one request's principal can never leak into the next (Chapter 10). @@ -96,10 +112,14 @@ **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). + **Testcontainers** — A library that starts real Docker containers for integration tests and tears them down afterward. `firefly/testing`'s `RequiresDocker` trait and `fireflyConfigFor()` helper make a testcontainers-backed test opt-in and CI-safe even with no Docker present (Chapter 12). **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). **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). + **Zero-reflection boot** — The state an application reaches once `firefly:cache` has run: every manifest is loaded from a pre-compiled PHP file under `bootstrap/cache/firefly/`, so no attribute, class, or method is ever reflected on again at request time (Chapter 13). From 539f598c3196bbf6a3dcb1c5e198d35d1d93da19 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 09:20:52 -0700 Subject: [PATCH 16/19] docs(tutorial): Step 12 moves the greeting events onto the database outbox and delivers them with a consumer, in both languages --- docs/tutorial.es.md | 73 +++++++++++++++++++++++++++++++++++++++++++++ docs/tutorial.md | 71 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+) diff --git a/docs/tutorial.es.md b/docs/tutorial.es.md index 7bce932f..d916d0ba 100644 --- a/docs/tutorial.es.md +++ b/docs/tutorial.es.md @@ -1147,6 +1147,79 @@ lista completa de endpoints y el modelo de exposición/seguridad. --- +## Paso 12: un outbox agnóstico de la base de datos y un consumidor + +En el Paso 10 el listener se ejecutó dentro de la petición `PATCH`, porque el bus de eventos del skeleton vive en +memoria: rápido, y perdido si el proceso muere entre el commit y el listener. El outbox de base de datos cierra ese +hueco sin que cambie una sola línea de tu código. El evento se escribe en una tabla **en la misma transacción** que +el greeting renombrado, y un worker lo entrega después — igual en SQLite, MySQL, MariaDB, PostgreSQL o SQL Server. + +El proveedor es la primera clave del bloque `eda` de `config/firefly.php`: + + + +```php +'eda' => [ + + 'provider' => env('FIREFLY_EDA_PROVIDER', 'memory'), + // … +], +``` + +Cámbialo con la variable de entorno y crea la tabla del outbox — la migración se registra en cuanto el proveedor +es `database`: + +```bash +echo 'FIREFLY_EDA_PROVIDER=database' >> .env +php artisan migrate # crea firefly_eda_outbox +``` + +Reinicia `php artisan firefly:serve`, vuelve a renombrar el greeting y mira el log: + +```bash +curl -X PATCH http://127.0.0.1:8000/greetings/Ada/record \ + -H "Content-Type: application/json" \ + -d '{"message": "Hello from the outbox!"}' +# {"name":"Ada","message":"Hello from the outbox!"} + +tail -n 1 storage/logs/laravel.log # todavía la línea del Paso 10: nada ha entregado aún el evento nuevo +``` + +El evento `GreetingRenamed` es una fila `PENDING` en `firefly_eda_outbox`, confirmada junto con el mensaje nuevo. +Entrégalo con el consumidor — un worker de larga duración en producción, un solo mensaje aquí: + +```bash +php artisan firefly:eda:consume --max-messages=1 +tail -n 1 storage/logs/laravel.log +``` + +``` +[2026-07-28 12:20:41] local.INFO: greeting.renamed {"name":"Ada","oldMessage":"Hello again from the database!","newMessage":"Hello from the outbox!", ...} +``` + +Qué cambió y qué no: + +- **Tu código no cambió.** `GreetingRenamedListener`, `RenameGreetingHandler` y `#[PublishDomainEvent]` están + exactamente como los dejaron los Pasos 9 y 10. El outbox se engancha *antes* del commit, a través de la misma + frontera `#[Transactional]` que publicaba el evento después de él. +- **La atomicidad sí.** Si el handler lanza una excepción después de `rename()`, la fila se revierte con el + greeting: ningún evento para una escritura que nunca ocurrió, y — como la fila se confirma con el greeting — + ningún evento perdido para una que sí ocurrió. +- **La entrega es un worker.** La fila se *arrienda* a un consumidor antes de que se ejecute el listener, así que + puedes lanzar tantos workers `php artisan firefly:eda:consume` como quieras. En SQLite la estrategia de + reclamación `auto` es un arrendamiento optimista; en PostgreSQL, MySQL 8.0.1+ y MariaDB 10.6+ es + `FOR UPDATE SKIP LOCKED`; en SQL Server, `READPAST`. Un worker que cae a mitad de una entrega deja su fila para + que se entregue de nuevo cuando termine el arrendamiento (`firefly.eda.database.lease-seconds`, 300 por + defecto) — así que mantén tus listeners idempotentes. +- **PostgreSQL puede hacerlo mejor.** `FIREFLY_EDA_PROVIDER=postgres` es el mismo outbox más un `pg_notify` al + escribir y un despertar por `LISTEN` en el consumidor, de modo que la entrega es inmediata en lugar de por sondeo. + +Consulta [Outbox de base de datos de EDA](modules/eda-database.md) para las estrategias de reclamación, el relay +opcional a RabbitMQ o Kafka (`php artisan firefly:outbox:relay`) y el componente de salud `outbox`, y la +[Matriz de soporte de bases de datos](database-support.md) para lo que hace cada servidor. + +--- + ## Paso 12: y ahora qué Has construido una pequeña porción de funcionalidad — `#[RestController]` → `#[Service]` → diff --git a/docs/tutorial.md b/docs/tutorial.md index e7184f57..fda7f3da 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -1124,6 +1124,77 @@ exposure/security model. --- +## Step 12: A Database-Agnostic Outbox and a Consumer + +In Step 10 the listener ran inside the `PATCH` request, because the skeleton's event bus is in memory: fast, and +gone if the process dies between the commit and the listener. The database outbox closes that gap without a +line of your code changing. The event is written to a table **in the same transaction** as the renamed greeting, +and a worker delivers it afterwards — on SQLite, MySQL, MariaDB, PostgreSQL or SQL Server alike. + +The provider is the first key of the `eda` block in `config/firefly.php`: + + + +```php +'eda' => [ + + 'provider' => env('FIREFLY_EDA_PROVIDER', 'memory'), + // … +], +``` + +Switch it with the environment variable and create the outbox table — the migration is registered as soon as the +provider is `database`: + +```bash +echo 'FIREFLY_EDA_PROVIDER=database' >> .env +php artisan migrate # creates firefly_eda_outbox +``` + +Restart `php artisan firefly:serve`, rename the greeting again, and look at the log: + +```bash +curl -X PATCH http://127.0.0.1:8000/greetings/Ada/record \ + -H "Content-Type: application/json" \ + -d '{"message": "Hello from the outbox!"}' +# {"name":"Ada","message":"Hello from the outbox!"} + +tail -n 1 storage/logs/laravel.log # still the line from Step 10: nothing has delivered the new event yet +``` + +The `GreetingRenamed` event is a `PENDING` row in `firefly_eda_outbox`, committed together with the new message. +Deliver it with the consumer — a long-running worker in production, one message here: + +```bash +php artisan firefly:eda:consume --max-messages=1 +tail -n 1 storage/logs/laravel.log +``` + +``` +[2026-07-28 12:20:41] local.INFO: greeting.renamed {"name":"Ada","oldMessage":"Hello again from the database!","newMessage":"Hello from the outbox!", ...} +``` + +What changed, and what did not: + +- **Your code did not.** `GreetingRenamedListener`, `RenameGreetingHandler` and `#[PublishDomainEvent]` are + exactly as Steps 9 and 10 left them. The outbox hooks in *before* the commit, through the same + `#[Transactional]` boundary that published the event after it. +- **Atomicity did.** If the handler throws after `rename()`, the row rolls back with the greeting: no event for a + write that never happened, and — because the row commits with the greeting — no lost event for one that did. +- **Delivery is a worker.** The row is *leased* to one consumer before the listener runs, so you can run as many + `php artisan firefly:eda:consume` workers as you like. On SQLite the `auto` claim strategy is an optimistic + lease; on PostgreSQL, MySQL 8.0.1+ and MariaDB 10.6+ it is `FOR UPDATE SKIP LOCKED`; on SQL Server + `READPAST`. A worker that crashes mid-delivery leaves its row to be delivered again when the lease + (`firefly.eda.database.lease-seconds`, 300 by default) ends — so keep listeners idempotent. +- **PostgreSQL can do better.** `FIREFLY_EDA_PROVIDER=postgres` is the same outbox plus a `pg_notify` on write and + a `LISTEN` wake-up in the consumer, so delivery is immediate instead of polled. + +See [EDA Database Outbox](modules/eda-database.md) for the claim strategies, the optional relay to RabbitMQ or +Kafka (`php artisan firefly:outbox:relay`), and the `outbox` health component, and the +[Database Support Matrix](database-support.md) for what each server does. + +--- + ## Step 12: What's Next You've built one small feature slice — `#[RestController]` → `#[Service]` → `#[ConfigProperties]` → From ad2a917e8d8981d2316f9376150e2ef79887f685 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 09:26:14 -0700 Subject: [PATCH 17/19] docs(tutorial): Step 13 sells a greeting card through a compensating saga; What's Next becomes Step 14, in both languages --- docs/tutorial.es.md | 257 +++++++++++++++++++++++++++++++++++++++++--- docs/tutorial.md | 255 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 487 insertions(+), 25 deletions(-) diff --git a/docs/tutorial.es.md b/docs/tutorial.es.md index d916d0ba..1582809b 100644 --- a/docs/tutorial.es.md +++ b/docs/tutorial.es.md @@ -3,15 +3,17 @@ Este es un recorrido paso a paso, construido a mano, de cómo levantar una funcionalidad LaraFly real desde 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 y un listener de evento de dominio. -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. +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 +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. 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 y un arranque cacheado sin reflexión — la misma -arquitectura que usa el [sample de Lumen](#paso-12-y-ahora-que) a mayor escala. +`#[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. ## Tabla de contenidos @@ -26,7 +28,9 @@ arquitectura que usa el [sample de Lumen](#paso-12-y-ahora-que) a mayor escala. 9. [Paso 9: comandos y consultas — CQRS](#paso-9-comandos-y-consultas-cqrs) 10. [Paso 10: reaccionando a un evento de dominio — `#[EventListener]`](#paso-10-reaccionando-a-un-evento-de-dominio-eventlistener) 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: y ahora qué](#paso-12-y-ahora-que) +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) ## Requisitos previos @@ -1220,11 +1224,232 @@ opcional a RabbitMQ o Kafka (`php artisan firefly:outbox:relay`) y el componente --- -## Paso 12: y ahora qué +## Paso 13: una saga con compensación — `#[Saga]` + +Hasta aquí todo ha cabido en una transacción. Vender una tarjeta de felicitación no cabe: reserva una tarjeta +impresa del stock, cobra al cliente a través de un proveedor de pagos y entrega la tarjeta a un servicio de envío — +tres sistemas, tres commits y ninguna transacción que los abarque. Si falla el envío, el cobro ya se ha hecho. La +respuesta es una **saga**: cada paso confirma por su cuenta, cada paso que cambió algo nombra una *compensación* +que lo deshace, y un fallo ejecuta las compensaciones de los pasos completados, del más reciente al más antiguo. + +Los tres sistemas tienen un único sustituto, `app/Cards/CardDesk.php` — en una aplicación real cada uno sería un +adaptador detrás de su propio puerto; aquí una sola clase en memoria mantiene el paso centrado en la saga, y su +`$journal` registra cada llamada: + + + +```php + */ + public array $journal = []; + + private int $cards = 0; + + private int $payments = 0; + + public function reserve(): string + { + $card = 'card-'.++$this->cards; + $this->journal[] = "reserve {$card}"; + + return $card; + } + + public function release(string $card): void + { + $this->journal[] = "release {$card}"; + } + + public function charge(int $amountMinor): string + { + $payment = 'payment-'.++$this->payments; + $this->journal[] = "charge {$payment} {$amountMinor}"; + + return $payment; + } + + public function refund(string $payment): void + { + $this->journal[] = "refund {$payment}"; + } + + public function send(string $card, string $to): string + { + if ($to === 'Nobody') { + throw new RuntimeException("The mailer has no address for {$to}."); + } + + $this->journal[] = "send {$card} to {$to}"; + + return "sent {$card} to {$to}"; + } +} +``` + +Ahora la saga, `app/Cards/GreetingCardSaga.php`: + + + +```php +desk->reserve(); + } + + public function release(string $card): void + { + $this->desk->release($card); + } + + #[SagaStep(id: 'charge', dependsOn: ['reserve'], compensate: 'refund')] + public function charge(): string + { + return $this->desk->charge(self::PRICE_MINOR); + } + + public function refund(string $payment): void + { + $this->desk->refund($payment); + } + + #[SagaStep(id: 'send', dependsOn: ['charge'])] + public function send(#[FromStep('reserve')] string $card, #[Input('to')] string $to): string + { + return $this->desk->send($card, $to); + } +} +``` + +Elemento a elemento: + +- **`#[Saga(name: 'greeting-card')]`** sobre un bean `#[Service]` declara la saga; `firefly:cache` la compila en + el manifiesto de orquestación, y la topología se valida en el arranque. +- **`#[SagaStep(id: 'reserve', compensate: 'release')]`** — un paso hacia delante y el método que lo deshace. La + compensación recibe el resultado del paso: `release()` recibe el id de tarjeta que devolvió `reserve()`. +- **`dependsOn: ['reserve']`** ordena los pasos en capas: `charge` solo se ejecuta después de que `reserve` haya + tenido éxito, y `send` solo después de `charge`. +- **`#[FromStep('reserve')]`** le pasa a `send()` la tarjeta que reservó el primer paso; **`#[Input('to')]`** lee + una clave de la entrada de la saga. +- `send` no tiene compensación: es el último paso, así que nada posterior puede fallar y dejarlo colgado. + +Exponla, `app/Http/GreetingCardController.php`: + + + +```php +sagas->execute('greeting-card', ['to' => $to]); + + if (! $result->success) { + throw new ConflictException("The card for {$to} was not sent; every completed step was undone.", 'CARD_NOT_SENT', $result->error); + } + + return ['order' => $result->correlationId, 'status' => $result->status->value]; + } +} +``` + +`SagaEngine::execute()` ejecuta la saga dentro de la petición y devuelve un `SagaResult`; una saga fallida se +convierte en una `ConflictException`, que la capa web renderiza como problem+json RFC-7807 exactamente igual que +los errores del Paso 8. Vuelve a compilar y prueba los dos casos: + +```bash +php artisan firefly:cache + +curl -X POST http://127.0.0.1:8000/cards/Ada +# {"order":"…","status":"COMPLETED"} + +curl -i -X POST http://127.0.0.1:8000/cards/Nobody +# HTTP/1.1 409 Conflict <- problem+json con el código CARD_NOT_SENT +``` + +El segundo pedido reservó una tarjeta y cobró al cliente antes de que el envío fallara. El journal muestra lo que +pasó después, y el propio test del repositorio (`tests/TutorialGreetingCardSagaTest.php`) comprueba exactamente +este orden: + +``` +reserve card-1 +charge payment-1 300 +refund payment-1 +release card-1 +``` + +El cobro se reembolsó **antes** de liberar la tarjeta — del más reciente al más antiguo, la política +`strict-sequential` que `firefly.orchestration.saga.compensation-policy` tiene por defecto. Por defecto los puntos +de control de la saga y sus cartas muertas viven en memoria, lo adecuado para una saga que termina dentro de una +petición. Define `FIREFLY_ORCHESTRATION_PERSISTENCE=database` y `FIREFLY_ORCHESTRATION_DLQ_STORE=database` +(después de `php artisan vendor:publish --tag=firefly-orchestration-migrations` y `php artisan migrate`) y cada +frontera de paso se escribe en `firefly_orchestration_executions`, un pedido fallido acaba en +`firefly_orchestration_dead_letters`, y `php artisan firefly:orchestration:list --name=greeting-card` y +`php artisan firefly:orchestration:dlq --name=greeting-card` los muestran. Consulta +[Orquestación](modules/orchestration.md) para las cinco políticas de compensación, TCC, los workflows duraderos +que esperan señales y temporizadores, la recuperación y los comandos de operación. + +--- + +## Paso 14: y ahora qué Has construido una pequeña porción de funcionalidad — `#[RestController]` → `#[Service]` → `#[ConfigProperties]` → `#[Repository]` → `#[Valid]` → errores RFC-7807 → -`#[CommandHandler]`/`#[QueryHandler]` → `#[EventListener]` → un arranque cacheado, sin reflexión — usando +`#[CommandHandler]`/`#[QueryHandler]` → `#[EventListener]` → un outbox de base de datos → una `#[Saga]` → +un arranque cacheado, sin reflexión — usando únicamente los atributos reales y ya distribuidos del propio framework. Desde aquí: ### Fundamentos @@ -1257,6 +1482,12 @@ Has construido una pequeña porción de funcionalidad — `#[RestController]` y la distinción entre las dos superficies de evento (`#[AsEventListener]` frente a `#[EventListener]`). - [Brokers de EDA](modules/eda-brokers.md) y [Mensajería](modules/messaging.md) — adaptadores de broker reales y el `MessageBrokerPort` de bajo nivel, de bytes en bruto. +- [Outbox de base de datos de EDA](modules/eda-database.md) — el outbox portable del Paso 12: las estrategias + de reclamación, el relay y la matriz de soporte de bases de datos. +- [Orquestación](modules/orchestration.md) — sagas, TCC y workflows duraderos: todo lo que el Paso 13 dejó + fuera. +- [Webhooks](modules/webhooks.md) y [Callbacks](modules/callbacks.md) — webhooks firmados de entrada, + callbacks firmados de salida, ambos sobre el bus de eventos. ### Producción @@ -1271,10 +1502,12 @@ Has construido una pequeña porción de funcionalidad — `#[RestController]` - [Trazas](modules/tracing.md) y [Logging](modules/logging.md) — un `traceparent` W3C propagado por HTTP, por los buses de CQRS y por el bus de eventos, y los mismos identificadores en cada registro de log estructurado. -- [Resiliencia](modules/resilience.md) y [Planificación](modules/scheduling.md) — `Retry`/ - `CircuitBreaker`/`Bulkhead` y `#[Scheduled]`. +- [Resiliencia](modules/resilience.md), [Planificación](modules/scheduling.md) y + [Bloqueos de planificación en base de datos](modules/scheduling-database.md) — `Retry`/ + `CircuitBreaker`/`Bulkhead`, `#[Scheduled]` y el bloqueo que mantiene una tarea en un solo nodo. - [Testing](modules/testing.md) — el arnés de arranque de `firefly/testing` y los dobles de grabación para - cada puerto usado en este tutorial. + cada puerto usado en este tutorial, y [Pruebas de integración](modules/integration-testing.md) para los + mismos tests sobre servidores reales. ### Verlo a escala diff --git a/docs/tutorial.md b/docs/tutorial.md index fda7f3da..2497f2d4 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -3,14 +3,15 @@ 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, and a domain event listener. 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. +command/query pair, a domain event listener, a database-agnostic outbox, and a compensating saga. 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, -and a zero-reflection cached boot — the same architecture the [Lumen sample](#step-12-whats-next) uses at -larger scale. +"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. ## Table of Contents @@ -25,7 +26,9 @@ larger scale. 9. [Step 9: Commands and Queries — CQRS](#step-9-commands-and-queries-cqrs) 10. [Step 10: Reacting to a Domain Event — `#[EventListener]`](#step-10-reacting-to-a-domain-event-eventlistener) 11. [Step 11: The Zero-Reflection Cache and Health Introspection](#step-11-the-zero-reflection-cache-and-health-introspection) -12. [Step 12: What's Next](#step-12-whats-next) +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) ## Prerequisites @@ -1195,12 +1198,231 @@ Kafka (`php artisan firefly:outbox:relay`), and the `outbox` health component, a --- -## Step 12: What's Next +## Step 13: A Saga with Compensation — `#[Saga]` + +Everything so far has fit in one transaction. Selling a greeting card does not: it reserves a printed card from +stock, charges the customer through a payment provider, and hands the card to a mailer — three systems, three +commits, and no transaction that spans them. If the mailer fails, the charge has already happened. A **saga** is +the answer: each step commits on its own, each step that changed something names a *compensation* that undoes it, +and a failure runs the compensations of the completed steps, newest first. + +The three systems get one stand-in, `app/Cards/CardDesk.php` — in a real application each would be an adapter +behind its own port; here one in-memory class keeps the step about the saga, and its `$journal` records every call: + + + +```php + */ + public array $journal = []; + + private int $cards = 0; + + private int $payments = 0; + + public function reserve(): string + { + $card = 'card-'.++$this->cards; + $this->journal[] = "reserve {$card}"; + + return $card; + } + + public function release(string $card): void + { + $this->journal[] = "release {$card}"; + } + + public function charge(int $amountMinor): string + { + $payment = 'payment-'.++$this->payments; + $this->journal[] = "charge {$payment} {$amountMinor}"; + + return $payment; + } + + public function refund(string $payment): void + { + $this->journal[] = "refund {$payment}"; + } + + public function send(string $card, string $to): string + { + if ($to === 'Nobody') { + throw new RuntimeException("The mailer has no address for {$to}."); + } + + $this->journal[] = "send {$card} to {$to}"; + + return "sent {$card} to {$to}"; + } +} +``` + +Now the saga, `app/Cards/GreetingCardSaga.php`: + + + +```php +desk->reserve(); + } + + public function release(string $card): void + { + $this->desk->release($card); + } + + #[SagaStep(id: 'charge', dependsOn: ['reserve'], compensate: 'refund')] + public function charge(): string + { + return $this->desk->charge(self::PRICE_MINOR); + } + + public function refund(string $payment): void + { + $this->desk->refund($payment); + } + + #[SagaStep(id: 'send', dependsOn: ['charge'])] + public function send(#[FromStep('reserve')] string $card, #[Input('to')] string $to): string + { + return $this->desk->send($card, $to); + } +} +``` + +Element by element: + +- **`#[Saga(name: 'greeting-card')]`** on a `#[Service]` bean declares the saga; `firefly:cache` compiles it into + the orchestration manifest, and the topology is validated at boot. +- **`#[SagaStep(id: 'reserve', compensate: 'release')]`** — a forward step and the method that undoes it. The + compensation receives the step's result: `release()` gets the card id `reserve()` returned. +- **`dependsOn: ['reserve']`** orders the steps into layers: `charge` runs only after `reserve` succeeded, `send` + only after `charge`. +- **`#[FromStep('reserve')]`** hands `send()` the card the first step reserved; **`#[Input('to')]`** reads a key of + the saga's input. +- `send` has no compensation: it is the last step, so nothing after it can fail and strand it. + +Expose it, `app/Http/GreetingCardController.php`: + + + +```php +sagas->execute('greeting-card', ['to' => $to]); + + if (! $result->success) { + throw new ConflictException("The card for {$to} was not sent; every completed step was undone.", 'CARD_NOT_SENT', $result->error); + } + + return ['order' => $result->correlationId, 'status' => $result->status->value]; + } +} +``` + +`SagaEngine::execute()` runs the saga inside the request and returns a `SagaResult`; a failed saga becomes a +`ConflictException`, which the web layer renders as RFC-7807 problem+json exactly like Step 8's errors. Recompile +and try both cases: + +```bash +php artisan firefly:cache + +curl -X POST http://127.0.0.1:8000/cards/Ada +# {"order":"…","status":"COMPLETED"} + +curl -i -X POST http://127.0.0.1:8000/cards/Nobody +# HTTP/1.1 409 Conflict <- problem+json with code CARD_NOT_SENT +``` + +The second order reserved a card and charged the customer before the mailer refused. The journal shows what +happened next, and the repository's own test (`tests/TutorialGreetingCardSagaTest.php`) asserts exactly this +order: + +``` +reserve card-1 +charge payment-1 300 +refund payment-1 +release card-1 +``` + +The charge was refunded **before** the card was released — newest first, the `strict-sequential` policy that +`firefly.orchestration.saga.compensation-policy` defaults to. By default the saga's checkpoints and its dead +letters live in memory, which is right for a saga that finishes inside one request. Set +`FIREFLY_ORCHESTRATION_PERSISTENCE=database` and `FIREFLY_ORCHESTRATION_DLQ_STORE=database` (after +`php artisan vendor:publish --tag=firefly-orchestration-migrations` and `php artisan migrate`) and every step +boundary is written to `firefly_orchestration_executions`, a failed order lands in +`firefly_orchestration_dead_letters`, and `php artisan firefly:orchestration:list --name=greeting-card` and +`php artisan firefly:orchestration:dlq --name=greeting-card` show them. See [Orchestration](modules/orchestration.md) +for the five compensation policies, TCC, durable workflows that wait for signals and timers, recovery, and the +operator commands. + +--- + +## Step 14: What's Next You've built one small feature slice — `#[RestController]` → `#[Service]` → `#[ConfigProperties]` → `#[Repository]` → `#[Valid]` → RFC-7807 errors → `#[CommandHandler]`/`#[QueryHandler]` → -`#[EventListener]` → a cached, zero-reflection boot — using nothing but the framework's own real, -shipped attributes. From here: +`#[EventListener]` → a database outbox → a `#[Saga]` → a cached, zero-reflection boot — using nothing but +the framework's own real, shipped attributes. From here: ### Foundation @@ -1231,6 +1453,11 @@ shipped attributes. From here: distinction (`#[AsEventListener]` vs. `#[EventListener]`). - [EDA Brokers](modules/eda-brokers.md) and [Messaging](modules/messaging.md) — real broker adapters and the lower-level raw-bytes `MessageBrokerPort`. +- [EDA Database Outbox](modules/eda-database.md) — the portable outbox from Step 12: claim strategies, the + relay, and the database support matrix. +- [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. ### Production @@ -1243,10 +1470,12 @@ shipped attributes. From here: the Prometheus/Micrometer-style metrics core. - [Tracing](modules/tracing.md) and [Logging](modules/logging.md) — a W3C `traceparent` carried across HTTP, the CQRS buses and the event bus, and the same ids on every structured log record. -- [Resilience](modules/resilience.md) and [Scheduling](modules/scheduling.md) — `Retry`/`CircuitBreaker`/ - `Bulkhead` and `#[Scheduled]`. +- [Resilience](modules/resilience.md), [Scheduling](modules/scheduling.md) and + [Scheduling Database Locks](modules/scheduling-database.md) — `Retry`/`CircuitBreaker`/`Bulkhead`, + `#[Scheduled]`, and the lock that keeps a task on one node. - [Testing](modules/testing.md) — the `firefly/testing` boot harness and recording doubles for every port - used in this tutorial. + used in this tutorial, and [Integration Testing](modules/integration-testing.md) for the same tests on real + servers. ### See It at Scale From 67cf3a9f2014a7bd75899cfa8180ef412c075ef5 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 09:28:53 -0700 Subject: [PATCH 18/19] docs: the front pages name orchestration, webhooks, callbacks and the database-agnostic outbox --- README.md | 14 +++++++++++--- docs/getting-started.md | 21 +++++++++++++++++++++ docs/index.md | 3 +++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 09ffad81..28d8948e 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@

Dependency injection with stereotypes, conditional auto-configuration, hexagonal ports & adapters, - CQRS, event-driven architecture with a genuine same-transaction outbox, Spring-Security-shaped method + CQRS, event-driven architecture with a genuine same-transaction outbox on any database, sagas and webhooks, Spring-Security-shaped method security on any bean, both halves of OAuth2, and OpenTelemetry-shaped tracing — wired the moment you install a package, with your own beans always winning.

@@ -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. -Four capabilities in that list are recent enough — and far enough from what teams assume a PHP framework +Five 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.** @@ -193,6 +193,14 @@ will have — that they are worth naming outright: [flagd](https://flagd.dev) definitions in-process on OpenFeature with the same semantics and percentage buckets as PyFly. Gate a bean method or route with `#[FeatureFlag]` and a view with `@featureflag`; change flags at runtime from the admin dashboard, `/actuator/flags` or `php artisan firefly:flags`, with an audit trail. +- **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 + 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. ### Who is LaraFly for? @@ -1169,7 +1177,7 @@ accurately: - **Read models / projections as a first-class concept.** The sample's `LedgerProjector` shows the pattern today via a plain `#[EventListener]`; a dedicated `firefly/eventsourcing`-style package for event sourcing/snapshots/projections is future work, as it is in PyFly. -- **Documentation.** The end-to-end [tutorial](docs/tutorial.md) (EN + ES), the *larafly by example* +- **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 [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/docs/getting-started.md b/docs/getting-started.md index 9659c536..6a664e06 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -82,6 +82,27 @@ its `composer.json` asks for the library and a CLI component requirement beside The template is bundled in the library and supplied to Composer by `firefly new`; it does not need a separate Packagist package. See [Publishing](publishing.md) for the transition from split packages. +## What else ships in the box + +The skeleton starts small, but the library it installs already carries the parts most services reach for next, +each off until its key is set: + +- **An outbox on any database.** `FIREFLY_EDA_PROVIDER=database` writes every domain event in the same transaction + as the data it describes, on SQLite, MySQL, MariaDB, PostgreSQL or SQL Server, and + `php artisan firefly:eda:consume` delivers it — [Tutorial Step 12](tutorial.md#step-12-a-database-agnostic-outbox-and-a-consumer), + [EDA Database Outbox](modules/eda-database.md). +- **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). +- **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). +- **A query cache, brokers and locks.** `#[CachedQuery]` ([CQRS](modules/cqrs.md#query-cache)), Kafka and + 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. + ## Where to next - [Architecture](architecture.md) — how the boot engine, DI, and auto-configuration fit together. diff --git a/docs/index.md b/docs/index.md index fcf7793d..0794f033 100644 --- a/docs/index.md +++ b/docs/index.md @@ -74,6 +74,9 @@ See [Installation](installation.md) for requirements and manual setup, and dispatches commands and queries, with domain events bridged onto the event-transport bus after commit. - **Event-driven, with real brokers** — an in-memory default plus a same-transaction outbox on every SQL database (with LISTEN/NOTIFY on PostgreSQL), RabbitMQ and Kafka adapters behind one `EventPublisher` port. +- **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. - **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** From 4d959e2004bd0a51134425302d3ee2eeede1ae3a Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 09:36:34 -0700 Subject: [PATCH 19/19] docs(changelog): record the D1 books, tutorials and Lumen sample work under [Unreleased] --- CHANGELOG.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a602355f..0cc06a0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,25 @@ All notable changes to LaraFly are documented here. This project uses CalVer (`Y pessimistic/optimistic repository locking are integration-tested on every server; `DatabaseCapabilities` (in `firefly/data`) reads SKIP LOCKED and READPAST support from the server version; the [database support matrix](docs/database-support.md) documents the result. +- **Book: Chapters 8A and 9A, in English and Spanish.** *Webhooks and Callbacks* (Lumen tops wallets up from a + signed card-processor webhook and sends signed callbacks to a partner) and *Sagas, TCC and Durable Workflows* + (Lumen's payout saga against a bank port, compensating newest first, with database checkpoints, recovery, dead + letters, TCC, durable workflows and the scheduler's distributed lock), slotted after Chapters 8 and 9 without + renumbering. Chapter 7 gains the query cache, Chapter 8 the portable database outbox (claim strategies per + server, any number of consumers, the relay, `eda-postgres` as the LISTEN/NOTIFY optimization, durable dead + letters and replay, Kafka/RabbitMQ messaging), and Chapter 12 the integration lanes (`IntegrationDsn`, + `composer test:integration`, the CI matrix, SQL Server's lessons); the preface, Chapter 1, Appendix A and the + glossary follow. The book now has eighteen chapters. +- **Tutorial Steps 12 and 13, in English and Spanish.** The greeting app moves its events onto the database outbox + and delivers them with `php artisan firefly:eda:consume`, then sells a greeting card through a compensating saga + at `POST /cards/{to}`; the saga's files are quoted from `tests/Fixtures/Tutorial/app`, which + `tests/TutorialGreetingCardSagaTest.php` boots through the real web and orchestration pipeline. +- **`samples/lumen` exercises W1, W2 and W4.** A `payout` saga over a `BankGateway` port with a simulated bank and + `POST /api/v1/wallets/{id}/payouts` (201 when the bank confirms, 409 `PAYOUT_FAILED` when the saga compensated, + 500 `PAYOUT_COMPENSATION_FAILED` when a refund itself failed); `CardTopUpListener` on a signed `firefly/webhooks` + 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`. ### Changed @@ -106,6 +125,10 @@ All notable changes to LaraFly are documented here. This project uses CalVer (`Y - **The CQRS/EDA bridge figure still credited only the Postgres adapter with same-transaction atomicity.** Its caption now names the database outbox (`provider=database` or `postgres`), and its adapter row draws the `database` provider; a test derives both providers from their outbox auto-configurations. +- **The Lumen test harness dropped the query-cache policies.** `LumenTestCase` built `HandlerManifest` from the + scanner's handlers and destinations only, so a `#[CachedQuery]` in the sample would have run uncached and every + `#[InvalidatesQuery]` would have evicted nothing once the cache was on; it now passes the cached queries and the + invalidation rules too. ### Deprecated