notiziephp.it

Oltre il CRUD: Architettura Event Sourcing in Laravel 📜

Traduzione da PHP Weekly
18 Settembre 2026 Tempo di lettura: 5 minuti
Oltre il CRUD: Architettura Event Sourcing in Laravel 📜

La natura distruttiva del CRUD

La stragrande maggioranza delle applicazioni web si basa sul paradigma CRUD (Create, Read, Update, Delete). Se un utente aggiorna il proprio indirizzo di spedizione, viene eseguita una query SQL UPDATE sulla tabella users. Il vecchio indirizzo viene sovrascritto, distrutto per sempre e sostituito dal nuovo indirizzo.

Per un semplice blog o un sito e-commerce di base va benissimo. Tuttavia, nei sistemi finanziari enterprise, nel legal tech o nelle applicazioni sanitarie, distruggere i dati è un peccato architetturale. Se il saldo di un conto bancario viene aggiornato da 500 $ a 200 $, la banca non si limita a sovrascrivere il numero. Ha bisogno di un registro esatto e matematicamente dimostrabile del perché è cambiato (ad esempio, un prelievo di 300 $). Se memorizzi soltanto lo stato attuale, perdi contesto, intento e verificabilità. Non potrai mai rispondere alla domanda: “Come appariva questo record martedì alle 16:00?”

In Smart Tech Devs progettiamo sistemi che richiedono una verificabilità assoluta, a livello crittografico. Abbandoniamo del tutto il CRUD e implementiamo l'Event Sourcing. Nell'Event Sourcing il database non memorizza lo “stato attuale” di un oggetto. Memorizza invece un log continuo, di tipo append-only, di ogni singolo evento che sia mai accaduto a quell'oggetto. Lo stato attuale viene calcolato dinamicamente riproducendo quella cronologia.

La filosofia del log append-only

In un sistema Event Sourced, un Evento è un registro fattuale di qualcosa che è accaduto nel passato (ad esempio AccountCreated, FundsDeposited, FundsWithdrawn). Gli eventi sono immutabili; una volta scritti nel database, non possono mai essere aggiornati né eliminati. Se commetti un errore, non elimini l'evento: aggiungi un nuovo evento TransactionReversed.

Fase 1: progettare l'Event Store

Invece di dozzine di tabelle per utenti, conti e transazioni, il cuore del nostro sistema si basa su un'unica, enorme tabella stored_events. Creiamo una migration per questo log append-only.

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('stored_events', function (Blueprint $table) {
            $table->id();
            // The UUID of the specific entity (e.g., the Bank Account ID)
            $table->uuid('aggregate_uuid')->index();
            // The class name of the event
            $table->string('event_class');
            // The JSON payload of what actually happened
            $table->json('event_properties');
            // Ensures events are replayed in the exact order they occurred
            $table->integer('aggregate_version');
            $table->timestamp('created_at')->useCurrent();

            // Prevent two events from having the same version for the same entity
            $table->unique(['aggregate_uuid', 'aggregate_version']);
        });
    }
};

Fase 2: l'Aggregate Root

Un “Aggregate” è l'oggetto di dominio (come un Bank Account). Nel Laravel standard sarebbe un modello Eloquent. Nell'Event Sourcing è una classe PHP pura che calcola il proprio stato leggendo la propria cronologia.

Nota che questa classe non ha alcun metodo save() che la colleghi a una tabella tradizionale del database. Ha solo proprietà e metodi apply().

namespace App\Domain\Banking;
use App\Events\AccountCreated;
use App\Events\FundsDeposited;
use App\Events\FundsWithdrawn;
use Exception;
class BankAccountAggregate
{
    public string $uuid;
    public int $balance = 0;
    public int $version = 0;
    /**
     * Reconstruct the current state by replaying all historical events.
     */
    public static function retrieve(string $uuid, array $historicalEvents): self
    {
        $account = new self();
        $account->uuid = $uuid;
        foreach ($historicalEvents as $event) {
            $account->apply($event);
            $account->version++;
        }
        return $account;
    }
    /**
     * Route the event to the correct state-mutation logic.
     */
    private function apply(object $event): void
    {
        match (get_class($event)) {
            AccountCreated::class => $this->balance = 0,
            FundsDeposited::class => $this->balance += $event->amount,
            FundsWithdrawn::class => $this->balance -= $event->amount,
        };
    }
    /**
     * Business Logic: Can we withdraw?
     */
    public function withdraw(int $amount): FundsWithdrawn
    {
        if ($this->balance < $amount) {
            throw new Exception("Insufficient funds. Current balance is {$this->balance}.");
        }
        // Return the FACT that funds were withdrawn.
        // We do not mutate state here; state is only mutated when the event is applied.
        return new FundsWithdrawn($this->uuid, $amount);
    }
}

Fase 3: l'Event Repository e il Dispatcher

Quando un utente avvia un'azione tramite un Controller HTTP, dobbiamo caricare l'Aggregate, eseguire la logica di business per generare un nuovo Evento e aggiungere quell'Evento al nostro log su database.

namespace App\Http\Controllers;
use App\Domain\Banking\BankAccountAggregate;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class WithdrawalController extends Controller
{
    public function store(Request $request, string $accountUuid)
    {
        DB::transaction(function () use ($request, $accountUuid) {
            // 1. Fetch all historical events for this specific account
            $rawEvents = DB::table('stored_events')
                ->where('aggregate_uuid', $accountUuid)
                ->orderBy('aggregate_version', 'asc')
                ->get();
            // 2. Deserialize JSON back into PHP Event Objects
            $events = $rawEvents->map(fn($row) => new $row->event_class(json_decode($row->event_properties, true)));
            // 3. Replay history to get the CURRENT state
            $account = BankAccountAggregate::retrieve($accountUuid, $events->toArray());
            // 4. Attempt the business action
            // This will throw an exception if the calculated balance is too low
            $newEvent = $account->withdraw($request->amount);
            // 5. Append the new event to the immutable log
            DB::table('stored_events')->insert([
                'aggregate_uuid' => $accountUuid,
                'event_class' => get_class($newEvent),
                'event_properties' => json_encode($newEvent->toArray()),
                'aggregate_version' => $account->version + 1,
            ]);
            // 6. (Optional) Dispatch a Laravel event to update CQRS Read Projections
            event($newEvent);
        });
        return response()->json(['message' => 'Withdrawal successful']);
    }
}

Il ROI ingegneristico: viaggi nel tempo e auditing

I vantaggi architetturali dell'Event Sourcing sono impressionanti. Primo, ottieni una verificabilità assoluta e non falsificabile: hai un log perfetto di ogni mutazione del sistema. Secondo, sblocchi la Query Temporale (Time Travel). Se un cliente contesta una commissione di sei mesi fa, ti basta caricare l'Aggregate dicendo al sistema di interrompere la riproduzione degli eventi quando il timestamp created_at raggiunge quella data esatta. Puoi ricostruire fisicamente lo stato esatto del software in qualsiasi millisecondo della storia.

Sebbene l'Event Sourcing introduca complessità (in particolare richiedendo CQRS per interrogare in modo efficiente elenchi di dati senza riprodurre milioni di eventi), è l'unico pattern architetturale praticabile per i sistemi enterprise mission-critical, dove l'integrità dei dati e l'accuratezza storica non sono negoziabili.

Condividi su: LinkedIn

Altri articoli recenti