Delayed reminder
Goal: send a reminder email one day before a reservation. The message is emitted when the reservation is made, with executeAt set to the reminder time.
Files
Redis, Mailpit and src/Hermes.php as in the welcome email tutorial.
{
"require": {
"php": ">=8.4",
"tomaj/hermes": "dev-master",
"predis/predis": "^2.3 || ^3.0",
"symfony/mailer": "^7.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"minimum-stability": "dev",
"prefer-stable": true
}<?php
declare(strict_types=1);
use App\Hermes;
use Tomaj\Hermes\Message;
require __DIR__ . '/../vendor/autoload.php';
// A customer reserved a table. Remind them one day before, or in REMIND_IN seconds while testing.
$reservation = ['id' => 77, 'email' => 'jane@example.com', 'at' => strtotime('+2 days 19:00')];
$remindAt = (float) (getenv('REMIND_IN') ? time() + (int) getenv('REMIND_IN') : $reservation['at'] - 86400);
Hermes::emitter()->emit(new Message(
'reservation.remind',
['reservation_id' => $reservation['id'], 'email' => $reservation['email']],
executeAt: $remindAt, // the worker will not run it before this time; id and created are generated
));
echo 'Reminder scheduled for ' . date('Y-m-d H:i:s', (int) $remindAt) . "\n";<?php
declare(strict_types=1);
namespace App;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Tomaj\Hermes\Handler\HandlerInterface;
use Tomaj\Hermes\Handler\RetryTrait;
use Tomaj\Hermes\MessageInterface;
final class ReminderHandler implements HandlerInterface
{
use RetryTrait;
public function __construct(private MailerInterface $mailer) {}
#[\Override]
public function handle(MessageInterface $message): bool
{
$payload = $message->getPayload() ?? [];
// The message was written a day ago. Check that it still applies.
if (!$this->stillBooked((int) $payload['reservation_id'])) {
return true;
}
$email = new Email();
$email
->from('bookings@example.com')
->to($payload['email'])
->subject('See you tomorrow')
->text('This is a reminder of your reservation tomorrow.');
$this->mailer->send($email);
return true;
}
private function stillBooked(int $reservationId): bool
{
return true; // replace with a database lookup
}
}<?php
declare(strict_types=1);
use App\Hermes;
use App\ReminderHandler;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport;
use Tomaj\Hermes\Dispatcher;
use Tomaj\Hermes\Shutdown\PredisShutdown;
require __DIR__ . '/../vendor/autoload.php';
$redis = Hermes::redis();
$driver = Hermes::driver($redis);
$driver->setShutdown(new PredisShutdown($redis));
$mailer = new Mailer(Transport::fromDsn(getenv('MAILER_DSN') ?: 'smtp://127.0.0.1:1025'));
$dispatcher = new Dispatcher($driver);
$dispatcher->registerHandler('reservation.remind', new ReminderHandler($mailer));
$dispatcher->handle();Run it
REMIND_IN overrides the delay, so you do not have to wait a day:
composer install
docker compose up -d
php bin/worker.php # terminal 1
REMIND_IN=10 php public/index.php # terminal 2
docker compose exec redis redis-cli zrange hermes_schedule 0 -1 withscoresThe message waits in hermes_schedule with the reminder time as its score. About ten seconds later the worker moves it to the live queue, handles it, and "See you tomorrow" appears in Mailpit at http://localhost:8025.
Pitfalls
- On 5.x only Predis delays reliably.
RedisSetDriver(ext-redis) 5.x writes the sorted set but never moves due messages back (#95, fixed in 6.0). RabbitMQ and SQS ignoreexecuteAtand deliver at once. See Delayed execution. - Check the state when the message runs. A day is long: the reservation can be cancelled or moved. Load it again in the handler and do nothing if it no longer applies.
- A worker must be running. Due messages move only inside
wait(). If no worker runs at the reminder time, the reminder is late, not lost. executeAtis a Unix timestamp in seconds, as a float. Compute it in UTC, for example withstrtotime()orDateTimeImmutable::getTimestamp(), so time zones cannot shift it.- To change a reminder, do not edit the message. Emit a new one and let the handler ignore old ones, for example by storing the current reminder id on the reservation.
Full source
Every file of this recipe, with its README.md, Makefile and the e2e.sh check, is in examples/reminder on GitHub. Clone the repository, cd examples/reminder, and run make install up, then make e2e.
FAQ
Can I use ext-redis for reminders?
Yes, from 6.0, which these recipes require. In 5.x its promotion step leaves due messages in the sorted set, so use Predis there. See Delayed execution.
What if the reservation is cancelled?
The handler loads it again and does nothing when it no longer applies.
What if no worker runs at the reminder time?
The reminder runs late, as soon as a worker starts. It is not lost.
How do I move a reminder?
Emit a new message and make the handler ignore the old one, for example by storing the current reminder id.
Which unit is executeAt?
A Unix timestamp in seconds, as a float.