Bài 17 — Event, Listener và Observer: TaskFlow bổ sung thông báo nội bộ khi Action tạo task thành công. Mục tiêu là tách phản ứng ghi log khỏi thao tác tạo dữ liệu, đồng thời kiểm chứng rằng transaction rollback không để lại log thành công giả.
1. Hai loại tín hiệu, hai ý nghĩa
TaskCreated là sự kiện nghiệp vụ do CreateTask chủ động phát. Nó diễn đạt use case đã tạo task; không có nghĩa mọi insert vào bảng tasks đều phát sự kiện này. Factory, seeder hoặc đoạn code gọi trực tiếp Eloquent sẽ không tự đi qua Action. Ngược lại, observer lắng nghe vòng đời model, chẳng hạn created hoặc updated, không biết người gọi đang thực hiện use case nào.
Trong bài này, chỉ Action phát TaskCreated. Không phát lại cùng event từ observer vì có thể tạo hai lần tác dụng phụ. Nếu quy tắc bắt buộc áp dụng cho mọi đường ghi, phải kiểm soát các đường ghi và constraint database; một observer riêng lẻ không đủ bảo đảm điều đó.
2. Triển khai event sau commit
app/Events/TaskCreated.php
<?php
namespace App\Events;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
final readonly class TaskCreated implements ShouldDispatchAfterCommit
{
public function __construct(public int $taskId, public int $projectId) {}
}app/Listeners/LogTaskCreated.php
<?php
namespace App\Listeners;
use App\Events\TaskCreated;
use Illuminate\Support\Facades\Log;
class LogTaskCreated
{
public function handle(TaskCreated $event): void
{
Log::info('taskflow.task.created', ['task_id' => $event->taskId, 'project_id' => $event->projectId]);
}
}app/Actions/CreateTask.php
<?php
namespace App\Actions;
use App\Data\CreateTaskData;
use App\Events\TaskCreated;
use App\Models\Project;
use App\Models\Task;
use InvalidArgumentException;
final class CreateTask
{
// The caller must authorize access to the project before invoking this action.
public function handle(Project $project, CreateTaskData $data): Task
{
if (! $project->exists) {
throw new InvalidArgumentException('A persisted project is required.');
}
$task = $project->tasks()->create([
'title' => trim($data->title),
'description' => $data->description,
'priority' => $data->priority,
'status' => 'todo',
]);
event(new TaskCreated($task->id, $project->id));
return $task;
}
}Event chỉ mang ID task và project, không mang request, token, email hay mô tả công việc. Listener đồng bộ ghi một tên sự kiện ổn định cùng hai ID. Đây là log phục vụ quan sát, không phải audit trail bất biến hoặc lịch sử đầy đủ thay đổi dữ liệu.
ShouldDispatchAfterCommit trì hoãn việc phát khi có transaction đang hoạt động: commit xong mới gọi listener, rollback thì bỏ callback. Nếu không có transaction, event được xử lý ngay. Action vẫn chỉ có một insert và không tự mở transaction; caller có thể bao nó trong transaction lớn hơn. Ranh giới này được kiểm thử trực tiếp bên dưới.
3. Kiểm tra listener thực sự được nối
php artisan event:list
php artisan test --filter=TaskCreatedEventTest
php artisan test
Trong cấu trúc hiện tại, Laravel tự tìm LogTaskCreated trong app/Listeners nhờ kiểu tham số của handle. event:list phải hiển thị TaskCreated nối với LogTaskCreated@handle đúng một lần. Không thêm đăng ký thủ công trùng với discovery. Khi deployment dùng event cache, phải xây lại cache theo source của release; cache cũ có thể giữ wiring cũ.
4. Kiểm thử commit, rollback và observer
tests/Feature/TaskCreatedEventTest.php
<?php
namespace Tests\Feature;
use App\Actions\CreateTask;
use App\Data\CreateTaskData;
use App\Models\Project;
use App\Models\Task;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Tests\TestCase;
class TaskCreatedEventTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:',
'database.connections.sqlite.url' => null]);
DB::purge('sqlite');
$this->artisan('migrate', ['--force' => true])->assertSuccessful();
}
public function test_discovered_listener_runs_only_after_commit(): void
{
$project = Project::factory()->create();
Log::spy();
$task = DB::transaction(function () use ($project) {
$task = app(CreateTask::class)->handle($project, new CreateTaskData('Commit me'));
Log::shouldNotHaveReceived('info');
return $task;
});
Log::shouldHaveReceived('info')->once()->with('taskflow.task.created', [
'task_id' => $task->id, 'project_id' => $project->id,
]);
}
public function test_rollback_discards_the_event_callback(): void
{
$project = Project::factory()->create();
Log::spy();
try {
DB::transaction(function () use ($project) {
app(CreateTask::class)->handle($project, new CreateTaskData('Rollback me'));
throw new RuntimeException('Rollback');
});
} catch (RuntimeException $exception) {
$this->assertSame('Rollback', $exception->getMessage());
}
Log::shouldNotHaveReceived('info');
$this->assertDatabaseCount('tasks', 0);
}
public function test_model_observer_does_not_receive_bulk_updates(): void
{
$observer = new class
{
public int $updates = 0;
public function updated(Task $task): void
{
$this->updates++;
}
};
$this->app->instance($observer::class, $observer);
Task::observe($observer);
$task = Task::factory()->create();
$task->update(['status' => 'doing']);
$this->assertSame(1, $observer->updates);
Task::query()->whereKey($task->id)->update(['status' => 'done']);
$this->assertSame('done', $task->fresh()->status);
$this->assertSame(1, $observer->updates);
}
}Hai test đầu dùng listener thật với Log::spy, không fake event dispatcher. Vì vậy chúng kiểm tra cả wiring lẫn thời điểm log: chưa có trong transaction, có đúng một lần sau commit, không có khi rollback. Database test là SQLite trong bộ nhớ, không xóa dữ liệu đang dùng. Toàn suite tại mốc bài này pass 43 test, 143 assertions.
Test thứ ba đăng ký observer chỉ trong ứng dụng test. Observer được bind thành cùng instance trong container để bộ đếm có thể quan sát chính xác. update trên model đã tải gọi updated; update hàng loạt qua query thay dữ liệu nhưng không gọi observer cho từng model. Không đăng ký observer đếm này vào ứng dụng thật.
5. Khi nào nên dùng observer?
Dùng observer khi hành vi thực sự gắn với vòng đời Eloquent và nhóm method liên quan giúp dễ quản lý hơn. Một class TaskObserver có method updated(Task $task) có thể được đăng ký bằng Task::observe(TaskObserver::class) trong boot của provider. Đây là lựa chọn thay cho đăng ký trong test, không phải bước cần thêm cho ví dụ TaskCreated.
Observer cần chạy sau commit có thể triển khai ShouldHandleEventsAfterCommit thuộc Illuminate\Contracts\Events. Không nhầm interface này với ShouldDispatchAfterCommit trên event. Tránh lưu lại chính model vô điều kiện trong updated vì dễ tạo vòng lặp. Các đường bulk update/delete, saveQuietly hoặc withoutEvents cũng cần được xem xét khi thiết kế hành vi dựa vào model event.
6. Sau commit không đồng nghĩa bảo đảm giao nhận
Listener hiện chạy trong cùng tiến trình. Nếu listener ném lỗi sau commit, dữ liệu đã commit không tự rollback; caller có thể nhận lỗi dù task đã tồn tại. Retry cả thao tác tạo lúc này có thể tạo task trùng. Nếu tiến trình chết giữa commit và callback, tác dụng phụ có thể bị mất. Không dùng log hiện tại làm bằng chứng giao nhận chắc chắn.
Email hoặc tích hợp bên ngoài nên được thiết kế với queue, retry và idempotency. Với yêu cầu không được mất sự kiện, cân nhắc transactional outbox: ghi bản ghi sự kiện cùng transaction rồi có worker chuyển tiếp và quản lý trạng thái xử lý. Bài queue sẽ triển khai phần bất đồng bộ; bài này không tuyên bố có exactly-once hay cơ chế outbox.
7. Bài tập và tiêu chí hoàn thành
Thêm test gọi Action không có transaction và xác nhận log xuất hiện ngay. Thử tạo task bằng factory, giải thích vì sao không có TaskCreated. Sau đó viết listener giả ném lỗi sau commit và kiểm tra task vẫn tồn tại, không đưa listener thử nghiệm đó vào production.
Đọc thêm Laravel Events và Eloquent Observers. Điều hướng: Bài 16: Action và DTO · Lộ trình. Bài tiếp theo xử lý exception và lỗi API.




Chưa có bình luận. Hãy là người đầu tiên chia sẻ ý kiến.