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.
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
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
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.
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]);
}
}
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 :
./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_DRIVER | Usage |
|---|---|
file (défaut) | un seul serveur |
database | tables jobs et failed_jobs : plusieurs workers, même sur plusieurs machines ; chaque job n'est réservé que par un seul |
redis | plusieurs workers et machines, sans base de données (voir Redis) |
sync | exé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 :
$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');
* * * * * cd /chemin/vers/le/projet && php bin/niang schedule:run >> /dev/null 2>&1
| Tâche | Comment 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.
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'.
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>';
}
}
Mail::to($user['email'])->send(new ResetPasswordMailable($signedUrl));
Copies, pièces jointes, envoi par la file
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 :
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_ENCRYPTION | Port habituel | Effet |
|---|---|---|
tls (défaut) | 587 | Connexion puis STARTTLS. Si le serveur ne le propose pas, l'envoi échoue : jamais de repli en clair. |
ssl | 465 | TLS dès la connexion (TLS implicite). |
none | 1025, 25 | En 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 :
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).
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...)
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
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 :
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');
}
<form method="POST" action="/profil/avatar" enctype="multipart/form-data">
<?= csrf_field() ?>
<input type="file" name="avatar" accept="image/*">
</form>
| Méthode | Retourne |
|---|---|
$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 :
$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.
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é] »
LOG_LEVEL=warning
LOG_DAYS=14
| Variable | Effet |
|---|---|
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.
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
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 :
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);
}
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 :
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
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.