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.

php
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

php
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

.env
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.

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('Welcome email sent to {email}', ['email' => $this->email]);
    }
}
php
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:

bash
./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_DRIVERUse
file (default)a single server
databasejobs and failed_jobs tables: several workers, even on several machines; each job is reserved by one only
redisseveral workers and machines, without a database (see Redis)
syncruns 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:

routes/schedule.php
$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');
crontab -e
* * * * * cd /path/to/the/project && php bin/niang schedule:run >> /dev/null 2>&1
TaskHow 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.

Mail

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'.

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 '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>';
    }
}
php
Mail::to($user['email'])->send(new ResetPasswordMailable($signedUrl));

Copies, attachments, sending through the queue

php
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:

.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_ENCRYPTIONUsual portEffect
tls (default)587Connects then issues STARTTLS. If the server does not offer it, sending fails: never a fallback to clear text.
ssl465TLS from the start (implicit TLS).
none1025, 25Clear 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:

php
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).

php
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...)

.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);   // 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:

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('/profile');
}
the form
<form method="POST" action="/profile/avatar" enctype="multipart/form-data">
    <?= csrf_field() ?>
    <input type="file" name="avatar" accept="image/*">
</form>
MethodReturns
$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:

php
$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.

php
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é]”
.env
LOG_LEVEL=warning
LOG_DAYS=14
VariableEffect
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.

php
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

php
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:

app/Providers/AppServiceProvider.php
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);
}
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));
    }
}

Triggered with Event::dispatch($name, ...$payload) — the payload is passed as-is to every listener:

php
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

php
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.

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