Gửi thông báo đa kênh — thêm SMS/Slack mà không sửa controller
Yêu cầu quen thuộc: hôm nay chỉ gửi mail, mai có thể mail + SMS, mốt là SMS + Skype, khách VIP
thì thêm Slack. Nếu controller đang gọi thẳng Mail::to(...)->send(...) thì mỗi lần
đổi là mỗi lần sửa controller và service — sửa 30 chỗ, review 30 chỗ, hỏng 30 chỗ.
Bài này trả lời: dùng design pattern nào, code ra sao trong Laravel, và quan trọng hơn — chỗ nào KHÔNG cần tự viết pattern vì framework đã làm sẵn. Kèm 4 bẫy chỉ lộ ra khi chạy thật.
Câu trả lời ngắn cho interviewer: pattern cần là Strategy (mỗi kênh là một cách gửi thay thế nhau được) cộng Adapter (mỗi kênh bọc SDK bên ngoài về cùng một hình dạng). Laravel Notification chính là bản cài sẵn của hai pattern đó. Tự viết
NotifierInterface+NotifierFactorylà dựng lại cái đã có sẵn trong framework. Việc còn lại chỉ là ai quyết định danh sách kênh — và đó là nội dung củavia().
Môi trường kiểm chứng. Mọi output trong bài chạy thật trên Laravel 12.64.0 + PHP 8.3.33 (container
laravel-app). Script lab để ở/tmp/lab*.phptrong container, chỉrequireautoload của project ở chế độ đọc — không thêm file nào vào projectlaravel-livewire. Mail đi qua driverarray/log, không gửi ra ngoài. Chỗ nào chưa verify được sẽ ghi rõ.
Thuật ngữ dùng trong bài
| Từ | Nghĩa |
|---|---|
| notifiable | Đối tượng nhận thông báo (thường là User). Dùng trait Notifiable để có method notify() |
| notification class | Một class mô tả nội dung một loại thông báo (vd OrderShipped), không quan tâm gửi bằng gì |
| channel (kênh) | Một cách gửi: mail, sms, slack, database, broadcast… Là một class có đúng một method send() |
via() | Method trong notification class, trả về danh sách kênh sẽ dùng cho lần gửi này. Đây là chỗ chọn strategy |
routeNotificationForX() | Method trên notifiable, trả về địa chỉ ở kênh X (email nào, số điện thoại nào) |
| Strategy pattern | Nhiều thuật toán cùng làm một việc, đổi qua lại lúc chạy mà code gọi không đổi |
| Adapter pattern | Bọc một API lạ (SDK Twilio, webhook Slack) về đúng hình dạng mà hệ thống mình mong đợi |
| driver manager | Cơ chế của Laravel: một tên chuỗi ('sms') → một object driver. ChannelManager là driver manager của notification |
| fan-out | Một sự kiện → nhiều đích đến cùng lúc (một đơn giao xong → mail + SMS + Slack) |
| on-demand notification | Gửi cho một địa chỉ rời, người nhận chưa có tài khoản trong hệ thống |
| kill switch | Công tắc tắt nhanh một kênh cho toàn hệ thống mà không cần deploy code |
| OCP (open/closed principle) | Mở để mở rộng (thêm kênh mới), đóng để sửa đổi (không đụng code cũ đang chạy) |
1. Nó là gì / chạy thế nào
1.1. Code đang sai chỗ nào
Bản hay gặp nhất:
// app/Http/Controllers/OrderController.php
public function ship(Order $order)
{
$order->markShipped();
Mail::to($order->user)->send(new OrderShippedMail($order)); // <-- vấn đề ở đây
return back();
}
Controller đang biết gửi bằng cách nào. Đáng lẽ nó chỉ nên biết chuyện gì
vừa xảy ra. Thêm SMS nghĩa là thêm một dòng nữa, ở đây và ở mọi chỗ khác có gửi
OrderShippedMail.
flowchart LR
subgraph SAI["Hôm nay"]
C1["Controller"] --> M1["Mail::to().send()"]
end
subgraph SAI2["Mai thêm SMS"]
C2["Controller (sửa)"] --> M2["Mail send"]
C2 --> S2["Sms send"]
end
subgraph SAI3["Mốt thêm Slack + Skype"]
C3["Controller (sửa lần 3)"] --> M3["Mail"]
C3 --> S3["Sms"]
C3 --> K3["Slack"]
C3 --> P3["Skype"]
end
SAI --> SAI2 --> SAI3
1.2. Hai pattern cần — và chỗ Laravel đã làm sẵn
| Pattern | Vai trò ở đây | Trong Laravel là cái gì |
|---|---|---|
| Strategy | Mỗi kênh là một cách gửi thay thế nhau được. Chọn kênh lúc chạy | via() trả về danh sách kênh; ChannelManager resolve tên kênh ra object |
| Adapter | Bọc SDK ngoài (Twilio, webhook Slack) về một hình dạng chung | Channel class — chỉ cần có method send($notifiable, $notification) |
| — | Không cần viết. ChannelManager (kế thừa Illuminate\Support\Manager) đã là factory | |
| — | Không cần. Có sẵn event NotificationSending / NotificationSent / NotificationFailed | |
| — | Không cần. Notification base class đã lo phần khung |
Nói thẳng. Rất nhiều bài blog dạy "tạo
NotificationChannelInterface,ChannelFactory,NotificationManager…". Đó là viết lạiIlluminate\Notifications\ChannelManagerbằng tay, dài hơn, ít tính năng hơn (mất queue per-kênh, mấtNotification::fake(), mất retry, mất event). Trong phỏng vấn, biết không viết ghi điểm cao hơn biết viết.
1.3. Ba mảnh code, mỗi mảnh một trách nhiệm
flowchart TD A["Controller / Service
chỉ 1 dòng: user.notify(new OrderShipped(order))"] B["Notification class: OrderShipped
NỘI DUNG + via() chọn kênh"] C1["MailChannel"] C2["SmsChannel"] C3["SlackChannel"] C4["SkypeChannel"] D["Notifiable: User
ĐỊA CHỈ nhận (routeNotificationForSms...)"] A --> B B --> C1 & C2 & C3 & C4 C1 & C2 & C3 & C4 --> D
| Mảnh | Biết cái gì | KHÔNG được biết |
|---|---|---|
| Controller / Service | "đơn vừa giao xong" | Gửi bằng kênh nào |
| Notification class | Nội dung mỗi kênh + danh sách kênh | Gọi API nhà cung cấp thế nào |
| Channel class | Cách nói chuyện với nhà cung cấp | Nội dung nghiệp vụ |
| Notifiable (User) | Địa chỉ của mình ở từng kênh | Nội dung, thứ tự kênh |
1.4. Luồng chạy thật (đọc từ source Laravel 12.64.0)
sequenceDiagram
participant SV as Service
participant U as User (Notifiable)
participant CM as ChannelManager
participant NS as NotificationSender
participant CH as SmsChannel
SV->>U: notify(new OrderShipped)
U->>CM: send(user, notification)
CM->>NS: sendNow()
NS->>NS: notification.via(user) trả về mail, sms
loop mỗi kênh trong via()
NS->>NS: shouldSend()? (shouldSend + event NotificationSending)
NS->>CM: driver("sms")
CM-->>NS: SmsChannel
NS->>CH: send(user, notification)
CH->>CH: notification.toSms(user)
CH-->>NS: response (message id của vendor)
NS->>NS: dispatch NotificationSent
end
Hai chi tiết trong ChannelManager::createDriver() đáng nhớ:
// vendor/laravel/framework/src/Illuminate/Notifications/ChannelManager.php
protected function createDriver($driver)
{
try {
return parent::createDriver($driver); // 1) kênh có sẵn, hoặc đã Notification::extend()
} catch (InvalidArgumentException $e) {
if (class_exists($driver)) {
return $this->container->make($driver); // 2) via() trả thẳng TÊN CLASS cũng chạy
}
throw $e;
}
}
Nghĩa là có hai cách đăng ký kênh mới, cả hai đều không đụng controller:
// Cách A — đặt tên ngắn, đăng ký 1 lần trong AppServiceProvider::boot()
Notification::extend('sms', fn ($app) => new SmsChannel($app->make(TwilioClient::class)));
// via() viết: return ['mail', 'sms'];
// Cách B — không đăng ký gì cả, via() trả thẳng class name
// via() viết: return [MailChannel::class, SmsChannel::class];
Chạy thật (lab, phần C):
C) via() trả thẳng class name:
ran ZaloChannel (đăng ký bằng tên class, không cần extend)
Chọn cách nào? Cách A nếu danh sách kênh đến từ config/DB (chuỗi
"sms"dễ lưu hơn tên class đầy đủ). Cách B nếu danh sách kênh cứng trong code — đỡ một bước đăng ký, IDE bắt được lỗi gõ sai tên.
1.5. Code đầy đủ — ba file
// 1) Channel — Adapter, bọc SDK ngoài. Chỉ cần đúng method send().
namespace App\Notifications\Channels;
use Illuminate\Notifications\Notification;
class SmsChannel
{
public function __construct(private SmsGateway $gateway) {}
public function send(object $notifiable, Notification $notification): mixed
{
$to = $notifiable->routeNotificationFor('sms', $notification);
if (! $to) {
return null; // user không có số — bỏ qua, không throw
}
return $this->gateway->send($to, $notification->toSms($notifiable));
}
}
// 2) Notification — nội dung + chọn kênh. KHÔNG biết gì về Twilio/Slack.
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class OrderShipped extends Notification implements ShouldQueue
{
use Queueable; // BẮT BUỘC khi implements ShouldQueue — xem bẫy §3.2
public function __construct(public Order $order) {}
public function via(object $notifiable): array
{
return config('notify.channels.order_shipped', ['mail']);
}
public function toMail($notifiable): MailMessage
{
return (new MailMessage)
->subject("Đơn {$this->order->code} đã giao")
->line('Đơn của bạn đã bàn giao cho đơn vị vận chuyển.');
}
public function toSms($notifiable): string
{
return "Don {$this->order->code} da giao cho don vi van chuyen."; // SMS: không dấu, ≤ 160 ký tự
}
public function toSlack($notifiable): string { /* ... */ }
public function toSkype($notifiable): string { /* ... */ }
}
// 3) Controller / Service — dòng này KHÔNG BAO GIỜ đổi nữa
public function ship(Order $order)
{
$order->markShipped();
$order->user->notify(new OrderShipped($order));
return back();
}
Và file config — chỗ duy nhất phải sửa khi đổi kênh:
// config/notify.php
return [
'channels' => [
'order_shipped' => explode(',', env('NOTIFY_ORDER_SHIPPED', 'mail')),
'order_cancelled' => explode(',', env('NOTIFY_ORDER_CANCELLED', 'mail,sms')),
'payment_failed' => explode(',', env('NOTIFY_PAYMENT_FAILED', 'mail,sms,slack')),
],
];
1.6. Chạy thật: đổi config, controller đứng yên
Lab chạy đúng một dòng $user->notify(new OrderShipped('ORD-1001')) bốn lần, chỉ đổi
config giữa các lần:
=> config = [mail]
=> config = [sms, skype]
[SmsChannel] -> 0900000001 : Don ORD-1001 da giao cho don vi van chuyen
[SkypeChannel] -> Chien : Đơn ORD-1001 đã giao
=> config = [mail, sms]
[SmsChannel] -> 0900000001 : Don ORD-1001 da giao cho don vi van chuyen
=> config = [mail, sms, skype]
[SmsChannel] -> 0900000001 : Don ORD-1001 da giao cho don vi van chuyen
[SkypeChannel] -> Chien : Đơn ORD-1001 đã giao
(mail đi qua driver log nên không in ra màn hình — nó nằm trong log file.)
Đúng yêu cầu đề bài: mail, hoặc sms + skype, hoặc mail + sms — mà
controller không đổi một ký tự.
1.7. Bốn mức "ai quyết định kênh"
Đây là phần thật sự cần suy nghĩ. via() có thể lấy danh sách kênh từ 4 nguồn:
| Mức | Nguồn | Đổi kênh cần gì | Khi nào dùng |
|---|---|---|---|
| 0 | Cứng trong code: return ['mail','sms']; | Sửa code + deploy | Dưới 3 loại thông báo, kênh gần như không đổi |
| 1 | Config + env | Đổi env + config:cache + restart worker | Mặc định nên chọn. Ops bật/tắt kênh được, không cần dev |
| 2 | Preference của user trong DB | User tự bấm trong trang Cài đặt | Có yêu cầu "cho khách chọn nhận thông báo kiểu gì" |
| 3 | Ma trận: config × preference × kênh bắt buộc | Cả hai | Khi có kênh không được phép tắt (hoá đơn, bảo mật) |
Mức 3 là bản thực tế nhất. Code gọn trong một base class:
abstract class ChannelAwareNotification extends Notification
{
/** khoá trong config/notify.php, vd 'order_shipped' */
abstract protected function notifyKey(): string;
/** kênh user KHÔNG được tắt (pháp lý / bảo mật) */
protected function requiredChannels(): array { return []; }
public function via(object $notifiable): array
{
$allowed = config('notify.channels.'.$this->notifyKey(), ['mail']); // ops bật gì
$wanted = $notifiable->notificationPreferences(); // user muốn gì
return array_values(array_unique(array_merge(
array_intersect($allowed, $wanted),
array_intersect($allowed, $this->requiredChannels()),
)));
}
}
class OrderShipped extends ChannelAwareNotification
{
protected function notifyKey(): string { return 'order_shipped'; }
protected function requiredChannels(): array { return ['mail']; } // hoá đơn luôn phải có mail
}
Chạy thật với 3 user khác nhau, cùng một config
(order_shipped = [mail, sms, skype], marketing = [mail, sms]):
D) cùng 1 config, mỗi user ra kênh khác nhau:
Chien order_shipped=["sms","skype","mail"] marketing=["sms"]
Lan order_shipped=["mail"] marketing=["mail"]
Tu order_shipped=["mail"] marketing=[]
Đọc kỹ dòng của Tu: user tắt hết mọi kênh → marketing về rỗng (đúng, không spam),
nhưng order_shipped vẫn còn mail vì mail nằm trong
requiredChannels(). Đây chính là chỗ đa số bản tự viết làm sai — để user tắt luôn cả
mail hoá đơn.
Bẫy:
via()chạy mỗi lần gửi, cho mỗi user. Nếu nó query DB lấy preference thì gửi cho 5.000 user là 5.000 query. Preference phải nằm sẵn trên model (cột JSONnotification_prefs, đãselectcùng user), hoặc eager-load, hoặc cache theo user. Xem thêm Cache nâng cao.
2. Lợi ích
| Được gì | Cụ thể |
|---|---|
| Controller/service đóng băng | Thêm kênh thứ 5 = thêm 1 channel class + 1 method toX() + 1 dòng config. Không đụng code cũ (đúng OCP) |
| Ops đổi kênh không cần dev | Ở mức 1, tắt SMS lúc 2h sáng = đổi env + restart worker, không cần release |
| Mỗi kênh một job queue riêng | Có sẵn, không phải code. Xem §2.1 |
| Retry / failed job có sẵn | tries, backoff, bảng failed_jobs dùng chung với queue — xem Scale out queue worker |
| Test không cần vendor thật | Notification::fake() + assert đúng kênh nào được chọn. Xem §2.2 |
| Gửi cho người chưa có account | Notification::route('mail', $email)->route('sms', $phone)->notify(...) — dùng lại y nguyên notification class |
| Chặn/đổi kênh từ bên ngoài | Listener của NotificationSending trả false là kênh đó không gửi. Kill switch miễn phí, xem §5.2 |
2.1. Queue tách theo kênh — miễn phí
Đọc NotificationSender::queueNotification(): nó lặp từng kênh và dispatch một job
riêng cho mỗi kênh ('channels' => [$channel]). Kèm theo là ba hook per-kênh:
public function viaQueues(): array { return ['mail' => 'q-mail', 'sms' => 'q-sms']; }
public function viaConnections(): array { return ['slack' => 'sqs']; }
public function withDelay($notifiable, $channel): int { return $channel === 'sms' ? 60 : 0; }
public function middleware($notifiable, $channel): array {
return $channel === 'sms' ? [new RateLimited('sms-vendor')] : [];
}
Chạy thật — một notification, ba kênh, viaQueues chỉ khai báo hai:
B) QUEUE — cùng notification nhưng implements ShouldQueue:
job: channels=["mail"] queue='q-mail'
job: channels=["sms"] queue='q-sms'
job: channels=["skype"] queue=NULL
=> mỗi kênh 1 job riêng, hỏng kênh này không ảnh hưởng kênh kia.
Ba job độc lập. SMS vendor timeout 30 giây thì mail vẫn ra ngay, và chỉ job SMS bị retry. Kênh nào
không khai báo queue thì rơi về queue mặc định (NULL ở trên) — nhớ khai báo đủ, nếu
không kênh chậm sẽ chen vào queue chung.
2.2. Test đúng kênh, không cần Twilio
Notification::fake();
$this->post("/orders/{$order->id}/ship");
Notification::assertSentTo($user, OrderShipped::class, function ($notification, array $channels) {
return in_array('sms', $channels, true) // SMS phải có
&& ! in_array('slack', $channels, true); // Slack phải không
});
Chạy thật:
channels mà fake ghi lại: ["mail","sms"]
assertSentTo + assertCount: PASS
assertSentOnDemand: PASS
Tham số thứ hai array $channels của closure chính là kết quả via(). Nghĩa là
logic chọn kênh test được bằng unit test, không cần mạng, không cần key vendor. Đây là
lý do mạnh nhất để không tự viết lại cơ chế này.
3. Tác hại / hạn chế
3.1. Bẫy lớn nhất: gửi đồng bộ, một kênh chết kéo sập kênh sau nó
Đọc NotificationSender::sendToNotifiable(): nếu channel throw, nó bắn event
NotificationFailed rồi throw tiếp ra ngoài. Vòng lặp kênh
dừng tại đó. Lab, ba kênh ['sms', 'slack', 'skype'], Slack đứng giữa và throw:
A) SYNC — slack đứng GIỮA, throw:
ran: SmsChannel
event NotificationSent channel=sms
ran: SlackChannel -> THROW
event NotificationFailed channel=slack msg=Slack API 503
exception thoát ra ngoài: RuntimeException: Slack API 503
=> skype KHÔNG chạy. Một kênh chết kéo sập kênh sau nó.
Hậu quả thật: Slack sập → khách không nhận được Skype, và exception bay lên controller → user thấy màn hình lỗi dù đơn đã tạo xong.
Cách chữa (theo thứ tự ưu tiên):
1.implements ShouldQueue— mỗi kênh một job, hết lây chéo. Đây là câu trả lời đúng trong 95% trường hợp.
2. Nếu bắt buộc gửi đồng bộ: channel class tự nuốt lỗi (try/catch + log) thay vì để throw. Đổi lại là mất retry tự động — phải tự log đủ để gửi bù.
3. Đừng "sắp xếp kênh quan trọng lên đầu" rồi coi là xong — đó là vá triệu chứng, kênh cuối vẫn mất.
3.2. Quên use Queueable khi implements ShouldQueue
Lỗi thật gặp khi viết lab này:
ErrorException: Undefined property: Shipped@anonymous::$connection
at vendor/.../Notifications/NotificationSender.php:234
$connection = $notification->connection;
NotificationSender đọc thẳng các property $connection, $queue,
$delay — những property này do trait Illuminate\Bus\Queueable cung cấp.
implements ShouldQueue mà quên use Queueable là nổ ngay lần gửi đầu tiên.
3.3. Những hạn chế còn lại
| Hạn chế | Vì sao | Xử lý |
|---|---|---|
| Không có fallback chain | Laravel gửi song song các kênh trong via(), không có kiểu "mail fail thì mới gửi SMS" | Tự làm: listener NotificationFailed → dispatch lại notification với sendNow($n, $notif, ['sms']). Cẩn thận vòng lặp vô tận |
| Không chống trùng | Job retry (queue timeout, worker chết giữa chừng) → gửi lần 2. SMS gửi 2 lần là tốn tiền thật | Khoá theo $notification->id (Laravel tự sinh UUID) trong Redis trước khi gọi vendor — xem Idempotency key ở Redis |
| Notification class phình to | 5 kênh = 5 method toX() trong một file, dễ 300 dòng | Tách phần dựng nội dung ra class riêng (OrderShippedContent), toX() chỉ còn một dòng gọi sang. Chỉ tách khi đã thật sự dài, đừng tách trước |
| Không hợp cho gửi hàng loạt | 100.000 user × 3 kênh = 300.000 job. Queue database sẽ chết | Marketing blast dùng đường khác: chunk + batch job + gọi thẳng API bulk của vendor. Notification là cho thông báo giao dịch, một người một lần |
| Mỗi kênh là một dependency | laravel/slack-notification-channel, laravel/vonage-notification-channel… mỗi package là một thứ phải nâng cấp | Kênh nào chỉ là một HTTP POST (Slack incoming webhook, Teams webhook) thì tự viết channel 15 dòng bằng Http::post(), đừng thêm package |
via() trả rỗng thì im lặng | sendNow() có if (empty($viaChannels)) continue; — không log, không lỗi | Nếu "không gửi gì" là bất thường thì phải tự log trong via() trước khi trả rỗng |
Về Skype (đề bài có nhắc). Microsoft đã khai tử Skype cho người dùng phổ thông (thông báo 2025, chuyển sang Teams), và Skype Bot Platform thì đã đóng từ lâu trước đó. Chưa verify được trong lab này — nhưng nếu requirement ghi "gửi Skype", nên hỏi lại stakeholder: thứ họ thật sự cần gần như chắc chắn là Microsoft Teams incoming webhook. Về mặt code thì không khác gì: vẫn là một channel class gọi
Http::post($webhookUrl, [...]). Đây cũng là một ví dụ tốt cho ý "kiến trúc đúng thì đổi vendor là đổi một file".
4. Trade-off
| Cách làm | Chọn khi | Mất gì |
|---|---|---|
Gọi thẳng Mail::send() trong controller | Đúng 1 kênh, đúng 1–2 chỗ gọi, chắc chắn không mở rộng (script nội bộ, tool admin) | Thêm kênh là sửa mọi chỗ gọi. Không test được nếu không mock Mail |
Laravel Notification + via() ← mặc định | Từ 2 kênh trở lên, hoặc biết trước sẽ thêm kênh | Phải học cách framework nghĩ. Gửi đồng bộ có bẫy §3.1 |
Tự viết NotifierInterface + factory | Gần như không bao giờ. Chỉ khi rời Laravel, hoặc cần cơ chế framework không có (vd fallback chain phức tạp, ưu tiên theo chi phí) | Mất Notification::fake(), queue per-kênh, event, retry — phải viết lại hết. Nhiều code hơn, ít tính năng hơn |
| Event + nhiều Listener (mỗi listener một kênh) | Khi sự kiện còn kéo theo việc khác ngoài gửi tin (trừ kho, cộng điểm thưởng, ghi log kiểm toán) | Danh sách kênh nằm rải trong EventServiceProvider, khó nhìn "đơn này gửi kênh nào". Bật/tắt theo user rất vướng |
| Đẩy sang service gửi tin riêng (message bus / nhà cung cấp tổng hợp) | Nhiều hệ thống (không chỉ Laravel) cùng gửi tin, cần một nơi quản lý mẫu tin, opt-out, báo cáo | Thêm một hệ thống phải vận hành, thêm độ trễ, thêm một chỗ có thể chết. Không đáng cho một monolith |
Tiêu chí chọn
flowchart TD
Q1{"Từ 2 kênh trở lên?"} -->|Không| A1["Gọi thẳng Mail::send()"]
Q1 -->|Có| Q2{"Ngoài gửi tin còn việc khác
xảy ra theo sự kiện?"}
Q2 -->|Có| A2["Event → Listener → notify()
(Notification lo phần gửi tin)"]
Q2 -->|Không| A3["Notification + via()"]
A2 --> Q3
A3 --> Q3{"Ai quyết định kênh?"}
Q3 -->|Dev| B0["via() cứng trong code"]
Q3 -->|Ops| B1["via() đọc config/env"]
Q3 -->|User| B2["via() = config giao preference
cộng requiredChannels()"]
Event và Notification không đối nhau. Bản thực tế hay dùng là ghép:
OrderShipped(event) → listenerSendShipmentNotification→$order->user->notify(new OrderShippedNotification($order)). Event lo "chuyện gì đã xảy ra và ai quan tâm", Notification lo "gửi tin qua kênh nào". Trùng tên nhưng khác vai.
5. Khó khăn khi vận hành thực tế
5.1. Nhìn được chuyện gì đang xảy ra
Ba event có sẵn, không cần thêm gì:
// app/Providers/AppServiceProvider.php — boot()
Event::listen(NotificationSent::class, function (NotificationSent $e) {
Log::info('notification.sent', [
'id' => $e->notification->id, // UUID Laravel tự sinh, chung cho mọi kênh 1 lần gửi
'type' => $e->notification::class,
'channel' => $e->channel,
'to' => $e->notifiable->getKey(),
'response' => $e->response, // message id của vendor — quan trọng nhất khi đi khiếu nại
]);
});
Event::listen(NotificationFailed::class, function (NotificationFailed $e) {
Log::error('notification.failed', [
'id' => $e->notification->id, 'channel' => $e->channel,
'error' => $e->data['exception']->getMessage(),
]);
});
Metric tối thiểu nên có, tách theo channel: số gửi, số fail, độ trễ p95 từ lúc
notify() tới lúc NotificationSent. Xem thêm nhóm
Observability.
NotificationFailedkhông bắn cho mọi kênh. Nó bắn khi channel class throw exception. Kênh nào "gửi xong" nhưng vendor lặng lẽ không giao (SMS bị nhà mạng chặn, mail vào spam) thì Laravel không biết — phải nhận webhook trạng thái từ vendor mới biết. Đừng báo cáo "tỉ lệ gửi thành công 99,9%" dựa trênNotificationSent.
5.2. Kill switch — tắt một kênh mà không deploy
Event::listen(NotificationSending::class, function (NotificationSending $e) {
if (in_array($e->channel, Cache::get('notify.disabled_channels', []), true)) {
return false; // trả false = kênh này không gửi, các kênh khác vẫn chạy
}
});
Chạy thật:
E) kill switch sms:
kill=false: ran SmsChannel
kill=true : kill-switch chặn sms
Giá trị thật lúc 3h sáng: vendor SMS trả 500 hàng loạt, queue phình. Bật kill switch (một lệnh
artisan, không deploy) → SMS bị bỏ qua ngay tại chỗ, mail vẫn chạy, queue thoát nghẽn.
Sáng hôm sau gửi bù từ log.
5.3. Những thứ hay quên
| Vấn đề | Xử lý |
|---|---|
| Staging gửi nhầm cho khách thật | Listener NotificationSending chặn mọi địa chỉ ngoài allowlist khi app()->environment() !== 'production'. Đừng chỉ dựa vào MAIL_MAILER=log — kênh SMS/Slack không đi qua mail driver |
| Vendor giới hạn tốc độ (vd 10 tin/giây) | middleware($notifiable, $channel) trả [new RateLimited('sms-vendor')] cho riêng kênh đó. Kênh khác không bị chậm lây |
| Tiền SMS vượt dự toán | Đếm theo ngày trong Redis, vượt ngưỡng thì via() tự bỏ kênh sms và cảnh báo — đừng để phát hiện lúc nhận hoá đơn |
| Đổi config xong không thấy tác dụng | php artisan config:cache và restart queue worker. Worker là process chạy dài, nó giữ config cũ trong bộ nhớ — xem Scale out queue worker |
| Thêm kênh mới, worker cũ không biết class mới | Job cũ trong queue được serialize với class cũ. Thứ tự deploy: deploy code → restart worker → mới bật kênh trong config |
| Nội dung mỗi kênh mỗi khác, ai viết? | SMS ≤ 160 ký tự không dấu, Slack có markdown, mail có HTML. Người viết nội dung cần xem được bản xem trước — làm route /dev/notification-preview chỉ bật ở local |
6. Case thực tế và cách xử lý
Case 1 — "Đơn trên 10 triệu thì ping Slack kênh #ops"
Yêu cầu đến chiều thứ Sáu. Không sửa controller, không sửa service:
// Bước 1: channel Slack — 12 dòng, không cần package
class SlackWebhookChannel
{
public function send($notifiable, Notification $notification): void
{
Http::timeout(5)->post(config('notify.slack_webhook'), [
'text' => $notification->toSlack($notifiable),
])->throw();
}
}
// Bước 2: via() thêm điều kiện
public function via(object $notifiable): array
{
$channels = config('notify.channels.order_shipped', ['mail']);
if ($this->order->total >= 10_000_000) {
$channels[] = 'slack';
}
return array_unique($channels);
}
// Bước 3: toSlack()
public function toSlack($notifiable): string
{
return "Đơn lớn {$this->order->code} — ".number_format($this->order->total)."đ đã giao";
}
Bước 4: test đúng ngưỡng, không cần Slack thật:
Notification::fake();
$this->ship(Order::factory()->create(['total' => 9_999_999]));
Notification::assertSentTo($user, OrderShipped::class,
fn ($n, $channels) => ! in_array('slack', $channels, true));
Tổng: 1 file mới, 2 method sửa trong notification class, 0 dòng trong controller/service.
Case 2 — Vendor SMS chết 30 phút lúc cao điểm
Triệu chứng: queue tồn 40.000 job, khách phàn nàn mail cũng không tới, trang thanh toán quay vòng tròn rồi timeout.
flowchart TD A["Vendor SMS timeout 30s mỗi request"] --> B["Job SMS chiếm hết worker"] B --> C["Job mail xếp hàng sau — mail chậm theo"] A --> D["Nếu gửi đồng bộ: exception kéo sập kênh sau nó"] D --> E["User thấy màn hình lỗi dù đơn đã tạo xong"]
Xử lý theo thứ tự:
- Ngay lập tức: bật kill switch kênh
sms(§5.2). Queue thoát nghẽn trong vài phút, mail chạy lại bình thường. - Trong ngày: tách queue theo kênh bằng
viaQueues()và chạy worker riêng choq-sms. Vendor chết thì chỉ queue đó tồn. - Trong tuần: đặt
Http::timeout(5)trong channel class (mặc định của nhiều SDK là 30 giây hoặc vô hạn), vàbackoff()tăng dần cho job SMS. - Nếu tái diễn: circuit breaker — đếm số lần fail liên tiếp trong Redis, quá
ngưỡng thì
via()tự bỏ kênhsmstrong 5 phút. Đây là kill switch tự động. - Gửi bù: từ log
notification.failed, lọc theo khoảng thời gian, chạy command gửi lại đúng kênhsms:Notification::sendNow($users, new OrderShipped($order), ['sms'])— tham số thứ ba ép kênh, bỏ quavia().
Case 3 — Khách tắt hết thông báo rồi kêu không nhận được hoá đơn
Nguyên nhân: trang Cài đặt cho tắt tất cả, via() trả rỗng → không gửi gì và
không log gì (§3.3).
- Chia thông báo làm hai loại rõ ràng: giao dịch (đơn hàng, thanh toán, đổi mật khẩu) và tiếp thị. Chỉ loại tiếp thị mới được tắt hoàn toàn.
- Loại giao dịch:
requiredChannels()giữ lạimail— xem output lab của user Tu ở §1.7. - Giao diện Cài đặt phải nói thật: checkbox của kênh bắt buộc hiển thị mờ, kèm dòng "Thông báo đơn hàng luôn được gửi qua email".
- Thêm log khi
via()trả rỗng cho thông báo giao dịch — để lần sau thấy được trong metric thay vì đợi khách gọi.
Case 4 — 3h sáng: "khách nói không nhận được SMS"
Cần trả lời ba câu, theo thứ tự:
| Câu hỏi | Tra ở đâu | Nếu thiếu thì thêm gì |
|---|---|---|
| 1. Hệ thống có định gửi SMS không? | Log notification.sending hoặc tự log kết quả via() | Log via() trả về gì, kèm notification.id |
| 2. Có gọi vendor không, vendor trả gì? | Log notification.sent → trường response (message id) hoặc notification.failed | Channel class phải return response của vendor, đừng trả void |
| 3. Vendor có giao được không? | Dashboard của vendor, tra theo message id ở bước 2 | Nhận webhook trạng thái của vendor, lưu vào bảng riêng |
Không có notification.id trong log thì bước 1 và 2 không nối được với nhau — đó là
lý do phải log nó ngay từ đầu, không phải "thêm sau khi cần".
Case 5 — "Mail fail thì tự động chuyển sang SMS"
Laravel không có sẵn. Cách làm gọn nhất:
Event::listen(NotificationFailed::class, function (NotificationFailed $e) {
if ($e->channel !== 'mail' || ! $e->notification instanceof SupportsFallback) {
return;
}
// ép kênh sms, KHÔNG đi qua via() — nếu không sẽ lại gửi mail và fail vòng tròn
Notification::sendNow($e->notifiable, $e->notification, ['sms']);
});
Ba chỗ dễ sai:
- Vòng lặp vô tận nếu gọi lại
notify()(vìvia()lại trảmail). Bắt buộc dùng tham số thứ ba củasendNow()để ép kênh. - Gửi hai lần: mail fail ở lần retry thứ 3 nhưng lần 1 đã gửi được — cần khoá
theo
notification->id. - Chi phí: mail lỗi hàng loạt (SMTP sập) → bắn SMS cho toàn bộ user. Phải có hạn mức.
Trước khi làm fallback, hỏi lại: có thật sự cần không? Trong đa số hệ thống, gửi cả hai kênh cùng lúc cho nhóm thông báo quan trọng vừa đơn giản hơn vừa đáng tin hơn — và không có ba cái bẫy ở trên.
7. Câu hỏi phụ interviewer hay hỏi tiếp
| Câu hỏi | Trả lời ngắn |
|---|---|
| Đây là pattern gì? | Strategy (chọn kênh lúc chạy qua via()) + Adapter (channel class bọc SDK vendor). ChannelManager đóng vai factory, nhưng framework viết sẵn rồi |
| Strategy và Adapter khác nhau chỗ nào? | Strategy: nhiều cách làm cùng một việc, mình chọn cái nào. Adapter: một cái đã có sẵn nhưng sai hình dạng, mình bọc lại cho vừa. Ở đây "chọn mail hay sms" là Strategy, "bọc Twilio SDK thành send()" là Adapter |
| Sao không tự viết interface + factory cho chủ động? | Vì phải viết lại queue per-kênh, retry, Notification::fake(), event, on-demand. Nhiều code hơn, ít tính năng hơn, và người mới vào team phải học một hệ thống riêng thay vì thứ đã có trong tài liệu Laravel |
| OCP thể hiện ở đâu? | Thêm kênh = thêm 1 class + 1 method toX() + 1 dòng config. Không sửa controller, service, hay channel đang chạy |
| Notification khác Event/Listener và Job thế nào? | Event = "chuyện gì vừa xảy ra", nhiều bên cùng quan tâm. Job = "một việc cần làm nền". Notification = "báo cho một người qua một hay nhiều kênh". Thực tế hay xếp chồng: Event → Listener → notify() → job per-kênh |
| Một kênh chết thì sao? | Gửi đồng bộ: exception bay ra, các kênh đứng sau nó không chạy (đã chứng minh trong lab). Dùng ShouldQueue thì mỗi kênh là một job riêng, chỉ job đó retry |
| Gửi cho 100.000 user thì sao? | Đừng dùng notification per-user cho blast — 100k × số kênh job. Dùng chunk + batch + API bulk của vendor. Notification là cho thông báo giao dịch |
| Test thế nào? | Notification::fake(), rồi assertSentTo với closure nhận array $channels — test được chính logic chọn kênh mà không cần vendor |
| Cho user tự chọn kênh thì để logic ở đâu? | Trong via(), giao của (kênh ops bật) và (kênh user muốn), hợp với (kênh bắt buộc). Preference phải nằm sẵn trên model, đừng query trong via() |
| Đổi vendor SMS thì sửa bao nhiêu chỗ? | Một file: channel class. Notification class, controller, config danh sách kênh đều không đổi |
| Gửi trùng thì sao? | Job retry là gửi lại thật. Khoá theo $notification->id (UUID Laravel tự sinh, chung cho một lần notify()) trong Redis trước khi gọi vendor |
Tóm tắt
- Pattern cần: Strategy + Adapter. Laravel Notification đã là bản cài sẵn — đừng viết lại factory/manager bằng tay.
- Controller/service chỉ còn một dòng
$user->notify(new OrderShipped($order))và không bao giờ đổi nữa. - Danh sách kênh nằm trong
via(): cứng trong code → config/env → preference của user → ma trận có kênh bắt buộc. Chọn mức thấp nhất đủ dùng. - Thêm kênh mới = 1 channel class + 1 method
toX()+ 1 dòng config. - Bẫy phải nhớ: gửi đồng bộ thì một kênh throw là các kênh sau không chạy —
dùng
ShouldQueueđể mỗi kênh một job. - Vận hành: log
notification.id+channel+ message id của vendor ngay từ đầu, và chuẩn bị sẵn kill switch cho từng kênh.
Bài liên quan: Scale out queue worker · Idempotency key ở Redis · Xây codebase dùng chung cho team · Observability