use ProjectSaturnStudios\EventSourcing\Middleware\RetryPersistMiddleware;
// Example: Manually adding to a command bus
$bus->middleware(new RetryPersistMiddleware(maximumTries: 5));
// app/Domain/Todo/Events/TodoTaskCreated.php
namespace App\Domain\Todo\Events;
use ProjectSaturnStudios\EventSourcing\Events\DataEvent;
use Spatie\LaravelData\Attributes\MapName;
use Spatie\LaravelData\Mappers\SnakeCaseMapper; // Optional: for snake_case mapping in DB
use Ramsey\Uuid\UuidInterface; // Or string if you prefer
#[MapName(SnakeCaseMapper::class)] // Optional, if you want stored event payload keys to be snake_case
class TodoTaskCreated extends DataEvent
{
public function __construct(
public readonly UuidInterface $taskUuid, // Or string for UUID
public readonly string $description,
public readonly string $status, // e.g., "pending", "completed"
public readonly \DateTimeImmutable $createdAt
) {}
// You typically don't need to define an eventType() method here.
// Spatie's event sourcing often uses the fully qualified class name by default,
// or you can configure aliases in config/event-sourcing.php if needed.
}
// app/Domain/Todo/Commands/CreateTodoTask.php
namespace App\Domain\Todo\Commands;
use ProjectSaturnStudios\EventSourcing\EventCommands\EventCommand; // Optional base class
use Lorisleiva\LaravelActions\Concerns\AsAction;
use App\Domain\Todo\Events\TodoTaskCreated; // The DataEvent this command will produce
use App\Domain\Todo\Models\TodoTaskAggregate; // Assuming an aggregate for TodoTask
use Ramsey\Uuid\Uuid;
class CreateTodoTask extends EventCommand // Or just use AsAction without extending
{
use AsAction;
// Properties to hold the command data, matching the handle method parameters
public string $description;
public function __construct(string $description)
{
$this->description = $description;
}
// The handle method contains the core logic of your command
public function handle(): void // Command handlers are typically void
{
$taskUuid = Uuid::uuid4();
$initialStatus = 'pending'; // Or from config, etc.
$currentTime = new \DateTimeImmutable();
// Create the DataEvent
$event = new TodoTaskCreated(
taskUuid: $taskUuid,
description: $this->description,
status: $initialStatus,
createdAt: $currentTime
);
// Here, you'd typically interact with an AggregateRoot
// For a creation command, you might instantiate a new aggregate
// or use a static factory method on the aggregate.
/** @var TodoTaskAggregate $todoAggregate */
// Option 1: Static constructor if your aggregate supports it
// $todoAggregate = TodoTaskAggregate::createWithEvent($taskUuid, $event);
// Option 2: Retrieve (if applicable, not for creation usually) then record
// This pattern is more for updates, but showing the recordThat flow:
// $todoAggregate = TodoTaskAggregate::retrieve($taskUuid); // Won't exist for creation
// $todoAggregate->recordThat($event);
// For creation, a common pattern with Spatie's package is to have
// the aggregate handle its own instantiation via a command-like method
// or by applying a creation event to a fresh instance.
// Let's assume a simplified aggregate method for this example:
// This is a conceptual example; your aggregate's API may differ.
TodoTaskAggregate::new($taskUuid) // Gets a new aggregate instance
->recordAndApply($event) // Custom method to record and apply (or just recordThat)
->persist(); // Persist the aggregate and its events
}
// Optional: Add Laravel Action features like validation
public function rules(): array
{
return [
'description' => '
// Typically in a Controller, another Action, or a Service class
use App\Domain\Todo\Commands\CreateTodoTask; // Your EventCommand
// ...
// Instantiate your command with the ware by default.
event_command($command);
// The `handle()` method of your CreateTodoTask action will be executed.
// If it successfully creates and persists the event via an aggregate, you're golden!
// If a PDOException or CouldNotPersistAggregate occurs during the process,
// the RetryPersistMiddleware will automatically attempt to retry.
use App\Http\Middleware\SomeCustomMiddleware; // Example custom middleware
use ProjectSaturnStudios\EventSourcing\Middleware\RetryPersistMiddleware;
// Dispatch with only your custom middleware (no retry by default in this case):
event_command($command, [new SomeCustomMiddleware()]);
// Dispatch with your custom middleware AND the retry middleware:
event_command($command, [
new SomeCustomMiddleware(),
new RetryPersistMiddleware(maximumTries: 5) // Optionally configure retries
]);
// Dispatch with NO middleware at all (if you have a specific reason):
event_command($command, []);
// app/Domain/Todo/Models/TodoTaskAggregate.php
namespace App\Domain\Todo\Models; // Adjust namespace as needed
use Spatie\EventSourcing\AggregateRoots\AggregateRoot;
use App\Domain\Todo\Events\TodoTaskCreated; // Our DataEvent
use App\Domain\Todo\Events\TodoTaskDescriptionChanged; // Another example event
use Ramsey\Uuid\UuidInterface;
class TodoTaskAggregate extends AggregateRoot
{
protected string $description = '';
protected string $status = '';
// Static "named constructor" for creation, often called by a command
public static function createTask(UuidInterface $uuid, string $description, string $initialStatus): self
{
$aggregateRoot = static::retrieve($uuid->toString()); // Get a new or existing instance mapped to UUID
// Don't record if already created (idempotency for creation can be tricky,
// often command validation should check if an entity with this ID/params already exists)
// For simplicity here, we assume the command ensures this is a new task.
$aggregateRoot->recordThat(new TodoTaskCreated(
taskUuid: $uuid,
description: $description,
status: $initialStatus,
createdAt: new \DateTimeImmutable()
));
return $aggregateRoot; // Return for persistence in the command
}
// Public method to change the description
public function changeDescription(string $newDescription): self
{
if ($this->description === $newDescription) {
// No change needed, don't record an event
return $this;
}
if (empty(trim($newDescription))) {
// Or throw a domain exception
throw new \InvalidArgumentException('Description cannot be empty.');
}
$this->recordThat(new TodoTaskDescriptionChanged(
taskUuid: Uuid::fromString($this->uuid()), // Assumes $this->uuid() returns the string UUID
newDescription: $newDescription,
changedAt: new \DateTimeImmutable()
));
return $this;
}
// Apply methods for each event type
protected function applyTodoTaskCreated(TodoTaskCreated $event): void
{
$this->description = $event->description;
$this->status = $event->status;
// Other properties from the event can be set here
}
protected function applyTodoTaskDescriptionChanged(TodoTaskDescriptionChanged $event): void
{
$this->description = $event->newDescription;
}
}
// app/Domain/Todo/Commands/CreateTodoTask.php
// ... (namespace, uses etc.)
class CreateTodoTask extends EventCommand
{
use AsAction;
public string $description;
public function __construct(string $description)
{
$this->description = $description;
}
public function handle(): void
{
$taskUuid = Uuid::uuid4();
$initialStatus = 'pending';
// Call the static method on the aggregate to handle event recording
$aggregate = TodoTaskAggregate::createTask(
uuid: $taskUuid,
description: $this->description,
initialStatus: $initialStatus
);
// Persist the aggregate (this saves the recorded TodoTaskCreated event)
$aggregate->persist();
}
// ... (rules etc.)
}
use App\Domain\Todo\Commands\CreateTodoTask;
use App\Domain\Todo\Events\TodoTaskCreated;
use App\Domain\Todo\Models\TodoTaskAggregate;
use Illuminate\Foundation\Testing\RefreshDatabase; // Or your preferred DB setup
use Tests\TestCase; // Your base TestCase
class CreateTodoTaskTest extends TestCase
{
use RefreshDatabase; // If your commands interact with the DB beyond event store
/** @test */
public function it_creates_a_todo_task_and_records_event()
{
$description = 'Write comprehensive tests';
// $command = new CreateTodoTask(description: $description);
// Execute the command
// event_command($command);
// Given our current EventCommand design (handle is void and no UUID is returned directly from event_command):
// Testing the full flow initiated by event_command() and then asserting specific aggregate events
//
use App\Domain\Todo\Models\TodoTaskAggregate;
use App\Domain\Todo\Events\TodoTaskCreated;
use App\Domain\Todo\Events\TodoTaskDescriptionChanged;
use Ramsey\Uuid\Uuid;
use Tests\TestCase;
class TodoTaskAggregateTest extends TestCase
{
/** @test */
public function it_can_create_a_task()
{
$uuid = Uuid::uuid4();
$aggregate = TodoTaskAggregate::createTask($uuid, 'My first task', 'pending');
// $aggregate->persist(); // Persist if you want to check DB, not needed for assertRecorded on instance
$aggregate->assertRecorded([
TodoTaskCreated::class,
]);
}
/** @test */
public function it_can_change_its_description()
{
$uuid = Uuid::uuid4();
$aggregate = TodoTaskAggregate::createTask($uuid, 'Old description', 'pending');
// Clear recorded events from creation if you only want to test the change
// $aggregate->flushRecordedEvents();
$aggregate->changeDescription('New shiny description');
$aggregate->assertEventRecorded(TodoTaskDescriptionChanged::class, function (TodoTaskDescriptionChanged $event) {
return $event->newDescription === 'New shiny description';
});
}
}
namespace App\Domain\Special\Aggregates;
use ProjectSaturnStudios\EventSourcing\Aggregates\AlternativeAggregateRoot;
use App\Domain\Special\Repositories\MyCustomEventRepository; // Your custom repo class
use App\Domain\Special\Repositories\MyCustomSnapshotRepository; // Your custom repo class
class MySpecialAggregate extends AlternativeAggregateRoot
{
// Point to your custom repository classes
protected string $event_repo = MyCustomEventRepository::class;
protected string $snapshot_repo = MyCustomSnapshotRepository::class;
// ... rest of your aggregate logic ...
public function doSomethingSpecial(): self
{
$this->recordThat(new SomethingSpecialHappened(/* ...data... */));
return $this;
}
protected function applySomethingSpecialHappened(SomethingSpecialHappened $event): void
{
// ... apply state ...
}
}
$eventsQueue = app_queue('events'); // Results in 'my_app_prod-events'
$notificationsQueue = app_queue('notifications'); // Results in 'my_app_prod-notifications'
// config/event-sourcing.php
/*
* A queue is used to guarantee that all events get passed to the projectors in
* the right order. Here you can set of the name of the queue.
*/
'queue' => env('EVENT_PROJECTOR_QUEUE_NAME', app_queue('event-sourcing')), // Using the helper