Skip to content

Commit 622ce18

Browse files
authored
docs: add Laravel and Symfony projection quickstart examples (#666)
* feat: add Laravel Projection quickstart examples (DatabaseReadModel and EloquentReadModel) 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. * 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. * 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. * docs: drop PostgreSQL-only pitfall callouts from projection READMEs * 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. * 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. * 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. * fixes * 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). * 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. * refactor(projection-examples): drop backfill step from run_example scripts 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. * 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.
1 parent 14f2abd commit 622ce18

87 files changed

Lines changed: 2984 additions & 1 deletion

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
# Laravel Projection — Database Read Model
2+
3+
## 1. What you'll learn
4+
5+
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.
6+
7+
## 2. The problem this solves
8+
9+
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.
10+
11+
## 3. How it fits together
12+
13+
```mermaid
14+
flowchart LR
15+
Client -->|send command| CommandBus
16+
CommandBus -->|route| User["User\n#[EventSourcingAggregate]"]
17+
User -->|return events| EventStore[(Event Store\nPostgreSQL)]
18+
EventStore -->|stream| UserListProjection["UserListProjection\n#[ProjectionV2]"]
19+
UserListProjection -->|INSERT / UPDATE| ReadModel[(user_list_database\ntable)]
20+
Client -->|sendWithRouting| QueryBus
21+
QueryBus -->|listActive| UserListProjection
22+
UserListProjection -->|SELECT| ReadModel
23+
```
24+
25+
*Files involved:*
26+
- `app/Domain/User.php` — aggregate that produces the events
27+
- `app/Domain/Event/``UserWasRegistered`, `UserNameWasChanged`, `UserWasDeactivated`
28+
- `app/ReadModel/UserListProjection.php` — projection that maintains `user_list_database`
29+
- `app/Infrastructure/EcotoneConfiguration.php` — wires the PostgreSQL connection
30+
31+
## 4. Walkthrough of the code
32+
33+
### 4.1 Domain — User aggregate
34+
35+
```mermaid
36+
sequenceDiagram
37+
participant Client
38+
participant CommandBus
39+
participant User
40+
participant EventStore
41+
42+
Client->>CommandBus: RegisterUser(userId, name, email)
43+
CommandBus->>User: register() static
44+
User-->>EventStore: [UserWasRegistered]
45+
46+
Client->>CommandBus: ChangeUserName(userId, name)
47+
CommandBus->>User: changeName()
48+
User-->>EventStore: [UserNameWasChanged]
49+
50+
Client->>CommandBus: DeactivateUser(userId)
51+
CommandBus->>User: deactivate()
52+
User-->>EventStore: [UserWasDeactivated]
53+
```
54+
55+
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.
56+
57+
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.
58+
59+
### 4.2 The projection — direct database writes
60+
61+
```mermaid
62+
flowchart TD
63+
ES[(Event Store)] -->|UserWasRegistered| onRegistered["onRegistered()\n#[EventHandler]"]
64+
ES -->|UserNameWasChanged| onNameChanged["onNameChanged()\n#[EventHandler]"]
65+
ES -->|UserWasDeactivated| onDeactivated["onDeactivated()\n#[EventHandler]"]
66+
onRegistered -->|INSERT| DB[(user_list_database)]
67+
onNameChanged -->|UPDATE name| DB
68+
onDeactivated -->|UPDATE active=false| DB
69+
```
70+
71+
`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.
72+
73+
### 4.3 Lifecycle hooks
74+
75+
| Hook | Attribute | What it does |
76+
|------|-----------|--------------|
77+
| Initialise | `#[ProjectionInitialization]` | `CREATE TABLE IF NOT EXISTS user_list_database (...)` |
78+
| Delete | `#[ProjectionDelete]` | `DROP TABLE IF EXISTS user_list_database` |
79+
80+
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.
81+
82+
### 4.4 Querying the read model
83+
84+
The `#[QueryHandler('user.listActive')]` method runs a simple `SELECT` via the `ConnectionInterface` and returns an array. Callers use the query bus:
85+
86+
```php
87+
$rows = $queryBus->sendWithRouting('user.listActive');
88+
// $rows[0]['name'] === 'Alice Cooper'
89+
```
90+
91+
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.
92+
93+
## 5. Running it
94+
95+
```bash
96+
# Start services
97+
docker compose up -d app database
98+
99+
# Enter the container
100+
docker compose exec app bash
101+
102+
# Install and run
103+
cd quickstart-examples/Laravel/Projection/DatabaseReadModel
104+
composer update
105+
php run_example.php
106+
```
107+
108+
The script exits 0 and prints a six-step ribbon showing each lifecycle phase.
109+
110+
## 6. Reset vs Delete
111+
112+
```mermaid
113+
stateDiagram-v2
114+
[*] --> Gone: start (no projection)
115+
Gone --> Empty: ecotone:projection:init
116+
Empty --> Active: events emitted\n(handlers fire synchronously)
117+
Active --> Empty: ecotone:projection:delete\n+ ecotone:projection:init\n(reset = clear rows + position)
118+
Active --> Gone: ecotone:projection:delete
119+
Gone --> [*]
120+
```
121+
122+
| Command | Effect |
123+
|---------|--------|
124+
| `ecotone:projection:init` | Calls `#[ProjectionInitialization]`, records projection as known |
125+
| `ecotone:projection:delete` | Calls `#[ProjectionDelete]`, removes projection tracking |
126+
127+
**Reset = delete + re-init.** This two-step approach makes the state transitions explicit: you see the table disappear, then reappear empty.
128+
129+
> **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.
130+
131+
## 7. When to choose this pattern
132+
133+
Use `DatabaseReadModel` when:
134+
- You want the simplest possible implementation
135+
- Your read model logic is straightforward SQL
136+
- You don't need Eloquent features (observers, mutators, scopes)
137+
138+
See [EloquentReadModel](../EloquentReadModel/README.md) when you want to use Eloquent's ORM features in your read model writers.
139+
140+
## 8. Common pitfalls
141+
142+
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.
143+
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.
144+
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.
145+
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.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
/*
4+
* licence Apache-2.0
5+
*/
6+
7+
declare(strict_types=1);
8+
9+
namespace App\Domain\Command;
10+
11+
final readonly class ChangeUserName
12+
{
13+
public function __construct(
14+
public string $userId,
15+
public string $name,
16+
) {
17+
}
18+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
/*
4+
* licence Apache-2.0
5+
*/
6+
7+
declare(strict_types=1);
8+
9+
namespace App\Domain\Command;
10+
11+
final readonly class DeactivateUser
12+
{
13+
public function __construct(
14+
public string $userId,
15+
) {
16+
}
17+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
/*
4+
* licence Apache-2.0
5+
*/
6+
7+
declare(strict_types=1);
8+
9+
namespace App\Domain\Command;
10+
11+
final readonly class RegisterUser
12+
{
13+
public function __construct(
14+
public string $userId,
15+
public string $name,
16+
public string $email,
17+
) {
18+
}
19+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
/*
4+
* licence Apache-2.0
5+
*/
6+
7+
declare(strict_types=1);
8+
9+
namespace App\Domain\Event;
10+
11+
use Ecotone\Modelling\Attribute\NamedEvent;
12+
13+
#[NamedEvent(self::EVENT_NAME)]
14+
final readonly class UserNameWasChanged
15+
{
16+
public const EVENT_NAME = 'user.name_was_changed';
17+
18+
public function __construct(
19+
public string $userId,
20+
public string $name,
21+
) {
22+
}
23+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
/*
4+
* licence Apache-2.0
5+
*/
6+
7+
declare(strict_types=1);
8+
9+
namespace App\Domain\Event;
10+
11+
use Ecotone\Modelling\Attribute\NamedEvent;
12+
13+
#[NamedEvent(self::EVENT_NAME)]
14+
final readonly class UserWasDeactivated
15+
{
16+
public const EVENT_NAME = 'user.was_deactivated';
17+
18+
public function __construct(
19+
public string $userId,
20+
) {
21+
}
22+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
/*
4+
* licence Apache-2.0
5+
*/
6+
7+
declare(strict_types=1);
8+
9+
namespace App\Domain\Event;
10+
11+
use Ecotone\Modelling\Attribute\NamedEvent;
12+
13+
#[NamedEvent(self::EVENT_NAME)]
14+
final readonly class UserWasRegistered
15+
{
16+
public const EVENT_NAME = 'user.was_registered';
17+
18+
public function __construct(
19+
public string $userId,
20+
public string $name,
21+
public string $email,
22+
) {
23+
}
24+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<?php
2+
3+
/*
4+
* licence Apache-2.0
5+
*/
6+
7+
declare(strict_types=1);
8+
9+
namespace App\Domain;
10+
11+
use App\Domain\Command\ChangeUserName;
12+
use App\Domain\Command\DeactivateUser;
13+
use App\Domain\Command\RegisterUser;
14+
use App\Domain\Event\UserNameWasChanged;
15+
use App\Domain\Event\UserWasDeactivated;
16+
use App\Domain\Event\UserWasRegistered;
17+
use Ecotone\Modelling\Attribute\CommandHandler;
18+
use Ecotone\Modelling\Attribute\EventSourcingAggregate;
19+
use Ecotone\Modelling\Attribute\EventSourcingHandler;
20+
use Ecotone\Modelling\Attribute\Identifier;
21+
use Ecotone\Modelling\WithAggregateVersioning;
22+
23+
#[EventSourcingAggregate]
24+
final class User
25+
{
26+
use WithAggregateVersioning;
27+
28+
#[Identifier]
29+
private string $userId;
30+
31+
private string $name;
32+
33+
private bool $active;
34+
35+
#[CommandHandler]
36+
public static function register(RegisterUser $command): array
37+
{
38+
return [new UserWasRegistered($command->userId, $command->name, $command->email)];
39+
}
40+
41+
#[CommandHandler]
42+
public function changeName(ChangeUserName $command): array
43+
{
44+
if ($command->name === $this->name) {
45+
return [];
46+
}
47+
48+
return [new UserNameWasChanged($this->userId, $command->name)];
49+
}
50+
51+
#[CommandHandler]
52+
public function deactivate(DeactivateUser $command): array
53+
{
54+
if (! $this->active) {
55+
return [];
56+
}
57+
58+
return [new UserWasDeactivated($this->userId)];
59+
}
60+
61+
#[EventSourcingHandler]
62+
public function applyRegistered(UserWasRegistered $event): void
63+
{
64+
$this->userId = $event->userId;
65+
$this->name = $event->name;
66+
$this->active = true;
67+
}
68+
69+
#[EventSourcingHandler]
70+
public function applyNameChanged(UserNameWasChanged $event): void
71+
{
72+
$this->name = $event->name;
73+
}
74+
75+
#[EventSourcingHandler]
76+
public function applyDeactivated(UserWasDeactivated $event): void
77+
{
78+
$this->active = false;
79+
}
80+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
/*
4+
* licence Apache-2.0
5+
*/
6+
7+
declare(strict_types=1);
8+
9+
namespace App\Infrastructure;
10+
11+
use Ecotone\Laravel\Config\LaravelConnectionReference;
12+
use Ecotone\Messaging\Attribute\ServiceContext;
13+
14+
final readonly class EcotoneConfiguration
15+
{
16+
#[ServiceContext]
17+
public function databaseConnection(): LaravelConnectionReference
18+
{
19+
return LaravelConnectionReference::defaultConnection('pgsql');
20+
}
21+
}

0 commit comments

Comments
 (0)