Background job processing PHP library
Documentation: https://tomaj.github.io/hermes/
Getting started · API reference · Changelog
Hermes is a lightweight PHP library for background job processing. When you need to handle time-consuming tasks outside of HTTP requests—such as sending emails, calling external APIs, or processing data—Hermes provides a clean, efficient solution.
Key features:
- Multiple queue backends: Support for Redis, RabbitMQ, Amazon SQS, Kafka, a local directory, and more
- Simple integration: Easy to add to existing projects with minimal setup
- Extensible architecture: Create custom drivers and handlers for your specific needs
- Production-ready: Built-in support for priorities, retries, and graceful shutdown
This library requires PHP 8.4 or later.
The recommended installation method is via Composer:
$ composer require tomaj/hermesLibrary is compliant with PSR-1, PER Coding Style, PSR-3 and PSR-4.
Hermes supports logging through any PSR-3 compatible logger. For more information, see psr/log.
While the library works without logging, we recommend installing monolog for production environments to track message processing and debugging.
Hermes includes built-in support for multiple queue backends:
- Redis - Two implementations available: phpredis (native extension) or Predis (pure PHP)
- Amazon SQS - AWS Simple Queue Service integration
- RabbitMQ - Industry-standard message broker
- FileSystem - A queue in a local directory, for one server without a broker (docs)
- Apache Kafka - Topics and consumer groups through ext-rdkafka (docs)
- ZeroMQ - Available as a separate package: tomaj/hermes-zmq-driver. It requires
tomaj/hermes^2.1and does not install with 6.x
Note: You need to install the corresponding client libraries for your chosen driver. For example, to use Redis with Predis, add predis/predis to your composer.json and configure your Redis connection.
Hermes acts as a message broker between your web application and background workers. Here's the flow:
Implementation steps:
- Choose a driver: Select a queue backend (Redis, RabbitMQ, etc.) and register it with the Dispatcher and Emitter
- Emit messages: Send messages to the queue when you need background processing
- Create handlers: Write handler classes to process your messages
- Run the worker: Create a PHP CLI script that runs continuously to process messages from the queue
This example demonstrates using the Redis driver to send emails in the background.
Emit messages from anywhere in your application—it's quick and straightforward:
use Tomaj\Hermes\Message;
use Tomaj\Hermes\Emitter;
use Tomaj\Hermes\Driver\RedisSetDriver;
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$driver = new RedisSetDriver($redis);
$emitter = new Emitter($driver);
$message = new Message('send-email', [
'to' => 'test@test.com',
'subject' => 'Testing hermes email',
'message' => 'Hello from hermes!'
]);
$emitter->emit($message);When you insert a row in a transaction and emit a message about it, the worker can pick up the message before the commit and not find the row. BufferedEmitter (since 6.0) holds the messages until you flush them from your commit hook and drops them on rollback:
use Tomaj\Hermes\BufferedEmitter;
$emitter = new BufferedEmitter(new Emitter($driver), [$database, 'isInTransaction']);
$database->registerOnCommit([$emitter, 'flush']);
$database->registerOnRollback([$emitter, 'discard']);Outside a transaction the predicate returns false and messages are sent straight away. The buffer lives in memory, so it is not a transactional outbox. See Emitting after a database commit and examples/emit-after-commit.
To process messages, create a PHP CLI script that runs continuously. Here's a simple implementation with a handler:
// file handler.php
use Tomaj\Hermes\Driver\RedisSetDriver;
use Tomaj\Hermes\Dispatcher;
use Tomaj\Hermes\Handler\HandlerInterface;
use Tomaj\Hermes\MessageInterface;
class SendEmailHandler implements HandlerInterface
{
// here you will receive message that was emitted from web application
public function handle(MessageInterface $message): bool
{
$payload = $message->getPayload();
mail($payload['to'], $payload['subject'], $payload['message']);
return true;
}
}
// create dispatcher like in the first snippet
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$driver = new RedisSetDriver($redis);
$dispatcher = new Dispatcher($driver);
// register handler for event
$dispatcher->registerHandler('send-email', new SendEmailHandler());
// at this point this script will wait for new message
$dispatcher->handle();To keep the worker running continuously on your server, use a process manager like supervisord, upstart, monit, or god.
Hermes supports any PSR-3 compliant logger. Set a logger for the Dispatcher or Emitter to track message flow and handler execution.
To enable logging in your handlers, add the Psr\Log\LoggerAwareTrait trait (or implement Psr\Log\LoggerAwareInterface)—the Dispatcher and Emitter will automatically inject the logger.
Example using monolog:
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use Tomaj\Hermes\Dispatcher;
// create a log channel
$log = new Logger('hermes');
$log->pushHandler(new StreamHandler('hermes.log'));
// $driver = ....
$dispatcher = new Dispatcher($driver, $log);To add logging within your handlers:
use Psr\Log\LoggerAwareTrait;
use Tomaj\Hermes\Handler\HandlerInterface;
use Tomaj\Hermes\MessageInterface;
class SendEmailHandlerWithLogger implements HandlerInterface
{
// enable logger
use LoggerAwareTrait;
public function handle(MessageInterface $message): bool
{
$payload = $message->getPayload();
// log info message; the logger is null until the Dispatcher injects it
$this->logger?->info("Trying to send email to {$payload['to']}");
mail($payload['to'], $payload['subject'], $payload['message']);
return true;
}
}If your handler fails, it can be retried automatically: implement RetryableHandlerInterface and add RetryTrait. Override maxRetry() to control the number of retry attempts (default is 25), and canRetry($message, $exception) to skip retries for permanent errors. A 5.x handler that has canRetry() and maxRetry() without the interface is still retried as in 5.x, with a deprecation; see UPGRADE-6.0.md.
Note: The retry waits only on drivers that support delayed execution (the $executeAt message parameter): RedisSetDriver, PredisSetDriver and FileSystemDriver. LazyRabbitMqDriver, AmazonSqsDriver and KafkaDriver deliver the retry at once.
declare(strict_types=1);
namespace App\Handler;
use Tomaj\Hermes\Handler\RetryableHandlerInterface;
use Tomaj\Hermes\Handler\RetryTrait;
use Tomaj\Hermes\MessageInterface;
class FailingHandler implements RetryableHandlerInterface
{
use RetryTrait;
public function handle(MessageInterface $message): bool
{
throw new \Exception('this will always fail');
}
// optional - default is 25
public function maxRetry(): int
{
return 10;
}
}You can configure multiple queues with different priority levels to ensure high-priority messages are processed first.
Example with Redis driver:
use Tomaj\Hermes\Driver\RedisSetDriver;
use Tomaj\Hermes\Emitter;
use Tomaj\Hermes\Message;
use Tomaj\Hermes\Dispatcher;
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$driver = new RedisSetDriver($redis);
$driver->setupPriorityQueue('hermes_low', Dispatcher::DEFAULT_PRIORITY - 10);
$driver->setupPriorityQueue('hermes_high', Dispatcher::DEFAULT_PRIORITY + 10);
$emitter = new Emitter($driver);
$emitter->emit(new Message('type1', ['a' => 'b']), Dispatcher::DEFAULT_PRIORITY - 10);
$emitter->emit(new Message('type1', ['c' => 'd']), Dispatcher::DEFAULT_PRIORITY + 10);Key points about priorities:
- Priority is the second argument of
Emitter::emit(). The third argument ofMessageis the message id, not a priority - Use
Dispatcher::DEFAULT_PRIORITY(100) or any integer you registered withsetupPriorityQueue() - Higher numbers indicate higher priority
- You can pass a list of priorities to
Dispatcher::handle(), for examplehandle([Dispatcher::DEFAULT_PRIORITY + 10]), to create workers that process only those queues
Hermes workers can be gracefully stopped without losing messages.
When you provide an implementation of Tomaj\Hermes\Shutdown\ShutdownInterface to the Dispatcher, Hermes checks ShutdownInterface::shouldShutdown() after each message. If it returns true, the worker shuts down cleanly.
Important: Hermes handles shutdown, but automatic restart must be managed by your process controller (e.g., supervisord, systemd, or Docker).
Three built-in shutdown implementations are available. Use RedisShutdown with the phpredis extension (Redis) and PredisShutdown with Predis\Client — the constructors are typed, so mixing them raises a TypeError.
Trigger shutdown by creating or touching a specific file:
use Tomaj\Hermes\Dispatcher;
use Tomaj\Hermes\Shutdown\SharedFileShutdown;
$shutdownFile = '/tmp/hermes_shutdown';
$shutdown = new SharedFileShutdown($shutdownFile);
// $log = ...
// $driver = ....
$dispatcher = new Dispatcher($driver, $log, $shutdown);
// ...
// shutdown can be triggered by calling `ShutdownInterface::shutdown()`
$shutdown->shutdown();Trigger shutdown by setting a Redis key (phpredis Redis client):
use Tomaj\Hermes\Dispatcher;
use Tomaj\Hermes\Shutdown\RedisShutdown;
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redisShutdownKey = 'hermes_shutdown'; // can be omitted; default is `hermes_shutdown`
$shutdown = new RedisShutdown($redis, $redisShutdownKey);
// $log = ...
// $driver = ....
$dispatcher = new Dispatcher($driver, $log, $shutdown);
// ...
// shutdown can be triggered by calling `ShutdownInterface::shutdown()`
$shutdown->shutdown();Same key and rules with a Predis\Client (use this with PredisSetDriver):
use Predis\Client;
use Tomaj\Hermes\Dispatcher;
use Tomaj\Hermes\Shutdown\PredisShutdown;
$redis = new Client(['scheme' => 'tcp', 'host' => '127.0.0.1', 'port' => 6379]);
$shutdown = new PredisShutdown($redis); // optional second argument: Redis key, default `hermes_shutdown`
$dispatcher = new Dispatcher($driver, $log, $shutdown);
$shutdown->shutdown();Hermes can easily scale to handle high message volumes. Simply run multiple worker instances—either on the same machine or distributed across multiple servers.
Requirements for scaling:
- Network-capable driver: Your driver must support remote connections (Redis, RabbitMQ, Amazon SQS and Kafka all support this)
- At-most-once delivery: Each message should be delivered to only one worker
Both Redis and RabbitMQ drivers satisfy these requirements and are designed for high-throughput scenarios.
Hermes uses interface-based architecture, making it easy to extend. You can create custom drivers, use different loggers, or implement your own message serialization.
Each driver implements Tomaj\Hermes\Driver\DriverInterface: send(), setupPriorityQueue() and wait(). Every concrete class in the package is final, so implement the interface or wrap a built-in driver instead of extending one.
namespace My\Custom\Driver;
use Closure;
use Tomaj\Hermes\Dispatcher;
use Tomaj\Hermes\Driver\DriverInterface;
use Tomaj\Hermes\Driver\NotSupportedException;
use Tomaj\Hermes\Driver\SerializerAwareTrait;
use Tomaj\Hermes\Driver\UnknownPriorityException;
use Tomaj\Hermes\MessageInterface;
use Tomaj\Hermes\MessageSerializer;
final class InMemoryDriver implements DriverInterface
{
use SerializerAwareTrait;
/** @var list<string> */
private array $items = [];
public function __construct()
{
$this->serializer = new MessageSerializer();
}
#[\Override]
public function send(MessageInterface $message, int $priority = Dispatcher::DEFAULT_PRIORITY): bool
{
if ($priority !== Dispatcher::DEFAULT_PRIORITY) {
throw new UnknownPriorityException("Unknown priority {$priority}");
}
$this->items[] = $this->serializer->serialize($message);
return true;
}
#[\Override]
public function setupPriorityQueue(string $name, int $priority): void
{
throw new NotSupportedException('InMemoryDriver has a single queue');
}
#[\Override]
public function wait(Closure $callback, array $priorities = []): void
{
if (array_diff($priorities, [Dispatcher::DEFAULT_PRIORITY]) !== []) {
throw new UnknownPriorityException('InMemoryDriver has only the default priority');
}
while (($item = array_shift($this->items)) !== null) {
$callback($this->serializer->unserialize($item), Dispatcher::DEFAULT_PRIORITY);
}
}
}A driver with several priorities, shutdown and max-items support is in Extending Hermes.
To use custom serialization, create a class that implements Tomaj\Hermes\SerializerInterface: serialize(MessageInterface $message): string and unserialize(string $string): MessageInterface, throwing Tomaj\Hermes\SerializeException on bad input. Drivers that use Tomaj\Hermes\Driver\SerializerAwareTrait (all built-in drivers do) accept it through setSerializer(). Use the same serializer on the emitter and the worker side.
A complete JSON serializer is in Extending Hermes.
Since version 2.0, you can schedule messages for future execution by passing a timestamp as the fifth parameter (executeAt) to the Message constructor. Currently supported by RedisSetDriver, PredisSetDriver and FileSystemDriver.
See UPGRADE-6.0.md.
Breaking Changes:
- Renamed Restart → Shutdown to better reflect functionality. Hermes can gracefully stop its own process, but restarting must be handled by an external process manager.
RestartInterface→ShutdownInterface- All implementation classes and namespaces have been updated accordingly
See CHANGELOG for a detailed list of changes and version history.
$ composer testTo generate code coverage reports:
# Generate coverage reports locally
$ composer coverageThe coverage reports will be generated in:
- HTML report:
build/coverage/index.html(open in browser to see line-by-line coverage) - Clover XML:
build/logs/clover.xml(for CI/CD integration)
Online Coverage Reports: Coverage reports are published with the documentation site after each successful test run on master, at https://tomaj.github.io/hermes/coverage/.
Please see CONTRIBUTING and the Code of Conduct for details.
Please report vulnerabilities privately. See SECURITY.md for supported versions and how to disclose an issue.
The MIT License (MIT). Please see License File for more information.