Welcome email, from zero
This tutorial builds a small app from an empty folder. A registration form emits a message, a worker picks it up and sends a welcome email, and you watch the email arrive in Mailpit. Then you break SMTP on purpose and watch Hermes retry, and finally you run the worker under Supervisor.
It takes about 15 minutes. Every file is complete, so you can copy each block as it is.
What you need
- PHP 8.4 or newer with Composer
- Docker with Compose, for Redis and Mailpit
The app uses PredisSetDriver. Predis is a plain PHP package, so there is no extension to install. It is also the Redis driver that moves delayed messages and retries back to the queue with ZREM, which the retry step below needs. See Delayed execution.
1. Create the project
mkdir welcome-email && cd welcome-email
mkdir -p public src bin tests deploy{
"name": "acme/welcome-email",
"type": "project",
"require": {
"php": ">=8.4",
"tomaj/hermes": "dev-master",
"predis/predis": "^2.3 || ^3.0",
"symfony/mailer": "^7.0",
"monolog/monolog": "^3.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"require-dev": {
"phpunit/phpunit": "^11"
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"minimum-stability": "dev",
"prefer-stable": true
}composer installThis installs Hermes from dev-master (6.0 until it is tagged), Predis, Symfony Mailer for SMTP, and Monolog so the worker prints what it does.
2. Start Redis and Mailpit
services:
redis:
image: redis:8-alpine
ports:
- "6379:6379"
mailpit:
image: axllent/mailpit
ports:
- "1025:1025" # SMTP
- "8025:8025" # web UIdocker compose up -dOpen http://localhost:8025. The Mailpit inbox is empty. Mailpit accepts any email on port 1025 and shows it in the browser, and nothing leaves your machine.
3. One place for the driver
The web app and the worker must use the same driver with the same keys. Put the setup in one class.
<?php
declare(strict_types=1);
namespace App;
use Predis\Client;
use Tomaj\Hermes\Driver\PredisSetDriver;
use Tomaj\Hermes\Emitter;
final class Hermes
{
public static function redis(): Client
{
return new Client(getenv('REDIS_URL') ?: 'tcp://127.0.0.1:6379');
}
public static function driver(Client $redis): PredisSetDriver
{
// Queue key "hermes", poll once a second, delayed messages and retries in "hermes_schedule".
return new PredisSetDriver($redis, 'hermes', 1, 'hermes_schedule');
}
public static function emitter(): Emitter
{
return new Emitter(self::driver(self::redis()));
}
}'hermes' is the queue, 1 is how many seconds the worker sleeps when the queue is empty, and 'hermes_schedule' is the sorted set for delayed messages and retries.
4. Emit from the controller
This is the web side. It validates the form, would save the user, and emits a user.registered message. Emitting is one Redis command, so the response is immediate. The email is not sent here.
<?php
declare(strict_types=1);
use App\Hermes;
use Tomaj\Hermes\Message;
require __DIR__ . '/../vendor/autoload.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$email = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL);
if ($email === false || $email === null) {
http_response_code(422);
echo 'Enter a valid email address.';
return;
}
// Save the user here. Then hand the slow part to the worker.
Hermes::emitter()->emit(new Message('user.registered', [
'email' => $email,
'name' => trim((string) ($_POST['name'] ?? '')),
]));
echo "Thanks! A welcome email is on its way to {$email}.";
return;
}
?>
<form method="post">
<input name="name" placeholder="Name">
<input name="email" type="email" placeholder="you@example.com" required>
<button>Register</button>
</form>5. Write the handler
The handler does the slow work in the worker process. RetryTrait tells the dispatcher that this handler wants retries, and maxRetry() limits them to five.
<?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 WelcomeEmailHandler implements HandlerInterface
{
use RetryTrait;
public function __construct(private MailerInterface $mailer) {}
#[\Override]
public function handle(MessageInterface $message): bool
{
$payload = $message->getPayload() ?? [];
$name = ($payload['name'] ?? '') !== '' ? $payload['name'] : 'there';
$email = new Email();
$email
->from('hello@example.com')
->to($payload['email'])
->subject('Welcome aboard!')
->text("Hi {$name},\n\nthanks for signing up.");
// A TransportException here (SMTP down, timeout) makes Hermes send a retry copy.
$this->mailer->send($email);
return true;
}
public function maxRetry(): int
{
return 5;
}
}6. Write the worker
The worker registers the handler for the user.registered type and calls handle(), which loops until it is told to stop.
<?php
declare(strict_types=1);
use App\Hermes;
use App\WelcomeEmailHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport;
use Tomaj\Hermes\Dispatcher;
use Tomaj\Hermes\Shutdown\PredisShutdown;
require __DIR__ . '/../vendor/autoload.php';
$logger = new Logger('worker', [new StreamHandler('php://stdout')]);
$redis = Hermes::redis();
$driver = Hermes::driver($redis);
$driver->setShutdown(new PredisShutdown($redis)); // php bin/restart.php stops every worker
$driver->setMaxProcessItems(1000); // exit after 1000 messages, Supervisor starts a fresh one
$mailer = new Mailer(Transport::fromDsn(getenv('MAILER_DSN') ?: 'smtp://127.0.0.1:1025'));
$dispatcher = new Dispatcher($driver, $logger);
$dispatcher->registerHandler('user.registered', new WelcomeEmailHandler($mailer));
$logger->info('Worker started, waiting for messages');
$dispatcher->handle();And a small script to stop all workers, used later for deploys:
<?php
declare(strict_types=1);
use App\Hermes;
use Tomaj\Hermes\Shutdown\PredisShutdown;
require __DIR__ . '/../vendor/autoload.php';
// Workers started before now exit after their current message.
$shutdown = new PredisShutdown(Hermes::redis());
$shutdown->shutdown();
echo "Shutdown signal sent.\n";7. Run it
Open two terminals in the project folder.
# terminal 1: the worker
php bin/worker.php# terminal 2: the web app
php -S localhost:8000 -t publicOpen http://localhost:8000, enter a name and an email address, and press Register. Or from a third terminal:
curl -d 'name=Jane&email=jane@example.com' http://localhost:8000/The worker prints:
worker.INFO: Start handle message #57e9ddf5-… (user.registered) priority:100
worker.INFO: End handle message #57e9ddf5-… (user.registered)Refresh http://localhost:8025. The "Welcome aboard!" email to jane@example.com is there.
8. Watch a retry
Stop Mailpit, so SMTP is down, and register again:
docker compose stop mailpit
curl -d 'name=Bob&email=bob@example.com' http://localhost:8000/The handler throws, and the worker logs it:
worker.ERROR: Handler App\WelcomeEmailHandler throws exception - …Because the handler uses RetryTrait, the dispatcher sent a copy of the message with retries set to 1 and an executeAt 16 to 45 seconds in the future. You can see it waiting in Redis:
docker compose exec redis redis-cli zrange hermes_schedule 0 -1Start Mailpit again:
docker compose start mailpitWithin a minute the worker logs a second Start handle message with the same id and "retries":1, and the email to bob@example.com appears in Mailpit. Mailpit keeps messages in memory, so the earlier email is gone after the restart.
The delay grows with each attempt: retries⁴ + 15 + jitter × (retries + 1) seconds, with a random jitter from 1 to 30. Five retries cover roughly ten minutes of SMTP downtime. Raise maxRetry() for longer outages. See Retry.
9. Test the handler
A handler is a plain class, so test it without Redis or a worker. The composer.json from step 1 already has PHPUnit in require-dev and maps App\Tests\ to tests/ in autoload-dev, so composer install set both up.
<?php
declare(strict_types=1);
namespace App\Tests;
use App\WelcomeEmailHandler;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Mailer\Envelope;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\Mime\RawMessage;
use Tomaj\Hermes\Message;
final class WelcomeEmailHandlerTest extends TestCase
{
public function testSendsTheWelcomeEmail(): void
{
$mailer = new class implements MailerInterface {
/** @var list<RawMessage> */
public array $sent = [];
public function send(RawMessage $message, ?Envelope $envelope = null): void
{
$this->sent[] = $message;
}
};
$handler = new WelcomeEmailHandler($mailer);
$result = $handler->handle(new Message('user.registered', ['email' => 'jane@example.com', 'name' => 'Jane']));
self::assertTrue($result);
self::assertCount(1, $mailer->sent);
$email = $mailer->sent[0];
self::assertInstanceOf(Email::class, $email);
self::assertSame('jane@example.com', $email->getTo()[0]->getAddress());
self::assertStringContainsString('Hi Jane', (string) $email->getTextBody());
}
}vendor/bin/phpunit tests10. Run it under Supervisor
In production a process manager keeps the worker running and starts a new one when it exits. The worker exits on purpose in two cases: after 1000 messages (setMaxProcessItems), and after php bin/restart.php.
[program:hermes-worker]
command=php /var/www/welcome-email/bin/worker.php
directory=/var/www/welcome-email
numprocs=2
process_name=%(program_name)s_%(process_num)02d
autostart=true
autorestart=true
startsecs=1
stopwaitsecs=60
user=www-data
environment=REDIS_URL="tcp://127.0.0.1:6379",MAILER_DSN="smtp://127.0.0.1:1025"
stdout_logfile=/var/log/hermes-worker.log
redirect_stderr=trueCopy it into place, with the paths changed to your server:
sudo cp deploy/hermes-worker.conf /etc/supervisor/conf.d/
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl statushermes-worker:hermes-worker_00 RUNNING pid 1643852, uptime 0:00:03
hermes-worker:hermes-worker_01 RUNNING pid 1643858, uptime 0:00:03After each deploy, run:
php bin/restart.phpPredisShutdown writes the current time to the hermes_shutdown key. Every worker that started before that time finishes its current message, logs Exiting hermes dispatcher - shutdown, and exits. Supervisor starts new workers, and they load the new code. Workers that start after the signal ignore it.
With systemd instead of Supervisor, the same worker runs as a template unit:
# /etc/systemd/system/hermes-worker@.service
[Unit]
Description=Hermes worker %i
After=network.target
[Service]
User=www-data
WorkingDirectory=/var/www/welcome-email
Environment=REDIS_URL=tcp://127.0.0.1:6379
Environment=MAILER_DSN=smtp://127.0.0.1:1025
ExecStart=/usr/bin/php bin/worker.php
Restart=always
RestartSec=1
[Install]
WantedBy=multi-user.targetsudo systemctl enable --now hermes-worker@1 hermes-worker@2Pitfalls
- The message is gone from Redis before the handler runs.
SPOPremoves it first. If the worker process dies in the middle ofhandle(), that email is not sent and not retried. Retries only happen when the handler throws. - Returning
falsedoes not retry. Only an exception does. Throw when a later attempt could succeed. - The emitter and the worker must agree. Same driver class, same queue key, same schedule key.
src/Hermes.phpexists so they cannot drift apart. - Long-running PHP keeps state. The worker builds the mailer once. A stale SMTP connection, a cached config value, or a slow memory leak stays until the process exits, which is why
setMaxProcessItemsand Supervisor's restart are worth having. - On 5.x retries need Predis. With
RedisSetDriver(ext-redis) in 5.x, delayed messages and retries are written to the sorted set but never moved back to the queue. See Delayed execution. - Restart after a deploy. A worker started before the deploy still runs the old code. Run
php bin/restart.phpas the last deploy step.
Full source
Every file of this recipe, with its README.md, Makefile and the e2e.sh check, is in examples/welcome-email on GitHub. Clone the repository, cd examples/welcome-email, and run make install up, then make e2e.
FAQ
Why does the email not arrive?
Check that the worker is running and that Mailpit listens on port 1025. The worker log shows Start handle message for each message it takes.
Can I use a real SMTP server instead of Mailpit?
Yes. Set MAILER_DSN, for example smtp://user:pass@smtp.example.com:587, for the worker.
Why Predis and not ext-redis?
It needs no PHP extension. Retries need the delayed-message path, which on 5.x only Predis promotes correctly; from 6.0 ext-redis works too. See Delayed execution.
How many times is a failed email retried?
Five times with this handler's maxRetry(), over roughly ten minutes. See Retry.
How do I deploy new code?
Run php bin/restart.php after the deploy. Supervisor starts the workers again on the new code. See Shutdown.