Event sourcing en Laravel: cómo guardar el historial completo de cambios

Si alguna vez has necesitado saber cómo llegó un registro a tener ese valor, el CRUD clásico no te ayuda mucho. Guardas el estado actual y listo: el historial desaparece con cada UPDATE. Event sourcing resuelve eso de raíz porque, en lugar de guardar el estado, guardas cada cosa que ocurre. El estado actual es simplemente la consecuencia de reproducir todos esos eventos desde el principio.

CRUD vs Event Sourcing: la diferencia real

Con CRUD, una tabla de pedidos tiene una columna status que va cambiando: pending, shipped, cancelled. Sabes dónde está el pedido ahora, pero no cuándo cambió ni por qué. Para eso hace falta una tabla de auditoría aparte, que siempre acaba quedándose desactualizada.

Con event sourcing no hay UPDATE. Cada acción genera un evento que se guarda de forma permanente: OrderPlaced, OrderShipped, OrderCancelled. Para saber el estado actual del pedido, reproduces los eventos en orden. La analogía más clara es la de un saldo bancario: el banco no guarda solo tu saldo actual, guarda cada movimiento, y el saldo es la suma de todos ellos.

¿Cuándo tiene sentido meterse en esto? Básicamente cuando necesitas auditoría completa, cuando quieres poder deshacer operaciones pasadas o cuando necesitas reproducir bugs en producción con el estado exacto que tenía el sistema en ese momento.

Spatie Laravel Event Sourcing

El paquete que usa casi todo el mundo para esto en Laravel es spatie/laravel-event-sourcing. La instalación es la de siempre:

composer require spatie/laravel-event-sourcing
php artisan vendor:publish --provider="SpatieEventSourcingEventSourcingServiceProvider"
php artisan migrate

El migrate crea la tabla stored_events, que es donde van a parar todos los eventos de tu app. El paquete gira en torno a tres conceptos: el AggregateRoot, los StorableEvent y los Projector.

Definir eventos

Un evento es una clase PHP simple. Solo necesita implementar StorableEvent o extender ShouldBeStored. Lo importante es que los eventos son inmutables: una vez guardados, no se tocan.

use SpatieEventSourcingStoredEventsShouldBeStored;

class OrderPlaced extends ShouldBeStored
{
    public function __construct(
        public readonly string $orderId,
        public readonly array $items,
        public readonly float $total,
    ) {}
}

class OrderShipped extends ShouldBeStored
{
    public function __construct(
        public readonly string $orderId,
        public readonly string $trackingCode,
    ) {}
}

class OrderCancelled extends ShouldBeStored
{
    public function __construct(
        public readonly string $orderId,
        public readonly string $reason,
    ) {}
}

No hay lógica aquí. Los eventos son datos puros: qué pasó, cuándo y con qué información.

AggregateRoot: el núcleo del modelo

El AggregateRoot es la clase que agrupa todos los eventos relacionados con una entidad concreta, en este caso un pedido. Es donde vive la lógica de negocio.

use SpatieEventSourcingAggregateRootsAggregateRoot;

class OrderAggregate extends AggregateRoot
{
    private string $status = 'pending';
    private array $items = [];

    public function place(array $items, float $total): static
    {
        $this->recordThat(new OrderPlaced(
            orderId: $this->uuid(),
            items: $items,
            total: $total,
        ));

        return $this;
    }

    public function ship(string $trackingCode): static
    {
        if ($this->status !== 'pending') {
            throw new Exception('El pedido no está en estado pendiente.');
        }

        $this->recordThat(new OrderShipped(
            orderId: $this->uuid(),
            trackingCode: $trackingCode,
        ));

        return $this;
    }

    public function cancel(string $reason): static
    {
        $this->recordThat(new OrderCancelled(
            orderId: $this->uuid(),
            reason: $reason,
        ));

        return $this;
    }

    public function applyOrderPlaced(OrderPlaced $event): void
    {
        $this->items = $event->items;
        $this->status = 'pending';
    }

    public function applyOrderShipped(OrderShipped $event): void
    {
        $this->status = 'shipped';
    }

    public function applyOrderCancelled(OrderCancelled $event): void
    {
        $this->status = 'cancelled';
    }
}

Los métodos apply* son los que modifican el estado interno del aggregate. Cada vez que recuperas un aggregate de la base de datos, el paquete reproduce todos sus eventos llamando a estos métodos en orden, y así reconstruye el estado actual sin necesidad de tener una columna status en ninguna tabla.

Para usarlo desde un controlador o un job:

// Crear un pedido nuevo
$orderId = (string) Str::uuid();
OrderAggregate::retrieve($orderId)
    ->place($items, $total)
    ->persist();

// Marcar como enviado
OrderAggregate::retrieve($orderId)
    ->ship('TRACK-12345')
    ->persist();

Proyectores: las tablas de lectura

Reconstruir el aggregate cada vez que quieres mostrar una lista de pedidos no es viable. Para eso existen los proyectores: escuchan los eventos y actualizan tablas normales de MySQL, que sí puedes consultar con queries rápidas.

use SpatieEventSourcingEventHandlersProjectorsProjector;

class OrderProjector extends Projector
{
    public function onOrderPlaced(OrderPlaced $event): void
    {
        DB::table('order_projections')->insert([
            'id'         => $event->orderId,
            'status'     => 'pending',
            'total'      => $event->total,
            'created_at' => now(),
        ]);
    }

    public function onOrderShipped(OrderShipped $event): void
    {
        DB::table('order_projections')
            ->where('id', $event->orderId)
            ->update([
                'status'       => 'shipped',
                'tracking_code' => $event->trackingCode,
            ]);
    }

    public function onOrderCancelled(OrderCancelled $event): void
    {
        DB::table('order_projections')
            ->where('id', $event->orderId)
            ->update(['status' => 'cancelled']);
    }
}

La tabla order_projections es tu tabla de lectura: la que usas para listar pedidos, filtrar por estado o mostrar datos en el panel. Si en algún momento cambias la lógica del proyector y necesitas recalcularlo todo desde cero, puedes hacerlo sin tocar los eventos originales:

php artisan event-sourcing:replay App\Projectors\OrderProjector

Esto borra la proyección y la reconstruye de nuevo procesando todos los eventos almacenados. Es una de las ventajas más concretas de este enfoque: puedes cambiar cómo interpretas los datos históricos sin perder ningún dato.

Reactors: efectos secundarios

Los reactores son como los proyectores pero para acciones con efectos fuera del sistema: enviar un email, lanzar una notificación push o llamar a una API externa.

use SpatieEventSourcingEventHandlersReactorsReactor;

class OrderReactor extends Reactor
{
    public function onOrderPlaced(OrderPlaced $event): void
    {
        Mail::to($event->customerEmail)->send(new OrderConfirmation($event->orderId));
    }

    public function onOrderShipped(OrderShipped $event): void
    {
        Notification::send(
            User::find($event->userId),
            new OrderShippedNotification($event->trackingCode)
        );
    }
}

La diferencia clave con los proyectores es que los reactores no se pueden reejecutar con event-sourcing:replay. Si lo hicieras, enviarías el email de confirmación otra vez a todos tus clientes. Aquí la idempotencia es tu responsabilidad: comprueba antes de actuar si la acción ya se realizó.

Event store y snapshots

Todos los eventos van a la tabla stored_events con su tipo, su UUID de aggregate, sus datos en JSON y una marca de tiempo. Es la única fuente de verdad del sistema.

El problema llega cuando un aggregate tiene miles de eventos. Cada vez que lo recuperas, el paquete tiene que reproducirlos todos para reconstruir el estado actual. Con un pedido con 10 eventos no hay problema, pero imagina un aggregate de cuenta bancaria con 50.000 movimientos.

Los snapshots solucionan eso: guardas el estado del aggregate en un momento dado y, a partir de ahí, solo reproduces los eventos posteriores al snapshot.

// Guardar un snapshot del aggregate
$aggregate = OrderAggregate::retrieve($orderId);
$aggregate->snapshot();

// Al recuperarlo, el paquete carga el snapshot más reciente
// y solo reproduce los eventos posteriores
$aggregate = OrderAggregate::retrieve($orderId);

Esto es algo que suele configurarse de forma periódica con un comando artisan o un job programado. Para la mayoría de aplicaciones no hace falta desde el primer día, pero conviene saber que existe.

Cuándo no usar event sourcing

Event sourcing añade complejidad. Si tu app es un CRUD sin necesidad de historial de cambios, estás introduciendo capas que no aportan nada. Un blog, un catálogo de productos o un panel de administración simple no lo necesitan.

Tampoco lo recomiendo para equipos que se enfrentan a este patrón por primera vez sin alguien que lo haya implementado antes. La curva de aprendizaje es real: hay que pensar de otra manera desde el diseño de la base de datos hasta cómo se leen los datos. El coste de hacerlo mal es alto, porque migrar una event store mal diseñada es complicado.

Tiene todo el sentido cuando la auditoría es un requisito legal o de negocio, cuando necesitas poder revertir operaciones pasadas o cuando el análisis del comportamiento histórico es parte del producto. En esos casos, la complejidad adicional se paga sola.

Más sobre arquitectura en Laravel

Si te interesa profundizar en patrones de arquitectura más avanzados, puedes leer sobre arquitectura avanzada en Laravel para ver cómo estructurar proyectos que escalan. Y si necesitas visualizar los datos de tu event store, FilamentPHP para visualizar datos de event sourcing encaja muy bien con este tipo de proyectos.

Imagen: Pexels / luis gomes

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP