Ở Bài 20 mình có hẹn: xóa project thì file đính kèm vẫn nằm lại trên disk. Tiếp tục với series, bài này mình sẽ giới thiệu với mọi người về event và listener trong Laravel 13, cách dùng chúng để xử lý việc này và ghi lại mỗi khi một task được hoàn thành.
1. Event và listener là gì?
Event là một sự kiện xảy ra trong ứng dụng (task được hoàn thành, đơn hàng được giao...), listener là class xử lý khi sự kiện đó xảy ra.
Code chỗ xảy ra sự kiện chỉ việc "bắn" (dispatch) event, không cần biết sau đó có bao nhiêu việc phải làm: ghi log, gửi mail, xóa cache... Mỗi việc là một listener riêng, thêm hay bỏ một listener không phải sửa code cũ. Event nằm trong app/Events, listener trong app/Listeners, hai thư mục này được tạo khi chạy lệnh make đầu tiên.
2. Tạo event TaskCompleted và listener.
Mình tạo event TaskCompleted và listener LogTaskCompleted nghe event đó:
php artisan make:event TaskCompleted
# INFO Event [app/Events/TaskCompleted.php] created successfully.
php artisan make:listener LogTaskCompleted --event=TaskCompleted
# INFO Listener [app/Listeners/LogTaskCompleted.php] created successfully.
Event chỉ là class chứa dữ liệu của sự kiện, ở đây là task vừa xong (mình bỏ phần broadcast có sẵn trong file tạo ra):
- app/Events/TaskCompleted.php
<?php
namespace App\Events;
use App\Models\Task;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class TaskCompleted
{
use Dispatchable, SerializesModels;
public function __construct(
public Task $task,
) {}
}
Trong đó:
Dispatchable: thêm các method staticdispatch(),dispatchIf(),dispatchUnless().SerializesModels: khi event được đưa vào queue thì chỉ lưu id của model, lúc chạy lấy lại từ database.
Listener nhận event qua method handle(), mình ghi log bằng Log facade như Bài 11:
- app/Listeners/LogTaskCompleted.php
<?php
namespace App\Listeners;
use App\Events\TaskCompleted;
use Illuminate\Support\Facades\Log;
class LogTaskCompleted
{
public function handle(TaskCompleted $event): void
{
Log::info('Task đã hoàn thành', [
'id' => $event->task->id,
'title' => $event->task->title,
'completed_at' => $event->task->completed_at->toDateTimeString(),
]);
}
}
Không cần đăng ký ở đâu cả: Laravel 13 tự quét thư mục app/Listeners (event discovery), method nào tên bắt đầu bằng handle hoặc là __invoke sẽ nghe event có kiểu ở tham số. Listener được tạo qua service container (Bài 25) nên inject vào constructor được. Kiểm tra bằng:
php artisan event:list --event=TaskCompleted
# App\Events\TaskCompleted ...................................................
# ⇂ App\Listeners\LogTaskCompleted@handle
Muốn đăng ký thủ công (listener ở thư mục khác, hoặc dùng closure) thì gọi Event::listen() trong boot() của AppServiceProvider, VD Event::listen(function (TaskCompleted $event) { ... });. Trên production nên chạy php artisan event:cache (hoặc optimize) để khỏi quét lại mỗi request.
3. Dispatch event.
Có 3 cách bắn event, cả 3 đều gọi tới các listener (trích, $task là một Task):
use App\Events\TaskCompleted;
use Illuminate\Support\Facades\Event;
TaskCompleted::dispatch($task); // tham số truyền vào constructor của event
event(new TaskCompleted($task)); // helper event()
Event::dispatch(new TaskCompleted($task)); // facade Event
TaskCompleted::dispatchIf($task->due_date !== null, $task); // chỉ bắn khi điều kiện đúng
Listener chạy ngay trong request. Việc chậm như gửi mail thì cho listener implements ShouldQueue để nó chạy nền trong queue, phần này mình sẽ nói ở Bài 28.
4. Model event và observer.
Bản thân Eloquent model cũng bắn event trong vòng đời của nó: retrieved, creating/created, updating/updated, saving/saved (cả tạo mới lẫn cập nhật), deleting/deleted, trashed, forceDeleting/forceDeleted, restoring/restored, replicating. Đuôi -ing chạy trước khi ghi vào database, đuôi -ed chạy sau.
Closure trong booted().
Với một vài event, các bạn đăng ký closure trong method static booted() của model. Khi xóa project, tasks và attachments bị xóa theo khóa ngoại cascadeOnDelete ngay trong database nên không có model event nào của Task chạy. Vì vậy mình xóa thư mục file của từng task (kể cả task đã soft delete) ở event deleting của Project:
- app/Models/Project.php (thêm use Illuminate\Support\Facades\Storage;)
protected static function booted(): void
{
// tasks và attachments bị xóa theo khóa ngoại cascade (không qua Eloquent) nên tự xóa file trước
static::deleting(function (Project $project) {
$project->tasks()->withTrashed()->pluck('id')
->each(fn (int $id) => Storage::deleteDirectory('attachments/'.$id));
});
}
Observer.
Nghe nhiều event của một model thì gom vào một class observer, mỗi method mang tên một event:
php artisan make:observer TaskObserver --model=Task
# INFO Observer [app/Observers/TaskObserver.php] created successfully.
- app/Observers/TaskObserver.php
<?php
namespace App\Observers;
use App\Enums\TaskStatus;
use App\Events\TaskCompleted;
use App\Models\Task;
use Illuminate\Support\Facades\Storage;
class TaskObserver
{
// trước khi lưu (tạo mới hoặc cập nhật): status đổi thì cập nhật completed_at
public function saving(Task $task): void
{
if ($task->isDirty('status')) {
$task->completed_at = $task->status === TaskStatus::Done ? ($task->completed_at ?? now()) : null;
}
}
// sau khi cập nhật: status vừa chuyển sang done thì bắn event
public function updated(Task $task): void
{
if ($task->wasChanged('status') && $task->status === TaskStatus::Done) {
TaskCompleted::dispatch($task);
}
}
// xóa hẳn task (không phải soft delete) thì xóa luôn thư mục file đính kèm
public function forceDeleted(Task $task): void
{
Storage::deleteDirectory('attachments/'.$task->id);
}
}
Trong đó:
isDirty('status'): status đã đổi nhưng chưa lưu, gáncompleted_atở đây sẽ được lưu cùng lầnsave().wasChanged('status'): status vừa được lưu thay đổi, dùng ở event-ed.forceDeleted: soft delete (Bài 16) vẫn giữ file để còn restore.
Đăng ký observer bằng attribute #[ObservedBy] trên model (hoặc Task::observe(TaskObserver::class) trong AppServiceProvider):
- app/Models/Task.php (trích)
use App\Observers\TaskObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
#[Fillable(['project_id', 'title', 'description', 'status', 'due_date', 'assignee_id'])]
#[Appends(['is_overdue'])]
#[ObservedBy([TaskObserver::class])]
class Task extends Model
Chú ý: update/delete hàng loạt như Task::where('project_id', 1)->update([...]) không lấy model ra nên không bắn model event. Ngoài ra DatabaseSeeder có trait WithoutModelEvents (Bài 13) nên observer không chạy khi seed, completed_at của dữ liệu mẫu vẫn do TaskFactory điền. Closure đó đang so status với chuỗi 'done', mình sửa để nhận cả enum: in_array($attributes['status'], ['done', TaskStatus::Done], true) (thêm use App\Enums\TaskStatus; vào TaskFactory).
5. Chạy thử.
Vừa thêm class mới nên chạy composer dump-autoload (Bài 15) và php artisan migrate:fresh --seed, rồi vào php artisan tinker:
$task = Task::find(3); // "Thiết kế trang chủ", status todo
$task->update(['status' => App\Enums\TaskStatus::Done]);
$task->completed_at->toDateTimeString(); // "2026-11-15 01:40:50"
$task->update(['status' => App\Enums\TaskStatus::Todo]);
$task->completed_at; // null
Storage::put('attachments/1/test.txt', 'toidicode.com'); // giả lập file của task 1
Project::find(1)->delete();
Storage::directoryExists('attachments/1'); // false
Trong storage/logs/laravel.log chỉ có một dòng do listener ghi (lần chuyển về todo không bắn event):
[2026-11-15 01:40:50] local.INFO: Task đã hoàn thành {"id":3,"title":"Thiết kế trang chủ","completed_at":"2026-11-15 01:40:50"}
Xóa project bằng nút Xóa trên web cũng vậy, vì ProjectController::destroy() gọi $project->delete().
Khi test (Bài 30), Event::fake() chặn listener để chỉ kiểm tra event có được bắn hay không. Nên truyền danh sách event cần fake, vì Event::fake() không tham số chặn luôn model event, observer sẽ không chạy (trích, các class đã use):
Event::fake([TaskCompleted::class]);
$task->update(['status' => TaskStatus::Done]); // observer vẫn chạy, listener thì không
Event::assertDispatched(TaskCompleted::class, fn ($event) => $event->task->is($task));
6. Lời kết.
Như vậy chúng ta đã tìm hiểu xong event và listener trong Laravel 13: tạo event, listener, event discovery, các cách dispatch, model event và observer. Các bạn xem thêm tại tài liệu chính thức. Bài sau mình sẽ giới thiệu về mail và notification trong Laravel 13, để báo cho người dùng khi họ được giao task.
Đăng ký nhận tin.
Chúng tôi chỉ gửi tối đa 2 lần trên 1 tháng. Tuyên bố không spam mail!





0 Comments