damgru/stupid-event-sourcing
A Laravel package that adds a simple, no-frills approach to event sourcing. Store and replay domain events to rebuild state, keep an audit trail, and model changes over time without heavy ceremony—aimed at small projects and quick adoption.
Installation
composer require damgru/stupid-event-sourcing
Add the service provider to config/app.php under providers:
Damgru\StupidEventSourcing\StupidEventSourcingServiceProvider::class,
Define an Aggregate Root
Create a class extending Damgru\StupidEventSourcing\AggregateRoot:
namespace App\Aggregates;
use Damgru\StupidEventSourcing\AggregateRoot;
class Order extends AggregateRoot
{
public function createOrder(string $customerId, array $items)
{
$this->recordThat(new OrderCreated($customerId, $items));
}
}
Define Events
Create a class implementing Damgru\StupidEventSourcing\Event:
namespace App\Events;
use Damgru\StupidEventSourcing\Event;
class OrderCreated implements Event
{
public function __construct(
public string $customerId,
public array $items
) {}
}
First Use Case: Persisting an Aggregate
use App\Aggregates\Order;
use Damgru\StupidEventSourcing\EventStore;
$order = new Order();
$order->createOrder('customer-123', ['item-1', 'item-2']);
$eventStore = app(EventStore::class);
$eventStore->persist($order);
Where to Look First
README.md or inline comments.tests directory for usage examples and edge cases.StupidEventSourcingServiceProvider for bindings and boot logic.Aggregate Root Design
Order, User, Invoice).recordThat() to emit events. Avoid direct state manipulation outside event handlers.
$this->recordThat(new EventClass($data));
Event Handling
apply() to update state based on events:
protected function apply(Event $event): void
{
if ($event instanceof OrderCreated) {
$this->customerId = $event->customerId;
$this->items = $event->items;
}
}
$order = $eventStore->reconstruct(Order::class, $aggregateId);
Event Store Integration
persist() to save an aggregate’s events:
$eventStore->persist($aggregate);
$events = $eventStore->getEvents(Order::class, $aggregateId);
Command Handling
public function cancelOrder()
{
$this->recordThat(new OrderCancelled());
}
$order->cancelOrder();
$eventStore->persist($order);
CQRS Pattern
$eventStore->getEvents(Order::class, $id)->each(function (Event $event) {
// Project event into a read model (e.g., database, cache)
});
Event Sourcing with Laravel
Event::listen(OrderCreated::class, function (OrderCreated $event) {
// Send notification, update analytics, etc.
});
$this->recordThat(new OrderCreated(...));
dispatch(new ProcessOrderJob($this->id));
Testing
$aggregate = new Order();
$aggregate->loadFromHistory([new OrderCreated(...)]);
$this->assertEquals('customer-123', $aggregate->customerId);
EventStore in unit tests:
$mockStore = Mockery::mock(EventStore::class);
$mockStore->shouldReceive('persist')->once();
Database Backend
events table or a custom table:
Schema::create('event_store', function (Blueprint $table) {
$table->id();
$table->string('aggregate_type');
$table->string('aggregate_id');
$table->text('event_data');
$table->timestamps();
});
EventStore implementation:
$this->app->bind(EventStore::class, function ($app) {
return new DatabaseEventStore($app['db']);
});
Serialization
json_encode/json_decode or a custom serializer for event data:
$eventData = json_encode($event);
$event = json_decode($eventData, false, 512, JSON_THROW_ON_ERROR);
Performance
DB::transaction(function () use ($eventStore, $aggregates) {
foreach ($aggregates as $aggregate) {
$eventStore->persist($aggregate);
}
});
aggregate_type and aggregate_id for fast lookups.No Built-in Event Store
EventStore (e.g., database, Redis). The package provides an interface but no default implementation.class DatabaseEventStore implements EventStore
{
public function persist(AggregateRoot $aggregate): void
{
foreach ($aggregate->getUncommittedEvents() as $event) {
DB::table('event_store')->insert([
'aggregate_type' => $aggregate::class,
'aggregate_id' => $aggregate->id,
'event_data' => json_encode($event),
'occurred_on' => now(),
]);
}
$aggregate->markEventsAsCommitted();
}
public function getEvents(string $aggregateType, string $aggregateId): array
{
return DB::table('event_store')
->where('aggregate_type', $aggregateType)
->where('aggregate_id', $aggregateId)
->orderBy('occurred_on')
->get()
->map(fn ($record) => json_decode($record->event_data, false, 512, JSON_THROW_ON_ERROR));
}
}
Event Ordering
EventStore returns events sorted by occurred_on.Aggregate Identity
id. Ensure this is unique and immutable (e.g., UUID).State vs. Events
recordThat() to emit events and let apply() update state.Concurrency
class Order extends AggregateRoot
{
protected int $version = 0;
public function createOrder(...): void
{
if ($this->version !== $expectedVersion) {
throw new ConcurrencyException();
}
$this->recordThat(new OrderCreated(...));
$this->version++;
}
}
Event Replay Issues
occurred_on?)apply() method (does it handle all event types?)Missing Events
getUncommittedEvents() to debug uncommitted events:
dd($aggregate->getUncommittedEvents());
persist() is called after recording events.How can I help you explore Laravel packages today?