Система отправки Firebase уведомлений через админ панель Admiral
Данная статья описывает пошаговый процесс создания системы отправки push-уведомлений через Firebase Cloud Messaging (FCM) в Laravel приложении с админ панелью Admiral.
Администраторы получат возможность отправлять индивидуальные сообщения пользователям, прикреплять изображения и указывать целевые страницы – всё это прямо из админ-панели, без обращения к разработчикам.
В статье рассмотрим: подготовку окружения, интеграцию FCM с Laravel, подключение модуля Admiral, настройку интерфейса и примеры кода для полноценной реализации.
🎯 Что мы создадим
Backend (Laravel)
- Модель NotificationBag для хранения уведомлений
- FormRequest для валидации данных
- Resources для структурированных API ответов
- Job для асинхронной отправки уведомлений
- FCM Service для интеграции с Firebase
- Controller с CRUD операциями
Frontend (Admiral админ панель)
- Страница списка уведомлений с фильтрами
- Форма создания уведомлений
- Страница просмотра деталей и статуса отправки
- Ajax поиск пользователей
Функциональность
- Отправка уведомлений всем пользователям или персонально
- Поддержка изображений в уведомлениях
- Настройка таргетов уведомлений
- Отслеживание статусов отправки
- Фильтрация по статусу, типу получателя, заголовку
- Обработка и отображение ошибок
Подготовка модели
Создаем миграцию для таблицы уведомлений:
Schema::create('notification_bags', function (Blueprint $table) {
$table->id();
$table->text('title');
$table->text('body')->nullable();
$table->string('status')->index();
$table->text('recipient_type')->index();
$table->foreignId('user_id')->nullable()->constrained('users')->cascadeOnDelete();
$table->text('error')->nullable();
$table->jsonb('target')->nullable();
$table->timestamps();
});Далее подготавливливаем необходимые енамы:
Статусы отправки - NotificationBagStatus:
* PENDING - Ожидание обработки
* SENT - Успешно отправлено
* FAILED - Ошибка при отправке
Тип получателя - NotificationBagRecipientType:
* ALL - Всем пользователям
* PERSONAL - Одному выбранному пользователю
Тип таргета - MessageTargetType:
* PAGE - Опр. Страница приложения
* POPUP - Всплывающее окно
Экраны приложения - MessageTargetPage (примеры):
* JOURNEYS - экран листинга путешествий
* FEEDBACK - экран обратной связи
Всплывающие окна - MessageTargetPopup (примеры):
* SHARE - окно “поделитесь приложением”
* SUBSCRIPTION - окно вариантов подписки
После прописывания всех енамов нужно написать класс для jsonb поля target
class NotificationBagTargetValue
{
public function __construct(
public ?MessageTargetType $type = null,
public MessageTargetPage|MessageTargetPopup|null $slug = null,
) {
}
public static function fromArray(array $data): static
{
$type = MessageTargetType::tryFrom(Arr::get($data, 'type'));
$slug = match ($type) {
MessageTargetType::PAGE => MessageTargetPage::tryFrom(Arr::get($data, 'slug')),
MessageTargetType::POPUP => MessageTargetPopup::tryFrom(Arr::get($data, 'slug')),
default => null
};
return new static(
$type,
$slug,
);
}
public function toJson(): string
{
return json_encode([
'type' => $this->type,
'slug' => $this->slug,
]);
}
}Поле Target вынесено в jsonb для обеспечения гибкой конфигурации. Класс можно легко расширить для направления пользователей на экраны с динамическими параметрами, такие как отдельные сущности с id или страницы каталога с предустановленными фильтрами.
Далее добавляем каст для модели:
class NotificationBagTarget implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes)
{
if (!$value) {
$value = [];
}
if (!is_array($value)) {
$value = json_decode($value, true, flags: JSON_THROW_ON_ERROR);
}
return NotificationBagTargetValue::fromArray($value ?: []);
}
public function set(Model $model, string $key, mixed $value, array $attributes)
{
if (!$value) {
return null;
}
if (!$value instanceof NotificationBagTargetValue) {
$value = NotificationBagTargetValue::fromArray(is_array($value) ? $value : []);
}
return $value->toJson();
}
}И саму NotificationBag модель
class NotificationBag extends Model implements HasMedia
{
use InteractsWithMedia;
public const IMAGE_COLLECTION = 'image';
protected $table = 'notification_bags';
protected $fillable = [
'status',
'recipient_type',
'title',
'body',
'user_id',
'target',
];
protected $casts = [
'status' => NotificationBagStatus::class,
'recipient_type' => NotificationBagRecipientType::class,
'target' => NotificationBagTarget::class,
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function registerMediaCollections(): void
{
$this->addMediaCollection(static::IMAGE_COLLECTION)->singleFile();
}
public function image(): MorphOne
{
return $this->morphOne(Media::class, 'model')
->where('collection_name', static::IMAGE_COLLECTION);
}
}Обратите внимание: в данном примере для работы с изображениями и файлами используется библиотека laravel-medialibrary. По умолчанию в прикрепленном репозитории настроена работа с локальным хранилищем (Storage), что приведет к неработоспособности функции прикрепления изображений к уведомлениям. FCM не сможет загрузить изображение из локальной сети. Для успешной отправки уведомлений с изображениями необходимо настроить medialibrary для работы с публично доступным хранилищем.
Создание CRUD контроллера и обработчика уведомлений
Вначале создадим форму для создания новых уведомлений с валидацией:
class NotificationBagRequest extends FormRequest
{
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'body' => ['nullable', 'string'],
'recipient_type' => ['required', new Enum(NotificationBagRecipientType::class)],
'user_id' => [
'nullable',
'integer',
'exists:users,id',
'required_if:recipient_type,' . NotificationBagRecipientType::PERSONAL->value
],
'target_type' => ['nullable', new Enum(MessageTargetType::class)],
'target_slug_page' => ['nullable', 'string', 'required_if:target_type,page'],
'target_slug_popup' => ['nullable', 'string', 'required_if:target_type,popup'],
'image' => ['nullable', 'image', 'max:2048'],
];
}
protected function prepareForValidation(): void
{
if ($this->recipient_type !== NotificationBagRecipientType::PERSONAL->value) {
$this->merge(['user_id' => null]);
}
}
}Сразу создадим ресурсы отдачи данныхДля индекса:
class NotificationBagIndexResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'body' => $this->body,
'status' => $this->status->description(),
'recipient_type' => $this->recipient_type->description(),
'target_type' => $this->target?->type?->description(),
'target_slug_page' => MessageTargetType::PAGE->eq($this->target?->type) ? $this->target->slug : null,
'target_slug_popup' => MessageTargetType::POPUP->eq($this->target?->type) ? $this->target->slug : null,
'target_enum' => $this->target?->slug?->description(),
'user_id' => $this->user_id,
'image' => $this->image ? [
'uid' => $this->image->uuid,
'url' => $this->image->getUrl(),
'type' => $this->image->mime_type,
'name' => $this->image->file_name,
] : null,
'error' => $this->error,
'created_at' => $this->created_at,
];
}
}Для просмотра одного уведомления:
class NotificationBagShowResource extends JsonResource
{
public function toArray(Request $request): array
{
/** @var NotificationBag $this */
return [
'id' => $this->id,
'title' => $this->title,
'body' => $this->body,
'status' => $this->status,
'recipient_type' => $this->recipient_type,
'target_type' => $this->target?->type,
'target_slug_page' => MessageTargetType::PAGE->eq($this->target?->type) ? $this->target->slug : null,
'target_slug_popup' => MessageTargetType::POPUP->eq($this->target->type) ? $this->target->slug : null,
'target_enum' => $this->target?->slug,
'user_id' => $this->user_id,
'image' => $this->image ? [
'uid' => $this->image->uuid,
'url' => $this->image->getUrl(),
'type' => $this->image->mime_type,
'name' => $this->image->file_name,
] : null,
'error' => $this->error,
'created_at' => $this->created_at,
];
}
}Также необходимо создать файл Values, который будет содержать все значения для селектов, используемых в формах создания, просмотра и фильтрации.
class NotificationBagValues
{
public function create(?NotificationBag $model = null): array
{
return [
'status' => NotificationBagStatus::options(),
'target_type' => MessageTargetType::options(),
'target_slug_page' => MessageTargetPage::options(),
'target_slug_popup' => MessageTargetPopup::options(),
'recipient_type' => NotificationBagRecipientType::options(),
'user_id' => User::query()
->when($model, fn ($query) => $query->orderByRaw("id = ? desc", [$model->user_id]))
->orderByDesc('id')
->limit(10)
->get()
->map(fn (User $user) => [
'value' => $user->id,
'label' => $user->name,
]),
];
}
public function update(NotificationBag $model): array
{
return $this->create($model);
}
public function filters(): array
{
return $this->create();
}
}И сам класс контроллера, содержащий логику фильтрации, создания, создания задачи в очереди для асинхронной обработки уведомления.
class NotificationBagController extends Controller
{
public function index(Request $request)
{
$notificationBags = NotificationBag::query()
->with(['user'])
->tap(Filter::make($request->input('filter'))
->scopes(
new TextFilter('title'),
new EqFilter('status'),
new EqFilter('recipient_type'),
new EqFilter('user_id'),
)
)
->tap(Sort::make($request->input('sort')))
->paginate($request->input('perPage'));
return [
'items' => NotificationBagIndexResource::collection($notificationBags->items()),
'meta' => MetaResource::make($notificationBags),
];
}
public function createForm(NotificationBagValues $values)
{
return [
'data' => [
'recipient_type' => NotificationBagRecipientType::ALL,
],
'values' => $values->create(),
];
}
public function create(NotificationBagRequest $request)
{
$data = $request->validated();
$notificationBag = new NotificationBag();
$notificationBag->fill([
'title' => $data['title'],
'body' => $data['body'] ?? null,
'status' => NotificationBagStatus::PENDING,
'recipient_type' => NotificationBagRecipientType::from($data['recipient_type']),
'user_id' => $data['user_id'] ?? null,
'target' => Arr::get($data, 'target_type') ? [
'type' => Arr::get($data, 'target_type'),
'slug' => match (MessageTargetType::tryFrom(Arr::get($data, 'target_type'))) {
MessageTargetType::PAGE => Arr::get($data, 'target_slug_page'),
MessageTargetType::POPUP => Arr::get($data, 'target_slug_popup'),
default => null
},
] : null,
]);
if (!NotificationBagRecipientType::PERSONAL->eq($notificationBag->recipient_type)) {
$notificationBag->user_id = null;
}
$notificationBag->save();
if (isset($data['image']) && $data['image'] instanceof UploadedFile) {
$notificationBag
->addMediaFromRequest('image')
->toMediaCollection(NotificationBag::IMAGE_COLLECTION);
}
NotifyFromAdmin::dispatch($notificationBag);
}
public function show(int $id, NotificationBagValues $values)
{
$notificationBag = NotificationBag::with(['user'])->findOrFail($id);
return [
'data' => new NotificationBagShowResource($notificationBag),
'values' => $values->update($notificationBag),
];
}
public function destroy(int $id)
{
$notificationBag = NotificationBag::findOrFail($id);
$notificationBag->delete();
}
public function filters(NotificationBagValues $values)
{
return [
'options' => $values->filters(),
];
}
public function ajaxSelect(string $field, Request $request)
{
$query = $request->input('query');
return match ($field) {
'user_id' => User::query()
->when($query, fn ($builder) => $builder->where('name', 'ilike', '%' . $query . '%'))
->limit(10)
->get()
->map(fn (User $user) => [
'value' => $user->id,
'label' => $user->name,
]),
default => []
};
}
}Далее создаем задачу для обработки уведомления с ленивым условным получением пользователей:
class NotifyFromAdmin implements ShouldQueue
{
use Queueable, Dispatchable, SerializesModels, InteractsWithQueue;
public function __construct(
private NotificationBag $notificationBag
) {}
public function handle(FCMService $fcmService): void
{
try {
$recipients = $this->getRecipients();
$message = [
'title' => $this->notificationBag->title,
'body' => $this->notificationBag->body,
'image' => $this->notificationBag->getFirstMediaUrl(NotificationBag::IMAGE_COLLECTION),
];
if ($this->notificationBag->target) {
$message['data'] = [
'target_type' => $this->notificationBag->target->type->value,
'target_slug' => $this->notificationBag->target->slug->value,
];
}
foreach ($recipients as $users) {
$tokens = $users->pluck('fcm_token')->toArray();
$fcmService->sendMessage($tokens, $message);
}
$this->notificationBag->update([
'status' => NotificationBagStatus::SENT,
'error' => null,
]);
} catch (\Exception $e) {
$this->notificationBag->update([
'status' => NotificationBagStatus::FAILED,
'error' => $e->getMessage(),
]);
Log::error('Failed to send notification', [
'notification_id' => $this->notificationBag->id,
'error' => $e->getMessage(),
]);
}
}
private function getRecipients(): LazyCollection
{
$query = User::query()
->whereNotNull('fcm_token');
if ($this->notificationBag->recipient_type->eq(NotificationBagRecipientType::PERSONAL)) {
$query->where('id', $this->notificationBag->user_id);
}
return $query->lazy()->chunk(2000);
}
}Для создания сервиса отправки пуш уведомлений (FCMService) необходимо подключить и настроить библиотеку kreait/firebase-php в соответствии с ее документацией.
FCMService будет отправлять уведомления пакетами, чтобы избежать чрезмерного количества запросов или задач на отправку.
class FCMService
{
public function __construct(
protected Messaging $messaging
) {
}
public function sendMessage(array $tokens, array $messageData): void
{
$notification = Notification::create(
$messageData['title'],
$messageData['body'],
$messageData['image'] ?? null,
);
$message = CloudMessage::new()
->withNotification($notification);
if (isset($messageData['data'])) {
$message = $message->withData($messageData['data']);
}
$messages = [];
foreach ($tokens as $registrationToken) {
$messages[] = $message->toToken($registrationToken);
}
$this->messaging->sendAll($messages);
}
}Последнее, что стоит сделать, это добавить роуты в файл роутинга admin.php:
Route::prefix('notifications/notification-bag')->group(function () {
Route::get('', [Controllers\NotificationBagController::class, 'index']);
Route::get('filters', [Controllers\NotificationBagController::class, 'filters']);
Route::get('create', [Controllers\NotificationBagController::class, 'createForm']);
Route::post('', [Controllers\NotificationBagController::class, 'create']);
Route::get('{id}/update', [Controllers\NotificationBagController::class, 'show']);
Route::delete('{id}', [Controllers\NotificationBagController::class, 'destroy']);
Route::get('/ajax-select/{field}', [Controllers\NotificationBagController::class, 'ajaxSelect']);
});Наш бэкенд готов, можно переходить к созданию страниц в админ панели.
Создание страниц в админ панели Admiral
После создания backend части, необходимо создать интерфейс для управления уведомлениями в админ панели.
Структура файлов админ панели
admin/
├── pages/notifications/notification-bag/
│ ├── index.tsx # Страница списка уведомлений
│ ├── create.tsx # Страница создания уведомления
│ └── [id].tsx # Страница просмотра/редактирования
└── src/crud/notifications/
└── notification-bag.tsx # CRUD компонентСоздание CRUD компонента
Создаем базовый CRUD-компонент, включающий формы и таблицу для управления уведомлениями. Этот компонент:
- Поддерживает создание, просмотр и удаление уведомлений
- Содержит зависимые поля: тип получателя с идентификатором пользователя, и тип цели с выбором экрана или всплывающего окна.
- При просмотре уведомления все поля формы доступны только для чтения
- При просмотре отображается информация об ошибке отправки, если таковая имеется.
// admin/src/crud/notifications/notification-bag.tsx
import React, {useEffect} from 'react'
import { AjaxSelectInput, BackButton, Button, createCRUD, DeleteAction, FilePictureInput, Form, SelectInput, TextInput, useForm } from '@devfamily/admiral'
import api from '@/src/config/api'
import * as Icons from "react-icons/fi";
import {FileField, DateField} from "../components/Fields";
const resource = 'notifications/notification-bag'
const path = '/notifications/notification-bag'
const TargetInput = ({isUpdate = false, disabled}: { isUpdate?: boolean, disabled?: boolean }) => {
const {values, setValues} = useForm()
useEffect(() => {
if (values.target_type === 'page') {
setValues((prev: any) => {
return {...prev, target_slug_popup: null}
})
} else if (values.target_type === 'popup') {
setValues((prev: any) => {
return {...prev, target_slug_page: null}
})
}
}, [values.target_type]);
return (
<>
<SelectInput disabled={disabled || isUpdate} name={'target_type'} label={'Target type'}
columnSpan={!values.target_type ? 2 : 1}/>
{values.target_type === 'page' &&
<SelectInput disabled={disabled || isUpdate} name={'target_slug_page'} label={'Target Page'}/>}
{values.target_type === 'popup' &&
<SelectInput disabled={disabled || isUpdate} name={'target_slug_popup'} label={'Target Popup'}/>}
</>
)
}
const CreateFields = () => {
const {values} = useForm()
return (
<>
<FilePictureInput maxCount={1} label='Image' name='image' columnSpan={2}
accept='image/*'/>
<TextInput label='Title' name='title' required columnSpan={2}/>
<TextInput label='Body' name='body' columnSpan={2}/>
<SelectInput name='recipient_type' label='Recipients' columnSpan={2}/>
<TargetInput/>
{values.recipient_type === 'personal' &&
<AjaxSelectInput
fetchOptions={(field, query) => api.getAjaxSelectOptions(resource, field, query)}
label='User' name='user_id' columnSpan={2}/>
}
</>
)
}
const UpdateFields = () => {
const {values} = useForm()
return (
<>
<FilePictureInput disabled maxCount={1} label='Image' name='image' columnSpan={2}
accept='image/*'/>
<TextInput disabled label='Title' name='title' required columnSpan={2}/>
<TextInput disabled label='Body' name='body' columnSpan={2}/>
<SelectInput disabled name='recipient_type' label='Recipients' columnSpan={2}/>
<TargetInput isUpdate={true}/>
{values.recipient_type === 'personal' &&
<AjaxSelectInput disabled
fetchOptions={(field, query) => api.getAjaxSelectOptions(resource, field, query)}
label='User' name='user_id' columnSpan={2}/>
}
{values.error &&
<div style={{color: "red", fontWeight: "bold", gridColumn: "1 / -1"}}>
<h2>Error</h2>
<div>{values.error}</div>
</div>
}
<Form.Footer>
<BackButton basePath={resource}>Back</BackButton>
</Form.Footer>
</>
)
}
export const CRUD = createCRUD({
path: path,
resource: resource,
index: {
title: 'Notifications',
newButtonText: 'Add',
tableActions: {
title: 'Actions',
key: 'actions',
fixed: 'right',
width: 140,
render: (_value, record: any) => {
return (
<div style={{display: 'flex'}}>
<Button
type="button"
view="clear"
size="S"
onClick={() => window.location.href = `${path}/${record.id}`}
iconLeft={<Icons.FiEye/>}
/>
<DeleteAction resource={`${resource}`} id={record.id}/>
</div>
)
},
},
tableColumns: [
{
sorter: true,
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 90,
},
{
sorter: false,
title: 'Image',
dataIndex: 'image',
key: 'image',
width: 150,
render: (value) => <FileField file={value} />
},
{
sorter: false,
title: 'Status',
dataIndex: 'status',
key: 'status',
width: 150,
},
{
sorter: false,
title: 'Recipients',
dataIndex: 'recipient_type',
key: 'recipient_type',
},
{
sorter: false,
title: 'Target',
dataIndex: 'target_enum',
key: 'target_enum',
},
{
sorter: false,
title: 'Title',
dataIndex: 'title',
key: 'title',
},
{
sorter: false,
title: 'Body',
dataIndex: 'body',
key: 'body',
},
{
sorter: true,
title: 'Created at',
dataIndex: 'created_at',
key: 'created_at',
render: (value, record) => <DateField value={value} />,
},
],
},
form: {
create: {
fields: <CreateFields/>,
},
edit: {
children: <UpdateFields/>,
},
},
create: {
title: 'Send new notification'
},
filter: {
topToolbarButtonText: 'Filter',
fields: (
<>
<TextInput label='Title' name='title'/>
<SelectInput label='Status' name='status'/>
<SelectInput label='Recipient Type' name='recipient_type'/>
</>
),
},
update: {
title: (id: string) => `Notification #${id}`,
},
})Создание страниц
1. Страница списка (Index)
// admin/pages/notifications/notification-bag/index.tsx
import {CRUD} from '@/src/crud/notifications/notification-bag'
export default CRUD.IndexPage2. Страница создания
// admin/pages/notifications/notification-bag/create.tsx
import {CRUD} from '@/src/crud/notifications/notification-bag'
export default CRUD.CreatePage3. Страница просмотра/редактирования
// admin/pages/notifications/notification-bag/[id].tsx
import {CRUD} from '@/src/crud/notifications/notification-bag'
export default CRUD.UpdatePageНастройка навигации
Добавляем пункт меню в навигацию админ панели:
// admin/src/config/menu.tsx
const CustomMenu = () => {
return <Menu>
/// ... Предыдущие пункты меню
<MenuItemLink icon={'FiSend'} name='Notifications' to='/notifications/notification-bag' />
</Menu>
}
export default CustomMenuИтоговая страница уведомлений выглядит так:

Итоговая отправка уведомлений будет выглядеть следующим образом: