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 + NotifierFactory là 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ủa via().

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*.php trong container, chỉ require autoload của project ở chế độ đọc — không thêm file nào vào project laravel-livewire. Mail đi qua driver array/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 classMộ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 patternNhiề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 patternBọc một API lạ (SDK Twilio, webhook Slack) về đúng hình dạng mà hệ thống mình mong đợi
driver managerCơ chế của Laravel: một tên chuỗi ('sms') → một object driver. ChannelManager là driver manager của notification
fan-outMột sự kiện → nhiều đích đến cùng lúc (một đơn giao xong → mail + SMS + Slack)
on-demand notificationGửi cho một địa chỉ rời, người nhận chưa có tài khoản trong hệ thống
kill switchCô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

PatternVai trò ở đâyTrong Laravel là cái gì
StrategyMỗi kênh là một cách gửi thay thế nhau được. Chọn kênh lúc chạyvia() trả về danh sách kênh; ChannelManager resolve tên kênh ra object
AdapterBọc SDK ngoài (Twilio, webhook Slack) về một hình dạng chungChannel class — chỉ cần có method send($notifiable, $notification)
Factory / Abstract Factory—Không cần viết. ChannelManager (kế thừa Illuminate\Support\Manager) đã là factory
Observer tự viết—Không cần. Có sẵn event NotificationSending / NotificationSent / NotificationFailed
Template Method—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ại Illuminate\Notifications\ChannelManager bằng tay, dài hơn, ít tính năng hơn (mất queue per-kênh, mất Notification::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ảnhBiế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 classNội dung mỗi kênh + danh sách kênhGọi API nhà cung cấp thế nào
Channel classCách nói chuyện với nhà cung cấpNội dung nghiệp vụ
Notifiable (User)Địa chỉ của mình ở từng kênhNộ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ứcNguồnĐổi kênh cần gìKhi nào dùng
0Cứng trong code: return ['mail','sms'];Sửa code + deployDưới 3 loại thông báo, kênh gần như không đổi
1Config + envĐổi env + config:cache + restart workerMặc định nên chọn. Ops bật/tắt kênh được, không cần dev
2Preference của user trong DBUser tự bấm trong trang Cài đặtCó yêu cầu "cho khách chọn nhận thông báo kiểu gì"
3Ma trận: config × preference × kênh bắt buộcCả haiKhi 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 JSON notification_prefs, đã select cù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ăngThê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êngCó sẵn, không phải code. Xem §2.1
Retry / failed job có sẵntries, backoff, bảng failed_jobs dùng chung với queue — xem Scale out queue worker
Test không cần vendor thậtNotification::fake() + assert đúng kênh nào được chọn. Xem §2.2
Gửi cho người chưa có accountNotification::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àiListener 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ì saoXử lý
Không có fallback chainLaravel 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ùngJob 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ậtKhoá theo $notification->id (Laravel tự sinh UUID) trong Redis trước khi gọi vendor — xem Idempotency key ở Redis
Notification class phình to5 kênh = 5 method toX() trong một file, dễ 300 dòngTá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ạt100.000 user × 3 kênh = 300.000 job. Queue database sẽ chếtMarketing 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 dependencylaravel/slack-notification-channel, laravel/vonage-notification-channel… mỗi package là một thứ phải nâng cấpKê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ặngsendNow() có if (empty($viaChannels)) continue; — không log, không lỗiNế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àmChọn khiMấ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 địnhTừ 2 kênh trở lên, hoặc biết trước sẽ thêm kênhPhải học cách framework nghĩ. Gửi đồng bộ có bẫy §3.1
Tự viết NotifierInterface + factoryGầ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áoThê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) → listener SendShipmentNotification → $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.

NotificationFailed khô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ên NotificationSent.

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ậtListener 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ụngphp 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ớiJob 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ự:

  1. 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.
  2. Trong ngày: tách queue theo kênh bằng viaQueues() và chạy worker riêng cho q-sms. Vendor chết thì chỉ queue đó tồn.
  3. 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.
  4. 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ênh sms trong 5 phút. Đây là kill switch tự động.
  5. Gửi bù: từ log notification.failed, lọc theo khoảng thời gian, chạy command gửi lại đúng kênh sms: Notification::sendNow($users, new OrderShipped($order), ['sms']) — tham số thứ ba ép kênh, bỏ qua via().

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).

  1. 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.
  2. Loại giao dịch: requiredChannels() giữ lại mail — xem output lab của user Tu ở §1.7.
  3. 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".
  4. 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ỏiTra ở đâuNế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.failedChannel 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 2Nhậ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:

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ỏiTrả 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

Bài liên quan: Scale out queue worker · Idempotency key ở Redis · Xây codebase dùng chung cho team · Observability