1. Go to this page and download the library: Download andydefer/laravel-reminder library. Choose the download type require.
2. Extract the ZIP file and open the index.php.
3. Add this code to the index.php.
<?php
require_once('vendor/autoload.php');
/* Start to develop here. Best regards https://php-download.com/ */
andydefer / laravel-reminder example snippets
// 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
);
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'),
],
];
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...
}
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...
}
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}");
}
}
// 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';
// 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());
}
// 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)
// 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";
}
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(),
];
$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
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)";
}
// 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
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']
}
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']);
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)
]);
}
}
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);
}
}
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',
};
}
}
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' : '');
}
}
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([]);
}
}
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
);
}
}
}
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;
}
}
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(),
]);
}
}
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;
}
}
use Andydefer\LaravelReminder\Facades\Reminder;
// Traiter tous les rappels
$results = Reminder::processPendingReminders();
// Traiter un rappel spécifique
$success = Reminder::processReminder($reminder);
// 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);
});
}
}
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
)
);
}
}
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() === [];
}
);
}
}
use Illuminate\Notifications\Notifiable;
class Article extends Model implements ShouldRemind
{
use Remindable, Notifiable; // Notifiable est OBLIGATOIRE pour recevoir des notifications
}
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 []
}
// 👍 À 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
);
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);
}
$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
);
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);
}
}
}
// config/reminder.php
'cleanup' => [
'enabled' => true, // Activer le nettoyage automatique
'after_days' => 30, // Supprimer les rappels de plus de 30 jours
],
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);
}
}
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']
);
}
}
}
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;
}
}
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%");
}
}
}
// 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
// ✅ 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';
}
// config/reminder.php
// Augmenter le nombre de tentatives
'max_attempts' => 5,
// Ou désactiver la limite (non recommandé)
// 'max_attempts' => 999,
bash
# 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
bash
# 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
bash
# É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"
bash
# 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
bash
# Lancer un worker dédié
php artisan queue:work --queue=reminders
bash
# 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
bash
# 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
Loading please wait ...
Before you can download the PHP files, the dependencies should be resolved. This can take some minutes. Please be patient.