andydefer/laravel-reminder
最新稳定版本:1.1.2
Composer 安装命令:
composer require andydefer/laravel-reminder
包简介
A flexible and robust reminder system for Laravel
README 文档
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
统计信息
- 总下载量: 5
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 5
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-03-18