Laravel
This page adds Hermes to a fresh Laravel 11 or 12 project. A controller emits user.registered, and php artisan hermes:work handles it. Read Framework integration first for the parts every framework shares.
1. Install
composer create-project laravel/laravel app
cd app
composer require tomaj/hermes:dev-master
composer require predis/predis # only for the predis driver
composer require php-amqplib/php-amqplib # only for the rabbitmq driver
composer require aws/aws-sdk-php # only for the sqs driverUntil Hermes 6.0 is tagged, require tomaj/hermes:dev-master as above; switch to ^6.0 once it is released. The default redis driver needs the phpredis extension (ext-redis).
2. Configure the driver
config/hermes.php reads everything from .env. Set HERMES_DRIVER to redis, predis, rabbitmq or sqs, and give each application its own HERMES_QUEUE.
<?php
declare(strict_types=1);
return [
// redis (ext-redis), predis, rabbitmq or sqs
'driver' => env('HERMES_DRIVER', 'redis'),
'queue' => env('HERMES_QUEUE', 'hermes'),
'max_items' => (int) env('HERMES_MAX_ITEMS', 1000),
'shutdown_file' => storage_path('framework/hermes-shutdown'),
'redis' => [
'host' => env('HERMES_REDIS_HOST', '127.0.0.1'),
'port' => (int) env('HERMES_REDIS_PORT', 6379),
],
'rabbitmq' => [
'host' => env('HERMES_RABBITMQ_HOST', '127.0.0.1'),
'port' => (int) env('HERMES_RABBITMQ_PORT', 5672),
'user' => env('HERMES_RABBITMQ_USER', 'guest'),
'password' => env('HERMES_RABBITMQ_PASSWORD', 'guest'),
'vhost' => env('HERMES_RABBITMQ_VHOST', '/'),
],
'sqs' => [
'region' => env('HERMES_SQS_REGION', 'eu-central-1'),
'endpoint' => env('HERMES_SQS_ENDPOINT'),
'key' => env('AWS_ACCESS_KEY_ID', ''),
'secret' => env('AWS_SECRET_ACCESS_KEY', ''),
],
];app/Hermes/DriverFactory.php turns that array into a driver. It is the same file in every framework; see Framework integration.
<?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);
}
}Hermes opens its own Redis connection instead of reusing Redis::connection(). Laravel's connection adds the REDIS_PREFIX to every key, and the Laravel cache and queues share it.
3. Register the services
The provider binds four singletons. Producers get EmitterInterface; only the worker command resolves DispatcherInterface, which is where the handlers are registered.
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Hermes\DriverFactory;
use App\Hermes\UserRegisteredHandler;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\ServiceProvider;
use Psr\Log\LoggerInterface;
use Tomaj\Hermes\Dispatcher;
use Tomaj\Hermes\DispatcherInterface;
use Tomaj\Hermes\Driver\DriverInterface;
use Tomaj\Hermes\Emitter;
use Tomaj\Hermes\EmitterInterface;
use Tomaj\Hermes\Shutdown\SharedFileShutdown;
use Tomaj\Hermes\Shutdown\ShutdownInterface;
final class HermesServiceProvider extends ServiceProvider
{
#[\Override]
public function register(): void
{
$this->app->singleton(
DriverInterface::class,
static fn(Application $app): DriverInterface => DriverFactory::create($app->make('config')->get('hermes')),
);
$this->app->singleton(
ShutdownInterface::class,
static fn(Application $app): ShutdownInterface => new SharedFileShutdown($app->make('config')->get('hermes.shutdown_file')),
);
// Producers (controllers, jobs, commands) depend on EmitterInterface.
$this->app->singleton(
EmitterInterface::class,
static fn(Application $app): EmitterInterface => new Emitter(
$app->make(DriverInterface::class),
$app->make(LoggerInterface::class),
),
);
// Only the worker command resolves the dispatcher.
$this->app->singleton(DispatcherInterface::class, static function (Application $app): DispatcherInterface {
$dispatcher = new Dispatcher(
$app->make(DriverInterface::class),
$app->make(LoggerInterface::class),
$app->make(ShutdownInterface::class),
);
$dispatcher->registerHandler('user.registered', $app->make(UserRegisteredHandler::class));
return $dispatcher;
});
}
}Add the provider to bootstrap/providers.php:
<?php
declare(strict_types=1);
use App\Providers\AppServiceProvider;
use App\Providers\HermesServiceProvider;
return [
AppServiceProvider::class,
HermesServiceProvider::class,
];The logger is Laravel's LoggerInterface binding, so Hermes writes its Start handle message and End handle message lines to storage/logs/laravel.log with your log channels. See Logging.
4. Emit from a controller
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Tomaj\Hermes\EmitterInterface;
use Tomaj\Hermes\Message;
final class UserController extends Controller
{
public function __construct(private readonly EmitterInterface $emitter) {}
public function store(Request $request): JsonResponse
{
$data = $request->validate(['email' => ['required', 'email']]);
// Save the user here, then hand the slow work to the worker.
$this->emitter->emit(new Message('user.registered', ['email' => $data['email']]));
return new JsonResponse(['status' => 'registered'], 201);
}
}<?php
declare(strict_types=1);
use App\Http\Controllers\UserController;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});
Route::post('/users', [UserController::class, 'store']);routes/web.php has CSRF protection, so a form must send @csrf, and a plain JSON POST gets 419. For a JSON API, put the route in routes/api.php (php artisan install:api).
A command can emit too. This one is the producer used below:
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Tomaj\Hermes\EmitterInterface;
use Tomaj\Hermes\Message;
final class RegisterUserCommand extends Command
{
protected $signature = 'app:register-user {email}';
protected $description = 'Emit user.registered for one email address';
public function handle(EmitterInterface $emitter): int
{
$email = (string) $this->argument('email');
$emitter->emit(new Message('user.registered', ['email' => $email]));
$this->info("Queued the welcome email for {$email}.");
return self::SUCCESS;
}
}5. Write a handler
<?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;
}
}The provider builds it with $app->make(), so its constructor can ask for any service. Throw to retry, return false to give up; see Retry.
6. Run the worker
Laravel 11 and 12 register every command in app/Console/Commands.
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Tomaj\Hermes\DispatcherInterface;
final class HermesWorkCommand extends Command
{
protected $signature = 'hermes:work {--priority=* : Handle only these priorities}';
protected $description = 'Run the Hermes worker until it is told to stop';
public function handle(DispatcherInterface $dispatcher): int
{
$priorities = array_map(intval(...), (array) $this->option('priority'));
// Blocks until shutdown (hermes:restart) or the max items limit.
$dispatcher->handle($priorities);
return self::SUCCESS;
}
}php artisan hermes:work # terminal 1
php artisan app:register-user jane@example.com # terminal 2
grep 'Welcome email sent' storage/logs/laravel.logphp artisan hermes:work --priority=200 handles only priority 200, if your driver has that priority queue. See Priority.
7. Restart workers on deploy
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Tomaj\Hermes\Shutdown\ShutdownInterface;
final class HermesRestartCommand extends Command
{
protected $signature = 'hermes:restart';
protected $description = 'Stop every running Hermes worker after its current message';
public function handle(ShutdownInterface $shutdown): int
{
$shutdown->shutdown();
$this->info('Workers will stop after their current message. Supervisor or systemd starts new ones.');
return self::SUCCESS;
}
}Run php artisan hermes:restart as the last deploy step. Each worker stops after its current message, and Supervisor starts a new one with the new code:
; /etc/supervisor/conf.d/hermes-worker.conf
; Two workers. hermes:restart stops them after their current message, and Supervisor starts new ones.
; With ext-pcntl, SIGTERM (stopsignal) also lets a worker finish its message; stopwaitsecs is the limit before SIGKILL.
[program:hermes-worker]
command=php artisan hermes:work
directory=/var/www/app
process_name=%(program_name)s_%(process_num)02d
numprocs=2
user=www-data
autostart=true
autorestart=true
startsecs=1
stopwaitsecs=60
stopsignal=TERM
redirect_stderr=true
stdout_logfile=/var/log/hermes-worker.logSee Production deployment for systemd and Kubernetes.
8. Test the producer
Bind a mock of EmitterInterface in the container. From Hermes 6.0 Emitter is final and cannot be mocked.
<?php
declare(strict_types=1);
namespace Tests\Feature;
use Tests\TestCase;
use Tomaj\Hermes\EmitterInterface;
use Tomaj\Hermes\MessageInterface;
final class UserControllerTest extends TestCase
{
public function testRegistrationEmitsUserRegistered(): void
{
// Mock the interface: Emitter is final from Hermes 6.0.
$emitter = $this->createMock(EmitterInterface::class);
$emitter->expects(self::once())
->method('emit')
->with(self::callback(
static fn(MessageInterface $message): bool => $message->getType() === 'user.registered'
&& $message->getPayload() === ['email' => 'jane@example.com'],
))
->willReturnSelf();
$this->app->instance(EmitterInterface::class, $emitter);
$this->postJson('/users', ['email' => 'jane@example.com'])->assertCreated();
}
public function testInvalidEmailEmitsNothing(): void
{
$emitter = $this->createMock(EmitterInterface::class);
$emitter->expects(self::never())->method('emit');
$this->app->instance(EmitterInterface::class, $emitter);
$this->postJson('/users', ['email' => 'not-an-email'])->assertUnprocessable();
}
}Test handlers on their own, without a driver; see Testing.
Retry jitter and a Random\Randomizer binding
From Hermes 6.0, Dispatcher takes an optional ?Random\Randomizer as its fourth argument (see Framework integration). The provider above calls new Dispatcher(...) with three arguments, so a Random\Randomizer binding elsewhere in the app does not change the retry times.
If you resolve the class with app(Dispatcher::class) instead, Laravel autowires the fourth argument and injects your Random\Randomizer binding. Keep new Dispatcher(...) in the provider, or add a contextual binding in register() so the dispatcher gets null and keeps the default:
use Random\Randomizer;
use Tomaj\Hermes\Dispatcher;
$this->app->when(Dispatcher::class)->needs(Randomizer::class)->give(fn () => null);Full source
Every file, with install.sh, e2e.sh and the Supervisor program, is in examples/frameworks/laravel on GitHub. Verified with Laravel 11.57.0 and 12.70.0 on PHP 8.4, with every driver.
FAQ
Why not use Laravel's own queue?
Use it if it fits. Hermes is useful when other PHP apps, for example Nette or Symfony services, emit or handle the same messages, or when you want Hermes priorities and its Redis layout. The two can run side by side on different keys; see Framework integration.
Does php artisan queue:restart stop Hermes workers?
No. Laravel's restart signal is in its cache. Hermes workers watch the file of SharedFileShutdown, so use php artisan hermes:restart. See Shutdown and max items.
Can I run the worker with php artisan schedule:work or from a job?
No. hermes:work blocks until it is told to stop. Run it as its own long-lived process under Supervisor or systemd, as in Production deployment.
Which Redis client does it use?
HERMES_DRIVER=redis uses the phpredis extension, predis uses the Predis package. It does not depend on REDIS_CLIENT. See Redis and Predis.
How do I use SQS locally?
Set HERMES_DRIVER=sqs, HERMES_SQS_ENDPOINT=http://127.0.0.1:4566 and any AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY for a local moto or LocalStack server. See Amazon SQS.