From 4ffe3c75f230b906bbfaa2588770cf658ccc584d Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Mon, 18 May 2026 18:18:18 +0200 Subject: [PATCH 01/12] feat: add Laravel Projection quickstart examples (DatabaseReadModel and EloquentReadModel) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two runnable examples under quickstart-examples/Laravel/Projection/ that demonstrate the full ProjectionV2 lifecycle (init → events → query → reset → backfill → delete) using a User event-sourced aggregate with three domain events, showing direct-write and outputChannelName patterns for read model construction. --- .../app/Domain/Command/ChangeUserName.php | 18 ++++ .../app/Domain/Command/DeactivateUser.php | 17 ++++ .../app/Domain/Command/RegisterUser.php | 19 +++++ .../app/Domain/Event/UserNameWasChanged.php | 18 ++++ .../app/Domain/Event/UserWasDeactivated.php | 17 ++++ .../app/Domain/Event/UserWasRegistered.php | 19 +++++ .../DatabaseReadModel/app/Domain/User.php | 80 +++++++++++++++++ .../Infrastructure/EcotoneConfiguration.php | 21 +++++ .../app/ReadModel/UserListProjection.php | 85 +++++++++++++++++++ .../DatabaseReadModel/bootstrap/app.php | 22 +++++ .../DatabaseReadModel/composer.json | 38 +++++++++ .../DatabaseReadModel/config/app.php | 40 +++++++++ .../DatabaseReadModel/config/database.php | 10 +++ .../DatabaseReadModel/config/ecotone.php | 5 ++ .../DatabaseReadModel/run_example.php | 78 +++++++++++++++++ .../DatabaseReadModel/storage/app/.gitignore | 3 + .../storage/app/public/.gitignore | 2 + .../storage/framework/.gitignore | 9 ++ .../storage/framework/sessions/.gitignore | 2 + .../storage/framework/testing/.gitignore | 2 + .../storage/framework/views/.gitignore | 2 + .../DatabaseReadModel/storage/logs/.gitignore | 2 + .../app/Application/ApplyUserDeactivated.php | 17 ++++ .../app/Application/ApplyUserNameChanged.php | 18 ++++ .../app/Application/ApplyUserRegistered.php | 19 +++++ .../app/Application/UserReadModelWriter.php | 38 +++++++++ .../app/Domain/Command/ChangeUserName.php | 18 ++++ .../app/Domain/Command/DeactivateUser.php | 17 ++++ .../app/Domain/Command/RegisterUser.php | 19 +++++ .../app/Domain/Event/UserNameWasChanged.php | 18 ++++ .../app/Domain/Event/UserWasDeactivated.php | 17 ++++ .../app/Domain/Event/UserWasRegistered.php | 19 +++++ .../EloquentReadModel/app/Domain/User.php | 80 +++++++++++++++++ .../Infrastructure/EcotoneConfiguration.php | 21 +++++ .../app/Models/UserReadModel.php | 26 ++++++ .../app/ReadModel/UserListProjection.php | 78 +++++++++++++++++ .../EloquentReadModel/bootstrap/app.php | 22 +++++ .../EloquentReadModel/composer.json | 38 +++++++++ .../EloquentReadModel/config/app.php | 40 +++++++++ .../EloquentReadModel/config/database.php | 10 +++ .../EloquentReadModel/config/ecotone.php | 5 ++ .../EloquentReadModel/run_example.php | 78 +++++++++++++++++ .../EloquentReadModel/storage/app/.gitignore | 3 + .../storage/app/public/.gitignore | 2 + .../storage/framework/.gitignore | 9 ++ .../storage/framework/sessions/.gitignore | 2 + .../storage/framework/testing/.gitignore | 2 + .../storage/framework/views/.gitignore | 2 + .../EloquentReadModel/storage/logs/.gitignore | 2 + 49 files changed, 1129 insertions(+) create mode 100644 quickstart-examples/Laravel/Projection/DatabaseReadModel/app/Domain/Command/ChangeUserName.php create mode 100644 quickstart-examples/Laravel/Projection/DatabaseReadModel/app/Domain/Command/DeactivateUser.php create mode 100644 quickstart-examples/Laravel/Projection/DatabaseReadModel/app/Domain/Command/RegisterUser.php create mode 100644 quickstart-examples/Laravel/Projection/DatabaseReadModel/app/Domain/Event/UserNameWasChanged.php create mode 100644 quickstart-examples/Laravel/Projection/DatabaseReadModel/app/Domain/Event/UserWasDeactivated.php create mode 100644 quickstart-examples/Laravel/Projection/DatabaseReadModel/app/Domain/Event/UserWasRegistered.php create mode 100644 quickstart-examples/Laravel/Projection/DatabaseReadModel/app/Domain/User.php create mode 100644 quickstart-examples/Laravel/Projection/DatabaseReadModel/app/Infrastructure/EcotoneConfiguration.php create mode 100644 quickstart-examples/Laravel/Projection/DatabaseReadModel/app/ReadModel/UserListProjection.php create mode 100644 quickstart-examples/Laravel/Projection/DatabaseReadModel/bootstrap/app.php create mode 100644 quickstart-examples/Laravel/Projection/DatabaseReadModel/composer.json create mode 100644 quickstart-examples/Laravel/Projection/DatabaseReadModel/config/app.php create mode 100644 quickstart-examples/Laravel/Projection/DatabaseReadModel/config/database.php create mode 100644 quickstart-examples/Laravel/Projection/DatabaseReadModel/config/ecotone.php create mode 100644 quickstart-examples/Laravel/Projection/DatabaseReadModel/run_example.php create mode 100644 quickstart-examples/Laravel/Projection/DatabaseReadModel/storage/app/.gitignore create mode 100644 quickstart-examples/Laravel/Projection/DatabaseReadModel/storage/app/public/.gitignore create mode 100644 quickstart-examples/Laravel/Projection/DatabaseReadModel/storage/framework/.gitignore create mode 100644 quickstart-examples/Laravel/Projection/DatabaseReadModel/storage/framework/sessions/.gitignore create mode 100644 quickstart-examples/Laravel/Projection/DatabaseReadModel/storage/framework/testing/.gitignore create mode 100644 quickstart-examples/Laravel/Projection/DatabaseReadModel/storage/framework/views/.gitignore create mode 100644 quickstart-examples/Laravel/Projection/DatabaseReadModel/storage/logs/.gitignore create mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/app/Application/ApplyUserDeactivated.php create mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/app/Application/ApplyUserNameChanged.php create mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/app/Application/ApplyUserRegistered.php create mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/app/Application/UserReadModelWriter.php create mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/app/Domain/Command/ChangeUserName.php create mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/app/Domain/Command/DeactivateUser.php create mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/app/Domain/Command/RegisterUser.php create mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/app/Domain/Event/UserNameWasChanged.php create mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/app/Domain/Event/UserWasDeactivated.php create mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/app/Domain/Event/UserWasRegistered.php create mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/app/Domain/User.php create mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/app/Infrastructure/EcotoneConfiguration.php create mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/app/Models/UserReadModel.php create mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/app/ReadModel/UserListProjection.php create mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/bootstrap/app.php create mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/composer.json create mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/config/app.php create mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/config/database.php create mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/config/ecotone.php create mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/run_example.php create mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/storage/app/.gitignore create mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/storage/app/public/.gitignore create mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/storage/framework/.gitignore create mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/storage/framework/sessions/.gitignore create mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/storage/framework/testing/.gitignore create mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/storage/framework/views/.gitignore create mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/storage/logs/.gitignore diff --git a/quickstart-examples/Laravel/Projection/DatabaseReadModel/app/Domain/Command/ChangeUserName.php b/quickstart-examples/Laravel/Projection/DatabaseReadModel/app/Domain/Command/ChangeUserName.php new file mode 100644 index 000000000..b1f9e2b51 --- /dev/null +++ b/quickstart-examples/Laravel/Projection/DatabaseReadModel/app/Domain/Command/ChangeUserName.php @@ -0,0 +1,18 @@ +userId, $command->name, $command->email)]; + } + + #[CommandHandler] + public function changeName(ChangeUserName $command): array + { + if ($command->name === $this->name) { + return []; + } + + return [new UserNameWasChanged($this->userId, $command->name)]; + } + + #[CommandHandler] + public function deactivate(DeactivateUser $command): array + { + if (! $this->active) { + return []; + } + + return [new UserWasDeactivated($this->userId)]; + } + + #[EventSourcingHandler] + public function applyRegistered(UserWasRegistered $event): void + { + $this->userId = $event->userId; + $this->name = $event->name; + $this->active = true; + } + + #[EventSourcingHandler] + public function applyNameChanged(UserNameWasChanged $event): void + { + $this->name = $event->name; + } + + #[EventSourcingHandler] + public function applyDeactivated(UserWasDeactivated $event): void + { + $this->active = false; + } +} diff --git a/quickstart-examples/Laravel/Projection/DatabaseReadModel/app/Infrastructure/EcotoneConfiguration.php b/quickstart-examples/Laravel/Projection/DatabaseReadModel/app/Infrastructure/EcotoneConfiguration.php new file mode 100644 index 000000000..b2c57ee3c --- /dev/null +++ b/quickstart-examples/Laravel/Projection/DatabaseReadModel/app/Infrastructure/EcotoneConfiguration.php @@ -0,0 +1,21 @@ +db->statement('CREATE TABLE IF NOT EXISTS user_list_database ( + user_id VARCHAR(36) PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + active BOOLEAN NOT NULL DEFAULT TRUE + )'); + } + + #[ProjectionDelete] + public function delete(): void + { + $this->db->statement('DROP TABLE IF EXISTS user_list_database'); + } + + #[EventHandler] + public function onRegistered(UserWasRegistered $event): void + { + $this->db->table('user_list_database')->insert([ + 'user_id' => $event->userId, + 'name' => $event->name, + 'email' => $event->email, + 'active' => true, + ]); + } + + #[EventHandler] + public function onNameChanged(UserNameWasChanged $event): void + { + $this->db->table('user_list_database') + ->where('user_id', $event->userId) + ->update(['name' => $event->name]); + } + + #[EventHandler] + public function onDeactivated(UserWasDeactivated $event): void + { + $this->db->table('user_list_database') + ->where('user_id', $event->userId) + ->update(['active' => false]); + } + + #[QueryHandler('user.listActive')] + public function listActive(): array + { + return $this->db->table('user_list_database') + ->where('active', true) + ->orderBy('name') + ->get() + ->map(fn ($row) => (array) $row) + ->toArray(); + } +} diff --git a/quickstart-examples/Laravel/Projection/DatabaseReadModel/bootstrap/app.php b/quickstart-examples/Laravel/Projection/DatabaseReadModel/bootstrap/app.php new file mode 100644 index 000000000..1ceadc334 --- /dev/null +++ b/quickstart-examples/Laravel/Projection/DatabaseReadModel/bootstrap/app.php @@ -0,0 +1,22 @@ +singleton( + Illuminate\Contracts\Http\Kernel::class, + \Illuminate\Foundation\Http\Kernel::class +); + +$app->singleton( + Illuminate\Contracts\Console\Kernel::class, + \Illuminate\Foundation\Console\Kernel::class +); + +$app->singleton( + Illuminate\Contracts\Debug\ExceptionHandler::class, + \Illuminate\Foundation\Exceptions\Handler::class +); + +return $app; diff --git a/quickstart-examples/Laravel/Projection/DatabaseReadModel/composer.json b/quickstart-examples/Laravel/Projection/DatabaseReadModel/composer.json new file mode 100644 index 000000000..b7061c8ab --- /dev/null +++ b/quickstart-examples/Laravel/Projection/DatabaseReadModel/composer.json @@ -0,0 +1,38 @@ +{ + "name": "ecotone/quickstart", + "license": "MIT", + "authors": [ + { + "name": "Dariusz Gafka", + "email": "dgafka.mail@gmail.com" + } + ], + "repositories": [ + { + "type": "path", + "url": "../../../../packages/*", + "options": { + "symlink": true + } + } + ], + "autoload": { + "psr-4": { + "App\\": "app" + } + }, + "require": { + "ecotone/laravel-starter": "^1.1.0", + "ecotone/lite-event-sourcing-starter": "^1.0", + "laravel/laravel": "^10.0", + "ramsey/uuid": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6|^10.5|^11.0" + }, + "config": { + "sort-packages": true + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/quickstart-examples/Laravel/Projection/DatabaseReadModel/config/app.php b/quickstart-examples/Laravel/Projection/DatabaseReadModel/config/app.php new file mode 100644 index 000000000..faabbbbb9 --- /dev/null +++ b/quickstart-examples/Laravel/Projection/DatabaseReadModel/config/app.php @@ -0,0 +1,40 @@ + env('APP_NAME', 'Laravel'), + 'env' => env('APP_ENV', 'production'), + 'debug' => (bool) env('APP_DEBUG', false), + 'url' => env('APP_URL', 'http://localhost'), + 'asset_url' => env('ASSET_URL'), + 'timezone' => 'UTC', + 'locale' => 'en', + 'fallback_locale' => 'en', + 'faker_locale' => 'en_US', + 'key' => env('APP_KEY'), + 'cipher' => 'AES-256-CBC', + + 'providers' => [ + Illuminate\Auth\AuthServiceProvider::class, + Illuminate\Broadcasting\BroadcastServiceProvider::class, + Illuminate\Bus\BusServiceProvider::class, + Illuminate\Cache\CacheServiceProvider::class, + Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class, + Illuminate\Database\DatabaseServiceProvider::class, + Illuminate\Encryption\EncryptionServiceProvider::class, + Illuminate\Filesystem\FilesystemServiceProvider::class, + Illuminate\Foundation\Providers\FoundationServiceProvider::class, + Illuminate\Mail\MailServiceProvider::class, + Illuminate\Notifications\NotificationServiceProvider::class, + Illuminate\Pipeline\PipelineServiceProvider::class, + Illuminate\Queue\QueueServiceProvider::class, + Illuminate\Redis\RedisServiceProvider::class, + Illuminate\Validation\ValidationServiceProvider::class, + Illuminate\View\ViewServiceProvider::class, + + \Ecotone\Laravel\EcotoneProvider::class, + ], + + 'aliases' => Facade::defaultAliases()->merge([])->toArray(), +]; diff --git a/quickstart-examples/Laravel/Projection/DatabaseReadModel/config/database.php b/quickstart-examples/Laravel/Projection/DatabaseReadModel/config/database.php new file mode 100644 index 000000000..147cd5c04 --- /dev/null +++ b/quickstart-examples/Laravel/Projection/DatabaseReadModel/config/database.php @@ -0,0 +1,10 @@ + 'pgsql', + 'connections' => [ + 'pgsql' => [ + 'url' => getenv('DATABASE_DSN') ?: 'pgsql://ecotone:secret@localhost:5432/ecotone', + ], + ], +]; diff --git a/quickstart-examples/Laravel/Projection/DatabaseReadModel/config/ecotone.php b/quickstart-examples/Laravel/Projection/DatabaseReadModel/config/ecotone.php new file mode 100644 index 000000000..ca5d8ed54 --- /dev/null +++ b/quickstart-examples/Laravel/Projection/DatabaseReadModel/config/ecotone.php @@ -0,0 +1,5 @@ +make(Kernel::class)->bootstrap(); + +/** @var ConfiguredMessagingSystem $messagingSystem */ +$messagingSystem = $app->get(ConfiguredMessagingSystem::class); +/** @var CommandBus $commandBus */ +$commandBus = $app->get(CommandBus::class); +/** @var QueryBus $queryBus */ +$queryBus = $app->get(QueryBus::class); +/** @var EventStore $eventStore */ +$eventStore = $app->get(EventStore::class); + +echo "== Laravel Projection Quickstart - Database Read Model ==\n\n"; + +if ($eventStore->hasStream(User::class)) { + $eventStore->delete(User::class); +} + +echo "1) Delete projection (clean slate)\n"; +$messagingSystem->runConsoleCommand('ecotone:projection:delete', ['name' => 'user_list_database']); +echo " Projection deleted\n\n"; + +echo "2) Initialise projection (create read model storage)\n"; +$messagingSystem->runConsoleCommand('ecotone:projection:init', ['name' => 'user_list_database']); +echo " Projection initialised\n\n"; + +echo "3) Emit events via commands\n"; +$aliceId = Uuid::uuid4()->toString(); +$bobId = Uuid::uuid4()->toString(); +$commandBus->send(new RegisterUser($aliceId, 'Alice', 'alice@example.com')); +$commandBus->send(new RegisterUser($bobId, 'Bob', 'bob@example.com')); +$commandBus->send(new ChangeUserName($aliceId, 'Alice Cooper')); +$commandBus->send(new DeactivateUser($bobId)); +echo " Registered Alice and Bob, renamed Alice to Alice Cooper, deactivated Bob\n\n"; + +echo "4) Query and assert active users\n"; +$rows = $queryBus->sendWithRouting('user.listActive'); +Assert::assertCount(1, $rows); +Assert::assertSame('Alice Cooper', $rows[0]['name']); +echo " Active users: " . count($rows) . " (Alice Cooper only - Bob is deactivated)\n\n"; + +echo "5) Reset projection (delete + re-initialise = wipe read model + clear position)\n"; +$messagingSystem->runConsoleCommand('ecotone:projection:delete', ['name' => 'user_list_database']); +$messagingSystem->runConsoleCommand('ecotone:projection:init', ['name' => 'user_list_database']); +$rows = $queryBus->sendWithRouting('user.listActive'); +Assert::assertSame([], $rows); +echo " Read model is empty after reset\n\n"; + +echo "6) Backfill projection (replay all events from event store)\n"; +$messagingSystem->runConsoleCommand('ecotone:projection:backfill', ['name' => 'user_list_database']); +$rows = $queryBus->sendWithRouting('user.listActive'); +Assert::assertCount(1, $rows); +Assert::assertSame('Alice Cooper', $rows[0]['name']); +echo " Read model rebuilt from events: " . count($rows) . " active user (Alice Cooper)\n\n"; + +echo "7) Delete projection (drop storage)\n"; +$messagingSystem->runConsoleCommand('ecotone:projection:delete', ['name' => 'user_list_database']); +echo " Projection deleted\n\n"; + +echo "== Example completed successfully ==\n"; diff --git a/quickstart-examples/Laravel/Projection/DatabaseReadModel/storage/app/.gitignore b/quickstart-examples/Laravel/Projection/DatabaseReadModel/storage/app/.gitignore new file mode 100644 index 000000000..8f4803c05 --- /dev/null +++ b/quickstart-examples/Laravel/Projection/DatabaseReadModel/storage/app/.gitignore @@ -0,0 +1,3 @@ +* +!public/ +!.gitignore diff --git a/quickstart-examples/Laravel/Projection/DatabaseReadModel/storage/app/public/.gitignore b/quickstart-examples/Laravel/Projection/DatabaseReadModel/storage/app/public/.gitignore new file mode 100644 index 000000000..d6b7ef32c --- /dev/null +++ b/quickstart-examples/Laravel/Projection/DatabaseReadModel/storage/app/public/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/quickstart-examples/Laravel/Projection/DatabaseReadModel/storage/framework/.gitignore b/quickstart-examples/Laravel/Projection/DatabaseReadModel/storage/framework/.gitignore new file mode 100644 index 000000000..05c4471f2 --- /dev/null +++ b/quickstart-examples/Laravel/Projection/DatabaseReadModel/storage/framework/.gitignore @@ -0,0 +1,9 @@ +compiled.php +config.php +down +events.scanned.php +maintenance.php +routes.php +routes.scanned.php +schedule-* +services.json diff --git a/quickstart-examples/Laravel/Projection/DatabaseReadModel/storage/framework/sessions/.gitignore b/quickstart-examples/Laravel/Projection/DatabaseReadModel/storage/framework/sessions/.gitignore new file mode 100644 index 000000000..d6b7ef32c --- /dev/null +++ b/quickstart-examples/Laravel/Projection/DatabaseReadModel/storage/framework/sessions/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/quickstart-examples/Laravel/Projection/DatabaseReadModel/storage/framework/testing/.gitignore b/quickstart-examples/Laravel/Projection/DatabaseReadModel/storage/framework/testing/.gitignore new file mode 100644 index 000000000..d6b7ef32c --- /dev/null +++ b/quickstart-examples/Laravel/Projection/DatabaseReadModel/storage/framework/testing/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/quickstart-examples/Laravel/Projection/DatabaseReadModel/storage/framework/views/.gitignore b/quickstart-examples/Laravel/Projection/DatabaseReadModel/storage/framework/views/.gitignore new file mode 100644 index 000000000..d6b7ef32c --- /dev/null +++ b/quickstart-examples/Laravel/Projection/DatabaseReadModel/storage/framework/views/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/quickstart-examples/Laravel/Projection/DatabaseReadModel/storage/logs/.gitignore b/quickstart-examples/Laravel/Projection/DatabaseReadModel/storage/logs/.gitignore new file mode 100644 index 000000000..d6b7ef32c --- /dev/null +++ b/quickstart-examples/Laravel/Projection/DatabaseReadModel/storage/logs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/quickstart-examples/Laravel/Projection/EloquentReadModel/app/Application/ApplyUserDeactivated.php b/quickstart-examples/Laravel/Projection/EloquentReadModel/app/Application/ApplyUserDeactivated.php new file mode 100644 index 000000000..cd56c4b8f --- /dev/null +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/app/Application/ApplyUserDeactivated.php @@ -0,0 +1,17 @@ + $dto->userId, + 'name' => $dto->name, + 'email' => $dto->email, + 'active' => true, + ]); + } + + #[InternalHandler(inputChannelName: 'user_read_model.apply_name_changed')] + public function applyNameChanged(ApplyUserNameChanged $dto): void + { + UserReadModel::where('user_id', $dto->userId)->update(['name' => $dto->name]); + } + + #[InternalHandler(inputChannelName: 'user_read_model.apply_deactivated')] + public function applyDeactivated(ApplyUserDeactivated $dto): void + { + UserReadModel::where('user_id', $dto->userId)->update(['active' => false]); + } +} diff --git a/quickstart-examples/Laravel/Projection/EloquentReadModel/app/Domain/Command/ChangeUserName.php b/quickstart-examples/Laravel/Projection/EloquentReadModel/app/Domain/Command/ChangeUserName.php new file mode 100644 index 000000000..b1f9e2b51 --- /dev/null +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/app/Domain/Command/ChangeUserName.php @@ -0,0 +1,18 @@ +userId, $command->name, $command->email)]; + } + + #[CommandHandler] + public function changeName(ChangeUserName $command): array + { + if ($command->name === $this->name) { + return []; + } + + return [new UserNameWasChanged($this->userId, $command->name)]; + } + + #[CommandHandler] + public function deactivate(DeactivateUser $command): array + { + if (! $this->active) { + return []; + } + + return [new UserWasDeactivated($this->userId)]; + } + + #[EventSourcingHandler] + public function applyRegistered(UserWasRegistered $event): void + { + $this->userId = $event->userId; + $this->name = $event->name; + $this->active = true; + } + + #[EventSourcingHandler] + public function applyNameChanged(UserNameWasChanged $event): void + { + $this->name = $event->name; + } + + #[EventSourcingHandler] + public function applyDeactivated(UserWasDeactivated $event): void + { + $this->active = false; + } +} diff --git a/quickstart-examples/Laravel/Projection/EloquentReadModel/app/Infrastructure/EcotoneConfiguration.php b/quickstart-examples/Laravel/Projection/EloquentReadModel/app/Infrastructure/EcotoneConfiguration.php new file mode 100644 index 000000000..b2c57ee3c --- /dev/null +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/app/Infrastructure/EcotoneConfiguration.php @@ -0,0 +1,21 @@ +db->statement('CREATE TABLE IF NOT EXISTS user_list_eloquent ( + user_id VARCHAR(36) PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + active BOOLEAN NOT NULL DEFAULT TRUE + )'); + } + + #[ProjectionDelete] + public function delete(): void + { + $this->db->statement('DROP TABLE IF EXISTS user_list_eloquent'); + } + + #[EventHandler(outputChannelName: 'user_read_model.apply_registered')] + public function onRegistered(UserWasRegistered $event): ApplyUserRegistered + { + return new ApplyUserRegistered($event->userId, $event->name, $event->email); + } + + #[EventHandler(outputChannelName: 'user_read_model.apply_name_changed')] + public function onNameChanged(UserNameWasChanged $event): ApplyUserNameChanged + { + return new ApplyUserNameChanged($event->userId, $event->name); + } + + #[EventHandler(outputChannelName: 'user_read_model.apply_deactivated')] + public function onDeactivated(UserWasDeactivated $event): ApplyUserDeactivated + { + return new ApplyUserDeactivated($event->userId); + } + + #[QueryHandler('user.listActive')] + public function listActive(): array + { + return UserReadModel::where('active', true) + ->orderBy('name') + ->get() + ->toArray(); + } +} diff --git a/quickstart-examples/Laravel/Projection/EloquentReadModel/bootstrap/app.php b/quickstart-examples/Laravel/Projection/EloquentReadModel/bootstrap/app.php new file mode 100644 index 000000000..1ceadc334 --- /dev/null +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/bootstrap/app.php @@ -0,0 +1,22 @@ +singleton( + Illuminate\Contracts\Http\Kernel::class, + \Illuminate\Foundation\Http\Kernel::class +); + +$app->singleton( + Illuminate\Contracts\Console\Kernel::class, + \Illuminate\Foundation\Console\Kernel::class +); + +$app->singleton( + Illuminate\Contracts\Debug\ExceptionHandler::class, + \Illuminate\Foundation\Exceptions\Handler::class +); + +return $app; diff --git a/quickstart-examples/Laravel/Projection/EloquentReadModel/composer.json b/quickstart-examples/Laravel/Projection/EloquentReadModel/composer.json new file mode 100644 index 000000000..b7061c8ab --- /dev/null +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/composer.json @@ -0,0 +1,38 @@ +{ + "name": "ecotone/quickstart", + "license": "MIT", + "authors": [ + { + "name": "Dariusz Gafka", + "email": "dgafka.mail@gmail.com" + } + ], + "repositories": [ + { + "type": "path", + "url": "../../../../packages/*", + "options": { + "symlink": true + } + } + ], + "autoload": { + "psr-4": { + "App\\": "app" + } + }, + "require": { + "ecotone/laravel-starter": "^1.1.0", + "ecotone/lite-event-sourcing-starter": "^1.0", + "laravel/laravel": "^10.0", + "ramsey/uuid": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6|^10.5|^11.0" + }, + "config": { + "sort-packages": true + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/quickstart-examples/Laravel/Projection/EloquentReadModel/config/app.php b/quickstart-examples/Laravel/Projection/EloquentReadModel/config/app.php new file mode 100644 index 000000000..faabbbbb9 --- /dev/null +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/config/app.php @@ -0,0 +1,40 @@ + env('APP_NAME', 'Laravel'), + 'env' => env('APP_ENV', 'production'), + 'debug' => (bool) env('APP_DEBUG', false), + 'url' => env('APP_URL', 'http://localhost'), + 'asset_url' => env('ASSET_URL'), + 'timezone' => 'UTC', + 'locale' => 'en', + 'fallback_locale' => 'en', + 'faker_locale' => 'en_US', + 'key' => env('APP_KEY'), + 'cipher' => 'AES-256-CBC', + + 'providers' => [ + Illuminate\Auth\AuthServiceProvider::class, + Illuminate\Broadcasting\BroadcastServiceProvider::class, + Illuminate\Bus\BusServiceProvider::class, + Illuminate\Cache\CacheServiceProvider::class, + Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class, + Illuminate\Database\DatabaseServiceProvider::class, + Illuminate\Encryption\EncryptionServiceProvider::class, + Illuminate\Filesystem\FilesystemServiceProvider::class, + Illuminate\Foundation\Providers\FoundationServiceProvider::class, + Illuminate\Mail\MailServiceProvider::class, + Illuminate\Notifications\NotificationServiceProvider::class, + Illuminate\Pipeline\PipelineServiceProvider::class, + Illuminate\Queue\QueueServiceProvider::class, + Illuminate\Redis\RedisServiceProvider::class, + Illuminate\Validation\ValidationServiceProvider::class, + Illuminate\View\ViewServiceProvider::class, + + \Ecotone\Laravel\EcotoneProvider::class, + ], + + 'aliases' => Facade::defaultAliases()->merge([])->toArray(), +]; diff --git a/quickstart-examples/Laravel/Projection/EloquentReadModel/config/database.php b/quickstart-examples/Laravel/Projection/EloquentReadModel/config/database.php new file mode 100644 index 000000000..147cd5c04 --- /dev/null +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/config/database.php @@ -0,0 +1,10 @@ + 'pgsql', + 'connections' => [ + 'pgsql' => [ + 'url' => getenv('DATABASE_DSN') ?: 'pgsql://ecotone:secret@localhost:5432/ecotone', + ], + ], +]; diff --git a/quickstart-examples/Laravel/Projection/EloquentReadModel/config/ecotone.php b/quickstart-examples/Laravel/Projection/EloquentReadModel/config/ecotone.php new file mode 100644 index 000000000..ca5d8ed54 --- /dev/null +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/config/ecotone.php @@ -0,0 +1,5 @@ +make(Kernel::class)->bootstrap(); + +/** @var ConfiguredMessagingSystem $messagingSystem */ +$messagingSystem = $app->get(ConfiguredMessagingSystem::class); +/** @var CommandBus $commandBus */ +$commandBus = $app->get(CommandBus::class); +/** @var QueryBus $queryBus */ +$queryBus = $app->get(QueryBus::class); +/** @var EventStore $eventStore */ +$eventStore = $app->get(EventStore::class); + +echo "== Laravel Projection Quickstart - Eloquent Read Model ==\n\n"; + +if ($eventStore->hasStream(User::class)) { + $eventStore->delete(User::class); +} + +echo "1) Delete projection (clean slate)\n"; +$messagingSystem->runConsoleCommand('ecotone:projection:delete', ['name' => 'user_list_eloquent']); +echo " Projection deleted\n\n"; + +echo "2) Initialise projection (create read model storage)\n"; +$messagingSystem->runConsoleCommand('ecotone:projection:init', ['name' => 'user_list_eloquent']); +echo " Projection initialised\n\n"; + +echo "3) Emit events via commands\n"; +$aliceId = Uuid::uuid4()->toString(); +$bobId = Uuid::uuid4()->toString(); +$commandBus->send(new RegisterUser($aliceId, 'Alice', 'alice@example.com')); +$commandBus->send(new RegisterUser($bobId, 'Bob', 'bob@example.com')); +$commandBus->send(new ChangeUserName($aliceId, 'Alice Cooper')); +$commandBus->send(new DeactivateUser($bobId)); +echo " Registered Alice and Bob, renamed Alice to Alice Cooper, deactivated Bob\n\n"; + +echo "4) Query and assert active users\n"; +$rows = $queryBus->sendWithRouting('user.listActive'); +Assert::assertCount(1, $rows); +Assert::assertSame('Alice Cooper', $rows[0]['name']); +echo " Active users: " . count($rows) . " (Alice Cooper only - Bob is deactivated)\n\n"; + +echo "5) Reset projection (delete + re-initialise = wipe read model + clear position)\n"; +$messagingSystem->runConsoleCommand('ecotone:projection:delete', ['name' => 'user_list_eloquent']); +$messagingSystem->runConsoleCommand('ecotone:projection:init', ['name' => 'user_list_eloquent']); +$rows = $queryBus->sendWithRouting('user.listActive'); +Assert::assertSame([], $rows); +echo " Read model is empty after reset\n\n"; + +echo "6) Backfill projection (replay all events from event store)\n"; +$messagingSystem->runConsoleCommand('ecotone:projection:backfill', ['name' => 'user_list_eloquent']); +$rows = $queryBus->sendWithRouting('user.listActive'); +Assert::assertCount(1, $rows); +Assert::assertSame('Alice Cooper', $rows[0]['name']); +echo " Read model rebuilt from events: " . count($rows) . " active user (Alice Cooper)\n\n"; + +echo "7) Delete projection (drop storage)\n"; +$messagingSystem->runConsoleCommand('ecotone:projection:delete', ['name' => 'user_list_eloquent']); +echo " Projection deleted\n\n"; + +echo "== Example completed successfully ==\n"; diff --git a/quickstart-examples/Laravel/Projection/EloquentReadModel/storage/app/.gitignore b/quickstart-examples/Laravel/Projection/EloquentReadModel/storage/app/.gitignore new file mode 100644 index 000000000..8f4803c05 --- /dev/null +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/storage/app/.gitignore @@ -0,0 +1,3 @@ +* +!public/ +!.gitignore diff --git a/quickstart-examples/Laravel/Projection/EloquentReadModel/storage/app/public/.gitignore b/quickstart-examples/Laravel/Projection/EloquentReadModel/storage/app/public/.gitignore new file mode 100644 index 000000000..d6b7ef32c --- /dev/null +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/storage/app/public/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/quickstart-examples/Laravel/Projection/EloquentReadModel/storage/framework/.gitignore b/quickstart-examples/Laravel/Projection/EloquentReadModel/storage/framework/.gitignore new file mode 100644 index 000000000..05c4471f2 --- /dev/null +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/storage/framework/.gitignore @@ -0,0 +1,9 @@ +compiled.php +config.php +down +events.scanned.php +maintenance.php +routes.php +routes.scanned.php +schedule-* +services.json diff --git a/quickstart-examples/Laravel/Projection/EloquentReadModel/storage/framework/sessions/.gitignore b/quickstart-examples/Laravel/Projection/EloquentReadModel/storage/framework/sessions/.gitignore new file mode 100644 index 000000000..d6b7ef32c --- /dev/null +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/storage/framework/sessions/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/quickstart-examples/Laravel/Projection/EloquentReadModel/storage/framework/testing/.gitignore b/quickstart-examples/Laravel/Projection/EloquentReadModel/storage/framework/testing/.gitignore new file mode 100644 index 000000000..d6b7ef32c --- /dev/null +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/storage/framework/testing/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/quickstart-examples/Laravel/Projection/EloquentReadModel/storage/framework/views/.gitignore b/quickstart-examples/Laravel/Projection/EloquentReadModel/storage/framework/views/.gitignore new file mode 100644 index 000000000..d6b7ef32c --- /dev/null +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/storage/framework/views/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/quickstart-examples/Laravel/Projection/EloquentReadModel/storage/logs/.gitignore b/quickstart-examples/Laravel/Projection/EloquentReadModel/storage/logs/.gitignore new file mode 100644 index 000000000..d6b7ef32c --- /dev/null +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/storage/logs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore From b502c43e5628dfb1a5def9fa0793275dfc1b2ef1 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Mon, 18 May 2026 18:23:50 +0200 Subject: [PATCH 02/12] feat: add READMEs, CI wiring, licence headers and gitignore fixes for Laravel Projection examples Adds beginner-friendly READMEs with 4 mermaid diagrams and 8-section skeletons for DatabaseReadModel and EloquentReadModel, the top-level index README, CI entries in quickstart-examples/composer.json tests:ci, Apache-2.0 licence headers on all PHP source files, and storage/framework cache/ exclusions to prevent generated files from being tracked by git. --- .../Projection/DatabaseReadModel/README.md | 146 +++++++++++++++ .../DatabaseReadModel/bootstrap/app.php | 4 + .../DatabaseReadModel/config/app.php | 4 + .../DatabaseReadModel/config/database.php | 4 + .../DatabaseReadModel/config/ecotone.php | 4 + .../DatabaseReadModel/run_example.php | 4 + .../storage/framework/.gitignore | 1 + .../Projection/EloquentReadModel/README.md | 166 ++++++++++++++++++ .../EloquentReadModel/bootstrap/app.php | 4 + .../EloquentReadModel/config/app.php | 4 + .../EloquentReadModel/config/database.php | 4 + .../EloquentReadModel/config/ecotone.php | 4 + .../EloquentReadModel/run_example.php | 4 + .../storage/framework/.gitignore | 1 + .../Laravel/Projection/README.md | 23 +++ quickstart-examples/composer.json | 4 +- 16 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 quickstart-examples/Laravel/Projection/DatabaseReadModel/README.md create mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/README.md create mode 100644 quickstart-examples/Laravel/Projection/README.md diff --git a/quickstart-examples/Laravel/Projection/DatabaseReadModel/README.md b/quickstart-examples/Laravel/Projection/DatabaseReadModel/README.md new file mode 100644 index 000000000..0d4a712a8 --- /dev/null +++ b/quickstart-examples/Laravel/Projection/DatabaseReadModel/README.md @@ -0,0 +1,146 @@ +# Laravel Projection — Database Read Model + +## 1. What you'll learn + +This example shows how to build a **projection** (a read-optimised view) on top of an event-sourced `User` aggregate using Laravel and Ecotone. You will see how the projection's `#[ProjectionInitialization]` hook creates the storage, how `#[EventHandler]` methods react to each domain event, and how the full projection lifecycle (init → query → reset → backfill → delete) lets you rebuild the read model from scratch whenever you need to. + +## 2. The problem this solves + +In a traditional application, if you need a new view on your data — say "all active users ordered by name" — you run a database migration and populate the new table. In an event-sourced system you still have every domain event ever emitted. You can **replay** them into any new shape without touching the write side. This is the projection pattern: the events are the truth; the read model is just a cache you can always discard and rebuild. + +## 3. How it fits together + +```mermaid +flowchart LR + Client -->|send command| CommandBus + CommandBus -->|route| User["User\n#[EventSourcingAggregate]"] + User -->|return events| EventStore[(Event Store\nPostgreSQL)] + EventStore -->|stream| UserListProjection["UserListProjection\n#[ProjectionV2]"] + UserListProjection -->|INSERT / UPDATE| ReadModel[(user_list_database\ntable)] + Client -->|sendWithRouting| QueryBus + QueryBus -->|listActive| UserListProjection + UserListProjection -->|SELECT| ReadModel +``` + +*Files involved:* +- `app/Domain/User.php` — aggregate that produces the events +- `app/Domain/Event/` — `UserWasRegistered`, `UserNameWasChanged`, `UserWasDeactivated` +- `app/ReadModel/UserListProjection.php` — projection that maintains `user_list_database` +- `app/Infrastructure/EcotoneConfiguration.php` — wires the PostgreSQL connection + +## 4. Walkthrough of the code + +### 4.1 Domain — User aggregate + +```mermaid +sequenceDiagram + participant Client + participant CommandBus + participant User + participant EventStore + + Client->>CommandBus: RegisterUser(userId, name, email) + CommandBus->>User: register() static + User-->>EventStore: [UserWasRegistered] + + Client->>CommandBus: ChangeUserName(userId, name) + CommandBus->>User: changeName() + User-->>EventStore: [UserNameWasChanged] + + Client->>CommandBus: DeactivateUser(userId) + CommandBus->>User: deactivate() + User-->>EventStore: [UserWasDeactivated] +``` + +The `User` aggregate is annotated with `#[EventSourcingAggregate]`. Command handlers are `static` for creation (`register`) and instance methods for mutations (`changeName`, `deactivate`). Each handler returns an array of events. `#[EventSourcingHandler]` methods reconstruct aggregate state from stored events — they must have no side effects. + +### 4.2 The projection — direct database writes + +```mermaid +flowchart TD + ES[(Event Store)] -->|UserWasRegistered| onRegistered["onRegistered()\n#[EventHandler]"] + ES -->|UserNameWasChanged| onNameChanged["onNameChanged()\n#[EventHandler]"] + ES -->|UserWasDeactivated| onDeactivated["onDeactivated()\n#[EventHandler]"] + onRegistered -->|INSERT| DB[(user_list_database)] + onNameChanged -->|UPDATE name| DB + onDeactivated -->|UPDATE active=false| DB +``` + +`UserListProjection` receives a `ConnectionInterface` (Laravel's default DB connection) injected by Ecotone's container. Each `#[EventHandler]` method writes directly to the `user_list_database` table. No DTO wiring, no intermediate services — this is the simplest possible pattern. + +### 4.3 Lifecycle hooks + +| Hook | Attribute | What it does | +|------|-----------|--------------| +| Initialise | `#[ProjectionInitialization]` | `CREATE TABLE IF NOT EXISTS user_list_database (...)` | +| Delete | `#[ProjectionDelete]` | `DROP TABLE IF EXISTS user_list_database` | + +Resetting the projection is done by deleting and re-initialising it, which clears both the read model table and Ecotone's stored stream position for this projection. A subsequent backfill replays all events from position 0. + +### 4.4 Querying the read model + +The `#[QueryHandler('user.listActive')]` method runs a simple `SELECT` via the `ConnectionInterface` and returns an array. Callers use the query bus: + +```php +$rows = $queryBus->sendWithRouting('user.listActive'); +// $rows[0]['name'] === 'Alice Cooper' +``` + +The query handler lives on the same class as the event handlers. You can move it to a separate class if you want read/write separation at the class level. + +## 5. Running it + +```bash +# Start services +docker compose up -d app database + +# Enter the container +docker compose exec app bash + +# Install and run +cd quickstart-examples/Laravel/Projection/DatabaseReadModel +composer update +php run_example.php +``` + +The script exits 0 and prints a seven-step ribbon showing each lifecycle phase. + +> **PostgreSQL only.** Event sourcing requires PostgreSQL; SQLite is not supported. + +## 6. Reset vs Delete vs Rebuild + +```mermaid +stateDiagram-v2 + [*] --> Gone: start (no projection) + Gone --> Empty: ecotone:projection:init + Empty --> Active: ecotone:projection:backfill\n(events processed) + Active --> Empty: ecotone:projection:delete\n+ ecotone:projection:init\n(reset = clear rows + position) + Empty --> Active: ecotone:projection:backfill\n(rebuild from event store) + Active --> Gone: ecotone:projection:delete + Gone --> [*] +``` + +| Command | Effect | +|---------|--------| +| `ecotone:projection:init` | Calls `#[ProjectionInitialization]`, records projection as known | +| `ecotone:projection:delete` | Calls `#[ProjectionDelete]`, removes projection tracking | +| `ecotone:projection:backfill` | Replays all events from the event store into the projection | + +**Reset = delete + re-init.** This two-step approach makes the state transitions explicit: you see the table disappear, then reappear empty, then fill up during backfill. + +## 7. When to choose this pattern + +Use `DatabaseReadModel` when: +- You want the simplest possible implementation +- Your read model logic is straightforward SQL +- You don't need Eloquent features (observers, mutators, scopes) + +See [EloquentReadModel](../EloquentReadModel/README.md) when you want to use Eloquent's ORM features in your read model writers. + +## 8. Common pitfalls + +1. **Forgetting `CREATE TABLE IF NOT EXISTS`.** Without `IF NOT EXISTS` the `init` hook fails if the table already exists, for example after a partial run. +2. **Querying before init.** If you call `user.listActive` before `ecotone:projection:init` the table does not exist and you get a DB error. Always initialise before querying. +3. **Event store accumulates across runs.** This example cleans up the User aggregate stream at the start of `run_example.php`. In production you would never delete the event stream — that is your source of truth. +4. **PostgreSQL only.** The Ecotone event store uses Prooph's PostgreSQL adapter. MySQL is supported via a different adapter but requires explicit configuration. +5. **Projection name collisions.** The name `user_list_database` is unique to this example. If you run both examples simultaneously they write to separate tables and use separate projection tracking entries. diff --git a/quickstart-examples/Laravel/Projection/DatabaseReadModel/bootstrap/app.php b/quickstart-examples/Laravel/Projection/DatabaseReadModel/bootstrap/app.php index 1ceadc334..992f489aa 100644 --- a/quickstart-examples/Laravel/Projection/DatabaseReadModel/bootstrap/app.php +++ b/quickstart-examples/Laravel/Projection/DatabaseReadModel/bootstrap/app.php @@ -1,5 +1,9 @@ 'pgsql', 'connections' => [ diff --git a/quickstart-examples/Laravel/Projection/DatabaseReadModel/config/ecotone.php b/quickstart-examples/Laravel/Projection/DatabaseReadModel/config/ecotone.php index ca5d8ed54..5c3ba7d57 100644 --- a/quickstart-examples/Laravel/Projection/DatabaseReadModel/config/ecotone.php +++ b/quickstart-examples/Laravel/Projection/DatabaseReadModel/config/ecotone.php @@ -1,5 +1,9 @@ |send command| CommandBus + CommandBus -->|route| User["User\n#[EventSourcingAggregate]"] + User -->|return events| EventStore[(Event Store\nPostgreSQL)] + EventStore -->|stream| Projection["UserListProjection\n#[ProjectionV2]"] + Projection -->|ApplyUserRegistered DTO\noutputChannelName| Channel1["user_read_model\n.apply_registered"] + Projection -->|ApplyUserNameChanged DTO\noutputChannelName| Channel2["user_read_model\n.apply_name_changed"] + Projection -->|ApplyUserDeactivated DTO\noutputChannelName| Channel3["user_read_model\n.apply_deactivated"] + Channel1 -->|#[InternalHandler]| Writer["UserReadModelWriter"] + Channel2 --> Writer + Channel3 --> Writer + Writer -->|create / update| Eloquent["UserReadModel\n(Eloquent)"] + Eloquent -->|persist| ReadModel[(user_list_eloquent\ntable)] + Client -->|sendWithRouting| QueryBus + QueryBus -->|listActive| Projection + Projection -->|UserReadModel::where| Eloquent +``` + +*Files involved:* +- `app/Domain/User.php` — aggregate +- `app/ReadModel/UserListProjection.php` — maps events to DTOs via `outputChannelName` +- `app/Application/Apply*.php` — DTO classes +- `app/Application/UserReadModelWriter.php` — `#[InternalHandler]` persists via Eloquent +- `app/Models/UserReadModel.php` — Eloquent model for `user_list_eloquent` + +## 4. Walkthrough of the code + +### 4.1 Domain — User aggregate + +```mermaid +sequenceDiagram + participant Client + participant CommandBus + participant User + participant EventStore + + Client->>CommandBus: RegisterUser(userId, name, email) + CommandBus->>User: register() static + User-->>EventStore: [UserWasRegistered] + + Client->>CommandBus: ChangeUserName(userId, name) + CommandBus->>User: changeName() + User-->>EventStore: [UserNameWasChanged] + + Client->>CommandBus: DeactivateUser(userId) + CommandBus->>User: deactivate() + User-->>EventStore: [UserWasDeactivated] +``` + +Identical to the DatabaseReadModel domain. The write side is shared; only the read side differs. + +### 4.2 The projection — outputChannelName routing + +```mermaid +flowchart TD + ES[(Event Store)] -->|UserWasRegistered| P1["onRegistered()\nreturns ApplyUserRegistered"] + ES -->|UserNameWasChanged| P2["onNameChanged()\nreturns ApplyUserNameChanged"] + ES -->|UserWasDeactivated| P3["onDeactivated()\nreturns ApplyUserDeactivated"] + P1 -->|outputChannelName| CH1["user_read_model.apply_registered"] + P2 -->|outputChannelName| CH2["user_read_model.apply_name_changed"] + P3 -->|outputChannelName| CH3["user_read_model.apply_deactivated"] + CH1 -->|#[InternalHandler]| W1["applyRegistered()\nUserReadModel::create()"] + CH2 -->|#[InternalHandler]| W2["applyNameChanged()\nUserReadModel::update()"] + CH3 -->|#[InternalHandler]| W3["applyDeactivated()\nUserReadModel::update()"] +``` + +Each `#[EventHandler]` on `UserListProjection` returns a typed DTO and declares an `outputChannelName`. Ecotone delivers the DTO to the matching `#[InternalHandler]` on `UserReadModelWriter`. The writer calls Eloquent's `create()` and `update()` — standard Eloquent, full lifecycle hooks available. + +### 4.3 Lifecycle hooks + +| Hook | Attribute | What it does | +|------|-----------|--------------| +| Initialise | `#[ProjectionInitialization]` | `CREATE TABLE IF NOT EXISTS user_list_eloquent (...)` | +| Delete | `#[ProjectionDelete]` | `DROP TABLE IF EXISTS user_list_eloquent` | + +Both hooks use raw SQL via `ConnectionInterface` for reliable table management regardless of Eloquent's migration state. + +### 4.4 Querying the read model + +The `#[QueryHandler('user.listActive')]` method uses Eloquent's fluent API directly: + +```php +#[QueryHandler('user.listActive')] +public function listActive(): array +{ + return UserReadModel::where('active', true) + ->orderBy('name') + ->get() + ->toArray(); +} +``` + +Callers use the query bus identically to the DatabaseReadModel example: + +```php +$rows = $queryBus->sendWithRouting('user.listActive'); +// $rows[0]['name'] === 'Alice Cooper' +``` + +## 5. Running it + +```bash +# Start services +docker compose up -d app database + +# Enter the container +docker compose exec app bash + +# Install and run +cd quickstart-examples/Laravel/Projection/EloquentReadModel +composer update +php run_example.php +``` + +> **PostgreSQL only.** Event sourcing requires PostgreSQL; SQLite is not supported. + +## 6. Reset vs Delete vs Rebuild + +```mermaid +stateDiagram-v2 + [*] --> Gone: start (no projection) + Gone --> Empty: ecotone:projection:init + Empty --> Active: ecotone:projection:backfill\n(events → DTOs → Eloquent) + Active --> Empty: ecotone:projection:delete\n+ ecotone:projection:init\n(reset = clear rows + position) + Empty --> Active: ecotone:projection:backfill\n(rebuild from event store) + Active --> Gone: ecotone:projection:delete + Gone --> [*] +``` + +| Command | Effect | +|---------|--------| +| `ecotone:projection:init` | Calls `#[ProjectionInitialization]`, records projection as known | +| `ecotone:projection:delete` | Calls `#[ProjectionDelete]`, removes projection tracking | +| `ecotone:projection:backfill` | Replays all events; each event flows through the outputChannelName chain into Eloquent | + +During backfill the full chain runs: event → projection handler returns DTO → Ecotone routes DTO to `InternalHandler` → Eloquent persists. Eloquent model observers fire normally during backfill. + +## 7. When to choose this pattern + +Use `EloquentReadModel` when: +- You want Eloquent's lifecycle hooks (observers, mutators, casts) on your read model +- Your team is more comfortable with Eloquent than raw SQL +- You want to leverage Eloquent's scopes for querying + +See [DatabaseReadModel](../DatabaseReadModel/README.md) for the simpler direct-write pattern. + +## 8. Common pitfalls + +1. **outputChannelName typos.** The channel name in `#[EventHandler(outputChannelName: '...')]` must match the `inputChannelName` in `#[InternalHandler(inputChannelName: '...')]` exactly. A mismatch causes a silent failure where no handler runs. +2. **`$fillable` must include all columns.** Eloquent's mass-assignment protection blocks fields not listed in `$fillable`. The `UserReadModel` lists `user_id`, `name`, `email`, `active`. +3. **`$incrementing = false` is required.** Without this Eloquent tries to cast the string UUID primary key to an integer after insert, producing a wrong key. +4. **`$timestamps = false`.** The `user_list_eloquent` table has no `created_at`/`updated_at` columns. Eloquent will throw if you leave timestamps enabled and the columns are missing. +5. **Schema facade in init hook.** This example uses raw SQL in `#[ProjectionInitialization]` for reliability. The Schema facade requires the application to be fully booted, which is guaranteed when Ecotone runs commands, but raw SQL is simpler and avoids blueprint/migration overhead in quickstart code. diff --git a/quickstart-examples/Laravel/Projection/EloquentReadModel/bootstrap/app.php b/quickstart-examples/Laravel/Projection/EloquentReadModel/bootstrap/app.php index 1ceadc334..992f489aa 100644 --- a/quickstart-examples/Laravel/Projection/EloquentReadModel/bootstrap/app.php +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/bootstrap/app.php @@ -1,5 +1,9 @@ 'pgsql', 'connections' => [ diff --git a/quickstart-examples/Laravel/Projection/EloquentReadModel/config/ecotone.php b/quickstart-examples/Laravel/Projection/EloquentReadModel/config/ecotone.php index ca5d8ed54..5c3ba7d57 100644 --- a/quickstart-examples/Laravel/Projection/EloquentReadModel/config/ecotone.php +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/config/ecotone.php @@ -1,5 +1,9 @@ Date: Mon, 18 May 2026 19:08:54 +0200 Subject: [PATCH 03/12] feat: add #[NamedEvent] to projection quickstart events Decouple stored event identity from PHP class names so the event stream stays readable across renames/moves. Stable names: user.was_registered, user.name_was_changed, user.was_deactivated. READMEs explain why this matters for any event you intend to keep on disk. --- .../Laravel/Projection/DatabaseReadModel/README.md | 2 ++ .../app/Domain/Event/UserNameWasChanged.php | 5 +++++ .../app/Domain/Event/UserWasDeactivated.php | 5 +++++ .../DatabaseReadModel/app/Domain/Event/UserWasRegistered.php | 5 +++++ .../Laravel/Projection/EloquentReadModel/README.md | 2 ++ .../app/Domain/Event/UserNameWasChanged.php | 5 +++++ .../app/Domain/Event/UserWasDeactivated.php | 5 +++++ .../EloquentReadModel/app/Domain/Event/UserWasRegistered.php | 5 +++++ 8 files changed, 34 insertions(+) diff --git a/quickstart-examples/Laravel/Projection/DatabaseReadModel/README.md b/quickstart-examples/Laravel/Projection/DatabaseReadModel/README.md index 0d4a712a8..52f9a39cc 100644 --- a/quickstart-examples/Laravel/Projection/DatabaseReadModel/README.md +++ b/quickstart-examples/Laravel/Projection/DatabaseReadModel/README.md @@ -54,6 +54,8 @@ sequenceDiagram The `User` aggregate is annotated with `#[EventSourcingAggregate]`. Command handlers are `static` for creation (`register`) and instance methods for mutations (`changeName`, `deactivate`). Each handler returns an array of events. `#[EventSourcingHandler]` methods reconstruct aggregate state from stored events — they must have no side effects. +Each event class is annotated with `#[NamedEvent('user.was_registered')]` (and so on). The name is what Ecotone stores alongside the event payload, so the recorded stream stays readable even if you later move or rename the PHP class. Without `#[NamedEvent]`, the fully-qualified class name is used — which couples your stored events to your namespace. For any event you intend to keep on disk, give it a stable name. + ### 4.2 The projection — direct database writes ```mermaid diff --git a/quickstart-examples/Laravel/Projection/DatabaseReadModel/app/Domain/Event/UserNameWasChanged.php b/quickstart-examples/Laravel/Projection/DatabaseReadModel/app/Domain/Event/UserNameWasChanged.php index df065a8f3..425cdf9a4 100644 --- a/quickstart-examples/Laravel/Projection/DatabaseReadModel/app/Domain/Event/UserNameWasChanged.php +++ b/quickstart-examples/Laravel/Projection/DatabaseReadModel/app/Domain/Event/UserNameWasChanged.php @@ -8,8 +8,13 @@ namespace App\Domain\Event; +use Ecotone\Modelling\Attribute\NamedEvent; + +#[NamedEvent(self::EVENT_NAME)] final readonly class UserNameWasChanged { + public const EVENT_NAME = 'user.name_was_changed'; + public function __construct( public string $userId, public string $name, diff --git a/quickstart-examples/Laravel/Projection/DatabaseReadModel/app/Domain/Event/UserWasDeactivated.php b/quickstart-examples/Laravel/Projection/DatabaseReadModel/app/Domain/Event/UserWasDeactivated.php index e9603e9c6..a5a88b7b6 100644 --- a/quickstart-examples/Laravel/Projection/DatabaseReadModel/app/Domain/Event/UserWasDeactivated.php +++ b/quickstart-examples/Laravel/Projection/DatabaseReadModel/app/Domain/Event/UserWasDeactivated.php @@ -8,8 +8,13 @@ namespace App\Domain\Event; +use Ecotone\Modelling\Attribute\NamedEvent; + +#[NamedEvent(self::EVENT_NAME)] final readonly class UserWasDeactivated { + public const EVENT_NAME = 'user.was_deactivated'; + public function __construct( public string $userId, ) { diff --git a/quickstart-examples/Laravel/Projection/DatabaseReadModel/app/Domain/Event/UserWasRegistered.php b/quickstart-examples/Laravel/Projection/DatabaseReadModel/app/Domain/Event/UserWasRegistered.php index 9a4db292f..dc507746a 100644 --- a/quickstart-examples/Laravel/Projection/DatabaseReadModel/app/Domain/Event/UserWasRegistered.php +++ b/quickstart-examples/Laravel/Projection/DatabaseReadModel/app/Domain/Event/UserWasRegistered.php @@ -8,8 +8,13 @@ namespace App\Domain\Event; +use Ecotone\Modelling\Attribute\NamedEvent; + +#[NamedEvent(self::EVENT_NAME)] final readonly class UserWasRegistered { + public const EVENT_NAME = 'user.was_registered'; + public function __construct( public string $userId, public string $name, diff --git a/quickstart-examples/Laravel/Projection/EloquentReadModel/README.md b/quickstart-examples/Laravel/Projection/EloquentReadModel/README.md index e0f6ae657..862a8e55b 100644 --- a/quickstart-examples/Laravel/Projection/EloquentReadModel/README.md +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/README.md @@ -62,6 +62,8 @@ sequenceDiagram Identical to the DatabaseReadModel domain. The write side is shared; only the read side differs. +Each event class is annotated with `#[NamedEvent('user.was_registered')]` (and so on). The name is what Ecotone stores alongside the event payload, so the recorded stream stays readable even if you later move or rename the PHP class. Without `#[NamedEvent]`, the fully-qualified class name is used — which couples your stored events to your namespace. For any event you intend to keep on disk, give it a stable name. + ### 4.2 The projection — outputChannelName routing ```mermaid diff --git a/quickstart-examples/Laravel/Projection/EloquentReadModel/app/Domain/Event/UserNameWasChanged.php b/quickstart-examples/Laravel/Projection/EloquentReadModel/app/Domain/Event/UserNameWasChanged.php index df065a8f3..425cdf9a4 100644 --- a/quickstart-examples/Laravel/Projection/EloquentReadModel/app/Domain/Event/UserNameWasChanged.php +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/app/Domain/Event/UserNameWasChanged.php @@ -8,8 +8,13 @@ namespace App\Domain\Event; +use Ecotone\Modelling\Attribute\NamedEvent; + +#[NamedEvent(self::EVENT_NAME)] final readonly class UserNameWasChanged { + public const EVENT_NAME = 'user.name_was_changed'; + public function __construct( public string $userId, public string $name, diff --git a/quickstart-examples/Laravel/Projection/EloquentReadModel/app/Domain/Event/UserWasDeactivated.php b/quickstart-examples/Laravel/Projection/EloquentReadModel/app/Domain/Event/UserWasDeactivated.php index e9603e9c6..a5a88b7b6 100644 --- a/quickstart-examples/Laravel/Projection/EloquentReadModel/app/Domain/Event/UserWasDeactivated.php +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/app/Domain/Event/UserWasDeactivated.php @@ -8,8 +8,13 @@ namespace App\Domain\Event; +use Ecotone\Modelling\Attribute\NamedEvent; + +#[NamedEvent(self::EVENT_NAME)] final readonly class UserWasDeactivated { + public const EVENT_NAME = 'user.was_deactivated'; + public function __construct( public string $userId, ) { diff --git a/quickstart-examples/Laravel/Projection/EloquentReadModel/app/Domain/Event/UserWasRegistered.php b/quickstart-examples/Laravel/Projection/EloquentReadModel/app/Domain/Event/UserWasRegistered.php index 9a4db292f..dc507746a 100644 --- a/quickstart-examples/Laravel/Projection/EloquentReadModel/app/Domain/Event/UserWasRegistered.php +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/app/Domain/Event/UserWasRegistered.php @@ -8,8 +8,13 @@ namespace App\Domain\Event; +use Ecotone\Modelling\Attribute\NamedEvent; + +#[NamedEvent(self::EVENT_NAME)] final readonly class UserWasRegistered { + public const EVENT_NAME = 'user.was_registered'; + public function __construct( public string $userId, public string $name, From 6b2b22d0ebbdb745497301b4cdaec3d99df858d2 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Mon, 18 May 2026 19:09:53 +0200 Subject: [PATCH 04/12] docs: drop PostgreSQL-only pitfall callouts from projection READMEs --- .../Laravel/Projection/DatabaseReadModel/README.md | 5 +---- .../Laravel/Projection/EloquentReadModel/README.md | 2 -- quickstart-examples/Laravel/Projection/README.md | 1 - 3 files changed, 1 insertion(+), 7 deletions(-) diff --git a/quickstart-examples/Laravel/Projection/DatabaseReadModel/README.md b/quickstart-examples/Laravel/Projection/DatabaseReadModel/README.md index 52f9a39cc..943e344bb 100644 --- a/quickstart-examples/Laravel/Projection/DatabaseReadModel/README.md +++ b/quickstart-examples/Laravel/Projection/DatabaseReadModel/README.md @@ -107,8 +107,6 @@ php run_example.php The script exits 0 and prints a seven-step ribbon showing each lifecycle phase. -> **PostgreSQL only.** Event sourcing requires PostgreSQL; SQLite is not supported. - ## 6. Reset vs Delete vs Rebuild ```mermaid @@ -144,5 +142,4 @@ See [EloquentReadModel](../EloquentReadModel/README.md) when you want to use Elo 1. **Forgetting `CREATE TABLE IF NOT EXISTS`.** Without `IF NOT EXISTS` the `init` hook fails if the table already exists, for example after a partial run. 2. **Querying before init.** If you call `user.listActive` before `ecotone:projection:init` the table does not exist and you get a DB error. Always initialise before querying. 3. **Event store accumulates across runs.** This example cleans up the User aggregate stream at the start of `run_example.php`. In production you would never delete the event stream — that is your source of truth. -4. **PostgreSQL only.** The Ecotone event store uses Prooph's PostgreSQL adapter. MySQL is supported via a different adapter but requires explicit configuration. -5. **Projection name collisions.** The name `user_list_database` is unique to this example. If you run both examples simultaneously they write to separate tables and use separate projection tracking entries. +4. **Projection name collisions.** The name `user_list_database` is unique to this example. If you run both examples simultaneously they write to separate tables and use separate projection tracking entries. diff --git a/quickstart-examples/Laravel/Projection/EloquentReadModel/README.md b/quickstart-examples/Laravel/Projection/EloquentReadModel/README.md index 862a8e55b..881bd4cfe 100644 --- a/quickstart-examples/Laravel/Projection/EloquentReadModel/README.md +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/README.md @@ -127,8 +127,6 @@ composer update php run_example.php ``` -> **PostgreSQL only.** Event sourcing requires PostgreSQL; SQLite is not supported. - ## 6. Reset vs Delete vs Rebuild ```mermaid diff --git a/quickstart-examples/Laravel/Projection/README.md b/quickstart-examples/Laravel/Projection/README.md index db130af91..2ab14af39 100644 --- a/quickstart-examples/Laravel/Projection/README.md +++ b/quickstart-examples/Laravel/Projection/README.md @@ -20,4 +20,3 @@ These two examples walk through the complete projection lifecycle using a `User` - `#[ProjectionInitialization]` and `#[ProjectionDelete]` lifecycle hooks - `#[QueryHandler]` on the projection class for `user.listActive` - A `run_example.php` script that exercises all seven lifecycle steps and asserts on the read model state -- PostgreSQL (event sourcing requires it; SQLite is not supported) From bcf95871b771a3d94ea43545088503f162a67110 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Mon, 18 May 2026 19:18:25 +0200 Subject: [PATCH 05/12] refactor(EloquentReadModel): make read model a stateful #[Aggregate] Replace the InternalHandler writer + plain Eloquent model with a single UserReadModel class in App\ReadModel that is both #[Aggregate] and an Eloquent Model. The projection now emits commands via outputChannelName (matched on command FQCN) to #[CommandHandler] methods on the aggregate; Ecotone handles load + save automatically. This demonstrates the auto-load/save "sugar" on the read side that stateful aggregates provide on the write side. Required symfony/expression-language for the identifierMapping payload.userId expression. --- .../Projection/EloquentReadModel/README.md | 113 +++++++++++------- .../app/Application/ApplyUserDeactivated.php | 17 --- .../app/Application/ApplyUserNameChanged.php | 18 --- .../app/Application/UserReadModelWriter.php | 38 ------ .../app/Models/UserReadModel.php | 26 ---- .../Command/ChangeUserReadModelName.php | 20 ++++ .../Command/DeactivateUserReadModel.php | 19 +++ .../Command/RegisterUserReadModel.php} | 4 +- .../app/ReadModel/UserListProjection.php | 25 ++-- .../app/ReadModel/UserReadModel.php | 62 ++++++++++ .../EloquentReadModel/composer.json | 3 +- .../Laravel/Projection/README.md | 4 +- 12 files changed, 188 insertions(+), 161 deletions(-) delete mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/app/Application/ApplyUserDeactivated.php delete mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/app/Application/ApplyUserNameChanged.php delete mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/app/Application/UserReadModelWriter.php delete mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/app/Models/UserReadModel.php create mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/app/ReadModel/Command/ChangeUserReadModelName.php create mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/app/ReadModel/Command/DeactivateUserReadModel.php rename quickstart-examples/Laravel/Projection/EloquentReadModel/app/{Application/ApplyUserRegistered.php => ReadModel/Command/RegisterUserReadModel.php} (72%) create mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/app/ReadModel/UserReadModel.php diff --git a/quickstart-examples/Laravel/Projection/EloquentReadModel/README.md b/quickstart-examples/Laravel/Projection/EloquentReadModel/README.md index 881bd4cfe..88a821b8a 100644 --- a/quickstart-examples/Laravel/Projection/EloquentReadModel/README.md +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/README.md @@ -2,11 +2,11 @@ ## 1. What you'll learn -This example extends the [DatabaseReadModel](../DatabaseReadModel/README.md) pattern by routing projection events through named channels to a dedicated `UserReadModelWriter` service that uses Eloquent to persist the read model. You will see how `outputChannelName` on `#[EventHandler]` decouples the projection class from persistence concerns, enabling Eloquent observers, mutators, and scopes on the read model. +This example shows how to drive an Eloquent read model through Ecotone's stateful aggregate machinery. The projection's `#[EventHandler]` methods translate domain events into read-model commands and route them via `outputChannelName`. Those commands hit `#[CommandHandler]` methods on `UserReadModel` — a `#[Aggregate]` that **is** an Eloquent `Model`. Ecotone auto-loads the aggregate by identifier and auto-saves it after the handler returns, so you get exactly the "load + mutate + save" sugar that stateful aggregates provide, applied to a read model. ## 2. The problem this solves -Sometimes you want your read model to be a proper Eloquent model — so it benefits from automatic timestamps, attribute casting, model observers, or serialisation helpers. Projections can emit DTO messages to named channels instead of writing directly to the database. A separate handler class receives those DTOs and calls Eloquent. This separates the event-to-DTO mapping (projection) from the DTO-to-Eloquent persistence (writer). +When you rebuild a read model from an event stream, you often want each event to land on a record that goes through your normal Eloquent lifecycle — observers, mutators, casts, the lot. Writing raw SQL in the projection bypasses all of that. By making the read model a stateful Eloquent aggregate, every event becomes a command on the aggregate, and Eloquent handles the rest. ## 3. How it fits together @@ -14,27 +14,22 @@ Sometimes you want your read model to be a proper Eloquent model — so it benef flowchart LR Client -->|send command| CommandBus CommandBus -->|route| User["User\n#[EventSourcingAggregate]"] - User -->|return events| EventStore[(Event Store\nPostgreSQL)] + User -->|return events| EventStore[(Event Store)] EventStore -->|stream| Projection["UserListProjection\n#[ProjectionV2]"] - Projection -->|ApplyUserRegistered DTO\noutputChannelName| Channel1["user_read_model\n.apply_registered"] - Projection -->|ApplyUserNameChanged DTO\noutputChannelName| Channel2["user_read_model\n.apply_name_changed"] - Projection -->|ApplyUserDeactivated DTO\noutputChannelName| Channel3["user_read_model\n.apply_deactivated"] - Channel1 -->|#[InternalHandler]| Writer["UserReadModelWriter"] - Channel2 --> Writer - Channel3 --> Writer - Writer -->|create / update| Eloquent["UserReadModel\n(Eloquent)"] - Eloquent -->|persist| ReadModel[(user_list_eloquent\ntable)] + Projection -->|RegisterUserReadModel\noutputChannelName| Aggregate["UserReadModel\n#[Aggregate] extends Model"] + Projection -->|ChangeUserReadModelName| Aggregate + Projection -->|DeactivateUserReadModel| Aggregate + Aggregate -->|Eloquent create/save| ReadModel[(user_list_eloquent\ntable)] Client -->|sendWithRouting| QueryBus QueryBus -->|listActive| Projection - Projection -->|UserReadModel::where| Eloquent + Projection -->|UserReadModel::where| ReadModel ``` *Files involved:* -- `app/Domain/User.php` — aggregate -- `app/ReadModel/UserListProjection.php` — maps events to DTOs via `outputChannelName` -- `app/Application/Apply*.php` — DTO classes -- `app/Application/UserReadModelWriter.php` — `#[InternalHandler]` persists via Eloquent -- `app/Models/UserReadModel.php` — Eloquent model for `user_list_eloquent` +- `app/Domain/User.php` — the write-side event-sourced aggregate +- `app/ReadModel/UserListProjection.php` — translates events into read-model commands and routes them +- `app/ReadModel/Command/*.php` — three commands the projection emits +- `app/ReadModel/UserReadModel.php` — `#[Aggregate]` Eloquent model with command handlers ## 4. Walkthrough of the code @@ -64,24 +59,59 @@ Identical to the DatabaseReadModel domain. The write side is shared; only the re Each event class is annotated with `#[NamedEvent('user.was_registered')]` (and so on). The name is what Ecotone stores alongside the event payload, so the recorded stream stays readable even if you later move or rename the PHP class. Without `#[NamedEvent]`, the fully-qualified class name is used — which couples your stored events to your namespace. For any event you intend to keep on disk, give it a stable name. -### 4.2 The projection — outputChannelName routing +### 4.2 The projection — event-to-command translation ```mermaid flowchart TD - ES[(Event Store)] -->|UserWasRegistered| P1["onRegistered()\nreturns ApplyUserRegistered"] - ES -->|UserNameWasChanged| P2["onNameChanged()\nreturns ApplyUserNameChanged"] - ES -->|UserWasDeactivated| P3["onDeactivated()\nreturns ApplyUserDeactivated"] - P1 -->|outputChannelName| CH1["user_read_model.apply_registered"] - P2 -->|outputChannelName| CH2["user_read_model.apply_name_changed"] - P3 -->|outputChannelName| CH3["user_read_model.apply_deactivated"] - CH1 -->|#[InternalHandler]| W1["applyRegistered()\nUserReadModel::create()"] - CH2 -->|#[InternalHandler]| W2["applyNameChanged()\nUserReadModel::update()"] - CH3 -->|#[InternalHandler]| W3["applyDeactivated()\nUserReadModel::update()"] + ES[(Event Store)] -->|UserWasRegistered| P1["onRegistered()\nreturns RegisterUserReadModel"] + ES -->|UserNameWasChanged| P2["onNameChanged()\nreturns ChangeUserReadModelName"] + ES -->|UserWasDeactivated| P3["onDeactivated()\nreturns DeactivateUserReadModel"] + P1 -->|outputChannelName\n= RegisterUserReadModel::class| AGG["UserReadModel::register()\n(static, #[CommandHandler])"] + P2 -->|outputChannelName\n= ChangeUserReadModelName::class| AGG2["UserReadModel::changeName()\n(instance, #[CommandHandler])"] + P3 -->|outputChannelName\n= DeactivateUserReadModel::class| AGG3["UserReadModel::deactivate()\n(instance, #[CommandHandler])"] ``` -Each `#[EventHandler]` on `UserListProjection` returns a typed DTO and declares an `outputChannelName`. Ecotone delivers the DTO to the matching `#[InternalHandler]` on `UserReadModelWriter`. The writer calls Eloquent's `create()` and `update()` — standard Eloquent, full lifecycle hooks available. +Each `#[EventHandler]` on `UserListProjection` returns a typed command and declares `outputChannelName: ::class`. Ecotone hands the command to the matching `#[CommandHandler]` on `UserReadModel`. Routing keys are the command FQCNs — refactor-safe. -### 4.3 Lifecycle hooks +### 4.3 The read model is a stateful Eloquent aggregate + +```php +#[Aggregate] +final class UserReadModel extends Model +{ + public $table = 'user_list_eloquent'; + public $primaryKey = 'user_id'; + public $incrementing = false; + public $keyType = 'string'; + public $timestamps = false; + public $fillable = ['user_id', 'name', 'email', 'active']; + + #[AggregateIdentifierMethod('user_id')] + public function getUserId(): string { return $this->user_id; } + + #[CommandHandler(RegisterUserReadModel::class)] + public static function register(RegisterUserReadModel $command): self + { + return self::create([...]); + } + + #[CommandHandler(ChangeUserReadModelName::class, identifierMapping: ['user_id' => 'payload.userId'])] + public function changeName(ChangeUserReadModelName $command): void + { + $this->name = $command->name; + } +} +``` + +Three things make this work end-to-end: + +- **`#[Aggregate]` + `extends Model`** — Ecotone detects an Eloquent aggregate and wires its `EloquentRepository` automatically. No repository configuration needed. +- **`#[AggregateIdentifierMethod('user_id')]`** — declares which Eloquent column identifies the aggregate. Ecotone uses this to load the model from the DB before invoking instance command handlers, and to persist it afterwards. +- **`identifierMapping: ['user_id' => 'payload.userId']`** — tells Ecotone where to find the identifier *in the inbound command*. The expression `payload.userId` reads the `userId` field of the command DTO. The `register` static handler doesn't need this — it creates a new aggregate, so there's nothing to load first. + +After the handler returns, Ecotone calls `$model->save()` for you. That's the "auto-load + auto-save" sugar applied to a read model. + +### 4.4 Lifecycle hooks | Hook | Attribute | What it does | |------|-----------|--------------| @@ -90,7 +120,7 @@ Each `#[EventHandler]` on `UserListProjection` returns a typed DTO and declares Both hooks use raw SQL via `ConnectionInterface` for reliable table management regardless of Eloquent's migration state. -### 4.4 Querying the read model +### 4.5 Querying the read model The `#[QueryHandler('user.listActive')]` method uses Eloquent's fluent API directly: @@ -115,13 +145,8 @@ $rows = $queryBus->sendWithRouting('user.listActive'); ## 5. Running it ```bash -# Start services docker compose up -d app database - -# Enter the container docker compose exec app bash - -# Install and run cd quickstart-examples/Laravel/Projection/EloquentReadModel composer update php run_example.php @@ -133,7 +158,7 @@ php run_example.php stateDiagram-v2 [*] --> Gone: start (no projection) Gone --> Empty: ecotone:projection:init - Empty --> Active: ecotone:projection:backfill\n(events → DTOs → Eloquent) + Empty --> Active: ecotone:projection:backfill\n(events → commands → Eloquent) Active --> Empty: ecotone:projection:delete\n+ ecotone:projection:init\n(reset = clear rows + position) Empty --> Active: ecotone:projection:backfill\n(rebuild from event store) Active --> Gone: ecotone:projection:delete @@ -144,23 +169,23 @@ stateDiagram-v2 |---------|--------| | `ecotone:projection:init` | Calls `#[ProjectionInitialization]`, records projection as known | | `ecotone:projection:delete` | Calls `#[ProjectionDelete]`, removes projection tracking | -| `ecotone:projection:backfill` | Replays all events; each event flows through the outputChannelName chain into Eloquent | +| `ecotone:projection:backfill` | Replays all events; each event flows through the outputChannelName chain and lands on a `UserReadModel` aggregate | -During backfill the full chain runs: event → projection handler returns DTO → Ecotone routes DTO to `InternalHandler` → Eloquent persists. Eloquent model observers fire normally during backfill. +During backfill the full chain runs: event → projection handler returns command → Ecotone routes command to `UserReadModel` → Eloquent loads/creates the row, applies the change, saves. Eloquent observers fire normally throughout. ## 7. When to choose this pattern Use `EloquentReadModel` when: - You want Eloquent's lifecycle hooks (observers, mutators, casts) on your read model +- You want the "auto-load + auto-save" experience on the read side, the same way stateful aggregates work on the write side - Your team is more comfortable with Eloquent than raw SQL -- You want to leverage Eloquent's scopes for querying See [DatabaseReadModel](../DatabaseReadModel/README.md) for the simpler direct-write pattern. ## 8. Common pitfalls -1. **outputChannelName typos.** The channel name in `#[EventHandler(outputChannelName: '...')]` must match the `inputChannelName` in `#[InternalHandler(inputChannelName: '...')]` exactly. A mismatch causes a silent failure where no handler runs. -2. **`$fillable` must include all columns.** Eloquent's mass-assignment protection blocks fields not listed in `$fillable`. The `UserReadModel` lists `user_id`, `name`, `email`, `active`. -3. **`$incrementing = false` is required.** Without this Eloquent tries to cast the string UUID primary key to an integer after insert, producing a wrong key. -4. **`$timestamps = false`.** The `user_list_eloquent` table has no `created_at`/`updated_at` columns. Eloquent will throw if you leave timestamps enabled and the columns are missing. -5. **Schema facade in init hook.** This example uses raw SQL in `#[ProjectionInitialization]` for reliability. The Schema facade requires the application to be fully booted, which is guaranteed when Ecotone runs commands, but raw SQL is simpler and avoids blueprint/migration overhead in quickstart code. +1. **`outputChannelName` must match a `#[CommandHandler]` routing key.** This example uses `::class` on both sides so the link is refactor-safe. +2. **`identifierMapping` is required on instance command handlers.** Without it Ecotone can't extract the aggregate id from the inbound command (chaining via `outputChannelName` bypasses the bus-level `#[TargetIdentifier]` extraction). Static creation handlers don't need it. +3. **`$fillable` must include all columns.** Eloquent's mass-assignment protection blocks fields not listed. +4. **`$incrementing = false` and `$keyType = 'string'` are required.** Without them Eloquent treats the UUID primary key as an auto-increment integer. +5. **`$timestamps = false`.** The table has no `created_at`/`updated_at` columns; leaving timestamps enabled will throw. diff --git a/quickstart-examples/Laravel/Projection/EloquentReadModel/app/Application/ApplyUserDeactivated.php b/quickstart-examples/Laravel/Projection/EloquentReadModel/app/Application/ApplyUserDeactivated.php deleted file mode 100644 index cd56c4b8f..000000000 --- a/quickstart-examples/Laravel/Projection/EloquentReadModel/app/Application/ApplyUserDeactivated.php +++ /dev/null @@ -1,17 +0,0 @@ - $dto->userId, - 'name' => $dto->name, - 'email' => $dto->email, - 'active' => true, - ]); - } - - #[InternalHandler(inputChannelName: 'user_read_model.apply_name_changed')] - public function applyNameChanged(ApplyUserNameChanged $dto): void - { - UserReadModel::where('user_id', $dto->userId)->update(['name' => $dto->name]); - } - - #[InternalHandler(inputChannelName: 'user_read_model.apply_deactivated')] - public function applyDeactivated(ApplyUserDeactivated $dto): void - { - UserReadModel::where('user_id', $dto->userId)->update(['active' => false]); - } -} diff --git a/quickstart-examples/Laravel/Projection/EloquentReadModel/app/Models/UserReadModel.php b/quickstart-examples/Laravel/Projection/EloquentReadModel/app/Models/UserReadModel.php deleted file mode 100644 index 26c876489..000000000 --- a/quickstart-examples/Laravel/Projection/EloquentReadModel/app/Models/UserReadModel.php +++ /dev/null @@ -1,26 +0,0 @@ -db->statement('DROP TABLE IF EXISTS user_list_eloquent'); } - #[EventHandler(outputChannelName: 'user_read_model.apply_registered')] - public function onRegistered(UserWasRegistered $event): ApplyUserRegistered + #[EventHandler(outputChannelName: RegisterUserReadModel::class)] + public function onRegistered(UserWasRegistered $event): RegisterUserReadModel { - return new ApplyUserRegistered($event->userId, $event->name, $event->email); + return new RegisterUserReadModel($event->userId, $event->name, $event->email); } - #[EventHandler(outputChannelName: 'user_read_model.apply_name_changed')] - public function onNameChanged(UserNameWasChanged $event): ApplyUserNameChanged + #[EventHandler(outputChannelName: ChangeUserReadModelName::class)] + public function onNameChanged(UserNameWasChanged $event): ChangeUserReadModelName { - return new ApplyUserNameChanged($event->userId, $event->name); + return new ChangeUserReadModelName($event->userId, $event->name); } - #[EventHandler(outputChannelName: 'user_read_model.apply_deactivated')] - public function onDeactivated(UserWasDeactivated $event): ApplyUserDeactivated + #[EventHandler(outputChannelName: DeactivateUserReadModel::class)] + public function onDeactivated(UserWasDeactivated $event): DeactivateUserReadModel { - return new ApplyUserDeactivated($event->userId); + return new DeactivateUserReadModel($event->userId); } #[QueryHandler('user.listActive')] diff --git a/quickstart-examples/Laravel/Projection/EloquentReadModel/app/ReadModel/UserReadModel.php b/quickstart-examples/Laravel/Projection/EloquentReadModel/app/ReadModel/UserReadModel.php new file mode 100644 index 000000000..f414ccdad --- /dev/null +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/app/ReadModel/UserReadModel.php @@ -0,0 +1,62 @@ +user_id; + } + + #[CommandHandler(RegisterUserReadModel::class)] + public static function register(RegisterUserReadModel $command): self + { + return self::create([ + 'user_id' => $command->userId, + 'name' => $command->name, + 'email' => $command->email, + 'active' => true, + ]); + } + + #[CommandHandler(ChangeUserReadModelName::class, identifierMapping: ['user_id' => 'payload.userId'])] + public function changeName(ChangeUserReadModelName $command): void + { + $this->name = $command->name; + } + + #[CommandHandler(DeactivateUserReadModel::class, identifierMapping: ['user_id' => 'payload.userId'])] + public function deactivate(DeactivateUserReadModel $command): void + { + $this->active = false; + } +} diff --git a/quickstart-examples/Laravel/Projection/EloquentReadModel/composer.json b/quickstart-examples/Laravel/Projection/EloquentReadModel/composer.json index b7061c8ab..8217f041b 100644 --- a/quickstart-examples/Laravel/Projection/EloquentReadModel/composer.json +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/composer.json @@ -25,7 +25,8 @@ "ecotone/laravel-starter": "^1.1.0", "ecotone/lite-event-sourcing-starter": "^1.0", "laravel/laravel": "^10.0", - "ramsey/uuid": "^4.0" + "ramsey/uuid": "^4.0", + "symfony/expression-language": "^8.0" }, "require-dev": { "phpunit/phpunit": "^9.6|^10.5|^11.0" diff --git a/quickstart-examples/Laravel/Projection/README.md b/quickstart-examples/Laravel/Projection/README.md index 2ab14af39..6114ccef7 100644 --- a/quickstart-examples/Laravel/Projection/README.md +++ b/quickstart-examples/Laravel/Projection/README.md @@ -9,9 +9,9 @@ These two examples walk through the complete projection lifecycle using a `User` | Example | Pattern | When to use | |---------|---------|-------------| | [DatabaseReadModel](./DatabaseReadModel/) | Projection writes directly to the DB via `ConnectionInterface` | Simplest approach; straightforward SQL; no ORM overhead | -| [EloquentReadModel](./EloquentReadModel/) | Projection emits DTOs via `outputChannelName`; a writer service persists via Eloquent | When you need Eloquent lifecycle hooks, observers, mutators, or scopes on the read model | +| [EloquentReadModel](./EloquentReadModel/) | Projection emits commands via `outputChannelName` to a stateful `#[Aggregate]` Eloquent model | When you want the "auto-load + auto-save" sugar on a read model and Eloquent's lifecycle hooks | -**Start with DatabaseReadModel.** It gets the projection lifecycle working with minimal moving parts. Once you understand init → backfill → reset → delete, switch to EloquentReadModel to see how the `outputChannelName` pattern separates projection logic from persistence. +**Start with DatabaseReadModel.** It gets the projection lifecycle working with minimal moving parts. Once you understand init → backfill → reset → delete, switch to EloquentReadModel to see how a stateful Eloquent aggregate becomes the read model's persistence layer. ## What both examples share From 4a5820419ec089e8d7d7b5fae48d92ab29c94d43 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Mon, 18 May 2026 19:20:30 +0200 Subject: [PATCH 06/12] refactor(EloquentReadModel): drop command DTOs, project arrays directly Replace the three Command DTO classes with plain associative arrays. The projection now returns the row data as an array and routes via a string outputChannelName matching the #[CommandHandler] routing key. identifierMapping uses bracket expression syntax (payload['user_id']) to read from the array on instance handlers. No DTO classes left. --- .../Projection/EloquentReadModel/README.md | 60 +++++++++++-------- .../Command/ChangeUserReadModelName.php | 20 ------- .../Command/DeactivateUserReadModel.php | 19 ------ .../Command/RegisterUserReadModel.php | 19 ------ .../app/ReadModel/UserListProjection.php | 31 ++++++---- .../app/ReadModel/UserReadModel.php | 24 +++----- 6 files changed, 63 insertions(+), 110 deletions(-) delete mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/app/ReadModel/Command/ChangeUserReadModelName.php delete mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/app/ReadModel/Command/DeactivateUserReadModel.php delete mode 100644 quickstart-examples/Laravel/Projection/EloquentReadModel/app/ReadModel/Command/RegisterUserReadModel.php diff --git a/quickstart-examples/Laravel/Projection/EloquentReadModel/README.md b/quickstart-examples/Laravel/Projection/EloquentReadModel/README.md index 88a821b8a..92c2eaf10 100644 --- a/quickstart-examples/Laravel/Projection/EloquentReadModel/README.md +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/README.md @@ -2,7 +2,7 @@ ## 1. What you'll learn -This example shows how to drive an Eloquent read model through Ecotone's stateful aggregate machinery. The projection's `#[EventHandler]` methods translate domain events into read-model commands and route them via `outputChannelName`. Those commands hit `#[CommandHandler]` methods on `UserReadModel` — a `#[Aggregate]` that **is** an Eloquent `Model`. Ecotone auto-loads the aggregate by identifier and auto-saves it after the handler returns, so you get exactly the "load + mutate + save" sugar that stateful aggregates provide, applied to a read model. +This example shows how to drive an Eloquent read model through Ecotone's stateful aggregate machinery. The projection's `#[EventHandler]` methods translate domain events into plain arrays and route them via `outputChannelName` to string-keyed `#[CommandHandler]` methods on `UserReadModel` — a `#[Aggregate]` that **is** an Eloquent `Model`. Ecotone auto-loads the aggregate by identifier and auto-saves it after the handler returns, so you get exactly the "load + mutate + save" sugar that stateful aggregates provide, applied to a read model. No DTO classes needed. ## 2. The problem this solves @@ -16,9 +16,9 @@ flowchart LR CommandBus -->|route| User["User\n#[EventSourcingAggregate]"] User -->|return events| EventStore[(Event Store)] EventStore -->|stream| Projection["UserListProjection\n#[ProjectionV2]"] - Projection -->|RegisterUserReadModel\noutputChannelName| Aggregate["UserReadModel\n#[Aggregate] extends Model"] - Projection -->|ChangeUserReadModelName| Aggregate - Projection -->|DeactivateUserReadModel| Aggregate + Projection -->|array data\noutputChannelName: 'RegisterUserReadModel'| Aggregate["UserReadModel\n#[Aggregate] extends Model"] + Projection -->|outputChannelName: 'ChangeUserReadModelName'| Aggregate + Projection -->|outputChannelName: 'DeactivateUserReadModel'| Aggregate Aggregate -->|Eloquent create/save| ReadModel[(user_list_eloquent\ntable)] Client -->|sendWithRouting| QueryBus QueryBus -->|listActive| Projection @@ -27,9 +27,8 @@ flowchart LR *Files involved:* - `app/Domain/User.php` — the write-side event-sourced aggregate -- `app/ReadModel/UserListProjection.php` — translates events into read-model commands and routes them -- `app/ReadModel/Command/*.php` — three commands the projection emits -- `app/ReadModel/UserReadModel.php` — `#[Aggregate]` Eloquent model with command handlers +- `app/ReadModel/UserListProjection.php` — translates events into row arrays and routes them +- `app/ReadModel/UserReadModel.php` — `#[Aggregate]` Eloquent model with string-routed command handlers ## 4. Walkthrough of the code @@ -59,19 +58,32 @@ Identical to the DatabaseReadModel domain. The write side is shared; only the re Each event class is annotated with `#[NamedEvent('user.was_registered')]` (and so on). The name is what Ecotone stores alongside the event payload, so the recorded stream stays readable even if you later move or rename the PHP class. Without `#[NamedEvent]`, the fully-qualified class name is used — which couples your stored events to your namespace. For any event you intend to keep on disk, give it a stable name. -### 4.2 The projection — event-to-command translation +### 4.2 The projection — event-to-array translation ```mermaid flowchart TD - ES[(Event Store)] -->|UserWasRegistered| P1["onRegistered()\nreturns RegisterUserReadModel"] - ES -->|UserNameWasChanged| P2["onNameChanged()\nreturns ChangeUserReadModelName"] - ES -->|UserWasDeactivated| P3["onDeactivated()\nreturns DeactivateUserReadModel"] - P1 -->|outputChannelName\n= RegisterUserReadModel::class| AGG["UserReadModel::register()\n(static, #[CommandHandler])"] - P2 -->|outputChannelName\n= ChangeUserReadModelName::class| AGG2["UserReadModel::changeName()\n(instance, #[CommandHandler])"] - P3 -->|outputChannelName\n= DeactivateUserReadModel::class| AGG3["UserReadModel::deactivate()\n(instance, #[CommandHandler])"] + ES[(Event Store)] -->|UserWasRegistered| P1["onRegistered()\nreturns array"] + ES -->|UserNameWasChanged| P2["onNameChanged()\nreturns array"] + ES -->|UserWasDeactivated| P3["onDeactivated()\nreturns array"] + P1 -->|outputChannelName: 'RegisterUserReadModel'| AGG["UserReadModel::register()\n(static, #[CommandHandler])"] + P2 -->|outputChannelName: 'ChangeUserReadModelName'| AGG2["UserReadModel::changeName()\n(instance, #[CommandHandler])"] + P3 -->|outputChannelName: 'DeactivateUserReadModel'| AGG3["UserReadModel::deactivate()\n(instance, #[CommandHandler])"] ``` -Each `#[EventHandler]` on `UserListProjection` returns a typed command and declares `outputChannelName: ::class`. Ecotone hands the command to the matching `#[CommandHandler]` on `UserReadModel`. Routing keys are the command FQCNs — refactor-safe. +Each `#[EventHandler]` on `UserListProjection` returns a plain associative array of the row data and declares `outputChannelName: 'RegisterUserReadModel'` (etc.). Ecotone hands that array to the matching `#[CommandHandler]` on `UserReadModel` by string routing key. No DTO classes are needed; the array travels straight from the projection to the aggregate. + +```php +#[EventHandler(outputChannelName: 'RegisterUserReadModel')] +public function onRegistered(UserWasRegistered $event): array +{ + return [ + 'user_id' => $event->userId, + 'name' => $event->name, + 'email' => $event->email, + 'active' => true, + ]; +} +``` ### 4.3 The read model is a stateful Eloquent aggregate @@ -89,16 +101,16 @@ final class UserReadModel extends Model #[AggregateIdentifierMethod('user_id')] public function getUserId(): string { return $this->user_id; } - #[CommandHandler(RegisterUserReadModel::class)] - public static function register(RegisterUserReadModel $command): self + #[CommandHandler('RegisterUserReadModel')] + public static function register(array $data): self { - return self::create([...]); + return self::create($data); } - #[CommandHandler(ChangeUserReadModelName::class, identifierMapping: ['user_id' => 'payload.userId'])] - public function changeName(ChangeUserReadModelName $command): void + #[CommandHandler('ChangeUserReadModelName', identifierMapping: ['user_id' => "payload['user_id']"])] + public function changeName(array $data): void { - $this->name = $command->name; + $this->name = $data['name']; } } ``` @@ -107,7 +119,7 @@ Three things make this work end-to-end: - **`#[Aggregate]` + `extends Model`** — Ecotone detects an Eloquent aggregate and wires its `EloquentRepository` automatically. No repository configuration needed. - **`#[AggregateIdentifierMethod('user_id')]`** — declares which Eloquent column identifies the aggregate. Ecotone uses this to load the model from the DB before invoking instance command handlers, and to persist it afterwards. -- **`identifierMapping: ['user_id' => 'payload.userId']`** — tells Ecotone where to find the identifier *in the inbound command*. The expression `payload.userId` reads the `userId` field of the command DTO. The `register` static handler doesn't need this — it creates a new aggregate, so there's nothing to load first. +- **`identifierMapping: ['user_id' => "payload['user_id']"]`** — tells Ecotone where to find the identifier *in the inbound payload*. The expression `payload['user_id']` reads the `user_id` key of the array. The static `register` handler doesn't need it — it creates a new aggregate, so there's nothing to load first. After the handler returns, Ecotone calls `$model->save()` for you. That's the "auto-load + auto-save" sugar applied to a read model. @@ -184,8 +196,8 @@ See [DatabaseReadModel](../DatabaseReadModel/README.md) for the simpler direct-w ## 8. Common pitfalls -1. **`outputChannelName` must match a `#[CommandHandler]` routing key.** This example uses `::class` on both sides so the link is refactor-safe. -2. **`identifierMapping` is required on instance command handlers.** Without it Ecotone can't extract the aggregate id from the inbound command (chaining via `outputChannelName` bypasses the bus-level `#[TargetIdentifier]` extraction). Static creation handlers don't need it. +1. **`outputChannelName` must match a `#[CommandHandler]` routing key string exactly.** A typo causes a silent "no handler found" failure. Consider extracting the strings to constants if you have many. +2. **`identifierMapping` is required on instance command handlers.** Without it Ecotone can't extract the aggregate id from the inbound payload (chaining via `outputChannelName` bypasses bus-level identifier extraction). Static creation handlers don't need it. For arrays use bracket syntax: `"payload['user_id']"`. 3. **`$fillable` must include all columns.** Eloquent's mass-assignment protection blocks fields not listed. 4. **`$incrementing = false` and `$keyType = 'string'` are required.** Without them Eloquent treats the UUID primary key as an auto-increment integer. 5. **`$timestamps = false`.** The table has no `created_at`/`updated_at` columns; leaving timestamps enabled will throw. diff --git a/quickstart-examples/Laravel/Projection/EloquentReadModel/app/ReadModel/Command/ChangeUserReadModelName.php b/quickstart-examples/Laravel/Projection/EloquentReadModel/app/ReadModel/Command/ChangeUserReadModelName.php deleted file mode 100644 index 60929e9fb..000000000 --- a/quickstart-examples/Laravel/Projection/EloquentReadModel/app/ReadModel/Command/ChangeUserReadModelName.php +++ /dev/null @@ -1,20 +0,0 @@ -db->statement('DROP TABLE IF EXISTS user_list_eloquent'); } - #[EventHandler(outputChannelName: RegisterUserReadModel::class)] - public function onRegistered(UserWasRegistered $event): RegisterUserReadModel + #[EventHandler(outputChannelName: 'RegisterUserReadModel')] + public function onRegistered(UserWasRegistered $event): array { - return new RegisterUserReadModel($event->userId, $event->name, $event->email); + return [ + 'user_id' => $event->userId, + 'name' => $event->name, + 'email' => $event->email, + 'active' => true, + ]; } - #[EventHandler(outputChannelName: ChangeUserReadModelName::class)] - public function onNameChanged(UserNameWasChanged $event): ChangeUserReadModelName + #[EventHandler(outputChannelName: 'ChangeUserReadModelName')] + public function onNameChanged(UserNameWasChanged $event): array { - return new ChangeUserReadModelName($event->userId, $event->name); + return [ + 'user_id' => $event->userId, + 'name' => $event->name, + ]; } - #[EventHandler(outputChannelName: DeactivateUserReadModel::class)] - public function onDeactivated(UserWasDeactivated $event): DeactivateUserReadModel + #[EventHandler(outputChannelName: 'DeactivateUserReadModel')] + public function onDeactivated(UserWasDeactivated $event): array { - return new DeactivateUserReadModel($event->userId); + return [ + 'user_id' => $event->userId, + ]; } #[QueryHandler('user.listActive')] diff --git a/quickstart-examples/Laravel/Projection/EloquentReadModel/app/ReadModel/UserReadModel.php b/quickstart-examples/Laravel/Projection/EloquentReadModel/app/ReadModel/UserReadModel.php index f414ccdad..6d2e4396e 100644 --- a/quickstart-examples/Laravel/Projection/EloquentReadModel/app/ReadModel/UserReadModel.php +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/app/ReadModel/UserReadModel.php @@ -8,9 +8,6 @@ namespace App\ReadModel; -use App\ReadModel\Command\ChangeUserReadModelName; -use App\ReadModel\Command\DeactivateUserReadModel; -use App\ReadModel\Command\RegisterUserReadModel; use Ecotone\Modelling\Attribute\Aggregate; use Ecotone\Modelling\Attribute\AggregateIdentifierMethod; use Ecotone\Modelling\Attribute\CommandHandler; @@ -37,25 +34,20 @@ public function getUserId(): string return $this->user_id; } - #[CommandHandler(RegisterUserReadModel::class)] - public static function register(RegisterUserReadModel $command): self + #[CommandHandler('RegisterUserReadModel')] + public static function register(array $data): self { - return self::create([ - 'user_id' => $command->userId, - 'name' => $command->name, - 'email' => $command->email, - 'active' => true, - ]); + return self::create($data); } - #[CommandHandler(ChangeUserReadModelName::class, identifierMapping: ['user_id' => 'payload.userId'])] - public function changeName(ChangeUserReadModelName $command): void + #[CommandHandler('ChangeUserReadModelName', identifierMapping: ['user_id' => "payload['user_id']"])] + public function changeName(array $data): void { - $this->name = $command->name; + $this->name = $data['name']; } - #[CommandHandler(DeactivateUserReadModel::class, identifierMapping: ['user_id' => 'payload.userId'])] - public function deactivate(DeactivateUserReadModel $command): void + #[CommandHandler('DeactivateUserReadModel', identifierMapping: ['user_id' => "payload['user_id']"])] + public function deactivate(array $data): void { $this->active = false; } From 60c9b7c7cd2f28d14a95dd42bb04252128286183 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Mon, 18 May 2026 19:38:32 +0200 Subject: [PATCH 07/12] feat: add Symfony Projection quickstart examples (DatabaseReadModel + EntityReadModel) Ports the two Laravel Projection examples to Symfony, providing complete projection lifecycle examples using Doctrine DBAL (DatabaseReadModel) and Doctrine ORM entities as stateful aggregates (EntityReadModel). Both examples run cleanly inside docker and are wired into quickstart-examples/composer.json tests:ci. --- .../Projection/DatabaseReadModel/.gitignore | 4 + .../Projection/DatabaseReadModel/README.md | 145 +++++++++++++ .../Projection/DatabaseReadModel/bin/console | 21 ++ .../DatabaseReadModel/composer.json | 42 ++++ .../DatabaseReadModel/config/bundles.php | 13 ++ .../config/packages/doctrine.yaml | 7 + .../DatabaseReadModel/config/services.php | 15 ++ .../DatabaseReadModel/run_example.php | 83 ++++++++ .../Configuration/EcotoneConfiguration.php | 21 ++ .../src/Configuration/Kernel.php | 21 ++ .../src/Domain/Command/ChangeUserName.php | 18 ++ .../src/Domain/Command/DeactivateUser.php | 17 ++ .../src/Domain/Command/RegisterUser.php | 19 ++ .../src/Domain/Event/UserNameWasChanged.php | 23 ++ .../src/Domain/Event/UserWasDeactivated.php | 22 ++ .../src/Domain/Event/UserWasRegistered.php | 24 +++ .../DatabaseReadModel/src/Domain/User.php | 80 +++++++ .../src/ReadModel/UserListProjection.php | 82 ++++++++ .../Projection/EntityReadModel/.gitignore | 4 + .../Projection/EntityReadModel/README.md | 199 ++++++++++++++++++ .../Projection/EntityReadModel/bin/console | 21 ++ .../Projection/EntityReadModel/composer.json | 42 ++++ .../EntityReadModel/config/bundles.php | 13 ++ .../config/packages/doctrine.yaml | 18 ++ .../EntityReadModel/config/services.php | 15 ++ .../EntityReadModel/run_example.php | 83 ++++++++ .../Configuration/EcotoneConfiguration.php | 29 +++ .../src/Configuration/Kernel.php | 21 ++ .../src/Domain/Command/ChangeUserName.php | 18 ++ .../src/Domain/Command/DeactivateUser.php | 17 ++ .../src/Domain/Command/RegisterUser.php | 19 ++ .../src/Domain/Event/UserNameWasChanged.php | 23 ++ .../src/Domain/Event/UserWasDeactivated.php | 22 ++ .../src/Domain/Event/UserWasRegistered.php | 24 +++ .../EntityReadModel/src/Domain/User.php | 80 +++++++ .../src/ReadModel/UserListProjection.php | 90 ++++++++ .../src/ReadModel/UserReadModel.php | 60 ++++++ .../Symfony/Projection/README.md | 22 ++ quickstart-examples/composer.json | 4 +- 39 files changed, 1480 insertions(+), 1 deletion(-) create mode 100644 quickstart-examples/Symfony/Projection/DatabaseReadModel/.gitignore create mode 100644 quickstart-examples/Symfony/Projection/DatabaseReadModel/README.md create mode 100644 quickstart-examples/Symfony/Projection/DatabaseReadModel/bin/console create mode 100644 quickstart-examples/Symfony/Projection/DatabaseReadModel/composer.json create mode 100644 quickstart-examples/Symfony/Projection/DatabaseReadModel/config/bundles.php create mode 100644 quickstart-examples/Symfony/Projection/DatabaseReadModel/config/packages/doctrine.yaml create mode 100644 quickstart-examples/Symfony/Projection/DatabaseReadModel/config/services.php create mode 100644 quickstart-examples/Symfony/Projection/DatabaseReadModel/run_example.php create mode 100644 quickstart-examples/Symfony/Projection/DatabaseReadModel/src/Configuration/EcotoneConfiguration.php create mode 100644 quickstart-examples/Symfony/Projection/DatabaseReadModel/src/Configuration/Kernel.php create mode 100644 quickstart-examples/Symfony/Projection/DatabaseReadModel/src/Domain/Command/ChangeUserName.php create mode 100644 quickstart-examples/Symfony/Projection/DatabaseReadModel/src/Domain/Command/DeactivateUser.php create mode 100644 quickstart-examples/Symfony/Projection/DatabaseReadModel/src/Domain/Command/RegisterUser.php create mode 100644 quickstart-examples/Symfony/Projection/DatabaseReadModel/src/Domain/Event/UserNameWasChanged.php create mode 100644 quickstart-examples/Symfony/Projection/DatabaseReadModel/src/Domain/Event/UserWasDeactivated.php create mode 100644 quickstart-examples/Symfony/Projection/DatabaseReadModel/src/Domain/Event/UserWasRegistered.php create mode 100644 quickstart-examples/Symfony/Projection/DatabaseReadModel/src/Domain/User.php create mode 100644 quickstart-examples/Symfony/Projection/DatabaseReadModel/src/ReadModel/UserListProjection.php create mode 100644 quickstart-examples/Symfony/Projection/EntityReadModel/.gitignore create mode 100644 quickstart-examples/Symfony/Projection/EntityReadModel/README.md create mode 100644 quickstart-examples/Symfony/Projection/EntityReadModel/bin/console create mode 100644 quickstart-examples/Symfony/Projection/EntityReadModel/composer.json create mode 100644 quickstart-examples/Symfony/Projection/EntityReadModel/config/bundles.php create mode 100644 quickstart-examples/Symfony/Projection/EntityReadModel/config/packages/doctrine.yaml create mode 100644 quickstart-examples/Symfony/Projection/EntityReadModel/config/services.php create mode 100644 quickstart-examples/Symfony/Projection/EntityReadModel/run_example.php create mode 100644 quickstart-examples/Symfony/Projection/EntityReadModel/src/Configuration/EcotoneConfiguration.php create mode 100644 quickstart-examples/Symfony/Projection/EntityReadModel/src/Configuration/Kernel.php create mode 100644 quickstart-examples/Symfony/Projection/EntityReadModel/src/Domain/Command/ChangeUserName.php create mode 100644 quickstart-examples/Symfony/Projection/EntityReadModel/src/Domain/Command/DeactivateUser.php create mode 100644 quickstart-examples/Symfony/Projection/EntityReadModel/src/Domain/Command/RegisterUser.php create mode 100644 quickstart-examples/Symfony/Projection/EntityReadModel/src/Domain/Event/UserNameWasChanged.php create mode 100644 quickstart-examples/Symfony/Projection/EntityReadModel/src/Domain/Event/UserWasDeactivated.php create mode 100644 quickstart-examples/Symfony/Projection/EntityReadModel/src/Domain/Event/UserWasRegistered.php create mode 100644 quickstart-examples/Symfony/Projection/EntityReadModel/src/Domain/User.php create mode 100644 quickstart-examples/Symfony/Projection/EntityReadModel/src/ReadModel/UserListProjection.php create mode 100644 quickstart-examples/Symfony/Projection/EntityReadModel/src/ReadModel/UserReadModel.php create mode 100644 quickstart-examples/Symfony/Projection/README.md diff --git a/quickstart-examples/Symfony/Projection/DatabaseReadModel/.gitignore b/quickstart-examples/Symfony/Projection/DatabaseReadModel/.gitignore new file mode 100644 index 000000000..0f232e01e --- /dev/null +++ b/quickstart-examples/Symfony/Projection/DatabaseReadModel/.gitignore @@ -0,0 +1,4 @@ +vendor/ +var/ +composer.lock +config/reference.php diff --git a/quickstart-examples/Symfony/Projection/DatabaseReadModel/README.md b/quickstart-examples/Symfony/Projection/DatabaseReadModel/README.md new file mode 100644 index 000000000..b216b0bf8 --- /dev/null +++ b/quickstart-examples/Symfony/Projection/DatabaseReadModel/README.md @@ -0,0 +1,145 @@ +# Symfony Projection — Database Read Model + +## 1. What you'll learn + +This example shows how to build a **projection** (a read-optimised view) on top of an event-sourced `User` aggregate using Symfony and Ecotone. You will see how the projection's `#[ProjectionInitialization]` hook creates the storage, how `#[EventHandler]` methods react to each domain event, and how the full projection lifecycle (init → query → reset → backfill → delete) lets you rebuild the read model from scratch whenever you need to. + +## 2. The problem this solves + +In a traditional application, if you need a new view on your data — say "all active users ordered by name" — you run a database migration and populate the new table. In an event-sourced system you still have every domain event ever emitted. You can **replay** them into any new shape without touching the write side. This is the projection pattern: the events are the truth; the read model is just a cache you can always discard and rebuild. + +## 3. How it fits together + +```mermaid +flowchart LR + Client -->|send command| CommandBus + CommandBus -->|route| User["User\n#[EventSourcingAggregate]"] + User -->|return events| EventStore[(Event Store\nPostgreSQL)] + EventStore -->|stream| UserListProjection["UserListProjection\n#[ProjectionV2]"] + UserListProjection -->|INSERT / UPDATE| ReadModel[(user_list_database\ntable)] + Client -->|sendWithRouting| QueryBus + QueryBus -->|listActive| UserListProjection + UserListProjection -->|SELECT| ReadModel +``` + +*Files involved:* +- `src/Domain/User.php` — aggregate that produces the events +- `src/Domain/Event/` — `UserWasRegistered`, `UserNameWasChanged`, `UserWasDeactivated` +- `src/ReadModel/UserListProjection.php` — projection that maintains `user_list_database` +- `src/Configuration/EcotoneConfiguration.php` — wires the PostgreSQL connection via Doctrine DBAL + +## 4. Walkthrough of the code + +### 4.1 Domain — User aggregate + +```mermaid +sequenceDiagram + participant Client + participant CommandBus + participant User + participant EventStore + + Client->>CommandBus: RegisterUser(userId, name, email) + CommandBus->>User: register() static + User-->>EventStore: [UserWasRegistered] + + Client->>CommandBus: ChangeUserName(userId, name) + CommandBus->>User: changeName() + User-->>EventStore: [UserNameWasChanged] + + Client->>CommandBus: DeactivateUser(userId) + CommandBus->>User: deactivate() + User-->>EventStore: [UserWasDeactivated] +``` + +The `User` aggregate is annotated with `#[EventSourcingAggregate]`. Command handlers are `static` for creation (`register`) and instance methods for mutations (`changeName`, `deactivate`). Each handler returns an array of events. `#[EventSourcingHandler]` methods reconstruct aggregate state from stored events — they must have no side effects. + +Each event class is annotated with `#[NamedEvent('user.was_registered')]` (and so on). The name is what Ecotone stores alongside the event payload, so the recorded stream stays readable even if you later move or rename the PHP class. Without `#[NamedEvent]`, the fully-qualified class name is used — which couples your stored events to your namespace. For any event you intend to keep on disk, give it a stable name. + +### 4.2 The projection — direct database writes + +```mermaid +flowchart TD + ES[(Event Store)] -->|UserWasRegistered| onRegistered["onRegistered()\n#[EventHandler]"] + ES -->|UserNameWasChanged| onNameChanged["onNameChanged()\n#[EventHandler]"] + ES -->|UserWasDeactivated| onDeactivated["onDeactivated()\n#[EventHandler]"] + onRegistered -->|INSERT| DB[(user_list_database)] + onNameChanged -->|UPDATE name| DB + onDeactivated -->|UPDATE active=false| DB +``` + +`UserListProjection` receives a Doctrine DBAL `Connection` injected by Ecotone's container. Each `#[EventHandler]` method writes directly to the `user_list_database` table using `$connection->insert()` and `$connection->update()`. No DTO wiring, no intermediate services — this is the simplest possible pattern. + +### 4.3 Lifecycle hooks + +| Hook | Attribute | What it does | +|------|-----------|--------------| +| Initialise | `#[ProjectionInitialization]` | `CREATE TABLE IF NOT EXISTS user_list_database (...)` | +| Delete | `#[ProjectionDelete]` | `DROP TABLE IF EXISTS user_list_database` | + +Resetting the projection is done by deleting and re-initialising it, which clears both the read model table and Ecotone's stored stream position for this projection. A subsequent backfill replays all events from position 0. + +### 4.4 Querying the read model + +The `#[QueryHandler('user.listActive')]` method runs a simple `SELECT` via the Doctrine DBAL `Connection` and returns an array. Callers use the query bus: + +```php +$rows = $queryBus->sendWithRouting('user.listActive'); +// $rows[0]['name'] === 'Alice Cooper' +``` + +The query handler lives on the same class as the event handlers. You can move it to a separate class if you want read/write separation at the class level. + +## 5. Running it + +```bash +# Start services +docker compose up -d app database + +# Enter the container +docker compose exec app bash + +# Install and run +cd quickstart-examples/Symfony/Projection/DatabaseReadModel +composer update +php run_example.php +``` + +The script exits 0 and prints a seven-step ribbon showing each lifecycle phase. + +## 6. Reset vs Delete vs Rebuild + +```mermaid +stateDiagram-v2 + [*] --> Gone: start (no projection) + Gone --> Empty: ecotone:projection:init + Empty --> Active: ecotone:projection:backfill\n(events processed) + Active --> Empty: ecotone:projection:delete\n+ ecotone:projection:init\n(reset = clear rows + position) + Empty --> Active: ecotone:projection:backfill\n(rebuild from event store) + Active --> Gone: ecotone:projection:delete + Gone --> [*] +``` + +| Command | Effect | +|---------|--------| +| `ecotone:projection:init` | Calls `#[ProjectionInitialization]`, records projection as known | +| `ecotone:projection:delete` | Calls `#[ProjectionDelete]`, removes projection tracking | +| `ecotone:projection:backfill` | Replays all events from the event store into the projection | + +**Reset = delete + re-init.** This two-step approach makes the state transitions explicit: you see the table disappear, then reappear empty, then fill up during backfill. + +## 7. When to choose this pattern + +Use `DatabaseReadModel` when: +- You want the simplest possible implementation +- Your read model logic is straightforward SQL via Doctrine DBAL +- You don't need Doctrine ORM features (lifecycle callbacks, repositories, entity managers) + +See [EntityReadModel](../EntityReadModel/README.md) when you want to use Doctrine ORM entities in your read model writers. + +## 8. Common pitfalls + +1. **Forgetting `CREATE TABLE IF NOT EXISTS`.** Without `IF NOT EXISTS` the `init` hook fails if the table already exists, for example after a partial run. +2. **Querying before init.** If you call `user.listActive` before `ecotone:projection:init` the table does not exist and you get a DB error. Always initialise before querying. +3. **Event store accumulates across runs.** This example cleans up the User aggregate stream at the start of `run_example.php`. In production you would never delete the event stream — that is your source of truth. +4. **Projection name collisions.** The name `user_list_database` is unique to this example. If you run both examples simultaneously they write to separate tables and use separate projection tracking entries. diff --git a/quickstart-examples/Symfony/Projection/DatabaseReadModel/bin/console b/quickstart-examples/Symfony/Projection/DatabaseReadModel/bin/console new file mode 100644 index 000000000..c49e3ce01 --- /dev/null +++ b/quickstart-examples/Symfony/Projection/DatabaseReadModel/bin/console @@ -0,0 +1,21 @@ +#!/usr/bin/env php +boot(); + +$application = new Application($kernel); +$application->run($input); diff --git a/quickstart-examples/Symfony/Projection/DatabaseReadModel/composer.json b/quickstart-examples/Symfony/Projection/DatabaseReadModel/composer.json new file mode 100644 index 000000000..8b6d1c462 --- /dev/null +++ b/quickstart-examples/Symfony/Projection/DatabaseReadModel/composer.json @@ -0,0 +1,42 @@ +{ + "name": "ecotone/quickstart", + "license": "MIT", + "authors": [ + { + "name": "Dariusz Gafka", + "email": "dgafka.mail@gmail.com" + } + ], + "repositories": [ + { + "type": "path", + "url": "../../../../packages/*", + "options": { + "symlink": true + } + } + ], + "autoload": { + "psr-4": { + "App\\": "src" + } + }, + "require": { + "ecotone/pdo-event-sourcing": "^1.211", + "ecotone/symfony-starter": "^1.1.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/orm-pack": "^2.4", + "symfony/yaml": "^6.4|^7.0|^8.0", + "ramsey/uuid": "^4.0" + }, + "require-dev": { + "doctrine/orm": "^2.11|^3.0", + "phpunit/phpunit": "^9.6|^10.5|^11.0" + }, + "config": { + "sort-packages": true + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/quickstart-examples/Symfony/Projection/DatabaseReadModel/config/bundles.php b/quickstart-examples/Symfony/Projection/DatabaseReadModel/config/bundles.php new file mode 100644 index 000000000..0fa1c8ca9 --- /dev/null +++ b/quickstart-examples/Symfony/Projection/DatabaseReadModel/config/bundles.php @@ -0,0 +1,13 @@ + ['all' => true], + Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], + EcotoneSymfonyBundle::class => ['all' => true], +]; diff --git a/quickstart-examples/Symfony/Projection/DatabaseReadModel/config/packages/doctrine.yaml b/quickstart-examples/Symfony/Projection/DatabaseReadModel/config/packages/doctrine.yaml new file mode 100644 index 000000000..8c83e81bc --- /dev/null +++ b/quickstart-examples/Symfony/Projection/DatabaseReadModel/config/packages/doctrine.yaml @@ -0,0 +1,7 @@ +doctrine: + dbal: + default_connection: default + connections: + default: + url: '%env(resolve:DATABASE_DSN)%' + charset: UTF8 diff --git a/quickstart-examples/Symfony/Projection/DatabaseReadModel/config/services.php b/quickstart-examples/Symfony/Projection/DatabaseReadModel/config/services.php new file mode 100644 index 000000000..c7bb653d7 --- /dev/null +++ b/quickstart-examples/Symfony/Projection/DatabaseReadModel/config/services.php @@ -0,0 +1,15 @@ +services(); + + $services->load('App\\', '%kernel.project_dir%/src/') + ->autowire() + ->autoconfigure(); +}; diff --git a/quickstart-examples/Symfony/Projection/DatabaseReadModel/run_example.php b/quickstart-examples/Symfony/Projection/DatabaseReadModel/run_example.php new file mode 100644 index 000000000..55b27dccc --- /dev/null +++ b/quickstart-examples/Symfony/Projection/DatabaseReadModel/run_example.php @@ -0,0 +1,83 @@ +boot(); +$container = $kernel->getContainer(); + +/** @var ConfiguredMessagingSystem $messagingSystem */ +$messagingSystem = $container->get(ConfiguredMessagingSystem::class); +/** @var CommandBus $commandBus */ +$commandBus = $container->get(CommandBus::class); +/** @var QueryBus $queryBus */ +$queryBus = $container->get(QueryBus::class); +/** @var EventStore $eventStore */ +$eventStore = $container->get(EventStore::class); + +echo "== Symfony Projection Quickstart - Database Read Model ==\n\n"; + +if ($eventStore->hasStream(User::class)) { + $eventStore->delete(User::class); +} + +echo "1) Delete projection (clean slate)\n"; +$messagingSystem->runConsoleCommand('ecotone:projection:delete', ['name' => 'user_list_database']); +echo " Projection deleted\n\n"; + +echo "2) Initialise projection (create read model storage)\n"; +$messagingSystem->runConsoleCommand('ecotone:projection:init', ['name' => 'user_list_database']); +echo " Projection initialised\n\n"; + +echo "3) Emit events via commands\n"; +$aliceId = Uuid::uuid4()->toString(); +$bobId = Uuid::uuid4()->toString(); +$commandBus->send(new RegisterUser($aliceId, 'Alice', 'alice@example.com')); +$commandBus->send(new RegisterUser($bobId, 'Bob', 'bob@example.com')); +$commandBus->send(new ChangeUserName($aliceId, 'Alice Cooper')); +$commandBus->send(new DeactivateUser($bobId)); +echo " Registered Alice and Bob, renamed Alice to Alice Cooper, deactivated Bob\n\n"; + +echo "4) Query and assert active users\n"; +$rows = $queryBus->sendWithRouting('user.listActive'); +Assert::assertCount(1, $rows); +Assert::assertSame('Alice Cooper', $rows[0]['name']); +echo " Active users: " . count($rows) . " (Alice Cooper only - Bob is deactivated)\n\n"; + +echo "5) Reset projection (delete + re-initialise = wipe read model + clear position)\n"; +$messagingSystem->runConsoleCommand('ecotone:projection:delete', ['name' => 'user_list_database']); +$messagingSystem->runConsoleCommand('ecotone:projection:init', ['name' => 'user_list_database']); +$rows = $queryBus->sendWithRouting('user.listActive'); +Assert::assertSame([], $rows); +echo " Read model is empty after reset\n\n"; + +echo "6) Backfill projection (replay all events from event store)\n"; +$messagingSystem->runConsoleCommand('ecotone:projection:backfill', ['name' => 'user_list_database']); +$rows = $queryBus->sendWithRouting('user.listActive'); +Assert::assertCount(1, $rows); +Assert::assertSame('Alice Cooper', $rows[0]['name']); +echo " Read model rebuilt from events: " . count($rows) . " active user (Alice Cooper)\n\n"; + +echo "7) Delete projection (drop storage)\n"; +$messagingSystem->runConsoleCommand('ecotone:projection:delete', ['name' => 'user_list_database']); +echo " Projection deleted\n\n"; + +echo "== Example completed successfully ==\n"; diff --git a/quickstart-examples/Symfony/Projection/DatabaseReadModel/src/Configuration/EcotoneConfiguration.php b/quickstart-examples/Symfony/Projection/DatabaseReadModel/src/Configuration/EcotoneConfiguration.php new file mode 100644 index 000000000..b2b2e653f --- /dev/null +++ b/quickstart-examples/Symfony/Projection/DatabaseReadModel/src/Configuration/EcotoneConfiguration.php @@ -0,0 +1,21 @@ +userId, $command->name, $command->email)]; + } + + #[CommandHandler] + public function changeName(ChangeUserName $command): array + { + if ($command->name === $this->name) { + return []; + } + + return [new UserNameWasChanged($this->userId, $command->name)]; + } + + #[CommandHandler] + public function deactivate(DeactivateUser $command): array + { + if (! $this->active) { + return []; + } + + return [new UserWasDeactivated($this->userId)]; + } + + #[EventSourcingHandler] + public function applyRegistered(UserWasRegistered $event): void + { + $this->userId = $event->userId; + $this->name = $event->name; + $this->active = true; + } + + #[EventSourcingHandler] + public function applyNameChanged(UserNameWasChanged $event): void + { + $this->name = $event->name; + } + + #[EventSourcingHandler] + public function applyDeactivated(UserWasDeactivated $event): void + { + $this->active = false; + } +} diff --git a/quickstart-examples/Symfony/Projection/DatabaseReadModel/src/ReadModel/UserListProjection.php b/quickstart-examples/Symfony/Projection/DatabaseReadModel/src/ReadModel/UserListProjection.php new file mode 100644 index 000000000..d0ca873d8 --- /dev/null +++ b/quickstart-examples/Symfony/Projection/DatabaseReadModel/src/ReadModel/UserListProjection.php @@ -0,0 +1,82 @@ +connection->executeStatement('CREATE TABLE IF NOT EXISTS user_list_database ( + user_id VARCHAR(36) PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + active BOOLEAN NOT NULL DEFAULT TRUE + )'); + } + + #[ProjectionDelete] + public function delete(): void + { + $this->connection->executeStatement('DROP TABLE IF EXISTS user_list_database'); + } + + #[EventHandler] + public function onRegistered(UserWasRegistered $event): void + { + $this->connection->executeStatement( + 'INSERT INTO user_list_database (user_id, name, email, active) VALUES (:user_id, :name, :email, TRUE)', + ['user_id' => $event->userId, 'name' => $event->name, 'email' => $event->email], + ); + } + + #[EventHandler] + public function onNameChanged(UserNameWasChanged $event): void + { + $this->connection->executeStatement( + 'UPDATE user_list_database SET name = :name WHERE user_id = :user_id', + ['name' => $event->name, 'user_id' => $event->userId], + ); + } + + #[EventHandler] + public function onDeactivated(UserWasDeactivated $event): void + { + $this->connection->executeStatement( + 'UPDATE user_list_database SET active = FALSE WHERE user_id = :user_id', + ['user_id' => $event->userId], + ); + } + + #[QueryHandler('user.listActive')] + public function listActive(): array + { + return $this->connection->fetchAllAssociative( + 'SELECT user_id, name, email, active FROM user_list_database WHERE active = TRUE ORDER BY name ASC', + ); + } +} diff --git a/quickstart-examples/Symfony/Projection/EntityReadModel/.gitignore b/quickstart-examples/Symfony/Projection/EntityReadModel/.gitignore new file mode 100644 index 000000000..0f232e01e --- /dev/null +++ b/quickstart-examples/Symfony/Projection/EntityReadModel/.gitignore @@ -0,0 +1,4 @@ +vendor/ +var/ +composer.lock +config/reference.php diff --git a/quickstart-examples/Symfony/Projection/EntityReadModel/README.md b/quickstart-examples/Symfony/Projection/EntityReadModel/README.md new file mode 100644 index 000000000..12048348e --- /dev/null +++ b/quickstart-examples/Symfony/Projection/EntityReadModel/README.md @@ -0,0 +1,199 @@ +# Symfony Projection — Entity Read Model + +## 1. What you'll learn + +This example shows how to drive a Doctrine ORM read model through Ecotone's stateful aggregate machinery. The projection's `#[EventHandler]` methods translate domain events into plain arrays and route them via `outputChannelName` to string-keyed `#[CommandHandler]` methods on `UserReadModel` — a `#[Aggregate]` that **is** a Doctrine ORM entity. Ecotone auto-loads the aggregate by identifier and auto-saves it after the handler returns, so you get exactly the "load + mutate + save" sugar that stateful aggregates provide, applied to a read model. No DTO classes needed. + +## 2. The problem this solves + +When you rebuild a read model from an event stream, you often want each event to land on a record that goes through your normal persistence layer — lifecycle callbacks, repositories, the entity manager. Writing raw SQL in the projection bypasses all of that. By making the read model a stateful Doctrine entity aggregate, every event becomes a command on the aggregate, and Doctrine handles the rest. + +## 3. How it fits together + +```mermaid +flowchart LR + Client -->|send command| CommandBus + CommandBus -->|route| User["User\n#[EventSourcingAggregate]"] + User -->|return events| EventStore[(Event Store)] + EventStore -->|stream| Projection["UserListProjection\n#[ProjectionV2]"] + Projection -->|array data\noutputChannelName: 'RegisterUserReadModel'| Aggregate["UserReadModel\n#[Aggregate] #[ORM\\Entity]"] + Projection -->|outputChannelName: 'ChangeUserReadModelName'| Aggregate + Projection -->|outputChannelName: 'DeactivateUserReadModel'| Aggregate + Aggregate -->|Doctrine persist/flush| ReadModel[(user_list_entity\ntable)] + Client -->|sendWithRouting| QueryBus + QueryBus -->|listActive| Projection + Projection -->|DBAL fetchAllAssociative| ReadModel +``` + +*Files involved:* +- `src/Domain/User.php` — the write-side event-sourced aggregate +- `src/ReadModel/UserListProjection.php` — translates events into row arrays and routes them +- `src/ReadModel/UserReadModel.php` — `#[Aggregate]` `#[ORM\Entity]` with string-routed command handlers + +## 4. Walkthrough of the code + +### 4.1 Domain — User aggregate + +```mermaid +sequenceDiagram + participant Client + participant CommandBus + participant User + participant EventStore + + Client->>CommandBus: RegisterUser(userId, name, email) + CommandBus->>User: register() static + User-->>EventStore: [UserWasRegistered] + + Client->>CommandBus: ChangeUserName(userId, name) + CommandBus->>User: changeName() + User-->>EventStore: [UserNameWasChanged] + + Client->>CommandBus: DeactivateUser(userId) + CommandBus->>User: deactivate() + User-->>EventStore: [UserWasDeactivated] +``` + +Identical to the DatabaseReadModel domain. The write side is shared; only the read side differs. + +Each event class is annotated with `#[NamedEvent('user.was_registered')]` (and so on). The name is what Ecotone stores alongside the event payload, so the recorded stream stays readable even if you later move or rename the PHP class. + +### 4.2 The projection — event-to-array translation + +```mermaid +flowchart TD + ES[(Event Store)] -->|UserWasRegistered| P1["onRegistered()\nreturns array"] + ES -->|UserNameWasChanged| P2["onNameChanged()\nreturns array"] + ES -->|UserWasDeactivated| P3["onDeactivated()\nreturns array"] + P1 -->|outputChannelName: 'RegisterUserReadModel'| AGG["UserReadModel::register()\n(static, #[CommandHandler])"] + P2 -->|outputChannelName: 'ChangeUserReadModelName'| AGG2["UserReadModel::changeName()\n(instance, #[CommandHandler])"] + P3 -->|outputChannelName: 'DeactivateUserReadModel'| AGG3["UserReadModel::deactivate()\n(instance, #[CommandHandler])"] +``` + +Each `#[EventHandler]` on `UserListProjection` returns a plain associative array of the row data and declares `outputChannelName: 'RegisterUserReadModel'` (etc.). Ecotone hands that array to the matching `#[CommandHandler]` on `UserReadModel` by string routing key. No DTO classes are needed; the array travels straight from the projection to the aggregate. + +```php +#[EventHandler(outputChannelName: 'RegisterUserReadModel')] +public function onRegistered(UserWasRegistered $event): array +{ + return [ + 'user_id' => $event->userId, + 'name' => $event->name, + 'email' => $event->email, + 'active' => true, + ]; +} +``` + +### 4.3 The read model is a stateful Doctrine entity aggregate + +```php +#[ORM\Entity] +#[ORM\Table(name: 'user_list_entity')] +#[Aggregate] +final class UserReadModel +{ + #[ORM\Id] + #[ORM\Column(name: 'user_id', type: 'string', length: 36)] + #[Identifier] + private string $userId; + + #[CommandHandler('RegisterUserReadModel')] + public static function register(array $data): self + { + return new self($data['user_id'], $data['name'], $data['email'], $data['active']); + } + + #[CommandHandler('ChangeUserReadModelName', identifierMapping: ['userId' => "payload['user_id']"])] + public function changeName(array $data): void + { + $this->name = $data['name']; + } +} +``` + +Three things make this work end-to-end: + +- **`#[ORM\Entity]` + `#[Aggregate]`** — Ecotone detects a Doctrine ORM aggregate and wires its Doctrine repository automatically when `DbalConfiguration::withDoctrineORMRepositories(true)` is set. +- **`#[Identifier]` on the id property** — declares which property identifies the aggregate. Ecotone uses this to load the entity from the DB before invoking instance command handlers, and to persist it afterwards. +- **`identifierMapping: ['userId' => "payload['user_id']"]`** — tells Ecotone where to find the identifier *in the inbound payload*. The key (`'userId'`) is the **PHP property name**; the expression (`payload['user_id']`) reads the column name key from the inbound array. The bracket notation is required because the payload is an array; dot notation (`payload.userId`) works only for object DTOs. The static `register` handler doesn't need it — it creates a new aggregate, so there's nothing to load first. + +After the handler returns, Ecotone calls the entity manager's persist and flush for you. That's the "auto-load + auto-save" sugar applied to a read model. + +### 4.4 Lifecycle hooks + +| Hook | Attribute | What it does | +|------|-----------|--------------| +| Initialise | `#[ProjectionInitialization]` | `CREATE TABLE IF NOT EXISTS user_list_entity (...)` | +| Delete | `#[ProjectionDelete]` | `DROP TABLE IF EXISTS user_list_entity` | + +Both hooks use raw SQL via Doctrine DBAL `Connection` for reliable table management regardless of the ORM's schema tool state. + +### 4.5 Querying the read model + +The `#[QueryHandler('user.listActive')]` method uses Doctrine DBAL's `fetchAllAssociative` directly: + +```php +#[QueryHandler('user.listActive')] +public function listActive(): array +{ + return $this->connection->fetchAllAssociative( + 'SELECT user_id, name, email, active FROM user_list_entity WHERE active = TRUE ORDER BY name ASC', + ); +} +``` + +Callers use the query bus identically to the DatabaseReadModel example: + +```php +$rows = $queryBus->sendWithRouting('user.listActive'); +// $rows[0]['name'] === 'Alice Cooper' +``` + +## 5. Running it + +```bash +docker compose up -d app database +docker compose exec app bash +cd quickstart-examples/Symfony/Projection/EntityReadModel +composer update +php run_example.php +``` + +## 6. Reset vs Delete vs Rebuild + +```mermaid +stateDiagram-v2 + [*] --> Gone: start (no projection) + Gone --> Empty: ecotone:projection:init + Empty --> Active: ecotone:projection:backfill\n(events → commands → Doctrine entity) + Active --> Empty: ecotone:projection:delete\n+ ecotone:projection:init\n(reset = clear rows + position) + Empty --> Active: ecotone:projection:backfill\n(rebuild from event store) + Active --> Gone: ecotone:projection:delete + Gone --> [*] +``` + +| Command | Effect | +|---------|--------| +| `ecotone:projection:init` | Calls `#[ProjectionInitialization]`, records projection as known | +| `ecotone:projection:delete` | Calls `#[ProjectionDelete]`, removes projection tracking | +| `ecotone:projection:backfill` | Replays all events; each event flows through the outputChannelName chain and lands on a `UserReadModel` aggregate | + +During backfill the full chain runs: event → projection handler returns command → Ecotone routes command to `UserReadModel` → Doctrine loads/creates the entity, applies the change, persists and flushes. + +## 7. When to choose this pattern + +Use `EntityReadModel` when: +- You want Doctrine ORM lifecycle callbacks on your read model entities +- You want the "auto-load + auto-save" experience on the read side, the same way stateful aggregates work on the write side +- Your team is more comfortable with Doctrine ORM than raw SQL + +See [DatabaseReadModel](../DatabaseReadModel/README.md) for the simpler direct-write pattern. + +## 8. Common pitfalls + +1. **`outputChannelName` must match a `#[CommandHandler]` routing key string exactly.** A typo causes a silent "no handler found" failure. Consider extracting the strings to constants if you have many. +2. **`identifierMapping` is required on instance command handlers.** Without it Ecotone can't extract the aggregate id from the inbound payload (chaining via `outputChannelName` bypasses bus-level identifier extraction). Static creation handlers don't need it. The key is the **PHP property name** (`'userId'`), not the column name (`'user_id'`). For arrays use bracket syntax: `"payload['user_id']"`. +3. **`withDoctrineORMRepositories(true)` is required.** Without it Ecotone doesn't wire the Doctrine entity manager as the aggregate repository. +4. **`#[Identifier]` goes on the property, not the getter.** For Doctrine entities, use the property-based `#[Identifier]` attribute — not `#[AggregateIdentifierMethod]` which is for Eloquent models with dynamic accessors. +5. **ORM mapping must point at `src/ReadModel`.** The `doctrine.yaml` maps only `App\ReadModel` to avoid scanning event-sourced domain classes (which are not entities). diff --git a/quickstart-examples/Symfony/Projection/EntityReadModel/bin/console b/quickstart-examples/Symfony/Projection/EntityReadModel/bin/console new file mode 100644 index 000000000..c49e3ce01 --- /dev/null +++ b/quickstart-examples/Symfony/Projection/EntityReadModel/bin/console @@ -0,0 +1,21 @@ +#!/usr/bin/env php +boot(); + +$application = new Application($kernel); +$application->run($input); diff --git a/quickstart-examples/Symfony/Projection/EntityReadModel/composer.json b/quickstart-examples/Symfony/Projection/EntityReadModel/composer.json new file mode 100644 index 000000000..8b6d1c462 --- /dev/null +++ b/quickstart-examples/Symfony/Projection/EntityReadModel/composer.json @@ -0,0 +1,42 @@ +{ + "name": "ecotone/quickstart", + "license": "MIT", + "authors": [ + { + "name": "Dariusz Gafka", + "email": "dgafka.mail@gmail.com" + } + ], + "repositories": [ + { + "type": "path", + "url": "../../../../packages/*", + "options": { + "symlink": true + } + } + ], + "autoload": { + "psr-4": { + "App\\": "src" + } + }, + "require": { + "ecotone/pdo-event-sourcing": "^1.211", + "ecotone/symfony-starter": "^1.1.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/orm-pack": "^2.4", + "symfony/yaml": "^6.4|^7.0|^8.0", + "ramsey/uuid": "^4.0" + }, + "require-dev": { + "doctrine/orm": "^2.11|^3.0", + "phpunit/phpunit": "^9.6|^10.5|^11.0" + }, + "config": { + "sort-packages": true + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/quickstart-examples/Symfony/Projection/EntityReadModel/config/bundles.php b/quickstart-examples/Symfony/Projection/EntityReadModel/config/bundles.php new file mode 100644 index 000000000..0fa1c8ca9 --- /dev/null +++ b/quickstart-examples/Symfony/Projection/EntityReadModel/config/bundles.php @@ -0,0 +1,13 @@ + ['all' => true], + Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], + EcotoneSymfonyBundle::class => ['all' => true], +]; diff --git a/quickstart-examples/Symfony/Projection/EntityReadModel/config/packages/doctrine.yaml b/quickstart-examples/Symfony/Projection/EntityReadModel/config/packages/doctrine.yaml new file mode 100644 index 000000000..c1afc7d3c --- /dev/null +++ b/quickstart-examples/Symfony/Projection/EntityReadModel/config/packages/doctrine.yaml @@ -0,0 +1,18 @@ +doctrine: + dbal: + default_connection: default + connections: + default: + url: '%env(resolve:DATABASE_DSN)%' + charset: UTF8 + orm: + entity_managers: + default: + connection: default + mappings: + App: + is_bundle: false + type: attribute + dir: '%kernel.project_dir%/src/ReadModel' + prefix: 'App\ReadModel' + alias: App diff --git a/quickstart-examples/Symfony/Projection/EntityReadModel/config/services.php b/quickstart-examples/Symfony/Projection/EntityReadModel/config/services.php new file mode 100644 index 000000000..c7bb653d7 --- /dev/null +++ b/quickstart-examples/Symfony/Projection/EntityReadModel/config/services.php @@ -0,0 +1,15 @@ +services(); + + $services->load('App\\', '%kernel.project_dir%/src/') + ->autowire() + ->autoconfigure(); +}; diff --git a/quickstart-examples/Symfony/Projection/EntityReadModel/run_example.php b/quickstart-examples/Symfony/Projection/EntityReadModel/run_example.php new file mode 100644 index 000000000..f53b71460 --- /dev/null +++ b/quickstart-examples/Symfony/Projection/EntityReadModel/run_example.php @@ -0,0 +1,83 @@ +boot(); +$container = $kernel->getContainer(); + +/** @var ConfiguredMessagingSystem $messagingSystem */ +$messagingSystem = $container->get(ConfiguredMessagingSystem::class); +/** @var CommandBus $commandBus */ +$commandBus = $container->get(CommandBus::class); +/** @var QueryBus $queryBus */ +$queryBus = $container->get(QueryBus::class); +/** @var EventStore $eventStore */ +$eventStore = $container->get(EventStore::class); + +echo "== Symfony Projection Quickstart - Entity Read Model ==\n\n"; + +if ($eventStore->hasStream(User::class)) { + $eventStore->delete(User::class); +} + +echo "1) Delete projection (clean slate)\n"; +$messagingSystem->runConsoleCommand('ecotone:projection:delete', ['name' => 'user_list_entity']); +echo " Projection deleted\n\n"; + +echo "2) Initialise projection (create read model storage)\n"; +$messagingSystem->runConsoleCommand('ecotone:projection:init', ['name' => 'user_list_entity']); +echo " Projection initialised\n\n"; + +echo "3) Emit events via commands\n"; +$aliceId = Uuid::uuid4()->toString(); +$bobId = Uuid::uuid4()->toString(); +$commandBus->send(new RegisterUser($aliceId, 'Alice', 'alice@example.com')); +$commandBus->send(new RegisterUser($bobId, 'Bob', 'bob@example.com')); +$commandBus->send(new ChangeUserName($aliceId, 'Alice Cooper')); +$commandBus->send(new DeactivateUser($bobId)); +echo " Registered Alice and Bob, renamed Alice to Alice Cooper, deactivated Bob\n\n"; + +echo "4) Query and assert active users\n"; +$rows = $queryBus->sendWithRouting('user.listActive'); +Assert::assertCount(1, $rows); +Assert::assertSame('Alice Cooper', $rows[0]['name']); +echo " Active users: " . count($rows) . " (Alice Cooper only - Bob is deactivated)\n\n"; + +echo "5) Reset projection (delete + re-initialise = wipe read model + clear position)\n"; +$messagingSystem->runConsoleCommand('ecotone:projection:delete', ['name' => 'user_list_entity']); +$messagingSystem->runConsoleCommand('ecotone:projection:init', ['name' => 'user_list_entity']); +$rows = $queryBus->sendWithRouting('user.listActive'); +Assert::assertSame([], $rows); +echo " Read model is empty after reset\n\n"; + +echo "6) Backfill projection (replay all events from event store)\n"; +$messagingSystem->runConsoleCommand('ecotone:projection:backfill', ['name' => 'user_list_entity']); +$rows = $queryBus->sendWithRouting('user.listActive'); +Assert::assertCount(1, $rows); +Assert::assertSame('Alice Cooper', $rows[0]['name']); +echo " Read model rebuilt from events: " . count($rows) . " active user (Alice Cooper)\n\n"; + +echo "7) Delete projection (drop storage)\n"; +$messagingSystem->runConsoleCommand('ecotone:projection:delete', ['name' => 'user_list_entity']); +echo " Projection deleted\n\n"; + +echo "== Example completed successfully ==\n"; diff --git a/quickstart-examples/Symfony/Projection/EntityReadModel/src/Configuration/EcotoneConfiguration.php b/quickstart-examples/Symfony/Projection/EntityReadModel/src/Configuration/EcotoneConfiguration.php new file mode 100644 index 000000000..865e8b086 --- /dev/null +++ b/quickstart-examples/Symfony/Projection/EntityReadModel/src/Configuration/EcotoneConfiguration.php @@ -0,0 +1,29 @@ +withDoctrineORMRepositories(true); + } +} diff --git a/quickstart-examples/Symfony/Projection/EntityReadModel/src/Configuration/Kernel.php b/quickstart-examples/Symfony/Projection/EntityReadModel/src/Configuration/Kernel.php new file mode 100644 index 000000000..47061080f --- /dev/null +++ b/quickstart-examples/Symfony/Projection/EntityReadModel/src/Configuration/Kernel.php @@ -0,0 +1,21 @@ +userId, $command->name, $command->email)]; + } + + #[CommandHandler] + public function changeName(ChangeUserName $command): array + { + if ($command->name === $this->name) { + return []; + } + + return [new UserNameWasChanged($this->userId, $command->name)]; + } + + #[CommandHandler] + public function deactivate(DeactivateUser $command): array + { + if (! $this->active) { + return []; + } + + return [new UserWasDeactivated($this->userId)]; + } + + #[EventSourcingHandler] + public function applyRegistered(UserWasRegistered $event): void + { + $this->userId = $event->userId; + $this->name = $event->name; + $this->active = true; + } + + #[EventSourcingHandler] + public function applyNameChanged(UserNameWasChanged $event): void + { + $this->name = $event->name; + } + + #[EventSourcingHandler] + public function applyDeactivated(UserWasDeactivated $event): void + { + $this->active = false; + } +} diff --git a/quickstart-examples/Symfony/Projection/EntityReadModel/src/ReadModel/UserListProjection.php b/quickstart-examples/Symfony/Projection/EntityReadModel/src/ReadModel/UserListProjection.php new file mode 100644 index 000000000..b061e0b9d --- /dev/null +++ b/quickstart-examples/Symfony/Projection/EntityReadModel/src/ReadModel/UserListProjection.php @@ -0,0 +1,90 @@ +connection->executeStatement('CREATE TABLE IF NOT EXISTS user_list_entity ( + user_id VARCHAR(36) PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + active BOOLEAN NOT NULL DEFAULT TRUE + )'); + } + + #[ProjectionDelete] + public function delete(): void + { + $this->connection->executeStatement('DROP TABLE IF EXISTS user_list_entity'); + } + + #[EventHandler(outputChannelName: 'RegisterUserReadModel')] + public function onRegistered(UserWasRegistered $event): array + { + return [ + 'user_id' => $event->userId, + 'name' => $event->name, + 'email' => $event->email, + 'active' => true, + ]; + } + + #[EventHandler(outputChannelName: 'ChangeUserReadModelName')] + public function onNameChanged(UserNameWasChanged $event): array + { + return [ + 'user_id' => $event->userId, + 'name' => $event->name, + ]; + } + + #[EventHandler(outputChannelName: 'DeactivateUserReadModel')] + public function onDeactivated(UserWasDeactivated $event): array + { + return [ + 'user_id' => $event->userId, + ]; + } + + #[QueryHandler('user.listActive')] + public function listActive(): array + { + $rows = $this->connection->fetchAllAssociative( + 'SELECT user_id, name, email, active FROM user_list_entity WHERE active = TRUE ORDER BY name ASC', + ); + + return array_map(fn (array $row) => [ + 'user_id' => $row['user_id'], + 'name' => $row['name'], + 'email' => $row['email'], + 'active' => $row['active'], + ], $rows); + } +} diff --git a/quickstart-examples/Symfony/Projection/EntityReadModel/src/ReadModel/UserReadModel.php b/quickstart-examples/Symfony/Projection/EntityReadModel/src/ReadModel/UserReadModel.php new file mode 100644 index 000000000..5df80b445 --- /dev/null +++ b/quickstart-examples/Symfony/Projection/EntityReadModel/src/ReadModel/UserReadModel.php @@ -0,0 +1,60 @@ +userId = $userId; + $this->name = $name; + $this->email = $email; + $this->active = $active; + } + + #[CommandHandler('RegisterUserReadModel')] + public static function register(array $data): self + { + return new self($data['user_id'], $data['name'], $data['email'], $data['active']); + } + + #[CommandHandler('ChangeUserReadModelName', identifierMapping: ['userId' => "payload['user_id']"])] + public function changeName(array $data): void + { + $this->name = $data['name']; + } + + #[CommandHandler('DeactivateUserReadModel', identifierMapping: ['userId' => "payload['user_id']"])] + public function deactivate(array $data): void + { + $this->active = false; + } +} diff --git a/quickstart-examples/Symfony/Projection/README.md b/quickstart-examples/Symfony/Projection/README.md new file mode 100644 index 000000000..b3f0701b9 --- /dev/null +++ b/quickstart-examples/Symfony/Projection/README.md @@ -0,0 +1,22 @@ +# Symfony Projection Quickstart + +A **projection** in Ecotone is a read model built by replaying events from an event-sourced aggregate. Unlike the aggregate itself — which stores state as events — a projection maintains a denormalised, query-friendly table that can be wiped and rebuilt from the event stream at any time. + +These two examples walk through the complete projection lifecycle using a `User` aggregate that emits `UserWasRegistered`, `UserNameWasChanged`, and `UserWasDeactivated` events. + +## Pick your starting point + +| Example | Pattern | When to use | +|---------|---------|-------------| +| [DatabaseReadModel](./DatabaseReadModel/) | Projection writes directly to the DB via Doctrine DBAL `Connection` | Simplest approach; straightforward SQL; no ORM overhead | +| [EntityReadModel](./EntityReadModel/) | Projection emits commands via `outputChannelName` to a stateful `#[Aggregate]` Doctrine entity | When you want the "auto-load + auto-save" sugar on a read model and Doctrine ORM's lifecycle callbacks | + +**Start with DatabaseReadModel.** It gets the projection lifecycle working with minimal moving parts. Once you understand init → backfill → reset → delete, switch to EntityReadModel to see how a stateful Doctrine entity aggregate becomes the read model's persistence layer. + +## What both examples share + +- A `User` `#[EventSourcingAggregate]` with `RegisterUser`, `ChangeUserName`, and `DeactivateUser` commands +- `#[ProjectionV2]` + `#[FromAggregateStream(User::class)]` for automatic stream wiring +- `#[ProjectionInitialization]` and `#[ProjectionDelete]` lifecycle hooks +- `#[QueryHandler]` on the projection class for `user.listActive` +- A `run_example.php` script that exercises all seven lifecycle steps and asserts on the read model state diff --git a/quickstart-examples/composer.json b/quickstart-examples/composer.json index c923159ba..9788f92c8 100644 --- a/quickstart-examples/composer.json +++ b/quickstart-examples/composer.json @@ -66,7 +66,9 @@ "(cd ConsoleCommand/Symfony && ./test.sh)", "(cd ConsoleCommand/Laravel && ./test.sh)", "(cd Laravel/Projection/DatabaseReadModel && composer update && php run_example.php)", - "(cd Laravel/Projection/EloquentReadModel && composer update && php run_example.php)" + "(cd Laravel/Projection/EloquentReadModel && composer update && php run_example.php)", + "(cd Symfony/Projection/DatabaseReadModel && composer update && php run_example.php)", + "(cd Symfony/Projection/EntityReadModel && composer update && php run_example.php)" ] }, "extra": { From bb7aaa9727c4145ad97a2c3ccdf7ab4e5bfedefd Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Mon, 18 May 2026 19:42:50 +0200 Subject: [PATCH 08/12] fixes --- .../EntityReadModel/src/ReadModel/UserReadModel.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/quickstart-examples/Symfony/Projection/EntityReadModel/src/ReadModel/UserReadModel.php b/quickstart-examples/Symfony/Projection/EntityReadModel/src/ReadModel/UserReadModel.php index 5df80b445..bd93c08e3 100644 --- a/quickstart-examples/Symfony/Projection/EntityReadModel/src/ReadModel/UserReadModel.php +++ b/quickstart-examples/Symfony/Projection/EntityReadModel/src/ReadModel/UserReadModel.php @@ -13,9 +13,9 @@ use Ecotone\Modelling\Attribute\CommandHandler; use Ecotone\Modelling\Attribute\Identifier; +#[Aggregate] #[ORM\Entity] #[ORM\Table(name: 'user_list_entity')] -#[Aggregate] final class UserReadModel { #[ORM\Id] @@ -53,7 +53,7 @@ public function changeName(array $data): void } #[CommandHandler('DeactivateUserReadModel', identifierMapping: ['userId' => "payload['user_id']"])] - public function deactivate(array $data): void + public function deactivate(): void { $this->active = false; } From 290273e222ada66b41a4a67ae3b8c48a7610254f Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Mon, 18 May 2026 19:45:13 +0200 Subject: [PATCH 09/12] refactor(EntityReadModel): match payload key to property name, drop identifierMapping Project the user id as 'userId' (camelCase) so the array key matches the aggregate's $userId PHP property; Ecotone now resolves the identifier automatically and instance command handlers no longer need an explicit identifierMapping. The Doctrine column name stays 'user_id' (DB convention). --- .../Projection/EntityReadModel/README.md | 17 +++++++++-------- .../src/ReadModel/UserListProjection.php | 6 +++--- .../src/ReadModel/UserReadModel.php | 6 +++--- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/quickstart-examples/Symfony/Projection/EntityReadModel/README.md b/quickstart-examples/Symfony/Projection/EntityReadModel/README.md index 12048348e..aae36fb7a 100644 --- a/quickstart-examples/Symfony/Projection/EntityReadModel/README.md +++ b/quickstart-examples/Symfony/Projection/EntityReadModel/README.md @@ -77,7 +77,7 @@ Each `#[EventHandler]` on `UserListProjection` returns a plain associative array public function onRegistered(UserWasRegistered $event): array { return [ - 'user_id' => $event->userId, + 'userId' => $event->userId, 'name' => $event->name, 'email' => $event->email, 'active' => true, @@ -85,6 +85,8 @@ public function onRegistered(UserWasRegistered $event): array } ``` +The array key `'userId'` matches the PHP property name on the aggregate (`$userId`), so Ecotone auto-resolves the identifier on instance command handlers — no `identifierMapping` needed. + ### 4.3 The read model is a stateful Doctrine entity aggregate ```php @@ -101,10 +103,10 @@ final class UserReadModel #[CommandHandler('RegisterUserReadModel')] public static function register(array $data): self { - return new self($data['user_id'], $data['name'], $data['email'], $data['active']); + return new self($data['userId'], $data['name'], $data['email'], $data['active']); } - #[CommandHandler('ChangeUserReadModelName', identifierMapping: ['userId' => "payload['user_id']"])] + #[CommandHandler('ChangeUserReadModelName')] public function changeName(array $data): void { $this->name = $data['name']; @@ -112,11 +114,10 @@ final class UserReadModel } ``` -Three things make this work end-to-end: +Two things make this work end-to-end: -- **`#[ORM\Entity]` + `#[Aggregate]`** — Ecotone detects a Doctrine ORM aggregate and wires its Doctrine repository automatically when `DbalConfiguration::withDoctrineORMRepositories(true)` is set. -- **`#[Identifier]` on the id property** — declares which property identifies the aggregate. Ecotone uses this to load the entity from the DB before invoking instance command handlers, and to persist it afterwards. -- **`identifierMapping: ['userId' => "payload['user_id']"]`** — tells Ecotone where to find the identifier *in the inbound payload*. The key (`'userId'`) is the **PHP property name**; the expression (`payload['user_id']`) reads the column name key from the inbound array. The bracket notation is required because the payload is an array; dot notation (`payload.userId`) works only for object DTOs. The static `register` handler doesn't need it — it creates a new aggregate, so there's nothing to load first. +- **`#[ORM\Entity]` + `#[Aggregate]`** — Ecotone detects a Doctrine ORM aggregate and wires its Doctrine repository automatically. No repository configuration needed. +- **`#[Identifier]` on the id property** — declares which property identifies the aggregate. Ecotone uses this to load the entity from the DB before invoking instance command handlers, and to persist it afterwards. Because the projection emits an array whose key (`'userId'`) matches this property name, Ecotone resolves the identifier without an explicit `identifierMapping`. After the handler returns, Ecotone calls the entity manager's persist and flush for you. That's the "auto-load + auto-save" sugar applied to a read model. @@ -193,7 +194,7 @@ See [DatabaseReadModel](../DatabaseReadModel/README.md) for the simpler direct-w ## 8. Common pitfalls 1. **`outputChannelName` must match a `#[CommandHandler]` routing key string exactly.** A typo causes a silent "no handler found" failure. Consider extracting the strings to constants if you have many. -2. **`identifierMapping` is required on instance command handlers.** Without it Ecotone can't extract the aggregate id from the inbound payload (chaining via `outputChannelName` bypasses bus-level identifier extraction). Static creation handlers don't need it. The key is the **PHP property name** (`'userId'`), not the column name (`'user_id'`). For arrays use bracket syntax: `"payload['user_id']"`. +2. **Match the payload key to the aggregate's PHP property name** to skip `identifierMapping`. The projection emits arrays keyed `userId` and the aggregate property is `$userId`, so Ecotone resolves the identifier automatically. If the payload key differs (e.g. `user_id`), an explicit `identifierMapping: ['userId' => "payload['user_id']"]` is required on instance command handlers (chaining via `outputChannelName` bypasses bus-level identifier extraction). Static creation handlers don't need either. 3. **`withDoctrineORMRepositories(true)` is required.** Without it Ecotone doesn't wire the Doctrine entity manager as the aggregate repository. 4. **`#[Identifier]` goes on the property, not the getter.** For Doctrine entities, use the property-based `#[Identifier]` attribute — not `#[AggregateIdentifierMethod]` which is for Eloquent models with dynamic accessors. 5. **ORM mapping must point at `src/ReadModel`.** The `doctrine.yaml` maps only `App\ReadModel` to avoid scanning event-sourced domain classes (which are not entities). diff --git a/quickstart-examples/Symfony/Projection/EntityReadModel/src/ReadModel/UserListProjection.php b/quickstart-examples/Symfony/Projection/EntityReadModel/src/ReadModel/UserListProjection.php index b061e0b9d..6788d9210 100644 --- a/quickstart-examples/Symfony/Projection/EntityReadModel/src/ReadModel/UserListProjection.php +++ b/quickstart-examples/Symfony/Projection/EntityReadModel/src/ReadModel/UserListProjection.php @@ -49,7 +49,7 @@ public function delete(): void public function onRegistered(UserWasRegistered $event): array { return [ - 'user_id' => $event->userId, + 'userId' => $event->userId, 'name' => $event->name, 'email' => $event->email, 'active' => true, @@ -60,7 +60,7 @@ public function onRegistered(UserWasRegistered $event): array public function onNameChanged(UserNameWasChanged $event): array { return [ - 'user_id' => $event->userId, + 'userId' => $event->userId, 'name' => $event->name, ]; } @@ -69,7 +69,7 @@ public function onNameChanged(UserNameWasChanged $event): array public function onDeactivated(UserWasDeactivated $event): array { return [ - 'user_id' => $event->userId, + 'userId' => $event->userId, ]; } diff --git a/quickstart-examples/Symfony/Projection/EntityReadModel/src/ReadModel/UserReadModel.php b/quickstart-examples/Symfony/Projection/EntityReadModel/src/ReadModel/UserReadModel.php index bd93c08e3..e6f8cb9e7 100644 --- a/quickstart-examples/Symfony/Projection/EntityReadModel/src/ReadModel/UserReadModel.php +++ b/quickstart-examples/Symfony/Projection/EntityReadModel/src/ReadModel/UserReadModel.php @@ -43,16 +43,16 @@ private function __construct(string $userId, string $name, string $email, bool $ #[CommandHandler('RegisterUserReadModel')] public static function register(array $data): self { - return new self($data['user_id'], $data['name'], $data['email'], $data['active']); + return new self($data['userId'], $data['name'], $data['email'], $data['active']); } - #[CommandHandler('ChangeUserReadModelName', identifierMapping: ['userId' => "payload['user_id']"])] + #[CommandHandler('ChangeUserReadModelName')] public function changeName(array $data): void { $this->name = $data['name']; } - #[CommandHandler('DeactivateUserReadModel', identifierMapping: ['userId' => "payload['user_id']"])] + #[CommandHandler('DeactivateUserReadModel')] public function deactivate(): void { $this->active = false; From 3786f868a5f4cf300a76269f129136564b516156 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Mon, 18 May 2026 19:45:58 +0200 Subject: [PATCH 10/12] docs: note that projections can return a typed class instead of an array Both EloquentReadModel and EntityReadModel READMEs now call out the class-vs-array trade-off: arrays are dependency-free; typed command classes give named fields and IDE/static-analysis support. Either form reaches the same #[CommandHandler] over the same outputChannelName. --- .../Laravel/Projection/EloquentReadModel/README.md | 2 ++ .../Symfony/Projection/EntityReadModel/README.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/quickstart-examples/Laravel/Projection/EloquentReadModel/README.md b/quickstart-examples/Laravel/Projection/EloquentReadModel/README.md index 92c2eaf10..43a1345bd 100644 --- a/quickstart-examples/Laravel/Projection/EloquentReadModel/README.md +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/README.md @@ -72,6 +72,8 @@ flowchart TD Each `#[EventHandler]` on `UserListProjection` returns a plain associative array of the row data and declares `outputChannelName: 'RegisterUserReadModel'` (etc.). Ecotone hands that array to the matching `#[CommandHandler]` on `UserReadModel` by string routing key. No DTO classes are needed; the array travels straight from the projection to the aggregate. +> **Arrays are not the only option.** You can return a typed command class instead — e.g. `RegisterUserReadModel` with a `public string $userId` property. The aggregate's command handler then type-hints that class instead of `array $data`, and identifier resolution uses dot syntax (`payload.userId`) on instance handlers. Use a class when you want named fields, IDE autocompletion, and static analysis on the payload shape. Use an array when you want to keep things dependency-free and skip a DTO class per channel. Both reach the same `#[CommandHandler]`. + ```php #[EventHandler(outputChannelName: 'RegisterUserReadModel')] public function onRegistered(UserWasRegistered $event): array diff --git a/quickstart-examples/Symfony/Projection/EntityReadModel/README.md b/quickstart-examples/Symfony/Projection/EntityReadModel/README.md index aae36fb7a..8a7d1937a 100644 --- a/quickstart-examples/Symfony/Projection/EntityReadModel/README.md +++ b/quickstart-examples/Symfony/Projection/EntityReadModel/README.md @@ -87,6 +87,8 @@ public function onRegistered(UserWasRegistered $event): array The array key `'userId'` matches the PHP property name on the aggregate (`$userId`), so Ecotone auto-resolves the identifier on instance command handlers — no `identifierMapping` needed. +> **Arrays are not the only option.** You can return a typed command class instead — e.g. `RegisterUserReadModel` with a `public string $userId` property. The aggregate's command handler then type-hints that class instead of `array $data`, and identifier resolution works the same way (matches the property name). Use a class when you want named fields, IDE autocompletion, and static analysis on the payload shape. Use an array when you want to keep the example dependency-free and avoid one DTO class per channel. Both reach the same `#[CommandHandler]`. + ### 4.3 The read model is a stateful Doctrine entity aggregate ```php From 023a55ae2f73ed7de3002c8b19a2132302724c43 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Mon, 18 May 2026 20:41:13 +0200 Subject: [PATCH 11/12] refactor(projection-examples): drop backfill step from run_example scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_example.php now walks six steps (delete → init → emit → query → reset → delete) instead of seven. The backfill step exercised behaviour that is incompatible with MySQL today (DDL inside the backfill transaction triggers an implicit commit), and removing it lets three of the four examples run successfully on MySQL while keeping the full picture on Postgres. READMEs updated: section 6 renamed to 'Reset vs Delete' and a note added pointing readers at ecotone:projection:backfill for the historical- event replay use case the script no longer demonstrates. --- .../Projection/DatabaseReadModel/README.md | 16 ++++++++-------- .../Projection/DatabaseReadModel/run_example.php | 9 +-------- .../Projection/EloquentReadModel/README.md | 10 +++++----- .../Projection/EloquentReadModel/run_example.php | 9 +-------- quickstart-examples/Laravel/Projection/README.md | 4 ++-- .../Projection/DatabaseReadModel/README.md | 16 ++++++++-------- .../Projection/DatabaseReadModel/run_example.php | 9 +-------- .../Symfony/Projection/EntityReadModel/README.md | 10 +++++----- .../Projection/EntityReadModel/run_example.php | 9 +-------- quickstart-examples/Symfony/Projection/README.md | 4 ++-- 10 files changed, 34 insertions(+), 62 deletions(-) diff --git a/quickstart-examples/Laravel/Projection/DatabaseReadModel/README.md b/quickstart-examples/Laravel/Projection/DatabaseReadModel/README.md index 943e344bb..add5c7be4 100644 --- a/quickstart-examples/Laravel/Projection/DatabaseReadModel/README.md +++ b/quickstart-examples/Laravel/Projection/DatabaseReadModel/README.md @@ -2,7 +2,7 @@ ## 1. What you'll learn -This example shows how to build a **projection** (a read-optimised view) on top of an event-sourced `User` aggregate using Laravel and Ecotone. You will see how the projection's `#[ProjectionInitialization]` hook creates the storage, how `#[EventHandler]` methods react to each domain event, and how the full projection lifecycle (init → query → reset → backfill → delete) lets you rebuild the read model from scratch whenever you need to. +This example shows how to build a **projection** (a read-optimised view) on top of an event-sourced `User` aggregate using Laravel and Ecotone. You will see how the projection's `#[ProjectionInitialization]` hook creates the storage, how `#[EventHandler]` methods react to each domain event, and how the projection lifecycle commands (init, delete, reset) let you wipe and recreate the read model whenever you need to. ## 2. The problem this solves @@ -77,7 +77,7 @@ flowchart TD | Initialise | `#[ProjectionInitialization]` | `CREATE TABLE IF NOT EXISTS user_list_database (...)` | | Delete | `#[ProjectionDelete]` | `DROP TABLE IF EXISTS user_list_database` | -Resetting the projection is done by deleting and re-initialising it, which clears both the read model table and Ecotone's stored stream position for this projection. A subsequent backfill replays all events from position 0. +Resetting the projection is done by deleting and re-initialising it, which clears both the read model table and Ecotone's stored stream position for this projection. Future events flow into the empty projection synchronously as they're emitted. ### 4.4 Querying the read model @@ -105,17 +105,16 @@ composer update php run_example.php ``` -The script exits 0 and prints a seven-step ribbon showing each lifecycle phase. +The script exits 0 and prints a six-step ribbon showing each lifecycle phase. -## 6. Reset vs Delete vs Rebuild +## 6. Reset vs Delete ```mermaid stateDiagram-v2 [*] --> Gone: start (no projection) Gone --> Empty: ecotone:projection:init - Empty --> Active: ecotone:projection:backfill\n(events processed) + Empty --> Active: events emitted\n(handlers fire synchronously) Active --> Empty: ecotone:projection:delete\n+ ecotone:projection:init\n(reset = clear rows + position) - Empty --> Active: ecotone:projection:backfill\n(rebuild from event store) Active --> Gone: ecotone:projection:delete Gone --> [*] ``` @@ -124,9 +123,10 @@ stateDiagram-v2 |---------|--------| | `ecotone:projection:init` | Calls `#[ProjectionInitialization]`, records projection as known | | `ecotone:projection:delete` | Calls `#[ProjectionDelete]`, removes projection tracking | -| `ecotone:projection:backfill` | Replays all events from the event store into the projection | -**Reset = delete + re-init.** This two-step approach makes the state transitions explicit: you see the table disappear, then reappear empty, then fill up during backfill. +**Reset = delete + re-init.** This two-step approach makes the state transitions explicit: you see the table disappear, then reappear empty. + +> **Replaying historical events.** Ecotone ships `ecotone:projection:backfill` to replay everything in the event store into a projection. This example doesn't exercise it because synchronous projections naturally fill from events as they're emitted; backfill is what you reach for after a reset to rebuild from history, or when introducing a new projection over an existing event stream. ## 7. When to choose this pattern diff --git a/quickstart-examples/Laravel/Projection/DatabaseReadModel/run_example.php b/quickstart-examples/Laravel/Projection/DatabaseReadModel/run_example.php index 1b0fe0d3f..613ac47c9 100644 --- a/quickstart-examples/Laravel/Projection/DatabaseReadModel/run_example.php +++ b/quickstart-examples/Laravel/Projection/DatabaseReadModel/run_example.php @@ -68,14 +68,7 @@ Assert::assertSame([], $rows); echo " Read model is empty after reset\n\n"; -echo "6) Backfill projection (replay all events from event store)\n"; -$messagingSystem->runConsoleCommand('ecotone:projection:backfill', ['name' => 'user_list_database']); -$rows = $queryBus->sendWithRouting('user.listActive'); -Assert::assertCount(1, $rows); -Assert::assertSame('Alice Cooper', $rows[0]['name']); -echo " Read model rebuilt from events: " . count($rows) . " active user (Alice Cooper)\n\n"; - -echo "7) Delete projection (drop storage)\n"; +echo "6) Delete projection (drop storage)\n"; $messagingSystem->runConsoleCommand('ecotone:projection:delete', ['name' => 'user_list_database']); echo " Projection deleted\n\n"; diff --git a/quickstart-examples/Laravel/Projection/EloquentReadModel/README.md b/quickstart-examples/Laravel/Projection/EloquentReadModel/README.md index 43a1345bd..d9ea6169e 100644 --- a/quickstart-examples/Laravel/Projection/EloquentReadModel/README.md +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/README.md @@ -166,15 +166,14 @@ composer update php run_example.php ``` -## 6. Reset vs Delete vs Rebuild +## 6. Reset vs Delete ```mermaid stateDiagram-v2 [*] --> Gone: start (no projection) Gone --> Empty: ecotone:projection:init - Empty --> Active: ecotone:projection:backfill\n(events → commands → Eloquent) + Empty --> Active: events emitted\n(handlers → commands → Eloquent) Active --> Empty: ecotone:projection:delete\n+ ecotone:projection:init\n(reset = clear rows + position) - Empty --> Active: ecotone:projection:backfill\n(rebuild from event store) Active --> Gone: ecotone:projection:delete Gone --> [*] ``` @@ -183,9 +182,10 @@ stateDiagram-v2 |---------|--------| | `ecotone:projection:init` | Calls `#[ProjectionInitialization]`, records projection as known | | `ecotone:projection:delete` | Calls `#[ProjectionDelete]`, removes projection tracking | -| `ecotone:projection:backfill` | Replays all events; each event flows through the outputChannelName chain and lands on a `UserReadModel` aggregate | -During backfill the full chain runs: event → projection handler returns command → Ecotone routes command to `UserReadModel` → Eloquent loads/creates the row, applies the change, saves. Eloquent observers fire normally throughout. +For each event the full chain runs: event → projection handler returns command → Ecotone routes command to `UserReadModel` → Eloquent loads/creates the row, applies the change, saves. Eloquent observers fire normally throughout. + +> **Replaying historical events.** Ecotone ships `ecotone:projection:backfill` to replay everything in the event store into a projection. This example doesn't exercise it because synchronous projections naturally fill from events as they're emitted; backfill is what you reach for after a reset to rebuild from history, or when introducing a new projection over an existing event stream. ## 7. When to choose this pattern diff --git a/quickstart-examples/Laravel/Projection/EloquentReadModel/run_example.php b/quickstart-examples/Laravel/Projection/EloquentReadModel/run_example.php index 835d40e99..eb7f0ad8b 100644 --- a/quickstart-examples/Laravel/Projection/EloquentReadModel/run_example.php +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/run_example.php @@ -68,14 +68,7 @@ Assert::assertSame([], $rows); echo " Read model is empty after reset\n\n"; -echo "6) Backfill projection (replay all events from event store)\n"; -$messagingSystem->runConsoleCommand('ecotone:projection:backfill', ['name' => 'user_list_eloquent']); -$rows = $queryBus->sendWithRouting('user.listActive'); -Assert::assertCount(1, $rows); -Assert::assertSame('Alice Cooper', $rows[0]['name']); -echo " Read model rebuilt from events: " . count($rows) . " active user (Alice Cooper)\n\n"; - -echo "7) Delete projection (drop storage)\n"; +echo "6) Delete projection (drop storage)\n"; $messagingSystem->runConsoleCommand('ecotone:projection:delete', ['name' => 'user_list_eloquent']); echo " Projection deleted\n\n"; diff --git a/quickstart-examples/Laravel/Projection/README.md b/quickstart-examples/Laravel/Projection/README.md index 6114ccef7..3919f8699 100644 --- a/quickstart-examples/Laravel/Projection/README.md +++ b/quickstart-examples/Laravel/Projection/README.md @@ -11,7 +11,7 @@ These two examples walk through the complete projection lifecycle using a `User` | [DatabaseReadModel](./DatabaseReadModel/) | Projection writes directly to the DB via `ConnectionInterface` | Simplest approach; straightforward SQL; no ORM overhead | | [EloquentReadModel](./EloquentReadModel/) | Projection emits commands via `outputChannelName` to a stateful `#[Aggregate]` Eloquent model | When you want the "auto-load + auto-save" sugar on a read model and Eloquent's lifecycle hooks | -**Start with DatabaseReadModel.** It gets the projection lifecycle working with minimal moving parts. Once you understand init → backfill → reset → delete, switch to EloquentReadModel to see how a stateful Eloquent aggregate becomes the read model's persistence layer. +**Start with DatabaseReadModel.** It gets the projection lifecycle working with minimal moving parts. Once you understand init → query → reset → delete, switch to EloquentReadModel to see how a stateful Eloquent aggregate becomes the read model's persistence layer. ## What both examples share @@ -19,4 +19,4 @@ These two examples walk through the complete projection lifecycle using a `User` - `#[ProjectionV2]` + `#[FromAggregateStream(User::class)]` for automatic stream wiring - `#[ProjectionInitialization]` and `#[ProjectionDelete]` lifecycle hooks - `#[QueryHandler]` on the projection class for `user.listActive` -- A `run_example.php` script that exercises all seven lifecycle steps and asserts on the read model state +- A `run_example.php` script that walks the projection lifecycle and asserts on the read model state diff --git a/quickstart-examples/Symfony/Projection/DatabaseReadModel/README.md b/quickstart-examples/Symfony/Projection/DatabaseReadModel/README.md index b216b0bf8..ca0cef71b 100644 --- a/quickstart-examples/Symfony/Projection/DatabaseReadModel/README.md +++ b/quickstart-examples/Symfony/Projection/DatabaseReadModel/README.md @@ -2,7 +2,7 @@ ## 1. What you'll learn -This example shows how to build a **projection** (a read-optimised view) on top of an event-sourced `User` aggregate using Symfony and Ecotone. You will see how the projection's `#[ProjectionInitialization]` hook creates the storage, how `#[EventHandler]` methods react to each domain event, and how the full projection lifecycle (init → query → reset → backfill → delete) lets you rebuild the read model from scratch whenever you need to. +This example shows how to build a **projection** (a read-optimised view) on top of an event-sourced `User` aggregate using Symfony and Ecotone. You will see how the projection's `#[ProjectionInitialization]` hook creates the storage, how `#[EventHandler]` methods react to each domain event, and how the projection lifecycle commands (init, delete, reset) let you wipe and recreate the read model whenever you need to. ## 2. The problem this solves @@ -77,7 +77,7 @@ flowchart TD | Initialise | `#[ProjectionInitialization]` | `CREATE TABLE IF NOT EXISTS user_list_database (...)` | | Delete | `#[ProjectionDelete]` | `DROP TABLE IF EXISTS user_list_database` | -Resetting the projection is done by deleting and re-initialising it, which clears both the read model table and Ecotone's stored stream position for this projection. A subsequent backfill replays all events from position 0. +Resetting the projection is done by deleting and re-initialising it, which clears both the read model table and Ecotone's stored stream position for this projection. Future events flow into the empty projection synchronously as they're emitted. ### 4.4 Querying the read model @@ -105,17 +105,16 @@ composer update php run_example.php ``` -The script exits 0 and prints a seven-step ribbon showing each lifecycle phase. +The script exits 0 and prints a six-step ribbon showing each lifecycle phase. -## 6. Reset vs Delete vs Rebuild +## 6. Reset vs Delete ```mermaid stateDiagram-v2 [*] --> Gone: start (no projection) Gone --> Empty: ecotone:projection:init - Empty --> Active: ecotone:projection:backfill\n(events processed) + Empty --> Active: events emitted\n(handlers fire synchronously) Active --> Empty: ecotone:projection:delete\n+ ecotone:projection:init\n(reset = clear rows + position) - Empty --> Active: ecotone:projection:backfill\n(rebuild from event store) Active --> Gone: ecotone:projection:delete Gone --> [*] ``` @@ -124,9 +123,10 @@ stateDiagram-v2 |---------|--------| | `ecotone:projection:init` | Calls `#[ProjectionInitialization]`, records projection as known | | `ecotone:projection:delete` | Calls `#[ProjectionDelete]`, removes projection tracking | -| `ecotone:projection:backfill` | Replays all events from the event store into the projection | -**Reset = delete + re-init.** This two-step approach makes the state transitions explicit: you see the table disappear, then reappear empty, then fill up during backfill. +**Reset = delete + re-init.** This two-step approach makes the state transitions explicit: you see the table disappear, then reappear empty. + +> **Replaying historical events.** Ecotone ships `ecotone:projection:backfill` to replay everything in the event store into a projection. This example doesn't exercise it because synchronous projections naturally fill from events as they're emitted; backfill is what you reach for after a reset to rebuild from history, or when introducing a new projection over an existing event stream. ## 7. When to choose this pattern diff --git a/quickstart-examples/Symfony/Projection/DatabaseReadModel/run_example.php b/quickstart-examples/Symfony/Projection/DatabaseReadModel/run_example.php index 55b27dccc..e62e1d4ad 100644 --- a/quickstart-examples/Symfony/Projection/DatabaseReadModel/run_example.php +++ b/quickstart-examples/Symfony/Projection/DatabaseReadModel/run_example.php @@ -69,14 +69,7 @@ Assert::assertSame([], $rows); echo " Read model is empty after reset\n\n"; -echo "6) Backfill projection (replay all events from event store)\n"; -$messagingSystem->runConsoleCommand('ecotone:projection:backfill', ['name' => 'user_list_database']); -$rows = $queryBus->sendWithRouting('user.listActive'); -Assert::assertCount(1, $rows); -Assert::assertSame('Alice Cooper', $rows[0]['name']); -echo " Read model rebuilt from events: " . count($rows) . " active user (Alice Cooper)\n\n"; - -echo "7) Delete projection (drop storage)\n"; +echo "6) Delete projection (drop storage)\n"; $messagingSystem->runConsoleCommand('ecotone:projection:delete', ['name' => 'user_list_database']); echo " Projection deleted\n\n"; diff --git a/quickstart-examples/Symfony/Projection/EntityReadModel/README.md b/quickstart-examples/Symfony/Projection/EntityReadModel/README.md index 8a7d1937a..fde9e7134 100644 --- a/quickstart-examples/Symfony/Projection/EntityReadModel/README.md +++ b/quickstart-examples/Symfony/Projection/EntityReadModel/README.md @@ -163,15 +163,14 @@ composer update php run_example.php ``` -## 6. Reset vs Delete vs Rebuild +## 6. Reset vs Delete ```mermaid stateDiagram-v2 [*] --> Gone: start (no projection) Gone --> Empty: ecotone:projection:init - Empty --> Active: ecotone:projection:backfill\n(events → commands → Doctrine entity) + Empty --> Active: events emitted\n(handlers → commands → Doctrine entity) Active --> Empty: ecotone:projection:delete\n+ ecotone:projection:init\n(reset = clear rows + position) - Empty --> Active: ecotone:projection:backfill\n(rebuild from event store) Active --> Gone: ecotone:projection:delete Gone --> [*] ``` @@ -180,9 +179,10 @@ stateDiagram-v2 |---------|--------| | `ecotone:projection:init` | Calls `#[ProjectionInitialization]`, records projection as known | | `ecotone:projection:delete` | Calls `#[ProjectionDelete]`, removes projection tracking | -| `ecotone:projection:backfill` | Replays all events; each event flows through the outputChannelName chain and lands on a `UserReadModel` aggregate | -During backfill the full chain runs: event → projection handler returns command → Ecotone routes command to `UserReadModel` → Doctrine loads/creates the entity, applies the change, persists and flushes. +For each event the full chain runs: event → projection handler returns command → Ecotone routes command to `UserReadModel` → Doctrine loads/creates the entity, applies the change, persists and flushes. + +> **Replaying historical events.** Ecotone ships `ecotone:projection:backfill` to replay everything in the event store into a projection. This example doesn't exercise it because synchronous projections naturally fill from events as they're emitted; backfill is what you reach for after a reset to rebuild from history, or when introducing a new projection over an existing event stream. ## 7. When to choose this pattern diff --git a/quickstart-examples/Symfony/Projection/EntityReadModel/run_example.php b/quickstart-examples/Symfony/Projection/EntityReadModel/run_example.php index f53b71460..fb2d0b66b 100644 --- a/quickstart-examples/Symfony/Projection/EntityReadModel/run_example.php +++ b/quickstart-examples/Symfony/Projection/EntityReadModel/run_example.php @@ -69,14 +69,7 @@ Assert::assertSame([], $rows); echo " Read model is empty after reset\n\n"; -echo "6) Backfill projection (replay all events from event store)\n"; -$messagingSystem->runConsoleCommand('ecotone:projection:backfill', ['name' => 'user_list_entity']); -$rows = $queryBus->sendWithRouting('user.listActive'); -Assert::assertCount(1, $rows); -Assert::assertSame('Alice Cooper', $rows[0]['name']); -echo " Read model rebuilt from events: " . count($rows) . " active user (Alice Cooper)\n\n"; - -echo "7) Delete projection (drop storage)\n"; +echo "6) Delete projection (drop storage)\n"; $messagingSystem->runConsoleCommand('ecotone:projection:delete', ['name' => 'user_list_entity']); echo " Projection deleted\n\n"; diff --git a/quickstart-examples/Symfony/Projection/README.md b/quickstart-examples/Symfony/Projection/README.md index b3f0701b9..6502fc2bf 100644 --- a/quickstart-examples/Symfony/Projection/README.md +++ b/quickstart-examples/Symfony/Projection/README.md @@ -11,7 +11,7 @@ These two examples walk through the complete projection lifecycle using a `User` | [DatabaseReadModel](./DatabaseReadModel/) | Projection writes directly to the DB via Doctrine DBAL `Connection` | Simplest approach; straightforward SQL; no ORM overhead | | [EntityReadModel](./EntityReadModel/) | Projection emits commands via `outputChannelName` to a stateful `#[Aggregate]` Doctrine entity | When you want the "auto-load + auto-save" sugar on a read model and Doctrine ORM's lifecycle callbacks | -**Start with DatabaseReadModel.** It gets the projection lifecycle working with minimal moving parts. Once you understand init → backfill → reset → delete, switch to EntityReadModel to see how a stateful Doctrine entity aggregate becomes the read model's persistence layer. +**Start with DatabaseReadModel.** It gets the projection lifecycle working with minimal moving parts. Once you understand init → query → reset → delete, switch to EntityReadModel to see how a stateful Doctrine entity aggregate becomes the read model's persistence layer. ## What both examples share @@ -19,4 +19,4 @@ These two examples walk through the complete projection lifecycle using a `User` - `#[ProjectionV2]` + `#[FromAggregateStream(User::class)]` for automatic stream wiring - `#[ProjectionInitialization]` and `#[ProjectionDelete]` lifecycle hooks - `#[QueryHandler]` on the projection class for `user.listActive` -- A `run_example.php` script that exercises all seven lifecycle steps and asserts on the read model state +- A `run_example.php` script that walks the projection lifecycle and asserts on the read model state From 40c90567c96498e3d66b20556cc2c5c5b448b982 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Mon, 18 May 2026 21:04:36 +0200 Subject: [PATCH 12/12] fix(EntityReadModel): pre-create event stream + guard read-model DDL for MySQL Doctrine ORM in DBAL 4 always wraps flush() in a savepoint when nested in an outer transaction, and that savepoint is silently dropped by any DDL MySQL implicit-commits (CREATE TABLE, DROP TABLE). Two paths inside the example previously triggered DDL during step 3's command flow: the Prooph event store's lazy stream table creation, and the projection's #[ProjectionInitialization] / #[ProjectionDelete] hooks re-running their CREATE/DROP statements. Pre-create the event stream in step 2 (outside any transaction) so the first commandBus->send doesn't trigger DDL. Guard the projection's init and delete hooks with createSchemaManager()->tablesExist() so they only emit DDL when the schema actually needs changing. All four projection examples now pass on both Postgres and MySQL. --- .../Projection/EntityReadModel/run_example.php | 3 +++ .../src/ReadModel/UserListProjection.php | 12 ++++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/quickstart-examples/Symfony/Projection/EntityReadModel/run_example.php b/quickstart-examples/Symfony/Projection/EntityReadModel/run_example.php index fb2d0b66b..0c0981f08 100644 --- a/quickstart-examples/Symfony/Projection/EntityReadModel/run_example.php +++ b/quickstart-examples/Symfony/Projection/EntityReadModel/run_example.php @@ -45,6 +45,9 @@ echo "2) Initialise projection (create read model storage)\n"; $messagingSystem->runConsoleCommand('ecotone:projection:init', ['name' => 'user_list_entity']); +if (! $eventStore->hasStream(User::class)) { + $eventStore->create(User::class); +} echo " Projection initialised\n\n"; echo "3) Emit events via commands\n"; diff --git a/quickstart-examples/Symfony/Projection/EntityReadModel/src/ReadModel/UserListProjection.php b/quickstart-examples/Symfony/Projection/EntityReadModel/src/ReadModel/UserListProjection.php index 6788d9210..d7c4bda0d 100644 --- a/quickstart-examples/Symfony/Projection/EntityReadModel/src/ReadModel/UserListProjection.php +++ b/quickstart-examples/Symfony/Projection/EntityReadModel/src/ReadModel/UserListProjection.php @@ -31,7 +31,11 @@ public function __construct(private Connection $connection) #[ProjectionInitialization] public function init(): void { - $this->connection->executeStatement('CREATE TABLE IF NOT EXISTS user_list_entity ( + if ($this->connection->createSchemaManager()->tablesExist(['user_list_entity'])) { + return; + } + + $this->connection->executeStatement('CREATE TABLE user_list_entity ( user_id VARCHAR(36) PRIMARY KEY, name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, @@ -42,7 +46,11 @@ public function init(): void #[ProjectionDelete] public function delete(): void { - $this->connection->executeStatement('DROP TABLE IF EXISTS user_list_entity'); + if (! $this->connection->createSchemaManager()->tablesExist(['user_list_entity'])) { + return; + } + + $this->connection->executeStatement('DROP TABLE user_list_entity'); } #[EventHandler(outputChannelName: 'RegisterUserReadModel')]