Перейти к содержанию

Уведомление после фиксации заявки

Оглавление серии «Laravel 13 и PHP: портал «Заявка»»

После закрытия сотруднику полезно увидеть сообщение в личном кабинете. Добавим database notification, выполняемую через очередь после commit. Результат урока — частное уведомление определённому владельцу и чтение его списка из текущей сессии. Мы не отправляем реальную почту и не утверждаем, что квитанция уже готова: уведомление сообщает только зафиксированный факт закрытия.

Notifications предоставляет общий интерфейс нескольких каналов, но каждый канал имеет собственный результат и отказ. Для этой серии выбираем database: строка уведомления принадлежит пользователю и показывается через Blade. MAIL_MAILER log остаётся настройкой базы, однако новый класс не выбирает mail. Поэтому появление notification не является выполненной отправкой письма, даже если имя технологии ассоциируется с внешними сообщениями.

Таблица уведомлений

Создадим полный database/migrations/2026_10_11_000300_create_notifications_table.php. UUID идентифицирует конкретное уведомление, morphs связывает получателя с моделью, data хранит выбранную полезную нагрузку. Стандартный User базы уже использует Notifiable.

<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
    public function up(): void
    {
        Schema::create('notifications', function (Blueprint $table) {
            $table->uuid('id')->primary();
            $table->string('type');
            $table->morphs('notifiable');
            $table->text('data');
            $table->timestamp('read_at')->nullable();
            $table->timestamps();
        });
    }
    public function down(): void { Schema::dropIfExists('notifications'); }
};

Ниже полный app/Notifications/TicketClosed.php. ShouldQueue отделяет канал от HTTP, afterCommit в конструкторе согласует момент обработки с транзакцией закрытия. В данные включены только id заявки и текст факта, а не весь объект с частными полями.

<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
class TicketClosed extends Notification implements ShouldQueue
{
    use Queueable;
    public function __construct(public int $ticketId) { $this->afterCommit(); }
    public function via(object $notifiable): array { return ['database']; }
    public function toDatabase(object $notifiable): array
    {
        return ['ticket_id' => $this->ticketId, 'message' => 'Ваша заявка закрыта.'];
    }
}

Документация Laravel Notifications и Context7 подтверждают queued notification с afterCommit. В нашем закрытии импортируется TicketClosed, а после dispatch квитанции добавляется следующий точный фрагмент. Полный контроллер состояния содержит обе операции.

$request->user()->notify(new TicketClosed($ticket->id));

Получатель берётся из сессии, совпадающей с владельцем по policy close. Не выбираем его из произвольного адреса в форме. Если в будущем менеджер сможет закрывать чужую заявку, эта строка должна будет получить именно ticket user, а не автора команды. Новая роль изменит источник получателя, поэтому текущее правило нельзя без пересмотра переносить на расширенную модель полномочий.

Чтение личных уведомлений

Создадим полный app/Http/Controllers/NotificationController.php. Relation notifications пользователя ограничивает выборку владельцем до вывода. Последние twenty строк достаточны для первого учебного экрана, но это не полноценная история с переходом ко всем ранним сообщениям.

<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class NotificationController extends Controller
{
    public function __invoke(Request $request)
    {
        $notifications = $request->user()->notifications()->latest()->limit(20)->get();
        return view('notifications.index', compact('notifications'));
    }
}

Полный resources/views/notifications/index.blade.php читает полезную нагрузку и формирует ссылку через известный маршрут. Body заявки не копируется в data сообщения.

@extends('layouts.app')
@section('content')
<h1>Мои уведомления</h1>
<ul>@forelse($notifications as $notification)
  <li>{{ $notification->data['message'] }}
    <a href="{{ route('tickets.show', $notification->data['ticket_id']) }}">Открыть заявку</a>
  </li>
@empty<li>Уведомлений пока нет.</li>@endforelse</ul>
@endsection

Добавьте маршрут с auth и импорт NotificationController. В общий header добавляется обычная ссылка только для вошедшего пользователя. Оба полных файла находятся в архивном состоянии.

Route::get('/notifications', NotificationController::class)->middleware('auth')->name('notifications.index');
@auth<a href="{{ route('notifications.index') }}">Уведомления</a>@endauth

Подтверждение закрытия и появление сообщения

Ожидаемая последовательность содержит commit заявки, обработку queued notification и чтение нового списка. Если worker ещё не запускался, уведомление может отсутствовать, хотя карточка уже closed. Это не противоречие двух статусов: одно относится к предметной записи, другое к фоновой доставке. Поэтому сообщение о close не обещает, что все каналы уже завершены.

Если transaction откатывается до закрытия, отложенное уведомление не должно сообщать о незафиксированном результате. Полезный неправильный случай — синхронный notify внутри transaction без afterCommit: внешний или иной обработчик мог бы увидеть решение до его фиксации. Выбранный класс содержит этот порядок явно, а не рассчитывает на случайную медленность worker.

Database channel даёт строку частного кабинета, но не обязательно гарантию ровно одной доставки при любых повторах. Рабочая система, которой недопустимы дубли, должна определить идемпотентный идентификатор предметного события и способ записи. Здесь уведомление служит удобством просмотра, а основная история closure остаётся устойчивым результатом транзакции. Нельзя объявить очередь системой exactly once только из-за UUID уведомления.

Частная полезная нагрузка

В data не хранится произвольный URL от клиента. Ссылка создаётся серверным route по ticket_id, который назначен нашей операцией. Даже если пользователь изменит id в собственном адресе, TicketShowController снова проверит policy. Доступность уведомления не является бессрочным правом просмотра объекта: когда полномочия или данные изменятся, конечный маршрут обязан выполнить свою проверку.

Нет маршрута отметки прочитанного, поэтому read_at остаётся незаполненным и список не обещает «непрочитанные». Для такого действия потребовался бы отдельный POST или PATCH с проверкой, что уведомление принадлежит текущему пользователю. Прямой поиск notification по UUID без relation пользователя был бы ошибкой, даже если UUID трудно угадать. Устойчивый идентификатор не заменяет право объекта.

Изменение общей навигации не открывает список гостю. @auth помогает интерфейсу, а middleware защищает запрос. Эти две части снова выполняют разные задачи. Для самостоятельного разбора сравните пустой список из успешно выполненного запроса и отсутствие сообщения из незапущенной очереди. Следующий урок добавит PDF-вложение, где частный путь файла тоже потребует контролируемого чтения через право заявки.

Событие и полезная нагрузка

Текст уведомления формируется приложением, а не копируется из произвольного сообщения пользователя. Поэтому получатель получает одинаковое объяснение закрытия, даже если body заявки содержит длинные инструкции или разметку. Уведомление не должно становиться вторым полным архивом всех частных данных. Для короткой подсказки достаточно стабильного ticket_id и предметного сообщения, а подробности читает уже защищённая карточка.

При изменении формата data нужно помнить о существующих строках notifications. Новый шаблон, ожидающий обязательный ключ нового вида, может перестать читать прежние сообщения. В текущей серии формат один, но дальнейшее сопровождение потребует версионирования полезной нагрузки или совместимого чтения. Изменение класса notification не переписывает ранее сохранённый JSON автоматически.

Latest упорядочивает сообщения по created_at. Если для продукта важен однозначный порядок равных моментов, следует добавить дополнительный критерий. В маленьком учебном списке мы не обещаем порядок между одновременно созданными уведомлениями. История закрытия остаётся упорядоченной по id comments и является другим источником факта.

Отсутствие notification сразу после close может быть нормальным ожиданием очереди, но длительная недоступность уже требует разбора процесса. Клиент не должен бесконечно говорить «всё доставлено» по факту успешного POST. Для рабочего кабинета полезны наблюдаемые pending и failed, если уведомление является значимым результатом. Наша страница показывает только реально сохранённые database-строки.

Для самостоятельного воспроизведения результата «Создать частное уведомление владельцу» выберите lesson-13 в архиве исходников. Статус выполнения этого состояния: not_run.