The Destructive Nature of CRUD
The vast majority of web applications are built on the CRUD paradigm (Create, Read, Update, Delete). If a user updates their shipping address, you run an UPDATE SQL query on the users table. The old address is overwritten, permanently destroyed, and replaced by the new address.
For a simple blog or a basic e-commerce site, this is perfectly fine. However, in enterprise financial systems, legal tech, or healthcare applications, destroying data is an architectural sin. If a bank account balance is updated from $500 to $200, the bank doesn't just overwrite the number. They need an exact, mathematically provable record of why it changed (e.g., a $300 withdrawal). If you only store current state, you lose context, intent, and auditability. You can never answer the question: "What did this record look like on Tuesday at 4:00 PM?"
At Smart Tech Devs, we architect systems that require absolute cryptographic-level auditability. We abandon CRUD entirely and implement Event Sourcing. In Event Sourcing, the database does not store the "current state" of an object. Instead, it stores a continuous, append-only log of every single event that has ever happened to that object. The current state is calculated dynamically by replaying that history.
The Philosophy of the Append-Only Log
In an Event Sourced system, an Event is a factual record of something that happened in the past (e.g., AccountCreated, FundsDeposited, FundsWithdrawn). Events are immutable; once written to the database, they can never be updated or deleted. If you make a mistake, you don't delete the event; you append a new TransactionReversed event.
Phase 1: Architecting the Event Store
Instead of dozens of tables for users, accounts, and transactions, the core of our system relies on a single, massive stored_events table. We create a migration for this append-only log.
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']);
});
}
};
Phase 2: The Aggregate Root
An "Aggregate" is the business object (like a Bank Account). In standard Laravel, this would be an Eloquent model. In Event Sourcing, it is a plain PHP class that calculates its own state by reading its history.
Notice that this class has no save() method connecting it to a traditional database table. It only has properties and apply() methods.
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);
}
}
Phase 3: The Event Repository and Dispatcher
When a user initiates an action via an HTTP Controller, we must load the Aggregate, execute the business logic to generate a new Event, and append that Event to our database log.
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']);
}
}
The Engineering ROI: Time Travel and Auditing
The architectural benefits of Event Sourcing are staggering. First, you achieve absolute, unforgeable auditability; you have a perfect log of every system mutation. Second, you unlock Temporal Querying (Time Travel). If a customer disputes a fee from six months ago, you simply load the Aggregate but tell the system to stop replaying events when the created_at timestamp hits that exact date. You can physically reconstruct the exact state of the software at any millisecond in history.
While Event Sourcing introduces complexity (specifically requiring CQRS to efficiently query lists of data without replaying millions of events), it is the only viable architectural pattern for mission-critical enterprise systems where data integrity and historical accuracy are non-negotiable.
Top comments (0)