Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions quickstart-examples/Laravel/Projection/DatabaseReadModel/README.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

/*
* licence Apache-2.0
*/

declare(strict_types=1);

namespace App\Domain\Command;

final readonly class ChangeUserName
{
public function __construct(
public string $userId,
public string $name,
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

/*
* licence Apache-2.0
*/

declare(strict_types=1);

namespace App\Domain\Command;

final readonly class DeactivateUser
{
public function __construct(
public string $userId,
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

/*
* licence Apache-2.0
*/

declare(strict_types=1);

namespace App\Domain\Command;

final readonly class RegisterUser
{
public function __construct(
public string $userId,
public string $name,
public string $email,
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

/*
* licence Apache-2.0
*/

declare(strict_types=1);

namespace App\Domain\Event;

use Ecotone\Modelling\Attribute\NamedEvent;

#[NamedEvent(self::EVENT_NAME)]
final readonly class UserNameWasChanged
{
public const EVENT_NAME = 'user.name_was_changed';

public function __construct(
public string $userId,
public string $name,
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

/*
* licence Apache-2.0
*/

declare(strict_types=1);

namespace App\Domain\Event;

use Ecotone\Modelling\Attribute\NamedEvent;

#[NamedEvent(self::EVENT_NAME)]
final readonly class UserWasDeactivated
{
public const EVENT_NAME = 'user.was_deactivated';

public function __construct(
public string $userId,
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

/*
* licence Apache-2.0
*/

declare(strict_types=1);

namespace App\Domain\Event;

use Ecotone\Modelling\Attribute\NamedEvent;

#[NamedEvent(self::EVENT_NAME)]
final readonly class UserWasRegistered
{
public const EVENT_NAME = 'user.was_registered';

public function __construct(
public string $userId,
public string $name,
public string $email,
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

/*
* licence Apache-2.0
*/

declare(strict_types=1);

namespace App\Domain;

use App\Domain\Command\ChangeUserName;
use App\Domain\Command\DeactivateUser;
use App\Domain\Command\RegisterUser;
use App\Domain\Event\UserNameWasChanged;
use App\Domain\Event\UserWasDeactivated;
use App\Domain\Event\UserWasRegistered;
use Ecotone\Modelling\Attribute\CommandHandler;
use Ecotone\Modelling\Attribute\EventSourcingAggregate;
use Ecotone\Modelling\Attribute\EventSourcingHandler;
use Ecotone\Modelling\Attribute\Identifier;
use Ecotone\Modelling\WithAggregateVersioning;

#[EventSourcingAggregate]
final class User
{
use WithAggregateVersioning;

#[Identifier]
private string $userId;

private string $name;

private bool $active;

#[CommandHandler]
public static function register(RegisterUser $command): array
{
return [new UserWasRegistered($command->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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

/*
* licence Apache-2.0
*/

declare(strict_types=1);

namespace App\Infrastructure;

use Ecotone\Laravel\Config\LaravelConnectionReference;
use Ecotone\Messaging\Attribute\ServiceContext;

final readonly class EcotoneConfiguration
{
#[ServiceContext]
public function databaseConnection(): LaravelConnectionReference
{
return LaravelConnectionReference::defaultConnection('pgsql');
}
}
Loading
Loading