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.
Altri articoli recenti
Laravel AI 1.1 e 1.2: Agent Skills, Cohere e structured output più solidi
Le release 1.1.0 e 1.2.0 di laravel/ai portano gli Agent Skills, l'approvazione obbligatoria per i tool built-in e nuovi provider come Cohere e Laya self-hosted. Sul fronte qualità arrivano i fix allo structured output per Bedrock e il supporto alle provider options. Aggiornati anche i modelli di default, con Claude 4.7+ che ignora i sampling params.
Laravel 13.35.0: scheduler più controllabile, code più flessibili e response Markdown
Laravel 13.35.0 porta Schedule::alwaysOnOneServer(), il supporto a schedule:interrupt per i comandi di lunga durata, --memory in percentuale per queue:work e il nuovo metodo markdown() sulla response factory. Tante piccole migliorie anche a test, view compilate in parallelo e dischi local.
BashBox: un interprete Bash in sandbox per agenti AI in PHP
BashBox è un interprete bash scritto in puro PHP che permette di eseguire gli script generati dagli agenti AI senza mai ricorrere a proc_open, exec o shell_exec. Ogni comando è una classe PHP che lavora su un filesystem virtuale, con limiti di esecuzione, quote disco e accesso di rete configurabili. Ideale per chi sviluppa agenti con il Laravel AI SDK o altre librerie simili.