Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: Сповіщення

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

6 питань

Mailable - це лист. Notification - це повідомлення, яке може піти листом, у базу, в Slack чи кудись ще, і канал вибирається окремо від змісту.

Створення:

php artisan make:notification VacancyPublished
class VacancyPublished extends Notification
{
    public function __construct(public Vacancy $vacancy)
    {
    }

    public function via(object $notifiable): array
    {
        return ['mail', 'database'];
    }

    public function toMail(object $notifiable): MailMessage
    {
        return (new MailMessage)
            ->subject('Вашу вакансію опубліковано')
            ->line("«{$this->vacancy->title}» тепер видно всім.")
            ->action('Переглянути', route('vacancies.show', $this->vacancy));
    }

    /**
     * @return array<string, mixed>
     */
    public function toArray(object $notifiable): array
    {
        return ['vacancy_id' => $this->vacancy->id];
    }
}

Відправлення:

$user->notify(new VacancyPublished($vacancy));

// або багатьом
Notification::send($users, new VacancyPublished($vacancy));

Метод notify() доступний завдяки трейту Notifiable на моделі.

Що дає канал database: сповіщення зберігається в таблиці notifications, і з нього робиться «дзвіночок» в інтерфейсі - $user->unreadNotifications.

Коли достатньо Mailable: одноразовий лист без варіантів доставки - чек, звіт, підтвердження форми. Notification доречний, коли те саме повідомлення має йти різними каналами або користувач сам обирає, як його повідомляти.

Докладніше в документації: Сповіщення

Канал database зберігає сповіщення в таблиці notifications - так будують «дзвіночок» з лічильником непрочитаних в інтерфейсі.

Таблиця:

php artisan make:notifications-table
php artisan migrate

Таблиця містить id (UUID), type (клас сповіщення), поліморфний notifiable, data (JSON), read_at і мітки часу.

Сповіщення:

class InvoicePaid extends Notification
{
    public function __construct(public Invoice $invoice) {}

    public function via(object $notifiable): array
    {
        return ['mail', 'database'];
    }

    public function toArray(object $notifiable): array
    {
        return [
            'invoice_id' => $this->invoice->id,
            'amount' => $this->invoice->amount,
            'url' => route('invoices.show', $this->invoice),
        ];
    }
}

Масив з toArray (чи toDatabase) кодується в JSON і зберігається в колонці data. Зберігайте в ньому все потрібне для показу: ідентифікатори, короткий текст, посилання - а не лише ID, щоб для списку сповіщень не довелося підвантажувати десяток моделей.

Читання:

$user->notifications;          // усі, найновіші першими
$user->unreadNotifications;    // read_at = null
$user->unreadNotifications()->count();
@foreach (auth()->user()->unreadNotifications as $notification)
    <a href="{{ $notification->data['url'] }}">
        Рахунок на {{ $notification->data['amount'] }} оплачено
    </a>
@endforeach

Позначити прочитаними:

$notification->markAsRead();
$user->unreadNotifications->markAsRead();                     // колекцію
$user->unreadNotifications()->update(['read_at' => now()]);   // одним запитом

Останній варіант кращий для «позначити все»: не завантажує сповіщення в пам'ять.

Що варто врахувати:

  • зміна структури data: старі записи лишаються у старому форматі. Шаблон має переживати відсутні ключі, а метод databaseType дозволяє зберігати стабільний тип замість імені класу (зручно, якщо клас перейменують);
  • таблиця росте безкінечно - старі прочитані сповіщення варто видаляти за розкладом;
  • індекс на (notifiable_type, notifiable_id, read_at) прискорює підрахунок непрочитаних;
  • для показу в реальному часі додають канал broadcast.

Докладніше в документації: Сповіщення: сповіщення в базі даних

Notification - одне повідомлення, яке можна доставити кількома каналами одночасно.

class InvoicePaid extends Notification
{
    public function via(object $notifiable): array
    {
        return ['mail', 'database', 'broadcast'];
    }

    public function toMail($notifiable): MailMessage { /* ... */ }
    public function toArray($notifiable): array { /* для database */ }
}

$user->notify(new InvoicePaid($invoice));

Канали з коробки: mail, database (зберігає в notifications), broadcast (WebSockets), Vonage (SMS), Slack. Є community-канали (Telegram, push). Канал database зручний для «дзвіночка» сповіщень у UI; реалізувавши ShouldQueue, відправку виносять у чергу.

Докладніше в документації: Notifications

Звичайно сповіщення надсилають моделі з трейтом Notifiable: $user->notify(...). Модель сама знає свої адреси - email для пошти, phone для SMS (через методи routeNotificationFor...).

Але інколи отримувач - не користувач: гість, що залишив заявку, адреса з форми «Повідомити, коли з'явиться товар», канал Slack команди, email бухгалтерії.

Сповіщення на льоту (on-demand) - через фасад з явною маршрутизацією:

use Illuminate\Support\Facades\Notification;

Notification::route('mail', 'accounting@example.com')
    ->route('slack', '#billing')
    ->notify(new InvoicePaid($invoice));

Можна вказати ім'я отримувача пошти:

Notification::route('mail', ['buyer@example.com' => 'Олена Петренко'])
    ->notify(new OrderReceived($order));

Як це працює всередині: route() створює об'єкт AnonymousNotifiable, який зберігає маршрути для каналів. Сповіщення отримує його як $notifiable у методах via() і toMail().

Що з цього випливає:

  • via() має працювати з анонімним отримувачем. Якщо ви вибираєте канали за налаштуваннями користувача ($notifiable->prefers_sms), для анонімного отримувача таких властивостей немає:
public function via(object $notifiable): array
{
    return $notifiable instanceof AnonymousNotifiable
        ? ['mail']
        : $notifiable->notificationChannels();
}
  • канал database недоступний - немає моделі, до якої прив'язати запис;
  • немає бажаної локалі - мову доведеться задати явно: ->locale('uk');
  • маршрут береться з route(), а не з routeNotificationForMail.

Типові задачі:

  • службові сповіщення команді (новий платіж у Slack, помилка імпорту на пошту);
  • листи гостям до реєстрації;
  • кілька отримувачів: Notification::send($users, ...) для моделей і route() для зовнішніх адрес.

Тести:

Notification::fake();
// ...
Notification::assertSentOnDemand(InvoicePaid::class,
    fn ($notification, $channels, $notifiable) => $notifiable->routes['mail'] === 'accounting@example.com');

Докладніше в документації: Сповіщення: сповіщення на льоту

Вбудованих каналів чотири - mail, database, broadcast, vonage. Усе інше - Slack, Telegram, SMS українського провайдера, пуші - або пакет, або власний канал.

Канал - це клас з одним методом:

class TelegramChannel
{
    public function __construct(private TelegramClient $client)
    {
    }

    public function send(object $notifiable, Notification $notification): void
    {
        $chatId = $notifiable->routeNotificationFor('telegram');

        if ($chatId === null) {
            return;
        }

        $this->client->sendMessage($chatId, $notification->toTelegram($notifiable));
    }
}

Підключення в сповіщенні:

class VacancyPublished extends Notification
{
    public function via(object $notifiable): array
    {
        return [TelegramChannel::class, 'mail'];
    }

    public function toTelegram(object $notifiable): string
    {
        return "Нова вакансія: {$this->vacancy->title}";
    }
}

Куди слати - вирішує сам отримувач:

class User extends Authenticatable
{
    public function routeNotificationForTelegram(): ?string
    {
        return $this->telegram_chat_id;
    }
}

Навіщо це, а не просто виклик API. Сповіщення дає те, чого ручний виклик не має: один клас описує повідомлення для всіх каналів одразу, via() вирішує канали за налаштуваннями користувача, ShouldQueue відправляє асинхронно, а Notification::fake() дозволяє перевірити відправлення в тесті без мережі.

Докладніше в документації: Сповіщення

Сповіщення з інтерфейсом ShouldQueue надсилаються у фоні. Важлива деталь реалізації: для кожного отримувача і кожного каналу Laravel ставить в чергу окреме завдання.

Сповіщення з каналами ['mail', 'database', 'slack'] для 1000 користувачів - це 3000 завдань.

Наслідки:

  • канали незалежні: якщо Slack недоступний, пошта й запис у базі все одно пройдуть. Повторюватиметься лише завдання Slack;
  • порядок не гарантований: запис у базі може з'явитися раніше чи пізніше за лист;
  • масові сповіщення створюють сплеск завдань - варто винести їх в окрему чергу.

Різні черги й підключення для каналів:

public function viaQueues(): array
{
    return [
        'mail' => 'mail',
        'slack' => 'notifications-slow',
        'database' => 'default',
    ];
}

Так повільний чи нестабільний канал не затримує швидкі. Аналогічно viaConnections() для різних підключень черги, а withDelay() - для різних затримок по каналах.

Остаточна перевірка у воркері - shouldSend:

public function shouldSend(object $notifiable, string $channel): bool
{
    return $this->invoice->fresh()->isUnpaid();
}

Між постановкою в чергу й обробкою можуть минути хвилини (при затримці - години). Нагадування про неоплачений рахунок, який уже оплатили, - типовий баг. shouldSend викликається у воркері окремо для кожного каналу, і false скасовує надсилання.

Транзакції - afterCommit:

$user->notify((new InvoicePaid($invoice))->afterCommit());

Без нього воркер може обробити сповіщення до коміту і не знайти запис або надіслати сповіщення про те, що потім відкотилося.

Збої:

  • метод failed(Throwable $e) на класі сповіщення викликається, коли завдання вичерпало спроби;
  • $tries, backoff(), retryUntil() і middleware завдань (RateLimited для API месенджерів) працюють як у звичайних завданнях;
  • події NotificationSending (можна скасувати, повернувши false), NotificationSent і NotificationFailed - для аудиту й метрик.

Ідемпотентність: при повторі після тайм-ауту лист чи SMS може піти двічі - провайдер прийняв повідомлення, але відповідь не дійшла. Для критичних каналів зберігайте ID сповіщення (він спільний для всіх каналів одного надсилання) і перевіряйте, чи його вже доставлено.

Докладніше в документації: Сповіщення: черги