Framework integration
Hermes has no framework bundle, and it does not need one. Its classes take their dependencies in the constructor, so any container can build them. The pages here show the same small integration in three frameworks:
- Nette 3.2: services in a NEON file, Tracy as the PSR-3 logger, contributte/console for the worker command.
- Laravel 11 and 12: a service provider,
config/hermes.php, and Artisan commands. - Symfony 7.4:
services.yamlwith autowiring, Monolog, and Symfony Console commands.
Every example is a set of files in examples/frameworks, and the code on these pages is imported from those files. Each one was installed into a freshly created project and run against Redis (ext-redis and Predis), RabbitMQ, and SQS (a local moto server).
What every integration has
| Piece | What it is | Who uses it |
|---|---|---|
DriverFactory | Builds a DriverInterface from plain config: redis (ext-redis), predis, rabbitmq or sqs, plus the queue name and the max items limit. | the container |
EmitterInterface | A shared Emitter with the framework's PSR-3 logger. | controllers, commands, jobs |
DispatcherInterface | A Dispatcher with the logger, a shutdown, and the handlers registered with registerHandler(). | only the worker command |
ShutdownInterface | SharedFileShutdown on a file in the app's temp or storage folder. | the worker and hermes:restart |
hermes:work | Calls $dispatcher->handle(). Blocks until shutdown or the max items limit. --priority limits it to some priorities. | Supervisor or systemd |
hermes:restart | Calls $shutdown->shutdown(). Running workers stop after their current message. | the deploy script |
app:register-user | A demo producer that emits user.registered. | you, to try it |
The same DriverFactory and UserRegisteredHandler are used in all three frameworks:
<?php
declare(strict_types=1);
namespace App\Hermes;
use Aws\Sqs\SqsClient;
use InvalidArgumentException;
use PhpAmqpLib\Connection\AMQPLazyConnection;
use Predis\Client;
use Redis;
use Tomaj\Hermes\Driver\AmazonSqsDriver;
use Tomaj\Hermes\Driver\DriverInterface;
use Tomaj\Hermes\Driver\LazyRabbitMqDriver;
use Tomaj\Hermes\Driver\PredisSetDriver;
use Tomaj\Hermes\Driver\RedisSetDriver;
/**
* Builds the Hermes driver from plain configuration, so the web process and the worker
* always use the same driver, queue name and connection.
*/
final class DriverFactory
{
/**
* @param array{
* driver: string,
* queue: string,
* max_items: int,
* redis: array{host: string, port: int},
* rabbitmq: array{host: string, port: int, user: string, password: string, vhost: string},
* sqs: array{region: string, endpoint: ?string, key: string, secret: string},
* } $config
*/
public static function create(array $config): DriverInterface
{
$queue = $config['queue'];
$driver = match ($config['driver']) {
'redis' => new RedisSetDriver(self::redis($config['redis']), $queue),
'predis' => new PredisSetDriver(new Client(['host' => $config['redis']['host'], 'port' => $config['redis']['port']]), $queue),
'rabbitmq' => new LazyRabbitMqDriver(
new AMQPLazyConnection(
$config['rabbitmq']['host'],
$config['rabbitmq']['port'],
$config['rabbitmq']['user'],
$config['rabbitmq']['password'],
$config['rabbitmq']['vhost'],
),
$queue,
[],
1, // seconds to sleep when the queue is empty
),
'sqs' => new AmazonSqsDriver(self::sqs($config['sqs']), $queue),
default => throw new InvalidArgumentException("Unknown Hermes driver \"{$config['driver']}\""),
};
// Start a fresh worker process after this many messages.
$driver->setMaxProcessItems($config['max_items']);
return $driver;
}
/**
* @param array{host: string, port: int} $config
*/
private static function redis(array $config): Redis
{
$redis = new Redis();
$redis->connect($config['host'], $config['port']);
return $redis;
}
/**
* @param array{region: string, endpoint: ?string, key: string, secret: string} $config
*/
private static function sqs(array $config): SqsClient
{
$options = [
'region' => $config['region'],
'version' => 'latest',
'credentials' => ['key' => $config['key'], 'secret' => $config['secret']],
];
if ($config['endpoint'] !== null && $config['endpoint'] !== '') {
$options['endpoint'] = $config['endpoint']; // a local SQS such as moto or ElasticMQ
}
return new SqsClient($options);
}
}The driver is built once per process. Both the web process and the worker must get the same driver class and queue name; the factory reads them from one config, so they cannot drift apart. See Drivers overview for what each driver can do.
<?php
declare(strict_types=1);
namespace App\Hermes;
use Psr\Log\LoggerInterface;
use Tomaj\Hermes\Handler\HandlerInterface;
use Tomaj\Hermes\Handler\RetryTrait;
use Tomaj\Hermes\MessageInterface;
final class UserRegisteredHandler implements HandlerInterface
{
use RetryTrait;
public function __construct(private readonly LoggerInterface $logger) {}
#[\Override]
public function handle(MessageInterface $message): bool
{
$email = $message->getPayload()['email'] ?? null;
if (!is_string($email)) {
return false; // a broken message stays broken, do not retry it
}
// Send the welcome email here. Throw an exception to retry later.
$this->logger->info("Welcome email sent to {$email}", ['message_id' => $message->getId()]);
return true;
}
}Rules that hold in every framework
- Producers depend on
EmitterInterface, never onEmitter. From Hermes 6.0 the classes arefinal, so PHPUnit cannot mockEmitter. Mock the interface; each framework page has a test. - Only the worker resolves the dispatcher. Building it in a web request is harmless, but it is never needed there.
- Give each application its own queue name. Two apps that share one Redis and the default
hermeskey take each other's messages. The worker drops a message it has no handler for. - Restart workers after a deploy. A worker keeps the old code until it exits. Run
hermes:restartas the last deploy step and let the process manager start new workers. See Shutdown and max items and Production deployment. - Stopping with a signal needs
ext-pcntl. With it,SIGTERMfrom Supervisor, systemd or Kubernetes lets the worker finish its message and exit, also insidehermes:work(Hermes takes over the console's signal handlers whilehandle()runs). Without it the signal ends PHP at once, even in the middle of a handler, so callhermes:restartfirst. See Shutdown and max items.
Retry jitter and a registered Random\Randomizer
Hermes 6.0 (#103) adds an optional fourth constructor argument to Dispatcher: ?Random\Randomizer $randomizer = null. Without it, the retry jitter uses a new Randomizer with the secure engine. See Retry.
Containers that autowire the dispatcher inject a Random\Randomizer service if the application registers one for something else, for example a seeded Mt19937 for fixtures. Retry times then come from that engine. Each framework page shows how to pass null instead. Add that line only on Hermes 6.0 or newer: on 5.x and on releases before #103, the argument does not exist and the container refuses the configuration.
The Laravel provider builds the dispatcher with new Dispatcher(...), so it is not affected.
Verified with
| Framework | Versions | Drivers | Also checked |
|---|---|---|---|
| Nette | nette/web-project, nette/application 3.3.0, nette/di 3.2.6, contributte/console 0.11.0 | ext-redis, Predis 3.7, RabbitMQ (php-amqplib 3.7.5), SQS on moto | POST to the presenter, the PHPUnit test, Supervisor |
| Laravel | 11.57.0 and 12.70.0 | the same four | feature tests, Supervisor |
| Symfony | 7.4.21, monolog-bundle 4.1.0 | the same four | POST to the controller, the WebTestCase test, Supervisor |
PHP 8.4, Hermes dev-master. Each framework was also run on Hermes 5.0.0 and on the branches of #103 and #105.
FAQ
Is there a Hermes bundle or package for my framework?
No. The integration is a factory, a few service definitions and two console commands, shown on the Nette, Laravel and Symfony pages. Copy them and change what you need.
Can I use Hermes next to Laravel queues or Symfony Messenger?
Yes. Hermes does not touch them. Use a different Redis key or RabbitMQ queue for Hermes, so the two never read each other's messages.
Which driver should I configure?
Predis or ext-redis if you want priorities and delayed messages, RabbitMQ or SQS if you already run them. The comparison lists what each driver supports.
Do I need one worker per handler?
No. One hermes:work process runs every handler registered on the dispatcher. Run more processes for more throughput, and use --priority to keep some for urgent messages. See Priority.
How do I test code that emits messages?
Type-hint EmitterInterface and replace it with a PHPUnit mock in the test. Each framework page has a working test, and Testing covers the handlers.