Skip to content

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 ​

bash
mkdir welcome-email && cd welcome-email
mkdir -p public src bin tests deploy
json
{
    "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
}
bash
composer install

This 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 ​

yaml
services:
  redis:
    image: redis:8-alpine
    ports:
      - "6379:6379"

  mailpit:
    image: axllent/mailpit
    ports:
      - "1025:1025" # SMTP
      - "8025:8025" # web UI
bash
docker compose up -d

Open 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
<?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
<?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
<?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
<?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
<?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.

bash
# terminal 1: the worker
php bin/worker.php
bash
# terminal 2: the web app
php -S localhost:8000 -t public

Open http://localhost:8000, enter a name and an email address, and press Register. Or from a third terminal:

bash
curl -d 'name=Jane&email=jane@example.com' http://localhost:8000/

The worker prints:

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

bash
docker compose stop mailpit
curl -d 'name=Bob&email=bob@example.com' http://localhost:8000/

The handler throws, and the worker logs it:

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

bash
docker compose exec redis redis-cli zrange hermes_schedule 0 -1

Start Mailpit again:

bash
docker compose start mailpit

Within 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
<?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());
    }
}
bash
vendor/bin/phpunit tests

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

ini
[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=true

Copy it into place, with the paths changed to your server:

bash
sudo cp deploy/hermes-worker.conf /etc/supervisor/conf.d/
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl status
text
hermes-worker:hermes-worker_00   RUNNING   pid 1643852, uptime 0:00:03
hermes-worker:hermes-worker_01   RUNNING   pid 1643858, uptime 0:00:03

After each deploy, run:

bash
php bin/restart.php

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

ini
# /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.target
bash
sudo systemctl enable --now hermes-worker@1 hermes-worker@2

Pitfalls ​

  • The message is gone from Redis before the handler runs. SPOP removes it first. If the worker process dies in the middle of handle(), that email is not sent and not retried. Retries only happen when the handler throws.
  • Returning false does 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.php exists 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 setMaxProcessItems and 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.php as 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.

Released under the MIT License.