Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Event Sourcing with Laravel Spatie Package

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.

Traditional CRUDUPDATE orders SET status='paid'Previous state lost foreverUPDATE orders SET status='shipped'No audit trail, no rollbackEvent SourcingOrderCreated {id: 101}OrderPaid {id: 101, amt: 5000}OrderShipped {id: 101, track: X}Full history • Replayable • Auditable
Traditional CRUD overwrites state destructively while event sourcing with Laravel Spatie package preserves every change as an immutable event for full auditability.

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\JsonSerializer with 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 StoredEvent model uses MySQL/MariaDB. For high-volume systems exceeding 10M events, consider configuring PostgreSQL with proper indexing on aggregate_uuid and created_at. MongoDB is supported via separate adapter packages but adds operational overhead.
  • Snapshot threshold: Set snapshot_threshold to 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.

CommandmarkAsPaid()Aggregate RootValidate invariantsRecord eventUpdate internal stateEvent StoreImmutable eventsstored_events tableProjectorsRebuild read modelsGenerate reportsTrigger side effects(Idempotent & async)
Event sourcing pipeline: commands validate through aggregates, persist immutable events, then projectors asynchronously build optimized read models for queries.

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:

CriteriaTraditional EloquentEvent Sourcing (Spatie)
Development speedFast initial CRUD implementationSlower setup; steeper learning curve
Audit capabilityRequires separate audit tables/triggersBuilt-in complete history by design
Query complexitySimple SELECT on current stateRequires read models; no direct ad-hoc queries on events
Data volumeRows stay constant; old data archivedEvents grow monotonically; needs retention/snapshot strategy
DebuggingLog files + DB state inspectionTime-travel replay; exact state reconstruction
Schema evolutionMigrations alter existing rowsEvent versioning required; old events immutable
Team onboardingFamiliar Laravel patternsRequires understanding CQRS/event concepts
Best fitContent sites, simple CRUD, prototypesFinancial, 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.

Start: New Feature?Is full audit/history required?NoYesUse Eloquent CRUDComplex business rules?NoYesAudit Log Table OnlyEvent Sourcing ✓Event sourcing adds complexity — justify with concrete compliance, debugging, or temporal query needs
Decision framework: choose event sourcing with Laravel Spatie package only when audit requirements combine with complex domain logic; otherwise prefer simpler Eloquent or audit logs.

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.

Frequently Asked Questions

Event sourcing stores every state change as an immutable event rather than overwriting database rows, enabling full audit trails and temporal queries.

Yes, version 7.x supports Laravel 11 and 12 with PHP 8.2+, used in production financial and legal systems requiring strict auditability.

Custom implementation typically costs NPR 300,000–800,000 (USD 2,200–5,900) depending on aggregate complexity and projection requirements.

Use event sourcing when you need complete audit trails, temporal state reconstruction, or complex business workflows where understanding why data changed matters more than current state alone. In my experience building legal-tech portals like Mijar Law Associates, this pattern proves essential for compliance and dispute resolution. Avoid it for simple content sites or catalogs where standard Eloquent models suffice, as the added complexity increases development time and debugging difficulty significantly.

Install via Composer using version 7.x for Laravel 12 compatibility. Publish the config file and run migrations to create the stored_events table. Configure your event store driver, typically MySQL or PostgreSQL for Nepal-based deployments. Set up at least one projector and reactor in the config. I always recommend starting with synchronous projections during development, then switching to queued projections in production once you understand the event flow and can monitor queue health properly.

Projectors build read models from events, creating queryable tables optimized for specific views. Reactors handle side effects like sending emails or triggering external APIs when specific events occur. Projectors must be idempotent and replayable; reactors execute once per event and cannot be safely replayed without duplicate side effects. On a client project handling payment confirmations, I separated balance calculations into projectors while keeping notification logic in reactors to prevent duplicate SMS messages during event replays.

Yes, this hybrid approach is common and practical. Keep event-sourced aggregates for core domain logic requiring audit trails while using standard Eloquent for supporting data like user preferences or cached lookups. The spatie package integrates cleanly alongside regular models. I have used this pattern on eCommerce platforms where order processing requires strict event history but product catalog management remains traditional CRUD. Define clear boundaries between event-sourced and non-event-sourced domains to avoid confusion.

Never modify past events directly. Create new event versions with updated payloads and maintain backward compatibility in projectors through version checking or transformation middleware. For breaking changes, write migration scripts that replay old events through transformers before storing new versions. On a legal document system I maintained, we introduced document type enums after launch by adding a v2 event class and updating projectors to handle both formats. This preserves historical accuracy while allowing evolution.

Stored events tables grow indefinitely, causing slow replays and large backups. Index stored_events on aggregate_uuid and created_at for efficient filtering. Partition tables by year or month in PostgreSQL, or archive old events to cold storage after projections are stable. Monitor replay times during deployment; if rebuilding a projector takes over five minutes, optimize queries or add intermediate snapshots. In production Laravel applications, I schedule weekly cleanup jobs and use Redis caching for frequently accessed read models to reduce database load.

Test aggregates by asserting expected events are recorded given specific commands, not by checking final state. Use the package's AggregateRootTestCase for unit testing aggregate behavior in isolation. Test projectors by feeding known event sequences and verifying resulting read model state. Avoid integration tests that depend on full replay during CI; they are too slow. On real client projects, I maintain separate test suites for aggregates, projectors, and end-to-end flows, running only aggregate tests on every commit and full replay tests nightly.

Over-engineering simple domains, neglecting projector idempotency, and failing to plan for event versioning cause most failures. Teams often underestimate replay time and backup size. Another frequent mistake is putting side effects inside projectors instead of reactors, breaking replay safety. Start small with a single bounded context rather than converting an entire application. Validate that your team understands eventual consistency before committing. I have seen projects stall because developers treated event sourcing as a universal solution rather than a specialized tool for specific problems.

Event sourcing itself does not impact SEO, but poorly optimized read models can slow page loads and hurt Core Web Vitals. Ensure projectors generate denormalized tables with proper indexes for frontend queries. Cache rendered pages or API responses serving public content. On content-heavy sites I have built, precomputed read models served from dedicated tables outperform joins across normalized event stores. Monitor Largest Contentful Paint after introducing event sourcing; if metrics degrade, add materialized views or adjust projection strategies to serve SEO-critical pages efficiently.

Yes, queued projections are recommended for production to avoid blocking HTTP requests during event handling. Configure separate queues for different projector priorities using Laravel Horizon for monitoring. High-priority projections like balance updates should use dedicated workers with retry limits, while low-priority analytics projections can tolerate delays. Handle failed jobs carefully; a failing projector can leave read models inconsistent. On a booking system I deployed, we used three priority queues with dead-letter monitoring, ensuring critical availability calendars updated within seconds while reporting tables refreshed hourly.

Events are immutable, making GDPR right-to-erasure requests challenging since you cannot delete individual records. Implement crypto-shredding by encrypting sensitive fields with per-aggregate keys and deleting keys upon erasure requests. Store PII separately from domain events when possible. Audit access to stored_events tables strictly. On legal-tech portals handling client data, I encrypt personal identifiers at rest and maintain separate key management, allowing compliant deletion without corrupting event streams. Always consult legal requirements before adopting event sourcing for regulated data.

Prooph offers more advanced CQRS features but has steeper learning curves and less active maintenance. Patchlevel/event-sourcing provides modern PHP 8.4 support with attribute-based configuration. Building custom event stores with plain Eloquent works for simple cases but lacks replay infrastructure. For Nepal-based teams already familiar with Laravel conventions, spatie remains the pragmatic choice due to documentation quality and community support. Evaluate based on your team's expertise and long-term maintenance capacity rather than feature lists alone; simpler tools often win in small-team environments.

Share this article

Quick Contact Options
Choose how you want to connect me: