Cache, Queue, Mail, Storage, Events

Cache, Queue, Mail, Storage, Events

De petites classes statiques, sans dépendance externe : pas de Redis, pas de SDK S3, pas de broker de messages — le cache, la file et le stockage sont sur fichier, et l'envoi d'emails passe par un client SMTP écrit à la main. Le principe assumé par le framework : plutôt l'absence d'une fonctionnalité que son implémentation non vérifiable.

Cache

Niang\Core\Cache : un fichier sérialisé par clé dans storage/framework/cache/, TTL optionnel.

php
Cache::put('homepage.stats', $stats, 300);  // 5 minutes
Cache::get('homepage.stats', []);           // valeur, ou le défaut si absente/expirée
Cache::has('homepage.stats');
Cache::forget('homepage.stats');
Cache::flush();                             // vide tout storage/framework/cache/

// Le plus utilisé en pratique : lire si présent, sinon calculer et mettre en cache
$stats = Cache::remember('homepage.stats', 300, fn () => Post::query()->count());

./bin/niang cache:clear appelle Cache::flush() depuis le CLI. Avec CACHE_DRIVER=database, le cache est dans la table cache_entries, partagée entre serveurs — voir Plusieurs serveurs web.

Compteurs et pilote array

php
Cache::increment('visites.accueil');   // 1, 2, 3... (0 si absente)
Cache::decrement('stock.42', 3);

Atomiques : deux requêtes simultanées ne perdent pas d'incrément (verrou sur fichier, compare-and-swap en base). Le TTL existant est conservé. CACHE_DRIVER=array et SESSION_DRIVER=array gardent les données en mémoire du process, pour les tests et la CLI.

Redis

.env
CACHE_DRIVER=redis
SESSION_DRIVER=redis
QUEUE_DRIVER=redis
REDIS_HOST=127.0.0.1        # ou tls://hote pour un service géré
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
REDIS_PREFIX=niangpro:

Cache, sessions, limitation de débit et file d'attente peuvent chacun passer sur Redis (ou Valkey, KeyDB, Upstash...). Aucune extension PHP n'est requise : le client parle directement le protocole Redis. Les incréments et les déplacements de jobs sont des scripts Lua, donc atomiques même avec plusieurs serveurs et plusieurs workers ; un job réservé par un worker arrêté revient dans la file après QUEUE_RETRY_AFTER. REDIS_PREFIX permet à plusieurs applications de partager un serveur. niang doctor vérifie la connexion.

Queue et Jobs

Un job différé est un objet sérialisé dans storage/framework/queue/, traité par niang queue:work — pas de démon : à lancer via cron ou en boucle selon vos besoins.

app/Jobs/SendWelcomeEmailJob.php

namespace App\Jobs;

use Niang\Core\Job;
use Niang\Core\Log;

class SendWelcomeEmailJob extends Job
{
    public function __construct(private string $email)
    {
    }

    public function handle(): void
    {
        Log::info('Email de bienvenue envoyé à {email}', ['email' => $this->email]);
    }
}
php
Queue::push(new SendWelcomeEmailJob($email));           // dès que possible
Queue::later(300, new SendWelcomeEmailJob($email));     // dans 5 minutes
Queue::pending();                                        // nombre de jobs en attente

Un job qui échoue (exception dans handle()) est retenté jusqu'à Job::$tries fois (1 par défaut — à augmenter dans le constructeur du job), avec un backoff exponentiel (10 s, 20 s, 40 s...) entre tentatives, puis déplacé vers les jobs échoués :

bash
./bin/niang queue:work        # traite une passe des jobs dus
./bin/niang queue:failed      # liste les jobs qui ont épuisé leurs tentatives
./bin/niang queue:retry <id>  # remet un job échoué en file, tentatives réinitialisées
./bin/niang queue:flush       # supprime définitivement tous les jobs échoués

Pilotes de la file

QUEUE_DRIVERUsage
file (défaut)un seul serveur
databasetables jobs et failed_jobs : plusieurs workers, même sur plusieurs machines ; chaque job n'est réservé que par un seul
redisplusieurs workers et machines, sans base de données (voir Redis)
syncexécution immédiate, sans worker (développement, tests)

Avec database, un job réservé par un worker arrêté en route est rendu à la file après QUEUE_RETRY_AFTER secondes (600 par défaut).

Tâches planifiées

Plutôt qu'une ligne cron par tâche, les tâches se déclarent en PHP dans routes/schedule.php, et une seule ligne cron appelle schedule:run chaque minute :

routes/schedule.php
$schedule->command('queue:work')->everyMinute()->withoutOverlapping();
$schedule->command('db:seed', ['Tags'])->dailyAt('03:00');
$schedule->call(fn () => Cache::forget('stats'), 'vider les statistiques')->hourly();
$schedule->job(new SendWeeklyReportJob())->weeklyOn(1, '08:00');
crontab -e
* * * * * cd /chemin/vers/le/projet && php bin/niang schedule:run >> /dev/null 2>&1
TâcheComment elle s'exécute
command('nom', [args])Une commande niang, dans un process séparé : une commande qui plante ou appelle exit() n'interrompt pas les autres tâches.
call(fn, 'description')Une closure, dans le process de schedule:run (application démarrée, Service Providers compris).
job(new Job)Pousse le job sur la file, traité ensuite par queue:work.

Fréquences : everyMinute(), everyFiveMinutes(), everyTenMinutes(), everyFifteenMinutes(), everyThirtyMinutes(), hourly(), hourlyAt(17), daily(), dailyAt('03:00'), weekly(), weeklyOn(1, '08:00') (0 = dimanche), monthly(), monthlyOn(15, '18:00'), weekdays(), weekends(), ou une expression brute cron('30 8 * * 1-5'). withoutOverlapping() saute une exécution tant que la précédente tourne encore (verrou libéré même si le process meurt). ./bin/niang schedule:list affiche chaque tâche, son expression cron et sa prochaine exécution.

Les tâches d'une même minute s'exécutent l'une après l'autre ; une tâche en échec est journalisée (storage/logs/) et schedule:run sort alors avec le code 1. L'heure est celle de PHP (date.timezone). Sur plusieurs serveurs, ne placez la ligne cron que sur l'un d'eux.

Mail

Trois drivers, pilotés par MAIL_MAILER dans .env : 'smtp' pour un envoi réel (le driver de production), 'log' (défaut) qui journalise le contenu complet de l'email dans storage/logs/ (pratique en développement pour lire un lien de vérification sans boîte mail réelle), et 'array' qui le garde en mémoire du process pour les assertions de test. Une valeur inconnue lève une ConfigurationException plutôt que de retomber silencieusement sur 'log'.

app/Mailables/ResetPasswordMailable.php

namespace App\Mailables;

use Niang\Core\Mailable;

class ResetPasswordMailable extends Mailable
{
    public function __construct(private string $signedUrl)
    {
    }

    public function subject(): string
    {
        return 'Réinitialisation de votre mot de passe';
    }

    public function body(): string
    {
        return "Cliquez sur ce lien : {$this->signedUrl}";
    }

    // Facultatif : avec une version HTML, l'email part en texte + HTML (multipart/alternative).
    public function html(): ?string
    {
        return '<p><a href="' . e($this->signedUrl) . '">Choisir un nouveau mot de passe</a></p>';
    }
}
php
Mail::to($user['email'])->send(new ResetPasswordMailable($signedUrl));

Copies, pièces jointes, envoi par la file

php
Mail::to('awa@example.com')
    ->cc('comptabilite@example.com')     // visible de tous
    ->bcc(['archives@example.com'])      // copie cachée : n'apparaît jamais dans le message
    ->send(new InvoiceMailable($facture));

class InvoiceMailable extends Mailable
{
    // subject(), body()...
    public function attachments(): array
    {
        return [
            MailAttachment::fromPath(Storage::path($this->facture['path']), 'Facture été 2026.pdf'),
            MailAttachment::fromData($csv, 'export.csv', 'text/csv'),   // contenu déjà en mémoire
        ];
    }
}

Mail::to($user['email'])->queue(new WelcomeMailable($user['name']));        // envoyé par queue:work
Mail::to($user['email'])->later(3600, new ReminderMailable($user['name']));  // dans une heure au plus tôt

queue() sort l'envoi de la requête HTTP : le job retente 3 fois si le serveur SMTP est indisponible. Le Mailable est sérialisé (pas de closure ni de connexion dans ses propriétés), et un fichier joint par fromPath() doit encore exister quand queue:work envoie. Le type MIME est détecté si vous ne le donnez pas, et un nom accentué est encodé (RFC 2231). En SMTP, chaque destinataire reçoit le message une seule fois, et aucun en-tête Bcc n'y figure. En test, Mail::sent() expose aussi cc et bcc.

SMTP

Niang\Core\SmtpTransport est un client SMTP écrit à la main, sans extension ni dépendance : STARTTLS ou TLS implicite, AUTH PLAIN/LOGIN, sujets et noms accentués encodés. Toute la configuration est dans .env :

.env
MAIL_MAILER=smtp
MAIL_HOST=smtp.example.com
MAIL_PORT=587
MAIL_ENCRYPTION=tls
MAIL_USERNAME=contact@example.com
MAIL_PASSWORD=secret
MAIL_FROM_ADDRESS=contact@example.com
MAIL_FROM_NAME=Mon site
MAIL_TIMEOUT=10
MAIL_ENCRYPTIONPort habituelEffet
tls (défaut)587Connexion puis STARTTLS. Si le serveur ne le propose pas, l'envoi échoue : jamais de repli en clair.
ssl465TLS dès la connexion (TLS implicite).
none1025, 25En clair — pour un serveur local (Mailpit, MailHog). Les identifiants ne partent jamais en clair vers un autre hôte que localhost.

Le certificat du serveur est vérifié. Un échec (serveur injoignable, identifiants refusés, destinataire rejeté...) lève une Niang\Core\Exceptions\MailException qui cite la réponse du serveur, jamais le mot de passe. niang doctor signale une configuration incomplète (MAIL_HOST, MAIL_FROM_ADDRESS) et avertit si MAIL_MAILER vaut log ou array en production.

Pas de commentaire en fin de ligne dans .env (MAIL_ENCRYPTION=tls # ...) : il ferait partie de la valeur. send() est synchrone : pour ne pas faire attendre la requête, utilisez queue() (voir ci-dessus).

En test, Mail::fake() bascule sur le driver 'array' quel que soit .env :

php
Mail::fake();
// ... déclenche l'envoi ...
$sent = Mail::sent(); // [['to' => '...', 'mailable' => ResetPasswordMailable], ...]

MAIL_MAILER=log écrit le corps complet de chaque email dans les logs — à ne jamais garder en production si vos emails contiennent des données sensibles.

Storage

Niang\Core\Storage : disque local uniquement, enraciné dans storage/app/ (pas de driver S3, même raison qu'un SMTP : pas de SDK externe).

php
Storage::put('avatars/1.png', $binaryContents);
Storage::get('avatars/1.png');      // ?string
Storage::exists('avatars/1.png');
Storage::size('avatars/1.png');     // ?int, en octets
Storage::delete('avatars/1.png');
Storage::url('avatars/1.png');      // '/storage/avatars/1.png' — à router vers Storage::get() pour servir le fichier

Tout chemin contenant .. est rejeté (\InvalidArgumentException) — impossible d'écrire ou lire hors de storage/app/ via cette classe.

Disque S3 (AWS, R2, MinIO...)

.env
FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
AWS_DEFAULT_REGION=eu-west-3
AWS_BUCKET=mon-bucket
AWS_ENDPOINT=https://<compte>.r2.cloudflarestorage.com
AWS_URL=https://cdn.exemple.sn
php
Storage::put('factures/1.pdf', $pdf, 'application/pdf');
Storage::temporaryUrl('factures/1.pdf', 300);   // lien de lecture de 5 minutes, sans rendre le fichier public
$file->store('avatars');                          // envoyé sur le disque choisi

Même API que le disque local ; Storage::path() n'existe que sur le disque local. Le client est écrit sans SDK (signature AWS SigV4, vérifiée contre les exemples publiés par AWS). AWS_ENDPOINT pour un service compatible ; AWS_URL (facultatif) pour Storage::url() derrière un CDN. Sur le disque local, temporaryUrl() donne une URL signée. niang doctor vérifie la configuration.

Upload de fichiers

$request->file('champ') retourne un Niang\Core\Http\UploadedFile (ou null). Le nom et le type envoyés par le navigateur ne sont jamais crus : le type réel est lu dans le contenu du fichier, et store() range le fichier dans storage/app/ — hors de public/, donc jamais exécutable — sous un nom aléatoire suivi de l'extension réelle :

php
public function updateAvatar(Request $request): Response
{
    $data = $this->validate($request, [
        'avatar' => 'required|image|max:2048|dimensions:min_width=100,min_height=100',
    ]);

    $path = $data['avatar']->store('avatars');   // 'avatars/3f9c...e1.png'
    User::update(Auth::id(), ['avatar' => $path]);

    return $this->redirect('/profil');
}
le formulaire
<form method="POST" action="/profil/avatar" enctype="multipart/form-data">
    <?= csrf_field() ?>
    <input type="file" name="avatar" accept="image/*">
</form>
MéthodeRetourne
$request->file('avatar') / hasFile('avatar')Le fichier (ou null) / s'il est arrivé sans erreur.
$request->files['photos']La liste des fichiers d'un champ multiple (name="photos[]").
store($dossier)Le chemin relatif dans storage/app/, nom aléatoire + extension réelle.
storeAs($dossier, $nom)Idem avec un nom choisi (refuse / et ..).
mimeType() / extension()Type réel lu dans le contenu / extension déduite de ce type.
clientName()Le nom d'origine — pour l'affichage seulement, jamais pour un chemin.
size()La taille en octets.

Les règles file, image, mimes, mimetypes et dimensions sont décrites dans Validation. Un champ fichier laissé vide est traité comme absent ; un upload arrivé en erreur affiche sa vraie raison (« dépasse la taille maximale autorisée par le serveur »...). Au-delà de post_max_size (php.ini), PHP vide la requête entière : réglez-le, avec upload_max_filesize, au-dessus de vos règles max:.

En test, UploadedFile::fake() et UploadedFile::fakeImage() (un vrai PNG, sans GD) simulent un envoi :

php
$this->post('/profil/avatar', ['avatar' => UploadedFile::fakeImage('moi.png', 200, 200)])
    ->assertRedirect('/profil');

$this->post('/cv', ['cv' => UploadedFile::fake('cv.pdf', "%PDF-1.4 ...")]);

Logs

Niveaux PSR-3 : emergency, alert, critical, error, warning, notice, info, debug. Les {clé} du message sont remplacées par le contexte. Un fichier par jour dans storage/logs/ ; les exceptions non interceptées y sont consignées automatiquement. Niang\Core\Logger implémente Psr\Log\LoggerInterface pour l'injection.

php
Log::info('Utilisateur {id} connecté', ['id' => $user['id']]);
Log::error('Échec du paiement', ['order' => $orderId]);
Log::info('Connexion', $request->all());   // le mot de passe est écrit « [masqué] »
.env
LOG_LEVEL=warning
LOG_DAYS=14
VariableEffet
LOG_LEVEL (défaut debug)Niveau minimal écrit : warning ignore debug, info et notice. Un niveau inconnu journalise tout plutôt que rien.
LOG_DAYS (défaut 14)Jours de fichiers conservés ; les plus anciens sont supprimés au premier message de chaque journée. 0 : tout garder.

Une valeur de contexte dont la clé évoque un secret (password, token, secret, api_key, authorization, cookie, card, cvv, iban...) est remplacée par [masqué], à n'importe quelle profondeur. niang doctor signale un LOG_LEVEL inconnu, et debug en production.

Canaux de journalisation

LOG_CHANNEL : daily (défaut, un fichier par jour), single (storage/logs/niangpro.log), errorlog (journal du serveur web ou de PHP-FPM), syslog (journal système, identifiant LOG_SYSLOG_IDENT) ou stderr (conteneurs Docker). Niveau minimal et masquage des secrets s'appliquent à tous. LOG_FORMAT=json écrit un objet JSON par ligne, et chaque message d'une requête porte son request_id : voir Observabilité.

Notifications

Un même message envoyé sur un ou plusieurs canaux : mail (colonne email du destinataire), database (table notifications, fournie avec les migrations), webhook, ou votre propre canal. Le destinataire est une ligne de users.

php
use Niang\Core\Notification;

class CommandeExpediee extends Notification
{
    public function __construct(private array $commande) {}

    public function via(array $user): array
    {
        return ['mail', 'database'];   // ou 'webhook', ou SmsChannel::class
    }

    public function toMail(array $user): Mailable
    {
        return new CommandeExpedieeMailable($this->commande);
    }

    public function toDatabase(array $user): array
    {
        return ['commande' => $this->commande['id'], 'message' => 'Votre commande est en route'];
    }
}

Notification::send($user, new CommandeExpediee($commande));     // ou une liste d'utilisateurs

Notification::for($user);              // ses notifications, les plus récentes d'abord (data décodé)
Notification::unread($user);           // Notification::unreadCount($user)
Notification::markAsRead($user, $id);  // false si la notification n'est pas la sienne
Notification::markAllAsRead($user);

Une notification qui implémente ShouldQueue part par la file (un job par destinataire, 3 tentatives) ; Notification::sendNow() l'envoie tout de suite. En test, Notification::fake() puis Notification::sent().

Webhook

php
class CommandePayee extends Notification
{
    public function via(array $boutique): array { return ['webhook']; }
    public function webhookUrl(array $boutique): string { return $boutique['webhook_url']; }
    public function webhookSecret(): ?string { return env('WEBHOOK_SECRET'); }
    public function toWebhook(array $boutique): array { return ['event' => 'order.paid', 'id' => 42]; }
}

// Chez le destinataire : vérifier la signature avant de faire confiance au contenu
$attendu = 'sha256=' . hash_hmac('sha256', file_get_contents('php://input'), $secret);
if (!hash_equals($attendu, $_SERVER['HTTP_X_NIANG_SIGNATURE'] ?? '')) {
    http_response_code(401);
    exit;
}

L'URL doit être en http ou https, les redirections ne sont pas suivies, et une réponse hors 2xx lève une NotificationException (le job est alors retenté). Avec webhookSecret(), l'en-tête X-Niang-Signature porte le HMAC-SHA256 du corps.

Canal sur mesure (SMS...) : une classe qui implémente Niang\Core\Contracts\NotificationChannel, avec send(array $notifiable, Notification $notification), et dont vous renvoyez le nom depuis via().

Events

Event::listen($nom, $listener) accepte une closure (toujours synchrone) ou une classe exposant handle(...). Une classe qui implémente Niang\Core\Contracts\ShouldQueue est automatiquement différée sur Queue plutôt qu'exécutée immédiatement :

app/Providers/AppServiceProvider.php
public function boot(): void
{
    Event::listen('user.registered', function (array $user): void {
        Log::info('Nouvel utilisateur inscrit : {email}', ['email' => $user['email']]);
    });

    // Enregistré comme classe (ShouldQueue) plutôt que comme closure : différé sur Queue,
    // ne bloque pas la requête d'inscription.
    Event::listen('user.registered', SendVerificationEmailListener::class);
}
app/Listeners/SendVerificationEmailListener.php
class SendVerificationEmailListener implements ShouldQueue
{
    public function handle(array $user): void
    {
        $signedUrl = signedRoute('verification.verify', ['id' => $user['id']], 24 * 3600);
        Mail::to($user['email'])->send(new VerifyEmailMailable($signedUrl));
    }
}

Déclenché avec Event::dispatch($nom, ...$payload) — le payload est passé tel quel à chaque listener :

php
Event::dispatch('user.registered', $user);

Un listener enregistré comme closure reste toujours synchrone, même si Event::dispatch() est appelé dans un contexte où d'autres listeners sont différés : une closure ne survivrait pas sérialisée sur la file. Seul un listener enregistré comme classe peut implémenter ShouldQueue.

Événements typés et PSR-14

php
class UserRegisteredEvent
{
    public function __construct(public readonly array $user) {}
}

Event::listen(UserRegisteredEvent::class, SendWelcomeEmailListener::class);
Event::dispatch(new UserRegisteredEvent($user));   // retourne l'événement

Un événement est un objet (./bin/niang make:event), écouté par le nom de sa classe, d'une classe parente ou d'une interface. Un événement qui étend Events\StoppableEvent s'arrête dès qu'un écouteur appelle stopPropagation(). Les événements nommés restent pris en charge. Pour l'injection, le conteneur résout Psr\EventDispatcher\EventDispatcherInterface, avec les mêmes écouteurs.

⏱ 13.69 ms 🗄 0 requête(s) SQL 🧠 4.00 MB ↩ 200