Laravel nâng cao

Laravel Event Sourcing: Khi nào nên dùng và cách implement

Event Sourcing không phải cho mọi project. Đây là hướng dẫn thực tế khi nào nên dùng Event Sourcing trong Laravel và cách implement đúng cách.

newspaper

Phạm Hoàng Long

5 tháng 1, 2026 schedule 6 phút đọc
Laravel Event Sourcing: Khi nào nên dùng và cách implement
Featured Image

Tháng 6 năm ngoái, tôi join một dự án fintech. Requirement đầu tiên: “Mọi transaction phải có full audit trail. Phải biết ai làm gì, khi nào, tại sao.”

Team lead gợi ý: “Thử Event Sourcing?”

Sau 3 tháng implement, tôi hiểu tại sao Event Sourcing vừa mạnh mẽ, vừa phức tạp.

Đây là những gì tôi học được.

Event Sourcing là gì?

Traditional approach (CRUD):

// Lưu state hiện tại
$account = Account::find(1);
$account->balance = 1000;
$account->save();

// Mất hết lịch sử: Không biết balance trước đó là bao nhiêu

Event Sourcing approach:

// Lưu events (những gì đã xảy ra)
AccountCreated::dispatch($accountId, $initialBalance);
MoneyDeposited::dispatch($accountId, 500);
MoneyWithdrawn::dispatch($accountId, 200);

// Rebuild state từ events
$balance = 0;
foreach ($events as $event) {
    if ($event instanceof MoneyDeposited) {
        $balance += $event->amount;
    } elseif ($event instanceof MoneyWithdrawn) {
        $balance -= $event->amount;
    }
}
// $balance = 300

Key difference: Thay vì lưu “state hiện tại”, lưu “history of changes”.

Khi nào NÊN dùng Event Sourcing?

Use Case 1: Banking/Finance

Requirement: Mọi transaction phải traceable, auditable, không được sửa/xóa.

// Events
AccountOpened::class
MoneyDeposited::class
MoneyWithdrawn::class
InterestAccrued::class
AccountClosed::class

// Có thể answer những câu hỏi:
// - Balance vào ngày 15/12/2025 là bao nhiêu?
// - Ai đã withdraw 1000$ lúc 3h sáng?
// - Tại sao balance âm?

Use Case 2: E-commerce Order Flow

Requirement: Track mọi state change của order.

// Events
OrderPlaced::class
PaymentReceived::class
OrderShipped::class
OrderDelivered::class
OrderCancelled::class
RefundIssued::class

// Có thể rebuild order state tại bất kỳ thời điểm nào

Use Case 3: Collaborative Editing

Requirement: Nhiều users edit cùng document, cần conflict resolution.

// Events
DocumentCreated::class
TextInserted::class
TextDeleted::class
TextFormatted::class

// Có thể replay events để resolve conflicts

Khi nào KHÔNG NÊN dùng Event Sourcing?

Không dùng khi:

  1. CRUD đơn giản (Blog, CMS)

    • Overhead quá lớn so với benefit
  2. Reporting/Analytics chính là use case

    • Event Sourcing làm queries phức tạp
    • Nên dùng traditional DB + materialized views
  3. Team chưa có kinh nghiệm

    • Learning curve dốc
    • Dễ implement sai
  4. Performance là ưu tiên số 1

    • Rebuild state từ events chậm hơn query DB

Implementation trong Laravel

Bước 1: Setup Event Store

php artisan make:migration create_events_table
Schema::create('events', function (Blueprint $table) {
    $table->id();
    $table->uuid('aggregate_id'); // ID của entity (account, order, etc.)
    $table->string('aggregate_type'); // Account, Order, etc.
    $table->string('event_type'); // MoneyDeposited, OrderPlaced, etc.
    $table->json('payload'); // Event data
    $table->json('metadata')->nullable(); // User ID, IP, timestamp, etc.
    $table->unsignedBigInteger('version'); // Version của aggregate
    $table->timestamp('occurred_at');
    
    $table->index(['aggregate_id', 'version']);
    $table->unique(['aggregate_id', 'version']);
});

Bước 2: Tạo Events

// app/Events/MoneyDeposited.php
namespace App\Events;

class MoneyDeposited
{
    public function __construct(
        public string $accountId,
        public int $amount,
        public string $description,
        public \DateTimeImmutable $occurredAt
    ) {}
    
    public function toArray(): array
    {
        return [
            'account_id' => $this->accountId,
            'amount' => $this->amount,
            'description' => $this->description,
            'occurred_at' => $this->occurredAt->format('Y-m-d H:i:s'),
        ];
    }
    
    public static function fromArray(array $data): self
    {
        return new self(
            $data['account_id'],
            $data['amount'],
            $data['description'],
            new \DateTimeImmutable($data['occurred_at'])
        );
    }
}

Bước 3: Event Store Repository

// app/Repositories/EventStore.php
namespace App\Repositories;

use App\Models\Event;
use Illuminate\Support\Collection;

class EventStore
{
    public function append(string $aggregateId, string $aggregateType, object $event, int $expectedVersion): void
    {
        $version = $this->getVersion($aggregateId) + 1;
        
        if ($version !== $expectedVersion) {
            throw new ConcurrencyException("Version mismatch");
        }
        
        Event::create([
            'aggregate_id' => $aggregateId,
            'aggregate_type' => $aggregateType,
            'event_type' => get_class($event),
            'payload' => $event->toArray(),
            'metadata' => [
                'user_id' => auth()->id(),
                'ip' => request()->ip(),
            ],
            'version' => $version,
            'occurred_at' => now(),
        ]);
    }
    
    public function getEvents(string $aggregateId): Collection
    {
        return Event::where('aggregate_id', $aggregateId)
            ->orderBy('version')
            ->get()
            ->map(function ($event) {
                $class = $event->event_type;
                return $class::fromArray($event->payload);
            });
    }
    
    private function getVersion(string $aggregateId): int
    {
        return Event::where('aggregate_id', $aggregateId)->max('version') ?? 0;
    }
}

Bước 4: Aggregate Root

// app/Aggregates/Account.php
namespace App\Aggregates;

class Account
{
    private string $id;
    private int $balance = 0;
    private int $version = 0;
    private array $uncommittedEvents = [];
    
    public static function open(string $id, int $initialBalance): self
    {
        $account = new self();
        $account->recordThat(new AccountOpened($id, $initialBalance, now()));
        return $account;
    }
    
    public function deposit(int $amount, string $description): void
    {
        if ($amount <= 0) {
            throw new \InvalidArgumentException("Amount must be positive");
        }
        
        $this->recordThat(new MoneyDeposited($this->id, $amount, $description, now()));
    }
    
    public function withdraw(int $amount, string $description): void
    {
        if ($amount <= 0) {
            throw new \InvalidArgumentException("Amount must be positive");
        }
        
        if ($this->balance < $amount) {
            throw new InsufficientFundsException();
        }
        
        $this->recordThat(new MoneyWithdrawn($this->id, $amount, $description, now()));
    }
    
    private function recordThat(object $event): void
    {
        $this->apply($event);
        $this->uncommittedEvents[] = $event;
    }
    
    private function apply(object $event): void
    {
        match (get_class($event)) {
            AccountOpened::class => $this->applyAccountOpened($event),
            MoneyDeposited::class => $this->applyMoneyDeposited($event),
            MoneyWithdrawn::class => $this->applyMoneyWithdrawn($event),
        };
        $this->version++;
    }
    
    private function applyAccountOpened(AccountOpened $event): void
    {
        $this->id = $event->accountId;
        $this->balance = $event->initialBalance;
    }
    
    private function applyMoneyDeposited(MoneyDeposited $event): void
    {
        $this->balance += $event->amount;
    }
    
    private function applyMoneyWithdrawn(MoneyWithdrawn $event): void
    {
        $this->balance -= $event->amount;
    }
    
    public function getUncommittedEvents(): array
    {
        return $this->uncommittedEvents;
    }
    
    public function getVersion(): int
    {
        return $this->version;
    }
    
    public function getBalance(): int
    {
        return $this->balance;
    }
    
    public static function reconstituteFrom(Collection $events): self
    {
        $account = new self();
        foreach ($events as $event) {
            $account->apply($event);
        }
        return $account;
    }
}

Bước 5: Repository

// app/Repositories/AccountRepository.php
namespace App\Repositories;

use App\Aggregates\Account;

class AccountRepository
{
    public function __construct(
        private EventStore $eventStore
    ) {}
    
    public function save(Account $account): void
    {
        foreach ($account->getUncommittedEvents() as $event) {
            $this->eventStore->append(
                $account->getId(),
                Account::class,
                $event,
                $account->getVersion()
            );
        }
    }
    
    public function find(string $id): Account
    {
        $events = $this->eventStore->getEvents($id);
        
        if ($events->isEmpty()) {
            throw new AccountNotFoundException();
        }
        
        return Account::reconstituteFrom($events);
    }
}

Bước 6: Usage

// Controller
class AccountController extends Controller
{
    public function deposit(Request $request, AccountRepository $repo)
    {
        $account = $repo->find($request->account_id);
        
        $account->deposit(
            $request->amount,
            $request->description
        );
        
        $repo->save($account);
        
        return response()->json([
            'balance' => $account->getBalance(),
        ]);
    }
}

Projections: Tối ưu Queries

Vấn đề: Rebuild state từ 10,000 events rất chậm.

Giải pháp: Projections (Read Models)

// Migration
Schema::create('account_balances', function (Blueprint $table) {
    $table->uuid('account_id')->primary();
    $table->integer('balance');
    $table->timestamp('updated_at');
});

// Projection
class AccountBalanceProjection
{
    public function handle(MoneyDeposited $event): void
    {
        DB::table('account_balances')
            ->updateOrInsert(
                ['account_id' => $event->accountId],
                [
                    'balance' => DB::raw("balance + {$event->amount}"),
                    'updated_at' => now(),
                ]
            );
    }
    
    public function handle(MoneyWithdrawn $event): void
    {
        DB::table('account_balances')
            ->updateOrInsert(
                ['account_id' => $event->accountId],
                [
                    'balance' => DB::raw("balance - {$event->amount}"),
                    'updated_at' => now(),
                ]
            );
    }
}

// Query nhanh
$balance = DB::table('account_balances')
    ->where('account_id', $accountId)
    ->value('balance');

Lưu ý: Projections có thể rebuild từ events nếu bị corrupt.

Packages giúp việc

Spatie Event Sourcing:

composer require spatie/laravel-event-sourcing
use Spatie\EventSourcing\StoredEvents\Models\EloquentStoredEvent;
use Spatie\EventSourcing\AggregateRoots\AggregateRoot;

class Account extends AggregateRoot
{
    private int $balance = 0;
    
    public function deposit(int $amount): self
    {
        $this->recordThat(new MoneyDeposited($amount));
        return $this;
    }
    
    protected function applyMoneyDeposited(MoneyDeposited $event): void
    {
        $this->balance += $event->amount;
    }
}

// Usage
Account::retrieve($uuid)
    ->deposit(100)
    ->persist();

Kết luận

Event Sourcing không phải silver bullet.

Pros:

  • Full audit trail
  • Temporal queries (state tại bất kỳ thời điểm)
  • Event replay
  • Natural fit cho DDD

Cons:

  • Complexity cao
  • Learning curve dốc
  • Queries phức tạp (cần projections)
  • Storage tăng (lưu mọi event)

Khi nào dùng:

  • Finance/Banking
  • Audit-heavy domains
  • Complex business logic với nhiều state changes

Khi nào không dùng:

  • CRUD đơn giản
  • Reporting-heavy applications
  • Team thiếu kinh nghiệm

Lời khuyên:

  • Bắt đầu nhỏ (1-2 aggregates)
  • Dùng package (Spatie Event Sourcing)
  • Implement projections từ đầu
  • Document kỹ event schemas

Event Sourcing là công cụ mạnh, nhưng chỉ khi dùng đúng chỗ.

quizQuick Quiz
Câu 1/3

Ưu điểm lớn nhất của Event Sourcing là gì?

history_edu Góc học tập & giải trí

Thử Thách Kiến Thức Lịch Sử?

Khám phá hàng trăm câu hỏi trắc nghiệm lịch sử thú vị tại HistoQuiz. Vừa học vừa chơi, nâng cao kiến thức ngay hôm nay!

Chơi Ngay arrow_forward