andydefer / laravel-notification
Extensible polymorphic notification system for Laravel with multi-channel support (Mail, Database, SMS, WhatsApp, etc.)
Requires
- php: ^8.2
- andydefer/laravel-nemesis: ^1.22.1
- andydefer/laravel-task: ^4.15.11
- google/apiclient: ^2.20
- minishlink/web-push: ^11.0
- pusher/pusher-php-server: ^7.3
Requires (Dev)
- barryvdh/laravel-ide-helper: ^3.6
- larastan/larastan: ^3.8
- laravel/pint: ^1.26
- orchestra/testbench: ^10.8
- phpunit/phpunit: ^12.5
- rector/rector: *
- vimeo/psalm: ^6.14
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v1.19.7
- v1.19.6
- v1.19.5
- v1.19.4
- v1.19.3
- v1.19.2
- v1.19.1
- v1.19.0
- v1.18.0
- v1.17.1
- v1.17.0
- v1.16.11
- v1.16.10
- v1.16.9
- v1.16.8
- v1.16.7
- v1.16.6
- v1.16.5
- v1.16.4
- v1.16.3
- v1.16.2
- v1.16.1
- v1.16.0
- v1.15.4
- v1.15.3
- v1.15.2
- v1.15.1
- v1.15.0
- v1.14.7
- v1.14.6
- v1.14.5
- v1.14.4
- v1.14.3
- v1.14.2
- v1.14.1
- v1.14.0
- v1.11.2
- v1.11.1
- v1.11.0
- v1.10.37
- v1.10.36
- v1.10.35
- v1.10.34
- v1.10.33
- v1.10.32
- v1.10.31
- v1.10.30
- v1.10.29
- v1.10.28
- v1.10.27
- v1.10.26
- v1.10.25
- v1.10.24
- v1.10.23
- v1.10.22
- v1.10.21
- v1.10.20
- v1.10.19
- v1.10.18
- v1.10.17
- v1.10.16
- v1.10.15
- v1.10.14
- v1.10.13
- v1.10.12
- v1.10.11
- v1.10.10
- v1.10.9
- v1.10.8
- v1.10.7
- v1.10.6
- v1.10.5
- v1.10.4
- v1.10.3
- v1.10.2
- v1.10.1
- v1.10.0
- v1.9.5
- v1.9.4
- v1.9.3
- v1.9.2
- v1.9.1
- v1.9.0
- v1.8.7
- v1.8.6
- v1.8.5
- v1.8.4
- v1.8.3
- v1.8.2
- v1.8.1
- v1.8.0
- v1.7.0
- v1.6.34
- v1.6.33
- v1.6.32
- v1.6.31
- v1.6.30
- v1.6.29
- v1.6.28
- v1.6.27
- v1.6.26
- v1.6.25
- v1.6.24
- v1.6.23
- v1.6.22
- v1.6.21
- v1.6.20
- v1.6.19
- v1.6.18
- v1.6.17
- v1.6.16
- v1.6.15
- v1.6.14
- v1.6.13
- v1.6.12
- v1.6.11
- v1.6.10
- v1.6.9
- v1.6.8
- v1.6.7
- v1.6.6
- v1.6.5
- v1.6.4
- v1.6.3
- v1.6.2
- v1.6.1
- v1.6.0
- v1.5.15
- v1.5.14
- v1.5.13
- v1.5.12
- v1.5.11
- v1.5.10
- v1.5.9
- v1.5.8
- v1.5.7
- v1.5.6
- v1.5.5
- v1.5.4
- v1.5.3
- v1.5.2
- v1.5.1
- v1.5.0
- v1.4.1
- v1.4.0
- v1.3.48
- v1.3.47
- v1.3.46
- v1.3.45
- v1.3.44
- v1.3.43
- v1.3.42
- v1.3.41
- v1.3.40
- v1.3.39
- v1.3.38
- v1.3.37
- v1.3.36
- v1.3.35
- v1.3.34
- v1.3.33
- v1.3.32
- v1.3.31
- v1.3.30
- v1.3.29
- v1.3.28
- v1.3.27
- v1.3.26
- v1.3.25
- v1.3.24
- v1.3.23
- v1.3.22
- v1.3.21
- v1.3.20
- v1.3.19
- v1.3.18
- v1.3.17
- v1.3.16
- v1.3.15
- v1.3.14
- v1.3.13
- v1.3.12
- v1.3.11
- v1.3.10
- v1.3.9
- v1.3.8
- v1.3.7
- v1.3.6
- v1.3.5
- v1.3.4
- v1.3.3
- v1.3.2
- v1.3.1
- v1.3.0
- v1.2.0
- v1.1.34
- v1.1.33
- v1.1.32
- v1.1.31
- v1.1.30
- v1.1.29
- v1.1.28
- v1.1.27
- v1.1.26
- v1.1.25
- v1.1.24
- v1.1.23
- v1.1.22
- v1.1.21
- v1.1.20
- v1.1.19
- v1.1.18
- v1.1.17
- v1.1.16
- v1.1.15
- v1.1.14
- v1.1.13
- v1.1.12
- v1.1.11
- v1.1.10
- v1.1.9
- v1.1.8
- v1.1.7
- v1.1.6
- v1.1.5
- v1.1.4
- v1.1.3
- v1.1.2
- v1.1.1
- v1.1.0
- v1.0.8
- v1.0.7
- v1.0.6
- v1.0.5
- v1.0.4
- v1.0.3
- v1.0.2
- v1.0.1
- v1.0.0
- v0.0.1
This package is auto-updated.
Last update: 2026-10-05 07:18:00 UTC
README
Système de notifications multi-canaux pour Laravel. Persistance, traçabilité, multiples destinations, planification avancée - avec une architecture extensible.
Table des matières
- Installation
- Pourquoi Laravel Notification ?
- Architecture en un coup d'œil
- Déclarer les canaux d'une entité
- Envoyer une notification
- Filtrage des destinations avec SendOptions
- NotifiableBuilder - Envoi sans entité
- MessageViewBodyVO - Corps de message basé sur une vue Laravel
- Gestion des tâches
- Statistiques et rapports
- Canaux fonctionnels
- Drivers fonctionnels
- Ping/Pong — Vérification et nettoyage des cibles
- Directives CLI
- Tâches internes
- Créer un canal personnalisé
- Cas d'usage concrets
- Bonnes pratiques
- Trait HasNotifications
- Référence de l'API
Installation
composer require andydefer/laravel-notification php artisan vendor:publish --tag=notification-migrations php artisan migrate php artisan vendor:publish --tag=notification-config
Pourquoi Laravel Notification ?
Le problème : Votre application doit notifier les utilisateurs par email, temps réel (Pusher), Web Push (navigateur), FCM (mobile) et dans la base de données. Chaque médecin a une adresse email professionnelle et une personnelle. Vous devez tracer toutes les notifications pour l'audit, savoir lesquelles ont échoué, et pouvoir consulter l'historique complet.
La solution : Laravel Notification. Un système complet qui orchestre l'envoi sur tous les canaux d'une entité, trace chaque tentative, et permet la planification avancée.
Comparatif rapide
| Besoin | Laravel Notifications (natif) | Laravel Notification |
|---|---|---|
| Plusieurs destinations par canal | ❌ | ✅ |
| Persistance automatique | ❌ (sauf database) | ✅ (tous les canaux) |
| Statut de l'envoi (SENT/FAILED) | ❌ | ✅ |
| Limitation par canal | ❌ | ✅ |
| Métadonnées par destination | ❌ | ✅ |
| Filtrage des destinations par canal | ❌ | ✅ |
| Envoi différé | ⚠️ (via queues) | ✅ (intégré) |
| Envoi récurrent | ⚠️ (via scheduler) | ✅ (intégré) |
| Gestion des tâches (pause/reprise) | ❌ | ✅ |
| Architecture extensible | ⚠️ (complexe) | ✅ (simple) |
| Envoi sans entité Notifiable | ❌ | ✅ (NotifiableBuilder) |
| Corps de message basé vue Laravel | ❌ | ✅ (MessageViewBodyVO) |
| Trait utilitaire pour les modèles | ❌ | ✅ (HasNotifications) |
| Temps réel (Pusher) | ❌ | ✅ |
| Web Push (VAPID) | ❌ | ✅ |
| Firebase Cloud Messaging | ❌ | ✅ |
| Ping/Pong (vérification de validité) | ❌ | ✅ |
| Pruning automatique des cibles invalides | ❌ | ✅ |
En une phrase
Laravel Notifications envoie un message sur un canal défini. Laravel Notification orchestre l'envoi sur tous les canaux d'une entité, trace chaque tentative et permet la planification avancée.
Architecture en un coup d'œil
┌─────────────────────────────────────────────────────────────────┐
│ NotificationService │
│ (Point d'entrée principal de l'API) │
└────────────────────────┬────────────────────────────────────────┘
│
┌───────────────┼───────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐┌─────────────────┐┌─────────────────────────┐
│ Notifiable ││ Notifiable ││ NotificationSender │
│ Builder ││ Service ││ Processor │
│ (API fluente) ││ (API standard) ││ (Orchestrateur) │
└─────────────────┘└─────────────────┘└─────────────────────────┘
│
▼
┌─────────────────────────┐
│ Channels (résolution) │
│ Mail / Database / │
│ Pusher / WebPush / │
│ FirebaseCloudMessaging │
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ Drivers (exécution) │
│ MailDriver / │
│ DatabaseDriver / │
│ PusherDriver / │
│ WebPushDriver / │
│ FcmDriver │
└─────────────────────────┘
Composants principaux
| Composant | Rôle |
|---|---|
NotificationService |
Service principal, point d'entrée de l'API |
NotifiableBuilder |
Builder fluide pour envoyer des notifications sans entité |
NotificationSenderProcessor |
Orchestre l'envoi : résolution des routes, filtres, limites |
SendOptions |
Configuration fluide des options d'envoi (canaux, limites, filtres) |
NotificationRouteVO |
Value Object définissant un canal + destination + métadonnées |
AbstractChannel |
Classe de base pour les canaux |
AbstractDriver |
Classe de base pour les drivers |
SendDelayedNotificationTask |
Tâche unique pour les envois différés/planifiés |
SendRecurringNotificationTask |
Tâche récurrente pour les envois périodiques |
MessageViewBodyVO |
Value Object pour corps de message basé sur vue Laravel |
HasNotifications |
Trait utilitaire pour les modèles Eloquent recevant des notifications |
HasPingPong |
Trait utilitaire implémentant PingableInterface |
PingPongAdapter |
Résout le helper ping/pong par modèle |
FcmPingPong |
Helper de vérification pour les appareils FCM |
WebPushPingPong |
Helper de vérification pour les souscriptions Web Push |
PingStatus |
Enum du résultat d'un ping (PONG, INVALID) |
Déclarer les canaux d'une entité
Pour qu'une entité (User, Order, Doctor, etc.) puisse recevoir des notifications, elle doit implémenter l'interface NotifiableInterface.
<?php namespace App\Models; use AndyDefer\LaravelNotification\Contracts\NotifiableInterface; use AndyDefer\LaravelNotification\Collections\NotificationRouteCollection; use AndyDefer\LaravelNotification\ValueObjects\NotificationRouteVO; use AndyDefer\LaravelNotification\Channels\MailChannel; use AndyDefer\LaravelNotification\Channels\DatabaseChannel; use AndyDefer\LaravelNotification\Channels\PusherChannel; use AndyDefer\LaravelNotification\Channels\WebPushChannel; use AndyDefer\LaravelNotification\Channels\FirebaseCloudMessagingChannel; use AndyDefer\DomainStructures\Utils\StrictDataObject; use Illuminate\Database\Eloquent\Model; class User extends Model implements NotifiableInterface { public function getNotificationChannels(): NotificationRouteCollection { $collection = new NotificationRouteCollection; // ✅ Email principal if ($this->email_primary) { $collection->add(new NotificationRouteVO( channelClass: MailChannel::class, destination: $this->email_primary, metadata: new StrictDataObject(['type' => 'primary']) )); } // ✅ Email secondaire if ($this->email_secondary) { $collection->add(new NotificationRouteVO( channelClass: MailChannel::class, destination: $this->email_secondary, metadata: new StrictDataObject(['type' => 'secondary']) )); } // ✅ Pusher (temps réel) $collection->add(new NotificationRouteVO( channelClass: PusherChannel::class, destination: "private-user.{$this->id}", metadata: new StrictDataObject(['event' => 'notification.received']) )); // ✅ Web Push (navigateur) foreach ($this->webPushSubscriptions as $subscription) { $collection->add(new NotificationRouteVO( channelClass: WebPushChannel::class, destination: $subscription->endpoint, metadata: new StrictDataObject([ 'endpoint' => $subscription->endpoint, 'p256dh' => $subscription->p256dh, 'auth' => $subscription->auth, ]) )); } // ✅ FCM (mobile) foreach ($this->fcmDevices as $device) { $collection->add(new NotificationRouteVO( channelClass: FirebaseCloudMessagingChannel::class, destination: $device->token, metadata: new StrictDataObject([ 'type' => 'fcm', 'device_id' => $device->device_id, ]) )); } // ✅ Base de données (traçabilité) $collection->add(new NotificationRouteVO( channelClass: DatabaseChannel::class, destination: 'database' )); return $collection; } public function getMorphClass(): string { return 'user'; } public function getKey(): int { return $this->id; } }
NotificationRouteVO
Le NotificationRouteVO définit une route de notification :
new NotificationRouteVO( channelClass: MailChannel::class, destination: 'user@example.com', metadata: new StrictDataObject(['type' => 'primary']) );
Envoyer une notification
Envoi immédiat
<?php use AndyDefer\LaravelNotification\Services\NotificationService; use AndyDefer\LaravelNotification\Records\SendNowRecord; use AndyDefer\LaravelNotification\ValueObjects\NotificationMessageVO; use AndyDefer\LaravelNotification\ValueObjects\MessageBodyVO; use AndyDefer\LaravelNotification\ValueObjects\MessageSubjectVO; use AndyDefer\LaravelNotification\Channels\MailChannel; use AndyDefer\LaravelNotification\Channels\PusherChannel; use AndyDefer\DomainStructures\Utils\StrictDataObject; class UserController extends Controller { public function __construct( private readonly NotificationService $service ) {} public function welcome(User $user) { $message = new NotificationMessageVO( body: new MessageBodyVO('<h1>Bonjour !</h1><p>Bienvenue sur notre plateforme.</p>'), subject: new MessageSubjectVO('Bienvenue !'), type: 'welcome', data: new StrictDataObject(['user_id' => $user->id]) ); $record = SendNowRecord::from([ 'channels' => [MailChannel::class, PusherChannel::class], 'limit_per_channel' => 1, ]); $results = $this->service->sendNow($user, $message, $record); return response()->json([ 'success' => $results->allSuccess(), 'sent' => $results->getSuccessCount(), 'failed' => $results->getFailureCount(), ]); } }
Envoi différé
use AndyDefer\LaravelNotification\Records\SendLaterRecord; $record = SendLaterRecord::from([ 'delay_seconds' => 1800, 'channels' => [MailChannel::class, PusherChannel::class], 'limit_per_channel' => 1, ]); $alias = $this->service->sendLater($user, $message, $record);
Envoi planifié
use AndyDefer\LaravelNotification\Records\SendAtRecord; use AndyDefer\LaravelNotification\ValueObjects\NotificationDateTimeVO; $record = SendAtRecord::from([ 'scheduled_at' => new NotificationDateTimeVO($scheduledAt->toIso8601String()), 'channels' => [MailChannel::class], 'limit_per_channel' => 1, ]); $alias = $this->service->sendAt($user, $message, $record);
Envoi récurrent
use AndyDefer\LaravelNotification\Records\SendRecurringRecord; use AndyDefer\Task\ValueObjects\MaxFailedAttemptsVO; $record = SendRecurringRecord::from([ 'interval_seconds' => 604800, 'start_at' => new NotificationDateTimeVO('2026-07-08 09:00:00'), 'end_at' => new NotificationDateTimeVO('2026-12-31 09:00:00'), 'channels' => [MailChannel::class], 'limit_per_channel' => 1, 'max_attempts' => new MaxFailedAttemptsVO(3), ]); $alias = $this->service->sendRecurring($user, $message, $record);
Filtrage des destinations avec SendOptions
use AndyDefer\LaravelNotification\Options\SendOptions; $options = SendOptions::init() ->withChannel(MailChannel::class) ->withDestinationFilter(MailChannel::class, 'user@example.com') ->withLimitPerChannel(1); $results = $notificationService ->withOptions($options) ->sendNow($user, $message);
NotifiableBuilder - Envoi sans entité
use AndyDefer\LaravelNotification\Builders\NotifiableBuilder; $results = NotifiableBuilder::create() ->to(MailChannel::class, 'user@example.com') ->to(PusherChannel::class, 'private-user.42') ->subject('Notification importante') ->body('Votre commande a été expédiée.') ->data(['order_id' => 12345]) ->sendNow();
MessageViewBodyVO - Corps de message basé sur une vue Laravel
use AndyDefer\LaravelNotification\ValueObjects\MessageViewBodyVO; $body = MessageViewBodyVO::html( view: 'emails.welcome', data: ['user' => $user] ); $finalBody = $body->withData(['extra' => 'value']);
Gestion des tâches
class TaskManager { public function __construct( private readonly NotificationService $service ) {} public function pause(string $alias): bool { return $this->service->pause($alias); } public function resume(string $alias): bool { return $this->service->resume($alias); } public function changeInterval(string $alias, int $newIntervalSeconds): bool { return $this->service->changeInterval($alias, $newIntervalSeconds); } public function cancel(string $alias): bool { return $this->service->cancel($alias); } }
Statistiques et rapports
$stats = $this->service->getStats($user); return response()->json([ 'total' => $stats->total, 'sent' => $stats->sent, 'failed' => $stats->failed, 'delivered' => $stats->delivered, 'pending' => $stats->pending, 'success_rate' => $stats->success_rate . '%', ]);
Canaux fonctionnels
| Canal | Nom | Icône | Description | Actif par défaut |
|---|---|---|---|---|
| MailChannel | 📧 | Envoi d'emails via Laravel Mail | ✅ | |
| DatabaseChannel | Base de données | 💾 | Persistance pour la traçabilité | ✅ |
| PusherChannel | Pusher | 📡 | Diffusion temps réel via Pusher | ❌ |
| WebPushChannel | Web Push (VAPID) | 🌐 | Notifications navigateur (W3C Push API) | ❌ |
| FirebaseCloudMessagingChannel | FCM | 🔥 | Notifications mobiles (Android/iOS) | ❌ |
Installation de chaque canal
1. MailChannel — Email
Prérequis : laravel/mail configuré.
// config/notification.php 'channels' => [ 'mail' => [ 'enabled' => true, 'default_from' => env('MAIL_FROM_ADDRESS'), 'default_from_name' => env('MAIL_FROM_NAME'), ], ],
Utilisation :
$record = SendNowRecord::from([ 'channels' => [MailChannel::class], 'limit_per_channel' => 1, ]); $results = $service->sendNow($user, $message, $record);
2. DatabaseChannel — Traçabilité
Prérequis : migrations publiées et exécutées.
php artisan vendor:publish --tag=notification-migrations php artisan migrate
Utilisation :
$collection->add(new NotificationRouteVO( DatabaseChannel::class, 'database' ));
Bénéfice : une ligne est insérée pour chaque tentative, avec status, channel, destination, error_message.
3. PusherChannel — Temps réel
Configuration :
// config/notification.php 'pusher' => [ 'enabled' => env('PUSHER_NOTIFICATION_ENABLED', false), 'app_id' => env('PUSHER_APP_ID'), 'key' => env('PUSHER_APP_KEY'), 'secret' => env('PUSHER_APP_SECRET'), 'cluster' => env('PUSHER_APP_CLUSTER', 'eu'), 'use_tls' => env('PUSHER_USE_TLS', true), 'timeout' => env('PUSHER_TIMEOUT', 30), 'default_channel' => env('PUSHER_NOTIFICATION_CHANNEL', 'notifications'), ],
Utilisation :
$collection->add(new NotificationRouteVO( PusherChannel::class, "private-user.{$this->id}", new StrictDataObject(['event' => 'notification.received']) ));
Côté client (Laravel Echo) :
Echo.private(`user.${userId}`) .listen('.notification.received', (payload) => { console.log(payload.body, payload.subject, payload.data); });
4. WebPushChannel — Notifications navigateur
Génération des clés VAPID :
./vendor/bin/directive notification:generate-vapid
Cette directive écrit dans .env :
WEBPUSH_PUBLIC_KEY=BDw92Y6vnPXYNN90QwiqmLAnnEX9... WEBPUSH_PRIVATE_KEY=m7lTHzndL5P7QL0be8a0l1whYsy8... WEBPUSH_NOTIFICATION_ENABLED=true WEBPUSH_SUBJECT="mailto:contact@afya-medical.com"
Configuration :
// config/notification.php 'webpush' => [ 'enabled' => env('WEBPUSH_NOTIFICATION_ENABLED', false), 'subject' => env('WEBPUSH_SUBJECT'), 'public_key' => env('WEBPUSH_PUBLIC_KEY'), 'private_key' => env('WEBPUSH_PRIVATE_KEY'), 'ttl' => env('WEBPUSH_TTL', 3600), 'urgency' => env('WEBPUSH_URGENCY', 'normal'), 'topic' => env('WEBPUSH_TOPIC', 'notification'), ],
Migration :
php artisan vendor:publish --tag=notification-migrations php artisan migrate
Modèle WebPushSubscription :
final class WebPushSubscription extends Model implements NotifiableInterface, PingableInterface { use HasPingPong; use HasUuids; protected $table = 'web_push_subscriptions'; protected $keyType = 'string'; public $incrementing = false; protected $fillable = [ 'id', 'endpoint', 'p256dh', 'auth', 'browser', 'user_agent', 'last_seen_at', 'notifiable_type', 'notifiable_id', ]; protected $casts = ['last_seen_at' => 'immutable_datetime']; public function notifiable(): MorphTo { return $this->morphTo(); } public function getNotificationChannels(): NotificationRouteCollection { $collection = new NotificationRouteCollection; $collection->add(new NotificationRouteVO( channelClass: WebPushChannel::class, destination: (string) $this->endpoint, metadata: new StrictDataObject([ 'type' => 'webpush', 'endpoint' => (string) $this->endpoint, 'p256dh' => (string) $this->p256dh, 'auth' => (string) $this->auth, ]), )); return $collection; } }
Utilisation :
$record = SendNowRecord::from([ 'channels' => [WebPushChannel::class], 'limit_per_channel' => 1, ]); $results = $service->sendNow($subscription, $message, $record);
Enregistrement côté API :
POST /notification/register-webpush-subscription Authorization: Bearer <token> Content-Type: application/json { "endpoint": "https://jmt17.google.com/fcm/send/...", "p256dh": "BGtKsvWiILk_...", "auth": "hv_Nwds7IHarbxh2KChgEw" }
Service worker côté navigateur :
self.addEventListener('push', (event) => { const payload = event.data ? event.data.json() : {}; // Log dans le service worker console.log('[webpush-sw] push received', payload); // Notifier les pages ouvertes self.clients.matchAll({ type: 'window', includeUncontrolled: true }) .then((wins) => { for (const win of wins) { win.postMessage({ type: 'WEBPUSH_RECEIVED', payload }); } }); // Afficher la notification système event.waitUntil(self.registration.showNotification( payload.title ?? 'Notification', { body: payload.body ?? '', data: payload.data ?? {}, icon: '/icon.png', badge: '/badge.png', } )); });
Écoute côté page :
navigator.serviceWorker.addEventListener('message', (event) => { if (event.data?.type === 'WEBPUSH_RECEIVED') { console.log("j'ai reçu un web push", event.data.payload); } });
5. FirebaseCloudMessagingChannel — Mobile
Prérequis :
composer require google/auth guzzlehttp/guzzle
Configuration :
// config/notification.php 'firebase' => [ 'enabled' => env('FIREBASE_NOTIFICATION_ENABLED', false), 'credentials_path' => env('FIREBASE_CREDENTIALS_PATH'), 'project_id' => env('FIREBASE_PROJECT_ID'), 'scope' => env('FIREBASE_SCOPE', 'https://www.googleapis.com/auth/firebase.messaging'), 'timeout' => env('FIREBASE_TIMEOUT', 30), ],
Modèle FcmDevice :
final class FcmDevice extends Model implements NotifiableInterface, PingableInterface { use HasPingPong; use HasUuids; protected $table = 'fcm_devices'; protected $keyType = 'string'; public $incrementing = false; protected $fillable = [ 'id', 'device_id', 'token', 'platform', 'user_agent', 'last_seen_at', 'notifiable_type', 'notifiable_id', ]; protected $casts = ['last_seen_at' => 'immutable_datetime']; public function notifiable(): MorphTo { return $this->morphTo(); } public function getNotificationChannels(): NotificationRouteCollection { $collection = new NotificationRouteCollection; $collection->add(new NotificationRouteVO( channelClass: FirebaseCloudMessagingChannel::class, destination: (string) $this->token, metadata: new StrictDataObject([ 'type' => 'fcm', 'device_id' => (string) $this->device_id, ]), )); return $collection; } }
Enregistrement côté API :
POST /notification/register-fcm-device Authorization: Bearer <token> Content-Type: application/json { "device_id": "550e8400-e29b-41d4-a716-446655440000", "token": "eQhyKer9t_jperz7AYDDVB:APA91b...", "platform": "android" }
Utilisation :
$record = SendNowRecord::from([ 'channels' => [FirebaseCloudMessagingChannel::class], 'limit_per_channel' => 1, ]); $results = $service->sendNow($device, $message, $record);
Drivers fonctionnels
| Driver | Canal | Dépendance externe | Comportement |
|---|---|---|---|
| MailDriver | MailChannel |
Laravel Mail | Utilise Mail::to(...)->send(...) |
| DatabaseDriver | DatabaseChannel |
Base de données | Insère une ligne par notification |
| PusherDriver | PusherChannel |
pusher/pusher-php-server |
Publie un événement via Pusher::trigger() |
| WebPushDriver | WebPushChannel |
minishlink/web-push |
Envoie via W3C Push API + VAPID |
| FirebaseCloudMessagingDriver | FirebaseCloudMessagingChannel |
HTTP client | Publie sur l'API HTTP v1 de FCM |
WebPushDriver — points clés
- Configuration :
WebPushConfigRecord(enabled, subject, public_key, private_key, ttl, urgency, topic). - Métadonnées obligatoires par route :
endpoint,p256dh,auth. - Métadonnées optionnelles :
title,data. - Payload fusionné :
metadata.data+message.data. - Toute erreur d'envoi lève une
RuntimeExceptioncapturée enerror_messageduSendResultRecord.
FirebaseCloudMessagingDriver — points clés
- Configuration :
FirebaseCloudMessagingConfigRecord(enabled, credentials_path, project_id, scope, timeout). - Authentification via OAuth 2.0 (
google/auth). - Résolution du token :
metadata.tokenoudestination. - Payload FCM :
title,body,data,type.
Ping/Pong — Vérification et nettoyage des cibles
Le module Ping/Pong permet de vérifier qu'un appareil FCM ou une souscription Web Push est toujours valide, et de le supprimer automatiquement sinon.
Principe
- Une notification légère (
ping) est envoyée via le canal cible. - Le résultat est interprété :
- Succès → la cible est valide →
PingStatus::PONG,last_seen_atmis à jour. - Échec → la cible est invalide →
PingStatus::INVALID→ suppression.
- Succès → la cible est valide →
- Aucun parsing de message d'erreur n'est effectué : tout échec = invalide.
Contrat PingPongInterface
interface PingPongInterface { public function ping(Model $notifiable): PingStatus; public function isAlive(Model $notifiable): bool; public function pingOrPrune(Model $notifiable): PingStatus; }
Enum PingStatus
enum PingStatus: string { case PONG = 'pong'; case INVALID = 'invalid'; public function isPong(): bool; public function isInvalid(): bool; }
Trait HasPingPong
À utiliser dans tous les modèles notifiables (FCM, Web Push) :
final class FcmDevice extends Model implements NotifiableInterface, PingableInterface { use HasPingPong; // ... } final class WebPushSubscription extends Model implements NotifiableInterface, PingableInterface { use HasPingPong; // ... }
Le trait délègue la résolution du helper au PingPongAdapter.
PingPongAdapter
Associe chaque modèle à son helper :
final class PingPongAdapter { private const HELPERS = [ FcmDevice::class => FcmPingPong::class, WebPushSubscription::class => WebPushPingPong::class, ]; public function for(Model $model): PingPongInterface; }
Utilisation
// Vérifier une cible if ($subscription->isAlive()) { // Toujours joignable } // Vérifier et supprimer si invalide $status = $subscription->pingOrPrune(); if ($status->isInvalid()) { logger()->info('Subscription pruned', ['id' => $subscription->id]); }
Helpers disponibles
| Helper | Canal cible | Suppression automatique |
|---|---|---|
FcmPingPong |
FCM | ✅ via pingOrPrune |
WebPushPingPong |
Web Push | ✅ via pingOrPrune |
Directives CLI
Le package expose plusieurs directives pour l'installation et la maintenance.
notification:generate-vapid
Génère une paire de clés VAPID et l'écrit dans un fichier .env.
./vendor/bin/directive notification:generate-vapid ./vendor/bin/directive notification:generate-vapid .env.production ./vendor/bin/directive notification:generate-vapid --force
Alias : notification:gvk, n:gvk
| Argument / Option | Description |
|---|---|
env |
Chemin du fichier env (défaut : .env) |
--force |
Écrase les clés existantes |
notification:register-prune-fcm
Enregistre la tâche récurrente de nettoyage des appareils FCM invalides.
./vendor/bin/directive notification:register-prune-fcm ./vendor/bin/directive notification:register-prune-fcm 3600 5 ./vendor/bin/directive notification:register-prune-fcm --force
Alias : notification:rpf, n:rpf
| Argument / Option | Défaut | Description |
|---|---|---|
interval |
1800 |
Intervalle en secondes (minimum 60) |
maxAttempts |
3 |
Nombre maximum de tentatives (minimum 1) |
--force |
false |
Ré-enregistre la tâche même si elle existe |
notification:register-prune-webpush
Enregistre la tâche récurrente de nettoyage des souscriptions Web Push invalides.
./vendor/bin/directive notification:register-prune-webpush ./vendor/bin/directive notification:register-prune-webpush 3600 5 ./vendor/bin/directive notification:register-prune-webpush --force
Alias : notification:rpwp, n:rpwp
| Argument / Option | Défaut | Description |
|---|---|---|
interval |
1800 |
Intervalle en secondes (minimum 60) |
maxAttempts |
3 |
Nombre maximum de tentatives (minimum 1) |
--force |
false |
Ré-enregistre la tâche même si elle existe |
Tâches internes
Le package utilise andydefer/laravel-task pour planifier et exécuter les tâches différées, récurrentes et de nettoyage.
| Tâche | Type | Rôle |
|---|---|---|
SendDelayedNotificationTask |
Unique | Envoi différé/planifié |
SendRecurringNotificationTask |
Récurrente | Envoi périodique |
PingAndPruneFcmDeviceTask |
Unique | Ping + suppression d'un appareil FCM |
PingAndPruneWebPushSubscriptionTask |
Unique | Ping + suppression d'une souscription Web Push |
PruneFailedFcmNotificationsTask |
Récurrente | Scan des notifications FCM échouées → planification des ping/prune |
PruneFailedWebPushNotificationsTask |
Récurrente | Scan des notifications Web Push échouées → planification des ping/prune |
Fonctionnement du nettoyage
PruneFailedFcmNotificationsTask (récurrente, toutes les 30 min)
↓
Scan des Notification FAILED pour FirebaseCloudMessagingChannel
↓
Pour chaque Notification :
├── Résolution du notifiable
├── Récupération des FcmDevice associés
└── Planification d'un PingAndPruneFcmDeviceTask par device
↓
PingAndPruneFcmDeviceTask (unique)
├── Appel FcmPingPong::pingOrPrune()
├── PONG → last_seen_at mis à jour
└── INVALID → appareil supprimé
La même logique s'applique à Web Push via PruneFailedWebPushNotificationsTask.
Créer un canal personnalisé
1. Créer le Driver
class DiscordDriver extends AbstractDriver { public function __construct(private readonly array $config) {} public function getChannel(): string { return 'discord'; } public function validateConfiguration(): bool { return !empty($this->config['webhook_url']); } protected function execute( NotificationMessageVO $message, NotificationRouteVO $route ): bool { $webhookUrl = $route->getMetadata()?->get('webhook_url') ?? $this->config['webhook_url']; $response = Http::post($webhookUrl, [ 'content' => $message->getBodyValue(), ]); if (!$response->successful()) { throw new RuntimeException('Discord API error: ' . $response->body()); } return true; } }
2. Créer le Channel
class DiscordChannel extends AbstractChannel { public function getName(): string { return 'discord'; } public function getLabel(): string { return 'Discord'; } public function getIcon(): string { return '🎮'; } public function isEnabled(): bool { return $this->configRepository->get('notification.channels.discord.enabled', false); } public function getConfig(): AbstractRecord { return DiscordConfigRecord::from( $this->configRepository->get('notification.channels.discord', []) ); } public function createDriver(): AbstractDriver { return new DiscordDriver($this->getConfig()->toArray()); } public static function validateDestination(string $destination): bool { return filter_var($destination, FILTER_VALIDATE_URL) !== false && str_contains($destination, 'discord.com/api/webhooks'); } }
3. Créer le Record de Configuration
final class DiscordConfigRecord extends AbstractRecord { public function __construct( public readonly bool $enabled = false, public readonly ?string $webhook_url = null, ) {} }
Cas d'usage concrets
1. Application médicale
class Doctor extends Model implements NotifiableInterface { public function getNotificationChannels(): NotificationRouteCollection { $collection = new NotificationRouteCollection; if ($this->email_professional) { $collection->add(new NotificationRouteVO( MailChannel::class, $this->email_professional, new StrictDataObject(['priority' => 'high']) )); } if ($this->email_personal) { $collection->add(new NotificationRouteVO( MailChannel::class, $this->email_personal, new StrictDataObject(['priority' => 'low']) )); } $collection->add(new NotificationRouteVO( DatabaseChannel::class, 'database' )); return $collection; } }
2. E-commerce multi-canal
class Order extends Model implements NotifiableInterface { public function getNotificationChannels(): NotificationRouteCollection { $collection = new NotificationRouteCollection; $collection->add(new NotificationRouteVO(MailChannel::class, $this->customer_email)); $collection->add(new NotificationRouteVO(PusherChannel::class, "private-order.{$this->id}")); foreach ($this->customer->webPushSubscriptions as $subscription) { $collection->add(new NotificationRouteVO( WebPushChannel::class, $subscription->endpoint, new StrictDataObject([ 'endpoint' => $subscription->endpoint, 'p256dh' => $subscription->p256dh, 'auth' => $subscription->auth, ]) )); } foreach ($this->customer->fcmDevices as $device) { $collection->add(new NotificationRouteVO( FirebaseCloudMessagingChannel::class, $device->token, new StrictDataObject([ 'device_id' => $device->device_id, ]) )); } return $collection; } }
3. Notification temps réel + push
$message = new NotificationMessageVO( body: new MessageBodyVO('Votre commande a été expédiée.'), subject: new MessageSubjectVO('Commande expédiée'), type: 'order_shipped', data: new StrictDataObject(['order_id' => 42, 'url' => '/orders/42']), ); $record = SendNowRecord::from([ 'channels' => [ PusherChannel::class, WebPushChannel::class, FirebaseCloudMessagingChannel::class, DatabaseChannel::class, ], 'limit_per_channel' => 1, ]); $results = $service->sendNow($order->customer, $message, $record);
Bonnes pratiques
✅ Injecter le service via le constructeur
class UserController extends Controller { public function __construct( private readonly NotificationService $service ) {} }
✅ Valider les destinations dans l'entité
if ($this->email) { $collection->add(new NotificationRouteVO(MailChannel::class, $this->email)); }
✅ Utiliser NotifiableBuilder pour les envois directs
$results = NotifiableBuilder::create() ->to(MailChannel::class, 'user@example.com') ->subject('Test') ->body('Contenu') ->sendNow();
✅ Toujours inclure le canal Database
$collection->add(new NotificationRouteVO( DatabaseChannel::class, 'database' ));
✅ Utiliser les limites par canal
$record = SendNowRecord::from([ 'channels' => [MailChannel::class, PusherChannel::class], 'limit_per_channel' => 1, ]);
✅ Vérifier périodiquement les cibles push
# Enregistrer les tâches de nettoyage
./vendor/bin/directive notification:register-prune-fcm
./vendor/bin/directive notification:register-prune-webpush
✅ Gérer les erreurs proprement
$results = $this->service->sendNow($user, $message, $record); if (!$results->allSuccess()) { foreach ($results->getFailures() as $failure) { Log::error('Notification failed', [ 'channel' => $failure->channel->getValue(), 'destination' => $failure->destination, 'error' => $failure->error_message->getValue(), ]); } }
Trait HasNotifications
Le trait HasNotifications fournit une API riche pour les modèles Eloquent qui reçoivent des notifications.
Installation
final class User extends Authenticatable { use HasNotifications; }
Attributs calculés
| Attribut | Type | Description |
|---|---|---|
unread_notifications_count |
int |
Notifications non lues |
unread_notifications |
Collection |
Notifications non lues |
read_notifications |
Collection |
Notifications lues |
sent_notifications |
Collection |
Notifications envoyées |
pending_notifications |
Collection |
Notifications en attente |
failed_notifications |
Collection |
Notifications échouées |
latest_notifications |
Collection |
10 dernières notifications |
database_notifications |
Collection |
Notifications du canal DatabaseChannel |
has_unread_notifications |
bool |
Y a-t-il des non lues ? |
has_notifications |
bool |
Y a-t-il au moins une notification ? |
Méthodes d'action
| Méthode | Description | Retour |
|---|---|---|
markNotificationAsRead(string $notificationId): bool |
Marque comme lue | bool |
markAllNotificationsAsRead(): int |
Marque toutes comme lues | int |
deleteNotification(string $notificationId): bool |
Soft delete | bool |
deleteAllNotifications(): int |
Supprime tout | int |
deleteReadNotifications(): int |
Supprime les lues | int |
countNotificationsByStatus(NotificationStatus $status): int |
Compte par statut | int |
notificationsByChannel(string $channel, int $limit = 10): Collection |
Par canal | Collection |
Référence de l'API
NotificationService
| Méthode | Description | Retour |
|---|---|---|
withOptions(SendOptions $options): self |
Définit les options | self |
resetOptions(): self |
Réinitialise les options | self |
sendNow(NotifiableInterface, NotificationMessageVO, ?SendNowRecord): SendResultCollection |
Envoi immédiat | SendResultCollection |
sendLater(NotifiableInterface, NotificationMessageVO, ?SendLaterRecord): TaskAliasVO |
Envoi différé | TaskAliasVO |
sendAt(NotifiableInterface, NotificationMessageVO, ?SendAtRecord): TaskAliasVO |
Envoi planifié | TaskAliasVO |
sendRecurring(NotifiableInterface, NotificationMessageVO, ?SendRecurringRecord): TaskAliasVO |
Envoi récurrent | TaskAliasVO |
cancel(string $signature): bool |
Annuler une tâche | bool |
pause(string $signature): bool |
Pause | bool |
resume(string $signature): bool |
Reprise | bool |
changeInterval(string $signature, int $newIntervalSeconds): bool |
Change l'intervalle | bool |
getStats(NotifiableInterface&Model): NotificationStatsVO |
Statistiques | NotificationStatsVO |
getSessionStats(string $sessionId): SessionStatsRecord |
Stats de session | SessionStatsRecord |
SendResultCollection
| Méthode | Description |
|---|---|
getSuccessCount(): int |
Nombre de réussis |
getFailureCount(): int |
Nombre d'échecs |
allSuccess(): bool |
Tous réussis ? |
hasFailures(): bool |
Au moins un échec ? |
filterBySuccess(): self |
Filtre réussis |
filterByFailure(): self |
Filtre échecs |
filterByChannel(string $channelClass): self |
Filtre par canal |
getSuccessfulDestinations(): array |
Destinations réussies |
getFailedDestinations(): array |
Destinations échouées |
NotificationStatsVO
| Propriété | Description |
|---|---|
total: int |
Total |
sent: int |
Nombre de SENT |
failed: int |
Nombre de FAILED |
delivered: int |
Nombre de DELIVERED |
pending: int |
Nombre de PENDING |
success_rate: float |
Taux de succès (%) |
getPercentageSent(): float |
% envoyé |
getPercentageFailed(): float |
% échoué |
isSuccess(): bool |
Tout a réussi |
hasFailures(): bool |
Au moins un échec |
PingStatus
| Case | Description |
|---|---|
PONG |
La cible est valide |
INVALID |
La cible est invalide (à supprimer) |
PingPongInterface
| Méthode | Description |
|---|---|
ping(Model $notifiable): PingStatus |
Envoie un ping |
isAlive(Model $notifiable): bool |
Retourne true si PONG |
pingOrPrune(Model $notifiable): PingStatus |
Ping + suppression si invalide |
Licence
MIT © Andy Defer