This article describes a step-by-step process for creating a push notification sending system using Firebase Cloud Messaging (FCM) in a Laravel application with the Admiral admin panel.Administrators will be able to send individual messages to users, attach images, and specify target pages – all directly from the admin panel, without involving developers.
The article covers environment setup, FCM integration with Laravel, connecting the Admiral module, configuring the interface, and providing code examples for a complete implementation.
🎯 What We’ll Build
Backend (Laravel)
- NotificationBag model for storing notifications
- FormRequest for data validation
- Resources for structured API responses
- Job for asynchronous notification sending
- FCM Service for Firebase integration
- Controller with CRUD operations
Frontend (Admiral admin panel)
- Notification list page with filters
- Notification creation form
- Detail view page with sending status
- Ajax user search
Functionality
- Send notifications to all users or individually
- Support for images in notifications
- Configurable notification targets
- Track sending statuses
- Filter by status, recipient type, title
- Handle and display errors
Model Setup
Create a migration for the notifications table:
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();
});Next, we prepare the required enums:
Sending statuses — NotificationBagStatus:
- PENDING — Waiting for processing
- SENT — Successfully sent
- FAILED — Error during sending
Recipient type — NotificationBagRecipientType:
- ALL — All users
- PERSONAL — One selected user
Target type — MessageTargetType:
- PAGE — Specific application page
- POPUP — Popup window
Application screens — MessageTargetPage (examples):
- JOURNEYS — journeys listing screen
- FEEDBACK — feedback screen
Popups — MessageTargetPopup (examples):
- SHARE — “share the app” popup
- SUBSCRIPTION — subscription options popup
After defining all enums, we need to write a class for the jsonb field 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,
]);
}
}The Target field is stored as jsonb to provide flexible configuration. The class can be easily extended to direct users to screens with dynamic parameters, such as individual entities with an id or catalog pages with predefined filters.
Next, we add a cast for the model.
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();
}
}And the NotificationBag model itself.
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);
}
}In this example, the laravel-medialibrary library is used for working with images and files. By default, the attached repository is configured to use local storage, which will make the image attachment feature for notifications non-functional. FCM will not be able to load an image from a local network. To successfully send notifications with images, you need to configure medialibrary to work with publicly accessible storage.
Creating the CRUD controller and the notification handler
First, we will create a form for creating new notifications with validation:
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]);
}
}
}Let’s immediately create the data response resources.For the index:
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,
];
}
}For viewing a single notification:
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,
];
}
}It’s also necessary to create a Values file that will contain all values for the selects used in the creation, viewing, and filtering forms.
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();
}
}And the controller class itself, which contains the logic for filtering, creation, and enqueuing a job for asynchronous processing of the notification.
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 => []
};
}
}Next, we create a job to process the notification with lazy conditional fetching of users:
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);
}
}To create the push notification sending service (FCMService), you need to install and configure the kreait/firebase-php library according to its documentation.
FCMService will send notifications in batches to avoid an excessive number of requests or sending jobs.
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);
}
}The last thing to do is to add the routes to the admin.php routing file.
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']);
});Our backend is ready; we can move on to creating the pages in the admin panel.
Creating pages in the Admiral admin panel
After creating the backend part, you need to create an interface for managing notifications in the admin panel.
Admin panel file structure
admin/
├── pages/notifications/notification-bag/
│ ├── index.tsx # Notification list page
│ ├── create.tsx # Notification creation page
│ └── [id].tsx # View/Edit page
└── src/crud/notifications/
└── notification-bag.tsx # CRUD componentCreating the CRUD component
We create a basic CRUD component that includes forms and a table for managing notifications. This component:
- Supports creating, viewing, and deleting notifications
- Contains dependent fields: recipient type with a user identifier, and target type with a selection of a screen or a popup
- In view mode, all form fields are read-only
- In view mode, error information for sending is displayed, if any
// 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}`,
},
})Creating pages
- List page (Index)
// admin/pages/notifications/notification-bag/index.tsx
import {CRUD} from '@/src/crud/notifications/notification-bag'
export default CRUD.IndexPage- Create page
// admin/pages/notifications/notification-bag/create.tsx
import {CRUD} from '@/src/crud/notifications/notification-bag'
export default CRUD.CreatePage- View/Edit page
// admin/pages/notifications/notification-bag/[id].tsx
import {CRUD} from '@/src/crud/notifications/notification-bag'
export default CRUD.UpdatePageAdd a menu item to the admin panel navigation:
// admin/src/config/menu.tsx
const CustomMenu = () => {
return <Menu>
/// ... Previous menu items
<MenuItemLink icon={'FiSend'} name='Notifications' to='/notifications/notification-bag' />
</Menu>
}
export default CustomMenuThe final notifications page looks like this:

The final notification will look like this:









