🧩 Aggregate Roots
Aggregates are the core building blocks of your domain model — they encapsulate state and enforce invariants through event-driven or state-driven updates.
In Pillar, all aggregates implement the interface AggregateRoot. If your aggregate is using event sourcing, instead implement the EventSourcedAggregateRoot interface and use the trait EventSourcing, which provides a consistent pattern for recording and applying domain events.
Example (Event-Sourced Aggregate)
use Pillar\Aggregate\EventSourcedAggregateRoot;
use Context\Document\Domain\Event\DocumentCreated;
use Context\Document\Domain\Event\DocumentRenamed;
use Context\Document\Domain\Identifier\DocumentId;
final class Document implements EventSourcedAggregateRoot
{
use EventSourcing;
private string $title;
public static function create(string $title): self
{
$self = new self(DocumentId::new());
$self->record(new DocumentCreated($title));
return $self;
}
public function rename(string $newTitle): void
{
if ($this->title === $newTitle) {
return;
}
$this->record(new DocumentRenamed($newTitle));
}
protected function applyDocumentCreated(DocumentCreated $event): void
{
// No need to set $this->id here — Pillar injects the stream id when reconstituting.
$this->title = $event->title;
}
protected function applyDocumentRenamed(DocumentRenamed $event): void
{
$this->title = $event->newTitle;
}
}Note: Including the aggregate id in events is optional. You can keep
DocumentCreated/DocumentRenamedfree of ids as long as the aggregate id is set before the first persist (for new aggregates) and Pillar can inject the stream id when reconstituting.
This is the event-sourced approach — every state change is expressed as a domain event, persisted to the event store, and used to rebuild the aggregate’s state later.
This model gives you:
- 🔍 Full auditability of all domain changes over time
- 🕰️ Reproducibility and replay capability
- ⚙️ Resilience against schema evolution with versioned events and upcasters
Example (State-Based Aggregate)
For simpler domains, you can skip event sourcing entirely. In that case, your repository can directly persist and retrieve aggregates from a storage backend (like Eloquent or a document store). You don’t record or apply events — you just mutate the state directly.
use Context\Document\Domain\Identifier\DocumentId;
use Pillar\Aggregate\AggregateRoot;
final class Document implements AggregateRoot
{
public function __construct(
private DocumentId $id,
private string $title
) {}
public function rename(string $newTitle): void
{
$this->title = $newTitle;
}
public function title(): string
{
return $this->title;
}
public function id(): DocumentId
{
return $this->id;
}
}This state-based model is ideal for:
- 🧾 Aggregates that don’t require audit trails or historical replay
- ⚡ Domains that favor direct persistence over event sourcing
- 🧰 Use cases where you want the same aggregate behavior API but backed by a simpler repository
Both models work seamlessly with Pillar’s repository and session abstractions — you can mix and match them in the same application.
Wiring a state‑based aggregate with Eloquent
For state‑based aggregates, the repository persists fields directly (no events). Here’s a minimal Eloquent mapping:
// app/Models/DocumentRecord.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class DocumentRecord extends Model
{
protected $table = 'documents';
public $timestamps = false;
protected $fillable = ['id', 'title'];
}Migration (documents table)
// database/migrations/XXXX_XX_XX_000000_create_documents_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void {
Schema::create('documents', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('title');
});
}
public function down(): void {
Schema::dropIfExists('documents');
}
};// app/Context/Document/Infrastructure/DocumentRepository.php
namespace App\Context\Document\Infrastructure;
use Pillar\Repository\AggregateRepository;
use Pillar\Repository\LoadedAggregate;
use Pillar\Aggregate\AggregateRootId;
use Pillar\Aggregate\AggregateRoot;
use App\Models\DocumentRecord;
use Context\Document\Domain\Aggregate\Document; // your aggregate class
use Context\Document\Domain\Identifier\DocumentId;
final class DocumentRepository implements AggregateRepository
{
public function find(AggregateRootId $id): ?LoadedAggregate
{
$row = DocumentRecord::query()->whereKey((string) $id)->first();
if (!$row) {
return null;
}
$aggregate = new Document(DocumentId::from($row->id), $row->title);
return new LoadedAggregate($aggregate);
}
public function save(AggregateRoot $aggregate, ?int $expectedVersion = null): void
{
/** @var Document $aggregate */
$id = (string) $aggregate->id();
// Upsert without optimistic locking
$row = DocumentRecord::query()->whereKey($id)->first();
if ($row) {
$row->title = $aggregate->title();
$row->save();
} else {
// First write
DocumentRecord::create([
'id' => $id,
'title' => $aggregate->title(),
]);
}
}
}Optional — optimistic locking: If you want optimistic concurrency for state‑based aggregates, add a
versioncolumn and fetch the row withDocumentRecord::query()->whereKey($id)->lockForUpdate()->first(), verify the currentversionmatches the expected value, then bump it on update. This mirrors the event store’s concurrency check.
Register the repository for this aggregate in config/pillar.php:
'repositories' => [
'default' => Pillar\Repository\EventStoreRepository::class,
Context\Document\Domain\Aggregate\Document::class => App\Context\Document\Infrastructure\DocumentRepository::class,
],Now a command handler can load and save via the AggregateSession (no event store involved):
$doc = $session->find(DocumentId::from($id));
$doc->rename('New Title');
$session->commit(); // persists through DocumentRepository🧠 Aggregate Lifecycle Overview
(For state-based aggregates, the “EventStore” step is replaced with a direct database update.)