Cache, Queue, Mail, Storage, Events
Cache, Queue, Mail, Storage, Events
Small static classes with no external dependency: no Redis, no S3 SDK, no message broker — cache, queue and storage are file-based, and email goes out through a hand-written SMTP client. The principle the framework stands by: better to leave a feature out than to ship an implementation that cannot be verified.
Cache
Niang\Core\Cache: one serialized file per key in storage/framework/cache/, optional TTL.
Cache::put('homepage.stats', $stats, 300); // 5 minutes
Cache::get('homepage.stats', []); // the value, or the default if missing/expired
Cache::has('homepage.stats');
Cache::forget('homepage.stats');
Cache::flush(); // empties all of storage/framework/cache/
// The most used in practice: read if present, otherwise compute and cache
$stats = Cache::remember('homepage.stats', 300, fn () => Post::query()->count());
./bin/niang cache:clear calls Cache::flush() from the CLI. With CACHE_DRIVER=database, the cache lives in the cache_entries table, shared between servers — see Several web servers.
Counters and the array driver
Cache::increment('visites.accueil'); // 1, 2, 3... (0 if missing)
Cache::decrement('stock.42', 3);
Atomic: two simultaneous requests never lose an increment (file lock, compare-and-swap in the database).
The existing TTL is kept. CACHE_DRIVER=array and SESSION_DRIVER=array keep data in the
process memory, for tests and the CLI.
Redis
CACHE_DRIVER=redis
SESSION_DRIVER=redis
QUEUE_DRIVER=redis
REDIS_HOST=127.0.0.1 # or tls://host for a managed service
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
REDIS_PREFIX=niangpro:
Cache, sessions, rate limiting and the queue can each move to Redis (or Valkey, KeyDB, Upstash...). No PHP
extension is required: the client speaks the Redis protocol directly. Increments and job moves are Lua
scripts, so they stay atomic across several servers and several workers; a job reserved by a worker that
stopped goes back to the queue after QUEUE_RETRY_AFTER. REDIS_PREFIX lets several
applications share one server. niang doctor checks the connection.
Queue and Jobs
A deferred job is an object serialized into storage/framework/queue/, processed by
niang queue:work — no daemon: run it through cron or in a loop as you need.
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('Welcome email sent to {email}', ['email' => $this->email]);
}
}
Queue::push(new SendWelcomeEmailJob($email)); // as soon as possible
Queue::later(300, new SendWelcomeEmailJob($email)); // in 5 minutes
Queue::pending(); // number of pending jobs
A job that fails (an exception in handle()) is retried up to Job::$tries
times (1 by default — raise it in the job's constructor), with exponential backoff (10 s, 20 s,
40 s...) between attempts, then moved to the failed jobs:
./bin/niang queue:work # processes one pass of due jobs
./bin/niang queue:failed # lists jobs that have used up their attempts
./bin/niang queue:retry <id> # puts a failed job back on the queue, attempts reset
./bin/niang queue:flush # permanently deletes every failed job
Queue drivers
QUEUE_DRIVER | Use |
|---|---|
file (default) | a single server |
database | jobs and failed_jobs tables: several workers, even on several machines; each job is reserved by one only |
redis | several workers and machines, without a database (see Redis) |
sync | runs immediately, no worker (development, tests) |
With database, a job reserved by a worker that stopped midway goes back to the queue after QUEUE_RETRY_AFTER seconds (600 by default).
Scheduled tasks
Rather than one cron line per task, tasks are declared in PHP in routes/schedule.php, and a
single cron line calls schedule:run every minute:
$schedule->command('queue:work')->everyMinute()->withoutOverlapping();
$schedule->command('db:seed', ['Tags'])->dailyAt('03:00');
$schedule->call(fn () => Cache::forget('stats'), 'clear the stats')->hourly();
$schedule->job(new SendWeeklyReportJob())->weeklyOn(1, '08:00');
* * * * * cd /path/to/the/project && php bin/niang schedule:run >> /dev/null 2>&1
| Task | How it runs |
|---|---|
command('name', [args]) | A niang command, in a separate process: a command that crashes or calls exit() does not interrupt the other tasks. |
call(fn, 'description') | A closure, in the schedule:run process (application booted, Service Providers included). |
job(new Job) | Pushes the job onto the queue, processed later by queue:work. |
Frequencies: everyMinute(), everyFiveMinutes(), everyTenMinutes(),
everyFifteenMinutes(), everyThirtyMinutes(), hourly(),
hourlyAt(17), daily(), dailyAt('03:00'), weekly(),
weeklyOn(1, '08:00') (0 = Sunday), monthly(), monthlyOn(15, '18:00'),
weekdays(), weekends(), or a raw expression cron('30 8 * * 1-5').
withoutOverlapping() skips a run while the previous one is still going (the lock is released
even if the process dies). ./bin/niang schedule:list shows each task, its cron expression and
its next run.
Tasks due in the same minute run one after the other; a failing task is logged
(storage/logs/) and schedule:run then exits with code 1. The time is PHP's
(date.timezone). On several servers, put the cron line on only one of them.
Three drivers, controlled by MAIL_MAILER in .env: 'smtp' for
real sending (the production driver), 'log' (the default), which logs the full content of
the email to storage/logs/ (handy in development to read a verification link without a
real mailbox), and 'array', which keeps it in the process memory for test assertions. An
unknown value throws a ConfigurationException rather than silently falling back to
'log'.
namespace App\Mailables;
use Niang\Core\Mailable;
class ResetPasswordMailable extends Mailable
{
public function __construct(private string $signedUrl)
{
}
public function subject(): string
{
return 'Reset your password';
}
public function body(): string
{
return "Click this link: {$this->signedUrl}";
}
// Optional: with an HTML version, the email is sent as text + HTML (multipart/alternative).
public function html(): ?string
{
return '<p><a href="' . e($this->signedUrl) . '">Choose a new password</a></p>';
}
}
Mail::to($user['email'])->send(new ResetPasswordMailable($signedUrl));
Copies, attachments, sending through the queue
Mail::to('awa@example.com')
->cc('accounting@example.com') // visible to everyone
->bcc(['archives@example.com']) // blind copy: never appears in the message
->send(new InvoiceMailable($invoice));
class InvoiceMailable extends Mailable
{
// subject(), body()...
public function attachments(): array
{
return [
MailAttachment::fromPath(Storage::path($this->facture['path']), 'Invoice summer 2026.pdf'),
MailAttachment::fromData($csv, 'export.csv', 'text/csv'), // content already in memory
];
}
}
Mail::to($user['email'])->queue(new WelcomeMailable($user['name'])); // sent by queue:work
Mail::to($user['email'])->later(3600, new ReminderMailable($user['name'])); // in one hour at the earliest
queue() takes sending out of the HTTP request: the job retries 3 times if the SMTP server
is unavailable. The Mailable is serialized (no closure or connection in its properties), and a file
attached with fromPath() must still exist when queue:work sends. The MIME type
is detected if you don't give it, and an accented name is encoded (RFC 2231). Over SMTP, every
recipient gets the message once, and no Bcc header appears in it. In tests,
Mail::sent() also exposes cc and bcc.
SMTP
Niang\Core\SmtpTransport is a hand-written SMTP client, with no extension or
dependency: STARTTLS or implicit TLS, AUTH PLAIN/LOGIN, non-ASCII subjects
and names encoded. The whole configuration lives in .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=My site
MAIL_TIMEOUT=10
MAIL_ENCRYPTION | Usual port | Effect |
|---|---|---|
tls (default) | 587 | Connects then issues STARTTLS. If the server does not offer it, sending fails: never a fallback to clear text. |
ssl | 465 | TLS from the start (implicit TLS). |
none | 1025, 25 | Clear text — for a local server (Mailpit, MailHog). Credentials are never sent in clear text to any host other than localhost. |
The server's certificate is verified. A failure (unreachable server, rejected credentials, rejected
recipient...) throws a Niang\Core\Exceptions\MailException quoting the server's
response, never the password. niang doctor reports an incomplete configuration
(MAIL_HOST, MAIL_FROM_ADDRESS) and warns if MAIL_MAILER is
log or array in production.
No end-of-line comments in .env (MAIL_ENCRYPTION=tls # ...): they would
become part of the value. send() is synchronous: to avoid making the request
wait, use queue() (see above).
In tests, Mail::fake() switches to the 'array' driver whatever .env says:
Mail::fake();
// ... trigger the sending ...
$sent = Mail::sent(); // [['to' => '...', 'mailable' => ResetPasswordMailable], ...]
MAIL_MAILER=log writes the full body of every email to the logs — never keep it in production if your emails contain sensitive data.
Storage
Niang\Core\Storage: local disk only, rooted in storage/app/ (no S3 driver, for the same reason as SMTP: no external SDK).
Storage::put('avatars/1.png', $binaryContents);
Storage::get('avatars/1.png'); // ?string
Storage::exists('avatars/1.png');
Storage::size('avatars/1.png'); // ?int, in bytes
Storage::delete('avatars/1.png');
Storage::url('avatars/1.png'); // '/storage/avatars/1.png' — route it to Storage::get() to serve the file
Any path containing .. is rejected (\InvalidArgumentException) — impossible to write or read outside storage/app/ through this class.
S3 disk (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); // 5-minute read link, without making the file public
$file->store('avatars'); // sent to the configured disk
Same API as the local disk; Storage::path() only exists on the local disk. The client is written
without an SDK (AWS SigV4 signature, checked against AWS's published examples). AWS_ENDPOINT for a
compatible service; AWS_URL (optional) for Storage::url() behind a CDN. On the local disk,
temporaryUrl() returns a signed URL. niang doctor checks the configuration.
File uploads
$request->file('field') returns a Niang\Core\Http\UploadedFile (or
null). The name and type sent by the browser are never trusted: the real type is read
from the file's content, and store() puts the file in storage/app/ — outside
public/, so never executable — under a random name followed by the real extension:
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('/profile');
}
<form method="POST" action="/profile/avatar" enctype="multipart/form-data">
<?= csrf_field() ?>
<input type="file" name="avatar" accept="image/*">
</form>
| Method | Returns |
|---|---|
$request->file('avatar') / hasFile('avatar') | The file (or null) / whether it arrived without error. |
$request->files['photos'] | The list of files of a multiple field (name="photos[]"). |
store($directory) | The relative path in storage/app/, random name + real extension. |
storeAs($directory, $name) | Same with a chosen name (rejects / and ..). |
mimeType() / extension() | Real type read from the content / extension deduced from that type. |
clientName() | The original name — for display only, never for a path. |
size() | The size in bytes. |
The file, image, mimes, mimetypes and
dimensions rules are described in Validation. A file
field left empty is treated as absent; an upload that arrived with an error shows its real reason
(“exceeds the maximum size allowed by the server”...). Beyond post_max_size
(php.ini), PHP empties the whole request: set it, along with
upload_max_filesize, above your max: rules.
In tests, UploadedFile::fake() and UploadedFile::fakeImage() (a real PNG, without GD) simulate an upload:
$this->post('/profile/avatar', ['avatar' => UploadedFile::fakeImage('me.png', 200, 200)])
->assertRedirect('/profile');
$this->post('/cv', ['cv' => UploadedFile::fake('cv.pdf', "%PDF-1.4 ...")]);
Logs
PSR-3 levels: emergency, alert, critical, error,
warning, notice, info, debug. {key}
placeholders in the message are replaced from the context. One file per day in
storage/logs/; uncaught exceptions are logged there automatically.
Niang\Core\Logger implements Psr\Log\LoggerInterface for injection.
Log::info('User {id} logged in', ['id' => $user['id']]);
Log::error('Payment failed', ['order' => $orderId]);
Log::info('Login', $request->all()); // the password is written as “[masqué]”
LOG_LEVEL=warning
LOG_DAYS=14
| Variable | Effect |
|---|---|
LOG_LEVEL (default debug) | Minimum level written: warning skips debug, info and notice. An unknown level logs everything rather than nothing. |
LOG_DAYS (default 14) | Days of files kept; older ones are deleted on the first message of each day. 0: keep everything. |
A context value whose key suggests a secret (password, token,
secret, api_key, authorization, cookie,
card, cvv, iban...) is replaced with [masqué], at any
depth. niang doctor reports an unknown LOG_LEVEL, and debug in
production.
Log channels
LOG_CHANNEL: daily (default, one file per day), single
(storage/logs/niangpro.log), errorlog (web server or PHP-FPM log), syslog
(system log, identifier LOG_SYSLOG_IDENT) or stderr (Docker containers). The minimum level
and secret masking apply to all of them. LOG_FORMAT=json writes one JSON object per line, and every
message of a request carries its request_id: see Observability.
Notifications
One message sent over one or more channels: mail (the recipient's email
column), database (notifications table, shipped with the migrations),
webhook, or your own channel. The recipient is a users row.
use Niang\Core\Notification;
class OrderShipped extends Notification
{
public function __construct(private array $order) {}
public function via(array $user): array
{
return ['mail', 'database']; // or 'webhook', or SmsChannel::class
}
public function toMail(array $user): Mailable
{
return new OrderShippedMailable($this->commande);
}
public function toDatabase(array $user): array
{
return ['order' => $this->commande['id'], 'message' => 'Your order is on its way'];
}
}
Notification::send($user, new OrderShipped($order)); // or a list of users
Notification::for($user); // their notifications, newest first (data decoded)
Notification::unread($user); // Notification::unreadCount($user)
Notification::markAsRead($user, $id); // false if the notification isn't theirs
Notification::markAllAsRead($user);
A notification implementing ShouldQueue goes through the queue (one job per recipient,
3 attempts); Notification::sendNow() sends it right away. In tests,
Notification::fake() then Notification::sent().
Webhook
class OrderPaid extends Notification
{
public function via(array $shop): array { return ['webhook']; }
public function webhookUrl(array $shop): string { return $shop['webhook_url']; }
public function webhookSecret(): ?string { return env('WEBHOOK_SECRET'); }
public function toWebhook(array $shop): array { return ['event' => 'order.paid', 'id' => 42]; }
}
// On the receiving side: check the signature before trusting the content
$expected = 'sha256=' . hash_hmac('sha256', file_get_contents('php://input'), $secret);
if (!hash_equals($expected, $_SERVER['HTTP_X_NIANG_SIGNATURE'] ?? '')) {
http_response_code(401);
exit;
}
The URL must be http or https, redirects are not followed, and a non-2xx response throws a
NotificationException (the job is then retried). With webhookSecret(), the
X-Niang-Signature header carries the HMAC-SHA256 of the body.
Custom channel (SMS...): a class implementing
Niang\Core\Contracts\NotificationChannel, with
send(array $notifiable, Notification $notification), whose name you return from
via().
Events
Event::listen($name, $listener) accepts a closure (always synchronous) or a class
exposing handle(...). A class that implements Niang\Core\Contracts\ShouldQueue
is automatically deferred to the Queue rather than run immediately:
public function boot(): void
{
Event::listen('user.registered', function (array $user): void {
Log::info('New user registered: {email}', ['email' => $user['email']]);
});
// Registered as a class (ShouldQueue) rather than a closure: deferred to the Queue,
// does not block the registration request.
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));
}
}
Triggered with Event::dispatch($name, ...$payload) — the payload is passed as-is to every listener:
Event::dispatch('user.registered', $user);
A listener registered as a closure always stays synchronous, even if
Event::dispatch() is called in a context where other listeners are deferred: a closure
would not survive being serialized onto the queue. Only a listener registered as a class
can implement ShouldQueue.
Typed events and PSR-14
class UserRegisteredEvent
{
public function __construct(public readonly array $user) {}
}
Event::listen(UserRegisteredEvent::class, SendWelcomeEmailListener::class);
Event::dispatch(new UserRegisteredEvent($user)); // returns the event
An event is an object (./bin/niang make:event), listened to by its class name, a parent class or an
interface. An event extending Events\StoppableEvent stops as soon as a listener calls
stopPropagation(). Named events are still supported. For injection, the container resolves
Psr\EventDispatcher\EventDispatcherInterface, with the same listeners.