
August 15, 2026
9 min read
Table of Contents
By Kokil Thapa | Last reviewed: August 2026
Most Laravel applications store the current state of a record and overwrite it on every update, permanently losing the history of how that state was reached. Implementing event sourcing with Laravel Spatie package (spatie/laravel-event-sourcing) solves this by persisting immutable events as the single source of truth, allowing you to reconstruct state, audit every change, and debug complex business logic without guesswork. This approach is particularly valuable for financial systems, legal-tech portals, and eCommerce platforms where "what happened" matters as much as "what is." If you are evaluating this for a production system, understanding the architectural trade-offs is essential before writing code; I often discuss these foundational decisions when outlining modern Laravel architecture best practices with clients who need long-term data integrity.
What is event sourcing with Laravel Spatie package and when should you use it?
Event sourcing is an architectural pattern where state changes are stored as a sequence of immutable events rather than destructive updates. The spatie/laravel-event-sourcing package (v7.x for Laravel 12) provides the infrastructure to implement this in PHP without building a custom event store from scratch. In practice, this means your orders table might not exist as a primary entity; instead, you have an events table containing OrderCreated, OrderPaid, and OrderShipped records. The current order status is derived by replaying these events through a projector.
This pattern is not a default replacement for CRUD. I reserve it for domains where the history is the business value. On legal-tech portals like those I’ve built for Nepal’s marriage and divorce services, the exact timestamp and actor for every document status change is a compliance requirement, not just a debugging aid. Similarly, in financial ledgers or inventory systems, being able to prove that a balance was computed correctly from first principles prevents catastrophic reconciliation errors. For standard brochure sites or simple content management, traditional Eloquent models remain simpler and faster. If you are building a system where users frequently ask "why is this in this state?" or "who changed this and when?", event sourcing earns its complexity.
How do you install and configure spatie/laravel-event-sourcing in Laravel 12?
The package requires PHP 8.2 or higher and Laravel 11.x/12.x. As of 2026, always target the latest stable v7 release for Laravel 12 compatibility. Installation follows standard Composer workflow, but configuration requires careful attention to storage and serialization settings.
composer require spatie/laravel-event-sourcing:^7.0
php artisan vendor:publish --provider="Spatie\EventSourcing\EventSourcingServiceProvider" --tag="event-sourcing-config"
php artisan migrate The published config file at config/event-sourcing.php controls critical behavior. Three settings demand immediate review:
- Event serializer: Defaults to JSON. For production systems with complex value objects, I switch to
Spatie\EventSourcing\Serializers\JsonSerializerwith explicit versioning metadata embedded in each event payload. This prevents breaking changes when event schemas evolve over years of operation. - Stored event model: The default
StoredEventmodel uses MySQL/MariaDB. For high-volume systems exceeding 10M events, consider configuring PostgreSQL with proper indexing onaggregate_uuidandcreated_at. MongoDB is supported via separate adapter packages but adds operational overhead. - Snapshot threshold: Set
snapshot_thresholdto 50–100 events depending on your aggregate complexity. Too low wastes storage; too high makes replay slow during peak loads.
After migration, verify the stored_events table includes indexes on aggregate_uuid, event_name, and created_at. Missing indexes here are the #1 cause of performance degradation in production event-sourced systems I’ve audited. Run php artisan event-sourcing:list to confirm all event handlers and projectors are registered correctly before deploying.
How do you define aggregates, events, and projectors correctly?
The core triad of event sourcing consists of Aggregates (decision makers), Events (facts), and Projectors (read model builders). Getting their boundaries wrong creates maintenance nightmares. Here is the disciplined approach I use on client projects:
Define immutable events first
Events must be pure data transfer objects with no behavior. They represent facts that already happened, never commands or intentions. Always include metadata for auditing:
<?php
namespace App\Events\Orders;
use Spatie\EventSourcing\StoredEvents\ShouldBeStored;
class OrderPaid extends ShouldBeStored
{
public function __construct(
public readonly string $orderId,
public readonly int $amountInCents,
public readonly string $paymentGatewayRef,
public readonly string $paidAt,
public readonly string $userId,
) {}
} Note the readonly properties (PHP 8.2+) and explicit typing. Never use nullable fields in events unless the absence itself carries semantic meaning. Include timestamps as ISO 8601 strings, not Carbon instances, to avoid serialization pitfalls across PHP versions.
Build aggregates that enforce invariants
Aggregates validate business rules and emit events. They never directly modify external state or call services:
<?php
namespace App\Aggregates;
use Spatie\EventSourcing\AggregateRoots\AggregateRoot;
use App\Events\Orders\OrderPaid;
use App\Exceptions\OrderAlreadyPaidException;
class OrderAggregate extends AggregateRoot
{
private bool $isPaid = false;
private int $totalAmount = 0;
public function markAsPaid(int $amount, string $gatewayRef, string $userId): self
{
if ($this->isPaid) {
throw new OrderAlreadyPaidException($this->aggregateUuid());
}
$this->recordThat(new OrderPaid(
orderId: $this->aggregateUuid(),
amountInCents: $amount,
paymentGatewayRef: $gatewayRef,
paidAt: now()->toISOString(),
userId: $userId,
));
return $this;
}
protected function onOrderPaid(OrderPaid $event): void
{
$this->isPaid = true;
$this->totalAmount = $event->amountInCents;
}
} The onOrderPaid method applies the event to internal state. This separation ensures validation happens before persistence, while state reconstruction uses the same logic. Never put side effects (emails, API calls) inside aggregates.
Create focused projectors for read models
Projectors transform events into queryable tables. Keep them idempotent and single-purpose:
<?php
namespace App\Projectors;
use Spatie\EventSourcing\Projectors\Projector;
use App\Events\Orders\OrderPaid;
use App\Models\OrderReadModel;
class OrderReadModelProjector extends Projector
{
public function onOrderPaid(OrderPaid $event): void
{
OrderReadModel::updateOrCreate(
['order_id' => $event->orderId],
[
'status' => 'paid',
'amount_cents' => $event->amountInCents,
'paid_at' => $event->paidAt,
'last_updated_by' => $event->userId,
]
);
}
} Use updateOrCreate defensively. During replay, events may arrive out of order or be reprocessed. Your projector must produce identical results regardless of execution count. For complex aggregations, maintain intermediate state in dedicated projector tables rather than computing on-the-fly.
How do you handle snapshots and performance optimization at scale?
Replaying thousands of events per request kills performance. Snapshots solve this by periodically serializing aggregate state, so future loads only replay events after the snapshot point. Configure this strategically:
// In config/event-sourcing.php
'snapshot_threshold' => 75, // Take snapshot every 75 events per aggregate
// Or per-aggregate override
class OrderAggregate extends AggregateRoot
{
protected int $snapshotThreshold = 50; // High-churn aggregates need lower threshold
} In my experience maintaining legal document workflows, setting thresholds too aggressively (e.g., every 10 events) bloats the snapshots table and slows writes. Start at 75–100 and monitor replay times in production logs. Use php artisan event-sourcing:replay --from=2026-01-01 during off-peak hours to rebuild snapshots after schema changes.
For read-heavy dashboards, denormalize aggressively. A common mistake is querying the event store directly for reporting. Instead, create dedicated projector tables with pre-computed aggregates. On one eCommerce project handling NPR 2M+ monthly transactions, we maintained separate daily_sales_summaries and customer_lifetime_values tables updated by projectors. Queries dropped from 8 seconds to under 50ms. Remember: event stores are write-optimized append logs, not analytical databases.
Indexing strategy matters enormously. Ensure composite indexes exist on (aggregate_uuid, created_at) for replay operations and (event_name, created_at) for projector filtering. On PostgreSQL, partial indexes on active aggregates reduce index size by 40–60% compared to MySQL. Monitor slow query logs weekly; event sourcing performance issues surface gradually as data grows.
What are the real-world trade-offs compared to traditional Eloquent models?
Event sourcing is not free. Understanding the costs prevents regrettable architectural decisions. Here is an honest comparison based on production deployments:
| Criteria | Traditional Eloquent | Event Sourcing (Spatie) |
|---|---|---|
| Development speed | Fast initial CRUD implementation | Slower setup; steeper learning curve |
| Audit capability | Requires separate audit tables/triggers | Built-in complete history by design |
| Query complexity | Simple SELECT on current state | Requires read models; no direct ad-hoc queries on events |
| Data volume | Rows stay constant; old data archived | Events grow monotonically; needs retention/snapshot strategy |
| Debugging | Log files + DB state inspection | Time-travel replay; exact state reconstruction |
| Schema evolution | Migrations alter existing rows | Event versioning required; old events immutable |
| Team onboarding | Familiar Laravel patterns | Requires understanding CQRS/event concepts |
| Best fit | Content sites, simple CRUD, prototypes | Financial, legal, compliance, complex workflows |
The biggest hidden cost is cognitive load. Junior developers struggle with eventual consistency and the indirection of projectors. On teams without prior event sourcing experience, budget 2–3 weeks for knowledge transfer and pair programming. Also consider hosting: event stores grow faster than normalized tables. A system processing 1,000 orders/day generates ~5,000–15,000 events daily. At Rs 8,000–15,000/month (~USD 60–110) for managed PostgreSQL in Nepal, storage costs add up within 18–24 months. Plan archival strategies early.
Implementing event sourcing with Laravel Spatie package responsibly
Event sourcing with Laravel Spatie package delivers unmatched transparency and temporal querying for domains where history is non-negotiable. Start small: apply it to a bounded context like payments or document approvals within a larger Laravel application, not the entire system. Invest time in proper event versioning, projector idempotency, and snapshot tuning before scaling. Monitor storage growth and replay performance proactively. If your team lacks event sourcing experience, allocate mentorship time or consider hybrid approaches where only critical aggregates use this pattern. For guidance on integrating this with broader API design, review Laravel API best practices to ensure your read models serve consumers efficiently. When the domain justifies the investment, event sourcing transforms debugging, compliance, and business intelligence from afterthoughts into architectural guarantees. Ready to evaluate whether your next project warrants this approach? Discuss your architecture requirements to make an informed decision grounded in production reality.

