andydefer / laravel-reminder
A flexible and robust reminder system for Laravel
Requires
- php: ^8.1
- illuminate/bus: ^10.0|^11.0|^12.0
- illuminate/console: ^10.0|^11.0|^12.0
- illuminate/database: ^10.0|^11.0|^12.0
- illuminate/notifications: ^10.0|^11.0|^12.0
- illuminate/support: ^10.0|^11.0|^12.0
Requires (Dev)
- mockery/mockery: ^1.6
- orchestra/testbench: ^8.0|^9.0
- phpunit/phpunit: ^10.5
README
Une solution flexible et robuste pour gérer les rappels dans vos applications Laravel.
Introduction
Laravel Reminder est un package qui vous permet d'ajouter facilement un système de rappels à vos modèles Eloquent. Que ce soit pour envoyer des notifications d'échéance, des rappels de rendez-vous, ou toute autre alerte temporelle, ce package vous offre une architecture propre et extensible, intégrée nativement avec le système de notification de Laravel.
<?php // Exemple simple : créer un rappel pour un rendez-vous $appointment = Appointment::find(1); $reminder = $appointment->scheduleReminder( scheduledAt: $appointment->date->subHours(24), // Le rappel sera envoyé 24h avant le rendez-vous metadata: ['type' => 'email', 'priority' => 'high'], // Données supplémentaires stockées avec le rappel channels: ['mail', 'sms'] // Canaux de notification personnalisés pour ce rappel );
Concept fondamental
Le package repose sur un principe simple mais puissant : tout modèle qui doit recevoir des rappels est "rappelable" (remindable). Ces rappels sont automatiquement traités selon une fenêtre de tolérance que vous définissez.
Comment ça marche ?
- Planification : Vous créez des rappels pour vos modèles à des dates spécifiques
- Traitement : Un job planifié vérifie régulièrement les rappels à envoyer
- Fenêtre de tolérance : Chaque modèle définit sa propre fenêtre d'acceptation (ex: 30 minutes)
- Notification : Le modèle retourne une notification Laravel à envoyer
- Envoi automatique : Le système utilise
notify()pour envoyer la notification - Suivi : Le système garde une trace de chaque tentative (succès/échec)
Installation
composer require andydefer/laravel-reminder
Publication des ressources (optionnel)
# Publier la configuration php artisan vendor:publish --provider="Andydefer\LaravelReminder\ReminderServiceProvider" --tag="reminder-config" # Publier et exécuter les migrations php artisan vendor:publish --provider="Andydefer\LaravelReminder\ReminderServiceProvider" --tag="reminder-migrations" php artisan migrate
Note : Les migrations sont automatiquement chargées par le package si vous ne les publiez pas.
Configuration
Le fichier de configuration config/reminder.php vous offre un contrôle total sur le comportement du package :
<?php use Andydefer\LaravelReminder\Enums\ToleranceUnit; return [ /* |-------------------------------------------------------------------------- | Tolérance par défaut |-------------------------------------------------------------------------- | | Cette valeur définit la fenêtre de tolérance par défaut pour tous les modèles. | Chaque modèle peut surcharger cette valeur en implémentant getTolerance(). | | Exemple: 30 minutes signifie que le rappel sera accepté 30 minutes avant | ou après la date prévue. | */ 'default_tolerance' => [ 'value' => 30, 'unit' => ToleranceUnit::MINUTE, // MINUTE, HOUR, DAY, WEEK, MONTH, YEAR ], /* |-------------------------------------------------------------------------- | Nombre maximum de tentatives |-------------------------------------------------------------------------- | | Détermine combien de fois le système tentera d'envoyer un rappel | avant de le marquer comme échoué. | */ 'max_attempts' => 3, /* |-------------------------------------------------------------------------- | Configuration de la file d'attente |-------------------------------------------------------------------------- | | Configure comment les rappels sont traités via le système de queue. | Mettre enabled à false pour un traitement synchrone. | */ 'queue' => [ 'enabled' => env('REMINDER_QUEUE_ENABLED', true), 'name' => env('REMINDER_QUEUE_NAME', 'default'), 'connection' => env('REMINDER_QUEUE_CONNECTION', env('QUEUE_CONNECTION', 'sync')), ], /* |-------------------------------------------------------------------------- | Fréquence du scheduler |-------------------------------------------------------------------------- | | Détermine à quelle fréquence le scheduler vérifie les rappels à envoyer. | Valeur en secondes. Valeurs courantes : 15, 30, 60. | */ 'schedule_frequency' => 15, /* |-------------------------------------------------------------------------- | Configuration du nettoyage |-------------------------------------------------------------------------- | | Nettoie automatiquement les anciens rappels pour garder la base propre. | */ 'cleanup' => [ 'enabled' => env('REMINDER_CLEANUP_ENABLED', false), 'after_days' => env('REMINDER_CLEANUP_AFTER_DAYS', 30), ], /* |-------------------------------------------------------------------------- | Configuration des logs |-------------------------------------------------------------------------- | | Configure le comportement des logs pour le traitement des rappels. | */ 'logging' => [ 'enabled' => env('REMINDER_LOGGING_ENABLED', true), 'channel' => env('REMINDER_LOG_CHANNEL', 'stack'), ], ];
Utilisation de base
1. Rendre un modèle "rappelable"
Commencez par ajouter le trait Remindable à votre modèle :
<?php namespace App\Models; use Andydefer\LaravelReminder\Traits\Remindable; use Illuminate\Database\Eloquent\Model; class Article extends Model { use Remindable; // Donne accès à toutes les méthodes de rappel // Le reste de votre modèle... }
2. Implémenter le contrat ShouldRemind
Pour qu'un modèle puisse recevoir des rappels, il doit implémenter l'interface ShouldRemind :
<?php namespace App\Models; use Andydefer\LaravelReminder\Contracts\ShouldRemind; // Interface à implémenter use Andydefer\LaravelReminder\Enums\ToleranceUnit; // Unité de temps pour la tolérance use Andydefer\LaravelReminder\Models\Reminder; // Modèle Reminder use Andydefer\LaravelReminder\Traits\Remindable; // Trait pour les rappels use Andydefer\LaravelReminder\ValueObjects\Tolerance; // Value Object pour la tolérance use App\Notifications\ArticleReminderNotification; // Votre notification use Illuminate\Database\Eloquent\Model; use Illuminate\Notifications\Notifiable; // Nécessaire pour les notifications use Illuminate\Notifications\Notification; class Article extends Model implements ShouldRemind { use Remindable, Notifiable; // Notifiable est requis pour recevoir des notifications /** * Retourne la notification à envoyer pour ce rappel * * @param Reminder $reminder Le rappel en cours de traitement * @return Notification */ public function toRemind(Reminder $reminder): Notification { // Récupère les métadonnées stockées ou un tableau vide $metadata = $reminder->metadata ?? []; // Retourne une notification Laravel standard return new ArticleReminderNotification($this, $reminder, $metadata); } /** * Définit la fenêtre de tolérance pour ce modèle * * @return Tolerance */ public function getTolerance(): Tolerance { // Exemple : tolérance de 2 heures return new Tolerance( value: 2, unit: ToleranceUnit::HOUR ); // Autres possibilités : // return new Tolerance(30, ToleranceUnit::MINUTE); // 30 minutes // return new Tolerance(1, ToleranceUnit::DAY); // 1 jour // return new Tolerance(1, ToleranceUnit::WEEK); // 1 semaine } // Le reste de votre modèle... }
3. Créer une notification Laravel
Créez une notification standard Laravel qui sera envoyée lors du rappel :
<?php namespace App\Notifications; use App\Models\Article; use Andydefer\LaravelReminder\Models\Reminder; use Illuminate\Bus\Queueable; use Illuminate\Notifications\Notification; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Messages\DatabaseMessage; class ArticleReminderNotification extends Notification { use Queueable; // Pour la mise en file d'attente /** * Constructeur de la notification * * @param Article $article Le modèle concerné * @param Reminder $reminder Le rappel en cours * @param array $metadata Métadonnées supplémentaires */ public function __construct( protected Article $article, protected Reminder $reminder, protected array $metadata = [] ) {} /** * Définit les canaux de notification pour ce rappel * * @param mixed $notifiable * @return array */ public function via($notifiable): array { // Utilise les canaux personnalisés du reminder // S'il n'y en a pas, utilise ['mail'] par défaut return $this->reminder->channelsForSending(['mail']); // Exemples de retour selon les canaux définis : // - ['mail'] → envoi par email uniquement // - ['mail', 'sms'] → envoi par email ET SMS // - ['database'] → stockage en base uniquement } /** * Notification par email * * @param mixed $notifiable * @return \Illuminate\Notifications\Messages\MailMessage */ public function toMail($notifiable): MailMessage { return (new MailMessage) ->subject("Rappel: {$this->article->title}") ->line("N'oubliez pas de publier votre article !") ->line("Prévu le: {$this->reminder->scheduled_at->format('d/m/Y H:i')}") ->action('Voir l\'article', url("/articles/{$this->article->id}")); } /** * Notification en base de données * * @param mixed $notifiable * @return array */ public function toArray($notifiable): array { return [ 'article_id' => $this->article->id, 'article_title' => $this->article->title, 'reminder_id' => $this->reminder->id, 'scheduled_at' => $this->reminder->scheduled_at->toDateTimeString(), 'metadata' => $this->metadata, 'channels' => $this->reminder->channels(), // Canaux utilisés ]; } /** * Notification par SMS (si configuré) * * @param mixed $notifiable * @return array */ public function toSms($notifiable): array { return [ 'message' => "Rappel: {$this->article->title} à publier le {$this->reminder->scheduled_at->format('d/m/Y H:i')}", ]; } /** * Notification Slack (si configuré) * * @param mixed $notifiable * @return \Illuminate\Notifications\Messages\SlackMessage */ public function toSlack($notifiable): SlackMessage { return (new SlackMessage) ->content("Rappel: {$this->article->title}"); } }
4. Planifier des rappels
Une fois votre modèle configuré, vous pouvez planifier des rappels de plusieurs façons :
<?php // Récupérer un modèle $article = Article::find(1); // 1. Rappel simple avec date Carbon $reminder = $article->scheduleReminder( scheduledAt: now()->addDays(7), // Date d'envoi : dans 7 jours metadata: ['type' => 'email'], // Données supplémentaires channels: ['mail', 'database'] // Canaux personnalisés ); // 2. Rappel avec date en string $reminder = $article->scheduleReminder( scheduledAt: '2025-12-25 09:00:00', // Date au format string channels: ['mail'] // Email uniquement ); // 3. Rappel sans canaux spécifiques (utilisera le fallback) $reminder = $article->scheduleReminder( scheduledAt: now()->addDays(3) ); // 4. Vérifier le résultat après planification echo $reminder->scheduled_at; // Date planifiée print_r($reminder->channels()); // Canaux utilisés echo $reminder->has_custom_channels ? 'Canaux personnalisés' : 'Canaux par défaut';
Planification multiple
Format 1 : Tableau indexé (mêmes canaux pour tous)
<?php // Tous les rappels utiliseront les mêmes canaux $reminders = $article->scheduleMultipleReminders( scheduledTimes: [ now()->addDays(7), // Premier rappel : dans 7 jours now()->addDays(3), // Deuxième rappel : dans 3 jours now()->addDay(), // Troisième rappel : demain ], metadata: ['priority' => 'high'], // Métadonnées communes globalChannels: ['sms'] // Tous ces rappels utiliseront le canal SMS ); // Résultat : 3 rappels avec channels = ['sms'] pour chacun foreach ($reminders as $index => $reminder) { echo "Rappel " . ($index + 1) . " : " . $reminder->scheduled_at; echo " - Canaux : " . implode(', ', $reminder->channels()); }
Format 2 : Tableau associatif (canaux différents par rappel)
<?php // Chaque rappel a ses propres canaux $reminders = $article->scheduleMultipleReminders( scheduledTimes: [ now()->addDays(7) => ['mail'], // J-7 : email uniquement now()->addDays(3) => ['mail', 'sms'], // J-3 : email + SMS now()->addDay() => ['sms'], // J-1 : SMS uniquement ], metadata: ['priority' => 'high'] // Métadonnées communes ); // Vérifier les canaux de chaque rappel echo "Premier rappel (J-7) : " . implode(', ', $reminders[0]->channels()); // mail echo "Deuxième rappel (J-3) : " . implode(', ', $reminders[1]->channels()); // mail, sms echo "Troisième rappel (J-1) : " . implode(', ', $reminders[2]->channels()); // sms
Format 3 : Format mixte
<?php // Mélange de dates seules et de dates avec canaux $reminders = $article->scheduleMultipleReminders( scheduledTimes: [ now()->addDays(7), // Date seule → utilisera les canaux globaux now()->addDays(3) => ['mail', 'sms'], // Date avec canaux → utilise ses propres canaux now()->addDay(), // Date seule → utilisera les canaux globaux ], metadata: ['priority' => 'high'], channels: ['mail'] // Canaux globaux pour les dates seules ); // Résultat : // - Rappel 1 (J-7) : channels = ['mail'] (canaux globaux) // - Rappel 2 (J-3) : channels = ['mail', 'sms'] (ses propres canaux) // - Rappel 3 (J-1) : channels = ['mail'] (canaux globaux)
Gestion des rappels existants
Récupération des rappels
<?php // Tous les rappels du modèle (relation Eloquent) $allReminders = $article->reminders; // Collection de tous les rappels // Rappels en attente uniquement $pendingReminders = $article->pendingReminders(); // Collection des rappels avec status = PENDING // Prochain rappel à venir (le plus proche dans le futur) $nextReminder = $article->nextReminder(); // Instance de Reminder ou null // Vérifier s'il y a des rappels en attente if ($article->hasPendingReminders()) { echo "Vous avez " . $article->pendingReminders()->count() . " rappel(s) en attente"; }
Requêtes avancées avec le modèle Reminder
<?php use Andydefer\LaravelReminder\Models\Reminder; use Andydefer\LaravelReminder\Enums\ReminderStatus; // Tous les rappels en attente (global) $pending = Reminder::pending()->get(); // Rappels à envoyer maintenant (scheduled_at <= now, attempts < max) $due = Reminder::due()->get(); // Rappels dans une fenêtre de tolérance (30 minutes) $withinTolerance = Reminder::withinTolerance(30)->get(); // Rappels avec canaux personnalisés $customChannels = Reminder::whereNotNull('channels') ->where('channels', '!=', json_encode([])) ->get(); // Rappels échoués $failed = Reminder::where('status', ReminderStatus::FAILED)->get(); // Rappels envoyés aujourd'hui $sentToday = Reminder::where('status', ReminderStatus::SENT) ->whereDate('sent_at', today()) ->get(); // Compter les rappels par statut $stats = [ 'pending' => Reminder::pending()->count(), 'sent' => Reminder::where('status', ReminderStatus::SENT)->count(), 'failed' => Reminder::where('status', ReminderStatus::FAILED)->count(), 'cancelled' => Reminder::where('status', ReminderStatus::CANCELLED)->count(), ];
Mise à jour manuelle des rappels
<?php $reminder = Reminder::find(1); // Marquer comme envoyé avec succès $reminder->markAsSent(); // Met à jour : // - status = SENT // - sent_at = maintenant // - last_attempt_at = maintenant // Marquer comme échoué $reminder->markAsFailed('Connection timeout'); // Met à jour : // - attempts = attempts + 1 // - last_attempt_at = maintenant // - error_message = 'Connection timeout' // - status = FAILED si attempts >= max_attempts, sinon reste PENDING // Annuler le rappel $reminder->cancel(); // Met à jour : status = CANCELLED
Vérifications d'état
<?php if ($reminder->isPending()) { echo "Rappel en attente d'envoi"; } if ($reminder->wasSent()) { echo "Rappel envoyé le " . $reminder->sent_at->format('d/m/Y H:i'); } if ($reminder->hasFailed()) { echo "Échec après " . $reminder->attempts . " tentative(s)"; echo "Erreur : " . $reminder->error_message; } // Vérifier le statut via l'énumération if ($reminder->status === ReminderStatus::PENDING) { // ... } if ($reminder->status->isTerminal()) { echo "Statut final (ne changera plus)"; }
Annulation de rappels
<?php // Annuler tous les rappels en attente d'un modèle $cancelledCount = $article->cancelReminders(); // Retourne le nombre de rappels annulés echo "$cancelledCount rappel(s) annulé(s)"; // Annuler un rappel spécifique $reminder->cancel(); // Annuler avec conditions $article->reminders() ->where('scheduled_at', '<', now()) // Rappels dans le passé ->pending() // En attente uniquement ->get() ->each->cancel(); // Annuler chacun
Les channels de notification
Définir des canaux personnalisés
<?php // Rappel avec canaux personnalisés $reminder = $article->scheduleReminder( scheduledAt: now()->addDays(7), channels: ['mail', 'sms', 'database'] ); // Vérifier les canaux $channels = $reminder->channels(); // Retourne ['mail', 'sms', 'database'] $hasCustom = $reminder->has_custom_channels; // true (car des canaux sont définis) // Rappel sans canaux personnalisés $reminder = $article->scheduleReminder(now()->addDays(3)); $channels = $reminder->channels(); // Retourne [] (tableau vide) $hasCustom = $reminder->has_custom_channels; // false
Utiliser channelsForSending()
Dans votre notification, utilisez cette méthode pour récupérer les canaux :
<?php public function via($notifiable): array { // channelsForSending() retourne : // - les canaux personnalisés s'ils existent // - le fallback ['mail'] si pas de canaux personnalisés return $this->reminder->channelsForSending(['mail']); // Exemples : // Si reminder a ['mail', 'sms'] → retourne ['mail', 'sms'] // Si reminder a [] → retourne ['mail'] // Si reminder a null → retourne ['mail'] }
Exemples concrets de combinaisons de canaux
<?php // 1. Email uniquement $reminder = $article->scheduleReminder( scheduledAt: now()->addDays(7), channels: ['mail'] ); // 2. Email + SMS $reminder = $article->scheduleReminder( scheduledAt: now()->addDays(3), channels: ['mail', 'sms'] ); // 3. SMS uniquement (pour les rappels urgents) $reminder = $article->scheduleReminder( scheduledAt: now()->addHours(2), channels: ['sms'] ); // 4. Tous les canaux disponibles $reminder = $article->scheduleReminder( scheduledAt: now()->addDay(), channels: ['mail', 'sms', 'database', 'slack', 'push'] ); // 5. Stockage en base uniquement (pas de notification temps réel) $reminder = $article->scheduleReminder( scheduledAt: now()->addDays(7), channels: ['database'] );
Commandes Artisan
Syntaxe et options
# Traitement synchrone (sans file d'attente) # Utile pour : tests, debugging, ou quand on veut le résultat immédiat php artisan reminders:send --sync # Traitement avec file d'attente (par défaut) # Dispatche un job qui sera traité par un worker php artisan reminders:send # Spécifier une file d'attente # Permet de prioriser certains rappels php artisan reminders:send --queue=emails php artisan reminders:send --queue=high-priority
Exemple de sortie
$ php artisan reminders:send --sync Starting reminder processing... Processing reminders synchronously... +-----------+-------+ | Metric | Count | +-----------+-------+ | Total | 10 | | Processed | 8 | | Failed | 2 | +-----------+-------+ Reminders processed successfully!
Utilisation dans du code
<?php use Illuminate\Support\Facades\Artisan; use Illuminate\Console\Command; // Dans un script ou une commande $exitCode = Artisan::call('reminders:send', ['--sync' => true]); if ($exitCode === Command::SUCCESS) { echo "Traitement terminé avec succès"; // Command::SUCCESS = 0 } else { echo "Erreur lors du traitement"; // Command::FAILURE = 1 } // Avec file d'attente Artisan::call('reminders:send'); Artisan::call('reminders:send', ['--queue' => 'high-priority']);
Scheduler et File d'attente
Configuration dans le fichier .env
# Activer/désactiver la file d'attente REMINDER_QUEUE_ENABLED=true # Connexion à utiliser (sync, database, redis, sqs, etc.) REMINDER_QUEUE_CONNECTION=database # Nom de la file d'attente REMINDER_QUEUE_NAME=reminders
Configuration du scheduler
<?php // config/reminder.php 'schedule_frequency' => 15, // secondes // Options disponibles : // - 15 → everyFifteenSeconds() (toutes les 15 secondes) // - 30 → everyThirtySeconds() (toutes les 30 secondes) // - 60 → everyMinute() (toutes les minutes) // - 120 → cron("*/120 * * * * *") (toutes les 2 minutes) // - 300 → cron("*/300 * * * * *") (toutes les 5 minutes)
Mise en place de la crontab
# Éditer la crontab crontab -e # Ajouter cette ligne (adapter le chemin vers votre projet) * * * * * cd /home/user/projets/mon-app && php artisan schedule:run >> /dev/null 2>&1 # Vérifier que la crontab est bien configurée crontab -l | grep "schedule:run"
Vérification du bon fonctionnement
# Voir les rappels en attente php artisan tinker >>> Reminder::pending()->count() # Vérifier les erreurs >>> Reminder::whereNotNull('error_message')->get() # Tester manuellement php artisan reminders:send --sync # Si vous utilisez la queue database : php artisan queue:table php artisan migrate php artisan queue:work --queue=reminders
Architecture détaillée
Le modèle Reminder
<?php namespace Andydefer\LaravelReminder\Models; use Andydefer\LaravelReminder\Casts\ChannelsCast; use Andydefer\LaravelReminder\Enums\ReminderStatus; use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphTo; class Reminder extends Model { protected $table = 'reminders'; protected $fillable = [ 'remindable_type', // Type du modèle associé (ex: App\Models\Article) 'remindable_id', // ID du modèle associé 'scheduled_at', // Date prévue pour l'envoi 'sent_at', // Date d'envoi effectif 'status', // Statut du rappel (pending, sent, failed, cancelled) 'metadata', // Données supplémentaires (JSON) 'channels', // Canaux de notification personnalisés (JSON) 'attempts', // Nombre de tentatives effectuées 'last_attempt_at', // Date de la dernière tentative 'error_message', // Message d'erreur en cas d'échec ]; protected $casts = [ 'scheduled_at' => 'datetime', 'sent_at' => 'datetime', 'last_attempt_at' => 'datetime', 'metadata' => 'array', 'channels' => ChannelsCast::class, // Cast personnalisé pour les canaux 'status' => ReminderStatus::class, // Cast vers l'énumération 'attempts' => 'integer', ]; protected $attributes = [ 'attempts' => 0, 'status' => ReminderStatus::PENDING, ]; /** * Relation polymorphique vers le modèle associé */ public function remindable(): MorphTo { return $this->morphTo(); } /** * Retourne les canaux de notification * * @return array */ public function channels(): array { return $this->channels ?? []; } /** * Indique si des canaux personnalisés sont définis * * @return bool */ public function getHasCustomChannelsAttribute(): bool { return !empty($this->channels()); } /** * Retourne les canaux à utiliser (personnalisés ou fallback) * * @param array $fallbackChannels Canaux par défaut * @return array */ public function channelsForSending(array $fallbackChannels = ['mail']): array { return $this->has_custom_channels ? $this->channels() : $fallbackChannels; } /** * Marque le rappel comme envoyé */ public function markAsSent(): self { $this->update([ 'status' => ReminderStatus::SENT, 'sent_at' => now(), 'last_attempt_at' => now(), ]); return $this; } /** * Marque le rappel comme échoué * * @param string $error Message d'erreur */ public function markAsFailed(string $error): self { $maxAttempts = config('reminder.max_attempts', 3); $newStatus = $this->attempts + 1 >= $maxAttempts ? ReminderStatus::FAILED : ReminderStatus::PENDING; $this->update([ 'status' => $newStatus, 'attempts' => $this->attempts + 1, 'last_attempt_at' => now(), 'error_message' => $error, ]); return $this; } /** * Annule le rappel */ public function cancel(): self { $this->update(['status' => ReminderStatus::CANCELLED]); return $this; } /** * Vérifie si le rappel est en attente */ public function isPending(): bool { return $this->status === ReminderStatus::PENDING; } /** * Vérifie si le rappel a été envoyé */ public function wasSent(): bool { return $this->status === ReminderStatus::SENT; } /** * Vérifie si le rappel a échoué */ public function hasFailed(): bool { return $this->status === ReminderStatus::FAILED; } /** * Scope : rappels en attente */ public function scopePending($query) { return $query->where('status', ReminderStatus::PENDING->value); } /** * Scope : rappels à envoyer maintenant */ public function scopeDue($query) { return $query->where('status', ReminderStatus::PENDING->value) ->where('scheduled_at', '<=', now()) ->where('attempts', '<', config('reminder.max_attempts', 3)); } /** * Scope : rappels dans une fenêtre de tolérance * * @param int $toleranceMinutes Tolérance en minutes */ public function scopeWithinTolerance($query, int $toleranceMinutes) { return $query->whereBetween('scheduled_at', [ now()->subMinutes($toleranceMinutes), now()->addMinutes($toleranceMinutes) ]); } }
Les énumérations
ReminderStatus
<?php namespace Andydefer\LaravelReminder\Enums; enum ReminderStatus: string { case PENDING = 'pending'; // En attente d'envoi case SENT = 'sent'; // Envoyé avec succès case FAILED = 'failed'; // Échoué après plusieurs tentatives case CANCELLED = 'cancelled'; // Annulé manuellement /** * Libellé affichable du statut */ public function label(): string { return match ($this) { self::PENDING => 'En attente', self::SENT => 'Envoyé', self::FAILED => 'Échoué', self::CANCELLED => 'Annulé', }; } /** * Vérifie si le statut est "en attente" */ public function isPending(): bool { return $this === self::PENDING; } /** * Vérifie si le statut est terminal (ne changera plus) */ public function isTerminal(): bool { return in_array($this, [self::SENT, self::FAILED, self::CANCELLED], true); } }
ToleranceUnit
<?php namespace Andydefer\LaravelReminder\Enums; enum ToleranceUnit: string { case YEAR = 'year'; case MONTH = 'month'; case WEEK = 'week'; case DAY = 'day'; case HOUR = 'hour'; case MINUTE = 'minute'; /** * Convertit l'unité en minutes */ public function toMinutes(): int { return match ($this) { self::YEAR => 525600, // 365 jours self::MONTH => 43800, // 30 jours self::WEEK => 10080, // 7 jours self::DAY => 1440, // 24 heures self::HOUR => 60, self::MINUTE => 1, }; } /** * Convertit l'unité en secondes */ public function toSeconds(): int { return $this->toMinutes() * 60; } /** * Libellé affichable de l'unité */ public function label(): string { return match ($this) { self::YEAR => 'Année', self::MONTH => 'Mois', self::WEEK => 'Semaine', self::DAY => 'Jour', self::HOUR => 'Heure', self::MINUTE => 'Minute', }; } }
Le Value Object Tolerance
<?php namespace Andydefer\LaravelReminder\ValueObjects; use Andydefer\LaravelReminder\Enums\ToleranceUnit; class Tolerance { /** * @param int $value Valeur numérique * @param ToleranceUnit $unit Unité de temps */ public function __construct( public readonly int $value, public readonly ToleranceUnit $unit ) { if ($value < 0) { throw new \InvalidArgumentException('La valeur de tolérance ne peut pas être négative'); } } /** * Convertit la tolérance en minutes */ public function toMinutes(): int { return $this->value * $this->unit->toMinutes(); } /** * Convertit la tolérance en secondes */ public function toSeconds(): int { return $this->value * $this->unit->toSeconds(); } /** * Vérifie si une date est dans la fenêtre de tolérance * * @param \DateTimeInterface $scheduledAt Date prévue * @param \DateTimeInterface $now Date actuelle * @return bool */ public function isWithinWindow(\DateTimeInterface $scheduledAt, \DateTimeInterface $now): bool { $diffInMinutes = abs($now->getTimestamp() - $scheduledAt->getTimestamp()) / 60; return $diffInMinutes <= $this->toMinutes(); } /** * Représentation textuelle (ex: "2 Heures") */ public function __toString(): string { $label = $this->unit->label(); return $this->value . ' ' . $label . ($this->value > 1 ? 's' : ''); } }
Le Cast ChannelsCast
<?php namespace Andydefer\LaravelReminder\Casts; use Illuminate\Contracts\Database\Eloquent\CastsAttributes; class ChannelsCast implements CastsAttributes { /** * Convertit le JSON en tableau * * @param \Illuminate\Database\Eloquent\Model $model * @param string $key * @param mixed $value * @param array $attributes * @return array */ public function get($model, string $key, $value, array $attributes): array { // Si la valeur est null, vide, ou '[]', retourne un tableau vide if (is_null($value) || $value === '[]' || $value === '') { return []; } $decoded = json_decode($value, true); if (!is_array($decoded)) { return []; } return $decoded; } /** * Convertit le tableau en JSON pour le stockage * * @param \Illuminate\Database\Eloquent\Model $model * @param string $key * @param mixed $value * @param array $attributes * @return string */ public function set($model, string $key, $value, array $attributes): string { // Si la valeur est null, retourne un tableau vide en JSON if (is_null($value)) { return json_encode([]); } // Si c'est un tableau, on le réindexe et on l'encode if (is_array($value)) { return json_encode(array_values($value)); } // Dans tous les autres cas, retourne un tableau vide return json_encode([]); } }
Le trait Remindable
<?php namespace Andydefer\LaravelReminder\Traits; use Andydefer\LaravelReminder\Enums\ReminderStatus; use Andydefer\LaravelReminder\Models\Reminder; use Carbon\Carbon; use DateTimeInterface; use Illuminate\Database\Eloquent\Relations\MorphMany; use InvalidArgumentException; trait Remindable { /** * Relation : tous les rappels du modèle * * @return MorphMany */ public function reminders(): MorphMany { return $this->morphMany(Reminder::class, 'remindable'); } /** * Planifie un nouveau rappel * * @param DateTimeInterface|string $scheduledAt Date d'envoi * @param array $metadata Métadonnées supplémentaires * @param array|null $channels Canaux personnalisés * @return Reminder * @throws InvalidArgumentException */ public function scheduleReminder( DateTimeInterface|string $scheduledAt, array $metadata = [], ?array $channels = [] ): Reminder { $scheduledAt = $this->parseScheduledAt($scheduledAt); if ($scheduledAt->isPast()) { throw new InvalidArgumentException('Impossible de planifier un rappel dans le passé'); } return $this->reminders()->create([ 'scheduled_at' => $scheduledAt, 'metadata' => $metadata, 'channels' => $channels ?? [], 'status' => ReminderStatus::PENDING, 'attempts' => 0, ]); } /** * Planifie plusieurs rappels * * @param array $scheduledTimes Tableau de dates ou tableau associatif [date => canaux] * @param array $metadata Métadonnées communes * @param array|null $globalChannels Canaux par défaut * @return array * @throws InvalidArgumentException */ public function scheduleMultipleReminders(array $scheduledTimes, array $metadata = [], ?array $globalChannels = []): array { if (empty($scheduledTimes)) { throw new InvalidArgumentException('Le tableau des dates ne peut pas être vide'); } $reminders = []; foreach ($scheduledTimes as $key => $value) { // Cas 1 : la valeur est un tableau → format [date => canaux] if (is_array($value)) { $scheduledAt = $key; $channels = $value; } // Cas 2 : la clé est un entier → format indexé [date, date] elseif (is_int($key)) { $scheduledAt = $value; $channels = $globalChannels; } // Cas 3 : la clé n'est pas un entier (donc c'est une date) et la valeur n'est pas un tableau // Format mixte avec date seule mais clé associative else { $scheduledAt = $key; $channels = $globalChannels; } $reminders[] = $this->scheduleReminder($scheduledAt, $metadata, $channels); } return $reminders; } /** * Annule tous les rappels en attente * * @return int Nombre de rappels annulés */ public function cancelReminders(): int { return $this->reminders() ->where('status', ReminderStatus::PENDING->value) ->update(['status' => ReminderStatus::CANCELLED]); } /** * Récupère tous les rappels en attente * * @return \Illuminate\Database\Eloquent\Collection */ public function pendingReminders() { return $this->reminders()->pending()->get(); } /** * Vérifie s'il y a des rappels en attente * * @return bool */ public function hasPendingReminders(): bool { return $this->reminders()->pending()->exists(); } /** * Récupère le prochain rappel à venir * * @return Reminder|null */ public function nextReminder(): ?Reminder { return $this->reminders() ->pending() ->orderBy('scheduled_at') ->first(); } /** * Parse la date d'envoi * * @param DateTimeInterface|string $scheduledAt * @return Carbon * @throws InvalidArgumentException */ private function parseScheduledAt(DateTimeInterface|string $scheduledAt): Carbon { if ($scheduledAt instanceof DateTimeInterface) { return $scheduledAt instanceof Carbon ? $scheduledAt : Carbon::instance($scheduledAt); } try { return Carbon::parse($scheduledAt); } catch (\Exception $e) { throw new InvalidArgumentException( 'Format de date invalide pour scheduled_at : ' . $scheduledAt ); } } }
Le service ReminderService
<?php namespace Andydefer\LaravelReminder\Services; use Andydefer\LaravelReminder\Contracts\ShouldRemind; use Andydefer\LaravelReminder\Models\Reminder; use Carbon\Carbon; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Notifications\Notification; use Throwable; class ReminderService { public function __construct( protected array $config, protected Dispatcher $events ) {} /** * Traite un rappel individuel * * @param Reminder $reminder * @return bool True si succès, False si échec */ public function processReminder(Reminder $reminder): bool { // Dispatch événement de début de traitement $this->events->dispatch('reminder.processing', $reminder); try { // Récupérer le modèle associé $remindable = $reminder->remindable; // Vérifier que le modèle implémente ShouldRemind if (!$remindable instanceof ShouldRemind) { throw new \RuntimeException( get_class($remindable) . ' n\'implémente pas l\'interface ShouldRemind' ); } // Vérifier la fenêtre de tolérance $tolerance = $remindable->getTolerance(); $now = Carbon::now(); if (!$tolerance->isWithinWindow($reminder->scheduled_at, $now)) { $reminder->markAsFailed('Hors de la fenêtre de tolérance'); $this->events->dispatch('reminder.outside_tolerance', $reminder); return false; } // Obtenir la notification $notification = $remindable->toRemind($reminder); if (!$notification instanceof Notification) { throw new \RuntimeException( 'toRemind() doit retourner une instance de Illuminate\Notifications\Notification' ); } // Envoyer la notification $remindable->notify($notification); // Marquer comme envoyé $reminder->markAsSent(); $this->events->dispatch('reminder.sent', $reminder); return true; } catch (Throwable $e) { // En cas d'erreur, marquer comme échoué $reminder->markAsFailed($e->getMessage()); $this->events->dispatch('reminder.failed', [$reminder, $e]); return false; } } /** * Traite tous les rappels en attente * * @return array Statistiques [total, processed, failed] */ public function processPendingReminders(): array { $reminders = Reminder::due()->get(); $results = [ 'total' => $reminders->count(), 'processed' => 0, 'failed' => 0, ]; foreach ($reminders as $reminder) { $success = $this->processReminder($reminder); if ($success) { $results['processed']++; } else { $results['failed']++; } } $this->events->dispatch('reminder.processed', $results); return $results; } /** * Retourne la configuration */ public function getConfig(): array { return $this->config; } }
Le job ProcessRemindersJob
<?php namespace Andydefer\LaravelReminder\Jobs; use Andydefer\LaravelReminder\Services\ReminderService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Log; class ProcessRemindersJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; /** * Nombre de tentatives maximum */ public int $tries = 1; /** * Nombre maximum d'exceptions avant échec */ public int $maxExceptions = 1; /** * Timeout en secondes */ public int $timeout = 120; /** * Échouer si timeout */ public bool $failOnTimeout = true; /** * Constructeur : configure la connexion et la file d'attente */ public function __construct() { $this->onConnection(config('reminder.queue.connection', config('queue.default'))); $this->onQueue(config('reminder.queue.name', 'default')); } /** * Exécute le job */ public function handle(ReminderService $reminderService): void { Log::info('Traitement des rappels en attente'); $startTime = microtime(true); $result = $reminderService->processPendingReminders(); $executionTime = round((microtime(true) - $startTime) * 1000, 2); Log::info('Rappels traités', array_merge($result, [ 'execution_time_ms' => $executionTime, 'job_id' => $this->job?->getJobId(), ])); // Si beaucoup de rappels, replanifier if ($result['total'] >= 100 && $this->attempts() < 3) { $this->release(30); // Replanifier dans 30 secondes } } /** * Gère l'échec du job */ public function failed(\Throwable $exception): void { Log::error('Échec du job ProcessRemindersJob', [ 'error' => $exception->getMessage(), 'trace' => $exception->getTraceAsString(), ]); } }
La commande SendRemindersCommand
<?php namespace Andydefer\LaravelReminder\Console\Commands; use Andydefer\LaravelReminder\Jobs\ProcessRemindersJob; use Illuminate\Console\Command; use Illuminate\Support\Facades\Log; class SendRemindersCommand extends Command { /** * Signature de la commande * * @var string */ protected $signature = 'reminders:send {--sync : Traiter les rappels de manière synchrone sans job} {--queue= : File d\'attente à utiliser}'; /** * Description de la commande * * @var string */ protected $description = 'Envoyer les rappels en attente'; /** * Exécute la commande */ public function handle(): int { $this->info('Démarrage du traitement des rappels...'); if ($this->option('sync')) { return $this->processSynchronously(); } return $this->dispatchJob(); } /** * Traitement synchrone */ protected function processSynchronously(): int { $this->info('Traitement synchrone des rappels...'); try { $service = app(\Andydefer\LaravelReminder\Services\ReminderService::class); $result = $service->processPendingReminders(); $this->table( ['Métrique', 'Nombre'], [ ['Total', $result['total']], ['Traités', $result['processed']], ['Échoués', $result['failed']], ] ); $this->info('Rappels traités avec succès !'); return Command::SUCCESS; } catch (\Throwable $e) { $this->error('Erreur lors du traitement : ' . $e->getMessage()); Log::error('Échec du traitement synchrone', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); return Command::FAILURE; } } /** * Dispatch du job dans la file d'attente */ protected function dispatchJob(): int { $job = new ProcessRemindersJob(); if ($queue = $this->option('queue')) { $job->onQueue($queue); } dispatch($job); $this->info('Job de traitement des rappels dispatché avec succès.'); return Command::SUCCESS; } }
Le facade Reminder
<?php namespace Andydefer\LaravelReminder\Facades; use Illuminate\Support\Facades\Facade; /** * @method static array processPendingReminders() * @method static bool processReminder(\Andydefer\LaravelReminder\Models\Reminder $reminder) * @method static \Andydefer\LaravelReminder\Services\ReminderService setEventDispatcher(\Illuminate\Contracts\Events\Dispatcher $events) * * @see \Andydefer\LaravelReminder\Services\ReminderService */ class Reminder extends Facade { protected static function getFacadeAccessor(): string { return \Andydefer\LaravelReminder\Services\ReminderService::class; } }
Utilisation du facade :
<?php use Andydefer\LaravelReminder\Facades\Reminder; // Traiter tous les rappels $results = Reminder::processPendingReminders(); // Traiter un rappel spécifique $success = Reminder::processReminder($reminder);
Les événements
<?php // Dans votre AppServiceProvider.php namespace App\Providers; use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider { public function boot(): void { // Écouter tous les événements de rappel Event::listen('reminder.*', function ($eventName, $payload) { Log::info("Événement reminder: {$eventName}", $payload); }); // Écouter un événement spécifique Event::listen('reminder.sent', function ($reminder) { Log::info("Rappel envoyé avec succès", [ 'reminder_id' => $reminder->id, 'channels' => $reminder->channels(), ]); }); Event::listen('reminder.failed', function ($reminder, $exception) { Log::error("Échec d'envoi de rappel", [ 'reminder_id' => $reminder->id, 'channels' => $reminder->channels(), 'attempts' => $reminder->attempts, 'error' => $exception->getMessage(), ]); }); Event::listen('reminder.outside_tolerance', function ($reminder) { Log::warning("Rappel hors fenêtre de tolérance", [ 'reminder_id' => $reminder->id, 'scheduled_at' => $reminder->scheduled_at, ]); }); Event::listen('reminder.processing', function ($reminder) { Log::debug("Traitement du rappel", [ 'reminder_id' => $reminder->id, ]); }); Event::listen('reminder.processed', function ($results) { Log::info("Traitement terminé", $results); }); } }
Les exceptions
<?php namespace Andydefer\LaravelReminder\Exceptions; use InvalidArgumentException; class InvalidNotificationException extends InvalidArgumentException { /** * Crée une exception pour un type de retour invalide * * @param mixed $actual Valeur retournée * @return self */ public static function create(mixed $actual): self { $type = is_object($actual) ? get_class($actual) : gettype($actual); return new self( sprintf( 'toRemind() doit retourner une instance de Illuminate\Notifications\Notification. Type reçu : %s', $type ) ); } }
Tests
Le package est fourni avec une suite de tests complète.
Exécuter les tests
composer test # ou ./vendor/bin/phpunit
Exemple de test pour les channels
<?php namespace Tests\Feature; use Andydefer\LaravelReminder\Facades\Reminder; use App\Models\Article; use App\Notifications\ArticleReminderNotification; use Carbon\Carbon; use Illuminate\Support\Facades\Notification; use Tests\TestCase; class ArticleRemindersTest extends TestCase { public function test_article_can_have_reminders_with_custom_channels() { // Créer un article $article = Article::factory()->create(); // Planifier un rappel avec channels personnalisés $reminder = $article->scheduleReminder( scheduledAt: now()->addDays(3), metadata: ['reason' => 'publication'], channels: ['mail', 'sms'] ); // Vérifications en base de données $this->assertDatabaseHas('reminders', [ 'id' => $reminder->id, 'remindable_id' => $article->id, 'status' => 'pending', ]); // Vérifier les channels $this->assertEquals(['mail', 'sms'], $reminder->channels()); $this->assertTrue($reminder->has_custom_channels); } public function test_reminder_sends_notification_on_specified_channels() { // Fake les notifications Notification::fake(); // Simuler une date Carbon::setTestNow('2025-03-20 10:00:00'); $article = Article::factory()->create(); // Rappel avec channels spécifiques $article->scheduleReminder( scheduledAt: Carbon::now()->subMinutes(5), channels: ['mail', 'database'] ); // Traiter les rappels Reminder::processPendingReminders(); // Vérifier que la notification a été envoyée Notification::assertSentTo( $article, ArticleReminderNotification::class, function ($notification) { // Vérifier que les channels sont correctement utilisés return $notification->reminder->channels() === ['mail', 'database']; } ); } public function test_reminder_uses_fallback_channels_when_no_custom_channels() { Notification::fake(); $article = Article::factory()->create(); // Rappel sans channels spécifiques $article->scheduleReminder(now()->subMinutes(5)); Reminder::processPendingReminders(); // Vérifier que la notification a utilisé le fallback Notification::assertSentTo( $article, ArticleReminderNotification::class, function ($notification) { // Doit utiliser le fallback (par défaut ['mail']) return $notification->reminder->channels() === []; } ); } }
Bonnes pratiques
1. Toujours inclure le trait Notifiable
<?php use Illuminate\Notifications\Notifiable; class Article extends Model implements ShouldRemind { use Remindable, Notifiable; // Notifiable est OBLIGATOIRE pour recevoir des notifications }
2. Utiliser channelsForSending() dans vos notifications
<?php public function via($notifiable): array { // Toujours utiliser un fallback explicite return $this->reminder->channelsForSending(['mail']); // Ne jamais faire ça : // return $this->reminder->channels(); // ❌ Peut retourner [] }
3. Nommer les métadonnées de façon cohérente
<?php // 👍 À faire : noms explicites et structurés $reminder = $order->scheduleReminder( scheduledAt: now()->addDays(3), metadata: [ 'notification_type' => 'email', 'template' => 'order.reminder', 'locale' => app()->getLocale(), 'user_id' => auth()->id(), 'order_id' => $order->id, ], channels: ['mail'] ); // 👎 À éviter : métadonnées cryptiques $reminder = $order->scheduleReminder( now()->addDays(3), ['abc' => 123, 'xyz' => true], // Qu'est-ce que ça veut dire ? ['abc'] // Channels invalides );
4. Structurer les notifications selon le contexte
<?php public function toRemind(Reminder $reminder): Notification { // Retourner différentes notifications selon le contexte if ($this->priority === 'high') { return new UrgentReminderNotification($this, $reminder); } if ($this->type === 'subscription') { return new SubscriptionReminderNotification($this, $reminder); } return new StandardReminderNotification($this, $reminder); }
5. Valider les channels avant utilisation
<?php $channels = ['mail', 'sms', 'whatsapp']; // Vérifier que les channels existent dans l'application $availableChannels = ['mail', 'database', 'slack']; $validChannels = array_intersect($channels, $availableChannels); if (empty($validChannels)) { // Fallback sur mail si aucun channel valide $validChannels = ['mail']; } $article->scheduleReminder( scheduledAt: now()->addDays(7), channels: $validChannels );
6. Gérer les erreurs gracieusement
<?php class Article implements ShouldRemind { public function toRemind(Reminder $reminder): Notification { try { // Logique métier potentiellement instable $template = $this->getNotificationTemplate(); return new DynamicReminderNotification($this, $reminder, $template); } catch (\Exception $e) { // Fallback en cas d'erreur Log::error('Erreur lors de la création de la notification', [ 'article' => $this->id, 'reminder' => $reminder->id, 'error' => $e->getMessage(), ]); // Retourner une notification de secours return new FallbackReminderNotification($this, $reminder); } } }
7. Nettoyer les anciens rappels
<?php // config/reminder.php 'cleanup' => [ 'enabled' => true, // Activer le nettoyage automatique 'after_days' => 30, // Supprimer les rappels de plus de 30 jours ],
8. Utiliser la file d'attente en production
REMINDER_QUEUE_ENABLED=true REMINDER_QUEUE_CONNECTION=database REMINDER_QUEUE_NAME=reminders
# Lancer un worker dédié
php artisan queue:work --queue=reminders
Cas d'usage avancés
Rappels récurrents
<?php trait RecurringReminders { /** * Planifie des rappels récurrents * * @param array $schedule Tableau associatif [intervalle => canaux] * @param array $metadata Métadonnées communes * @return array */ public function scheduleRecurringReminders(array $schedule, array $metadata = []): array { $reminders = []; foreach ($schedule as $interval => $channels) { $nextDate = $this->calculateNextDate($interval); $reminders[] = $this->scheduleReminder( scheduledAt: $nextDate, metadata: array_merge($metadata, ['pattern' => $interval]), channels: $channels ); } return $reminders; } /** * Calcule la prochaine date selon l'intervalle */ private function calculateNextDate(string $interval): Carbon { return match ($interval) { 'daily' => now()->addDay(), 'weekly' => now()->addWeek(), 'biweekly' => now()->addWeeks(2), 'monthly' => now()->addMonth(), 'quarterly' => now()->addMonths(3), 'yearly' => now()->addYear(), default => now()->addDay(), }; } } // Utilisation class Subscription extends Model implements ShouldRemind { use Remindable, Notifiable, RecurringReminders; public function activate() { $this->scheduleRecurringReminders( schedule: [ 'daily' => ['mail'], // Rappel quotidien par email 'weekly' => ['mail', 'sms'], // Rappel hebdomadaire email + SMS 'monthly' => ['sms'], // Rappel mensuel SMS uniquement ], metadata: ['subscription_id' => $this->id, 'type' => 'renewal'] ); } public function toRemind(Reminder $reminder): Notification { return new SubscriptionReminderNotification($this, $reminder); } public function getTolerance(): Tolerance { return new Tolerance(24, ToleranceUnit::HOUR); } }
Rappels avec conditions
<?php class Task extends Model implements ShouldRemind { use Remindable, Notifiable; public function toRemind(Reminder $reminder): Notification { // Adapter la notification selon le contexte if ($this->priority === 'high') { return new UrgentTaskNotification($this, $reminder); } if ($this->assigned_to === auth()->id()) { return new AssignedTaskNotification($this, $reminder); } return new TaskReminderNotification($this, $reminder); } public function getTolerance(): Tolerance { // Tolérance variable selon la priorité return match ($this->priority) { 'high' => new Tolerance(1, ToleranceUnit::HOUR), 'medium' => new Tolerance(6, ToleranceUnit::HOUR), default => new Tolerance(24, ToleranceUnit::HOUR), }; } public function scheduleTaskReminders(): void { // J-7 : email $this->scheduleReminder( scheduledAt: $this->due_date->subDays(7), channels: ['mail'] ); // J-1 : email + SMS $this->scheduleReminder( scheduledAt: $this->due_date->subDay(), channels: ['mail', 'sms'] ); // J-0 (urgent) : tous les canaux if ($this->priority === 'high') { $this->scheduleReminder( scheduledAt: $this->due_date, channels: ['mail', 'sms', 'database', 'slack'] ); } } }
Combinaison avec les préférences utilisateur
<?php class User extends Authenticatable implements ShouldRemind { use Notifiable, Remindable; /** * Préférences de notification de l'utilisateur * Stockées en JSON dans la base */ protected $casts = [ 'notification_preferences' => 'array', ]; public function toRemind(Reminder $reminder): Notification { return new UserReminderNotification($this, $reminder); } public function getTolerance(): Tolerance { return new Tolerance(24, ToleranceUnit::HOUR); } /** * Planifie un rappel en respectant les préférences */ public function schedulePersonalizedReminder(Carbon $date, array $metadata = []): Reminder { $channels = $this->notification_preferences['reminders'] ?? ['mail']; return $this->scheduleReminder( scheduledAt: $date, metadata: $metadata, channels: $channels ); } } // Dans la notification class UserReminderNotification extends Notification { public function via($notifiable): array { // Priorité : // 1. Canaux du reminder (définis à la planification) // 2. Sinon, préférences de l'utilisateur // 3. Sinon, fallback ['mail'] $channels = $this->reminder->channelsForSending([]); if (empty($channels)) { $channels = $notifiable->notification_preferences['reminders'] ?? ['mail']; } return $channels; } }
Statistiques et reporting
<?php namespace App\Console\Commands; use Andydefer\LaravelReminder\Models\Reminder; use Andydefer\LaravelReminder\Enums\ReminderStatus; use Illuminate\Console\Command; class ReminderStatsCommand extends Command { protected $signature = 'reminder:stats'; public function handle() { $this->info('Statistiques des rappels'); // Statistiques globales $stats = [ 'total' => Reminder::count(), 'pending' => Reminder::pending()->count(), 'sent' => Reminder::where('status', ReminderStatus::SENT)->count(), 'failed' => Reminder::where('status', ReminderStatus::FAILED)->count(), 'cancelled' => Reminder::where('status', ReminderStatus::CANCELLED)->count(), ]; $this->table( ['Statut', 'Nombre'], collect($stats)->map(fn($count, $status) => [$status, $count])->values()->toArray() ); // Rappels avec canaux personnalisés $customChannels = Reminder::whereNotNull('channels') ->where('channels', '!=', json_encode([])) ->count(); $this->info("Rappels avec canaux personnalisés : $customChannels"); // Taux de succès if ($stats['total'] > 0) { $successRate = round(($stats['sent'] / $stats['total']) * 100, 2); $this->info("Taux de succès : $successRate%"); } } }
Dépannage
Problème : Les rappels ne s'envoient pas
# 1. Vérifier que le scheduler est configuré crontab -l | grep "schedule:run" # 2. Vérifier les rappels en attente php artisan tinker >>> Reminder::pending()->count() # 3. Voir les erreurs >>> Reminder::whereNotNull('error_message')->get() # 4. Tester manuellement php artisan reminders:send --sync
Problème : Les channels personnalisés ne sont pas utilisés
<?php // Vérifier votre méthode via() public function via($notifiable): array { // ✅ Bon : utilise channelsForSending() return $this->reminder->channelsForSending(['mail']); // ❌ Mauvais : ignore les channels personnalisés // return ['mail', 'database']; // ❌ Mauvais : risque de retourner [] // return $this->reminder->channels(); } // Vérifier que le reminder a bien des canaux $reminder = Reminder::find(1); dd($reminder->channels()); // Doit retourner un tableau dd($reminder->has_custom_channels); // Doit être true si des canaux sont définis
Problème : "toRemind() must return an instance of Notification"
<?php // ✅ Correct : retourne une instance de Notification public function toRemind(Reminder $reminder): Notification { return new MyNotification($this, $reminder); } // ❌ Incorrect : retourne un tableau public function toRemind(Reminder $reminder): array { return ['title' => 'test']; } // ❌ Incorrect : retourne une string public function toRemind(Reminder $reminder): string { return 'notification'; }
Problème : Trop de tentatives échouées
<?php // config/reminder.php // Augmenter le nombre de tentatives 'max_attempts' => 5, // Ou désactiver la limite (non recommandé) // 'max_attempts' => 999,
Problème : "Call to undefined method channelsForSending()"
# Vérifier que la migration est à jour php artisan tinker >>> Schema::hasColumn('reminders', 'channels'); # Si false, publier et migrer php artisan vendor:publish --provider="Andydefer\LaravelReminder\ReminderServiceProvider" --tag="reminder-migrations" --force php artisan migrate
Problème : Les dates ne sont pas reconnues
<?php try { $reminder = $article->scheduleReminder('2025-13-45'); // Date invalide } catch (InvalidArgumentException $e) { echo "Format de date invalide : " . $e->getMessage(); } // Formats supportés : $reminder = $article->scheduleReminder('2025-12-25'); // Y-m-d $reminder = $article->scheduleReminder('2025-12-25 09:00:00'); // Y-m-d H:i:s $reminder = $article->scheduleReminder('tomorrow'); // Mots-clés Carbon $reminder = $article->scheduleReminder('+7 days'); // Expressions Carbon
Contribuer
Les contributions sont les bienvenues !
Comment contribuer
- Forkez le projet
- Créez une branche (
git checkout -b feature/amazing-feature) - Committez vos changements (
git commit -m 'Add amazing feature') - Pushez vers la branche (
git push origin feature/amazing-feature) - Ouvrez une Pull Request
Guide de style
- Suivez les conventions PSR-12
- Ajoutez des tests pour vos nouvelles fonctionnalités
- Mettez à jour la documentation si nécessaire
- Commentez votre code en français ou en anglais
Tests
# Exécuter les tests composer test # Exécuter un test spécifique ./vendor/bin/phpunit --filter test_name # Avec couverture de code composer test-coverage
Licence
Ce package est open-sourcé sous licence MIT. Voir le fichier LICENSE pour plus d'informations.
Créé avec ❤️ par Andy Kani