diff --git a/quickstart-examples/Laravel/Projection/DatabaseReadModel/README.md b/quickstart-examples/Laravel/Projection/DatabaseReadModel/README.md new file mode 100644 index 000000000..add5c7be4 --- /dev/null +++ b/quickstart-examples/Laravel/Projection/DatabaseReadModel/README.md @@ -0,0 +1,145 @@ +# 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 projection lifecycle commands (init, delete, reset) let you wipe and recreate the read model 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. + +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 `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. Future events flow into the empty projection synchronously as they're emitted. + +### 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 six-step ribbon showing each lifecycle phase. + +## 6. Reset vs Delete + +```mermaid +stateDiagram-v2 + [*] --> Gone: start (no projection) + Gone --> Empty: ecotone:projection:init + Empty --> Active: events emitted\n(handlers fire synchronously) + Active --> Empty: ecotone:projection:delete\n+ ecotone:projection:init\n(reset = clear rows + position) + 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 | + +**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 + +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. **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/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..992f489aa --- /dev/null +++ b/quickstart-examples/Laravel/Projection/DatabaseReadModel/bootstrap/app.php @@ -0,0 +1,26 @@ +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..ef155746f --- /dev/null +++ b/quickstart-examples/Laravel/Projection/DatabaseReadModel/config/app.php @@ -0,0 +1,44 @@ + 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..cf90e65b3 --- /dev/null +++ b/quickstart-examples/Laravel/Projection/DatabaseReadModel/config/database.php @@ -0,0 +1,14 @@ + '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..5c3ba7d57 --- /dev/null +++ b/quickstart-examples/Laravel/Projection/DatabaseReadModel/config/ecotone.php @@ -0,0 +1,9 @@ +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) 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..1444ef073 --- /dev/null +++ b/quickstart-examples/Laravel/Projection/DatabaseReadModel/storage/framework/.gitignore @@ -0,0 +1,10 @@ +cache/ +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/README.md b/quickstart-examples/Laravel/Projection/EloquentReadModel/README.md new file mode 100644 index 000000000..d9ea6169e --- /dev/null +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/README.md @@ -0,0 +1,205 @@ +# Laravel Projection — Eloquent Read Model + +## 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 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 + +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 + +```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] 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 + Projection -->|UserReadModel::where| ReadModel +``` + +*Files involved:* +- `app/Domain/User.php` — the write-side event-sourced aggregate +- `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 + +### 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. 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-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. + +> **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 +{ + return [ + 'user_id' => $event->userId, + 'name' => $event->name, + 'email' => $event->email, + 'active' => true, + ]; +} +``` + +### 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')] + public static function register(array $data): self + { + return self::create($data); + } + + #[CommandHandler('ChangeUserReadModelName', identifierMapping: ['user_id' => "payload['user_id']"])] + public function changeName(array $data): void + { + $this->name = $data['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['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. + +### 4.4 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.5 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 +docker compose up -d app database +docker compose exec app bash +cd quickstart-examples/Laravel/Projection/EloquentReadModel +composer update +php run_example.php +``` + +## 6. Reset vs Delete + +```mermaid +stateDiagram-v2 + [*] --> Gone: start (no projection) + Gone --> Empty: ecotone:projection:init + Empty --> Active: events emitted\n(handlers → commands → Eloquent) + Active --> Empty: ecotone:projection:delete\n+ ecotone:projection:init\n(reset = clear rows + position) + 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 | + +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 + +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 + +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. 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/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: '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 + { + return UserReadModel::where('active', true) + ->orderBy('name') + ->get() + ->toArray(); + } +} 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..6d2e4396e --- /dev/null +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/app/ReadModel/UserReadModel.php @@ -0,0 +1,54 @@ +user_id; + } + + #[CommandHandler('RegisterUserReadModel')] + public static function register(array $data): self + { + return self::create($data); + } + + #[CommandHandler('ChangeUserReadModelName', identifierMapping: ['user_id' => "payload['user_id']"])] + public function changeName(array $data): void + { + $this->name = $data['name']; + } + + #[CommandHandler('DeactivateUserReadModel', identifierMapping: ['user_id' => "payload['user_id']"])] + public function deactivate(array $data): void + { + $this->active = false; + } +} 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..992f489aa --- /dev/null +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/bootstrap/app.php @@ -0,0 +1,26 @@ +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..8217f041b --- /dev/null +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/composer.json @@ -0,0 +1,39 @@ +{ + "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", + "symfony/expression-language": "^8.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..ef155746f --- /dev/null +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/config/app.php @@ -0,0 +1,44 @@ + 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..cf90e65b3 --- /dev/null +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/config/database.php @@ -0,0 +1,14 @@ + '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..5c3ba7d57 --- /dev/null +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/config/ecotone.php @@ -0,0 +1,9 @@ +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) 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..1444ef073 --- /dev/null +++ b/quickstart-examples/Laravel/Projection/EloquentReadModel/storage/framework/.gitignore @@ -0,0 +1,10 @@ +cache/ +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 diff --git a/quickstart-examples/Laravel/Projection/README.md b/quickstart-examples/Laravel/Projection/README.md new file mode 100644 index 000000000..3919f8699 --- /dev/null +++ b/quickstart-examples/Laravel/Projection/README.md @@ -0,0 +1,22 @@ +# Laravel 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 `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 → query → reset → delete, switch to EloquentReadModel to see how a stateful Eloquent 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 walks the projection lifecycle and asserts on the read model state 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..ca0cef71b --- /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 projection lifecycle commands (init, delete, reset) let you wipe and recreate the read model 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. Future events flow into the empty projection synchronously as they're emitted. + +### 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 six-step ribbon showing each lifecycle phase. + +## 6. Reset vs Delete + +```mermaid +stateDiagram-v2 + [*] --> Gone: start (no projection) + Gone --> Empty: ecotone:projection:init + Empty --> Active: events emitted\n(handlers fire synchronously) + Active --> Empty: ecotone:projection:delete\n+ ecotone:projection:init\n(reset = clear rows + position) + 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 | + +**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 + +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..e62e1d4ad --- /dev/null +++ b/quickstart-examples/Symfony/Projection/DatabaseReadModel/run_example.php @@ -0,0 +1,76 @@ +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) 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..fde9e7134 --- /dev/null +++ b/quickstart-examples/Symfony/Projection/EntityReadModel/README.md @@ -0,0 +1,202 @@ +# 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 [ + 'userId' => $event->userId, + 'name' => $event->name, + 'email' => $event->email, + 'active' => true, + ]; +} +``` + +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 +#[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['userId'], $data['name'], $data['email'], $data['active']); + } + + #[CommandHandler('ChangeUserReadModelName')] + public function changeName(array $data): void + { + $this->name = $data['name']; + } +} +``` + +Two things make this work end-to-end: + +- **`#[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. + +### 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 + +```mermaid +stateDiagram-v2 + [*] --> Gone: start (no projection) + Gone --> Empty: ecotone:projection:init + Empty --> Active: events emitted\n(handlers → commands → Doctrine entity) + Active --> Empty: ecotone:projection:delete\n+ ecotone:projection:init\n(reset = clear rows + position) + 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 | + +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 + +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. **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/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..0c0981f08 --- /dev/null +++ b/quickstart-examples/Symfony/Projection/EntityReadModel/run_example.php @@ -0,0 +1,79 @@ +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']); +if (! $eventStore->hasStream(User::class)) { + $eventStore->create(User::class); +} +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) 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..d7c4bda0d --- /dev/null +++ b/quickstart-examples/Symfony/Projection/EntityReadModel/src/ReadModel/UserListProjection.php @@ -0,0 +1,98 @@ +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, + active BOOLEAN NOT NULL DEFAULT TRUE + )'); + } + + #[ProjectionDelete] + public function delete(): void + { + if (! $this->connection->createSchemaManager()->tablesExist(['user_list_entity'])) { + return; + } + + $this->connection->executeStatement('DROP TABLE user_list_entity'); + } + + #[EventHandler(outputChannelName: 'RegisterUserReadModel')] + public function onRegistered(UserWasRegistered $event): array + { + return [ + 'userId' => $event->userId, + 'name' => $event->name, + 'email' => $event->email, + 'active' => true, + ]; + } + + #[EventHandler(outputChannelName: 'ChangeUserReadModelName')] + public function onNameChanged(UserNameWasChanged $event): array + { + return [ + 'userId' => $event->userId, + 'name' => $event->name, + ]; + } + + #[EventHandler(outputChannelName: 'DeactivateUserReadModel')] + public function onDeactivated(UserWasDeactivated $event): array + { + return [ + 'userId' => $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..e6f8cb9e7 --- /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['userId'], $data['name'], $data['email'], $data['active']); + } + + #[CommandHandler('ChangeUserReadModelName')] + public function changeName(array $data): void + { + $this->name = $data['name']; + } + + #[CommandHandler('DeactivateUserReadModel')] + public function deactivate(): 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..6502fc2bf --- /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 → 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 + +- 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 walks the projection lifecycle and asserts on the read model state diff --git a/quickstart-examples/composer.json b/quickstart-examples/composer.json index d8c7e8715..9788f92c8 100644 --- a/quickstart-examples/composer.json +++ b/quickstart-examples/composer.json @@ -64,7 +64,11 @@ "(cd Workflows/AsynchronousStateless && composer update && php run_example.php)", "(cd Workflows/Saga && composer update && php run_example.php)", "(cd ConsoleCommand/Symfony && ./test.sh)", - "(cd ConsoleCommand/Laravel && ./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 Symfony/Projection/DatabaseReadModel && composer update && php run_example.php)", + "(cd Symfony/Projection/EntityReadModel && composer update && php run_example.php)" ] }, "extra": {