Skip to content

Queue Workers & Framework Workers

Lerd can run framework-defined workers as persistent systemd user services. Workers run inside the project's PHP-FPM container and restart automatically on failure.

Queue worker

CommandDescription
lerd queue:startStart the queue worker for the current project
lerd queue:stopStop the queue worker for the current project
lerd queue startSame as queue:start (subcommand form)
lerd queue stopSame as queue:stop (subcommand form)

These commands are generated from the framework definition, so they exist for any framework that defines a queue worker: Laravel (php artisan queue:work) and CodeIgniter (php spark queue:work, once codeigniter4/queue is installed). The flags come from the definition too and are rendered into each framework's own syntax, so lerd queue:start --queue emails --tries 5 runs the right command either way. The same holds for every other worker a framework declares, schedule, reverb, horizon and whatever the store adds next.

Worker options

A project that runs several queues sets them once rather than on every start. Whatever you pass to a worker's start command is written to worker_options in the project's .lerd.yaml, and every later start reads it back: the dashboard toggle, the restore lerd start performs after a reinstall, and a colleague who clones the repository and runs lerd link.

bash
lerd queue:start --queue high,default,low --tries 5
yaml
# .lerd.yaml
worker_options:
  queue:
    queue: high,default,low
    tries: "5"

The keys are the placeholders the framework definition declares in the worker's tune_command, so Laravel's queue worker offers queue, tries and timeout while CodeIgniter's offers queue and tries. A value that matches the definition's own default is not stored, which keeps a later store update to that default in play. Values are interpolated into the command the worker's unit runs, so whitespace is refused; a list of queues is comma separated, exactly as the framework expects it.

The dashboard offers the same knobs: a worker whose definition declares options gets a gear next to its toggle, with one field per option prefilled with what the project committed and showing the definition's default as the placeholder. Saving writes .lerd.yaml and restarts the worker when it is running, so the new command takes effect without touching the toggle. lerd_worker and the MCP worker tool write to the same place; arguments they leave out keep whatever the project already runs. The MCP tool derives its knobs from the definition too rather than naming any of them itself: worker(action: "list") reports each worker's options with the definition's default and the project's value, and start takes them back as options: ["queue=emails", "tries=5"], so a definition that makes another worker tunable reaches an assistant with no new argument.


Laravel Horizon

If laravel/horizon is present in composer.json, lerd detects it automatically and switches to Horizon mode:

  • The queue toggle in the web UI is replaced by a Horizon toggle
  • Use lerd horizon:start / lerd horizon:stop instead of queue:start / queue:stop
CommandDescription
lerd horizon:startStart Horizon for the current project as a systemd service
lerd horizon:stopStop Horizon for the current project
lerd horizon:reload [on|off]Toggle auto-reload on file changes (prints the current state with no argument)
lerd horizon startSame as horizon:start (subcommand form)
lerd horizon stopSame as horizon:stop (subcommand form)
lerd horizon reload [on|off]Same as horizon:reload (subcommand form)

Horizon manages its own worker pools via config/horizon.php and does not accept --queue, --tries, or --timeout flags. Those are configured in the Horizon config file instead.

The systemd unit is named lerd-horizon-{sitename}. Logs:

bash
journalctl --user -u lerd-horizon-my-app -f

Auto-reload on file changes

By default lerd runs php artisan horizon, which boots the app once and caches your code, so after editing a job, listener, or any class a worker touches you have to restart Horizon for the change to take effect.

Turn on auto-reload to run php artisan horizon:listen instead. Horizon then watches your project and restarts its workers automatically whenever a file changes, so you never stop/restart Horizon by hand while developing. The dashboard and config/horizon.php keep working exactly the same.

bash
lerd horizon:reload on    # use horizon:listen (auto-restart on file changes)
lerd horizon:reload off   # back to standard horizon
lerd horizon:reload       # show the current state

Auto-reload is off by default. The preference is per project, stored as reload_workers in the project's .lerd.yaml (a list of worker names opted into reload mode, currently just horizon), so one project can develop with auto-reload while another stays in standard mode. In the dashboard the Horizon toggle and the reload toggle sit together as one grouped control, and the reload toggle only appears while Horizon is running, since reload is a property of a live worker. Either way, the running Horizon worker for the project is restarted so the change applies immediately, and the new state is pushed to every open dashboard over the websocket.

Three notes:

  • The watcher shells out to Node and resolves chokidar from your project's node_modules. Horizon ships the watcher script but not chokidar itself, so the project has to provide it. It used to arrive for free as a transitive dependency of Vite, but Vite 8 dropped it, so a plain npm install is no longer enough. If chokidar is missing, the toggle never silently reads as on: from the dashboard, enabling pops a modal that offers a one-click npm install --save-dev chokidar and then turns reload on once the watcher is present; from the CLI, lerd horizon:reload on refuses with the same npm install -D chokidar hint.
  • lerd adds --poll where the container can't see host filesystem events: on macOS, where workers run in the podman virtual machine, and under WSL2, where projects on /mnt (9p) mounts get no inotify delivery. On native Linux the container shares the host filesystem directly and inotify works, so polling is left off to avoid the wasted CPU.
  • Where the watcher does poll, lerd re-stats watched files once a second rather than at chokidar's default of ten times a second, which is the difference between a busy virtiofs mount and an idle one. On a laptop the interval also follows how the machine is powered: running from the battery doubles it, and asking for less background work outright (macOS Low Power Mode, the power-saver profile on Linux, read from UPower and power-profiles-daemon) doubles it again. Unplugging mid-session restarts the affected reload workers so the new cadence applies straight away, at most once every five minutes.

Generic workers (lerd worker)

Use this for any other framework-defined worker:

CommandDescription
lerd worker start <name>Start a named worker for the current project
lerd worker stop <name>Stop a named worker
lerd worker listList all workers defined for this project's framework

Example, start the Symfony Messenger consumer:

bash
lerd worker start messenger
# Systemd unit: lerd-messenger-myapp.service
# Logs: journalctl --user -u lerd-messenger-myapp -f

Workers are defined in framework YAML definitions at ~/.config/lerd/frameworks/. See Frameworks for how to add custom workers to any framework.


Options for queue:start

The flags come from the framework's own tune_command, one per placeholder, with the defaults read out of the command the definition already runs. For Laravel that is:

FlagDefaultDescription
--queuedefaultQueue name to process
--tries3Max attempts before marking a job as failed
--timeout60Seconds a job may run before timing out

CodeIgniter takes the queue positionally and has no per-job timeout, so there queue:start offers --queue and --tries only. Run lerd queue:start --help to see what your project's framework declares.


Redis requirement

Laravel's queue worker declares that it needs the redis service when the project's .env sets QUEUE_CONNECTION=redis, so lerd checks lerd-redis before starting it. If it is not running, you will see:

worker "queue" needs the redis service, which is not running
Start it first: lerd services start redis

Example

bash
cd ~/Lerd/my-app
lerd queue:start --queue=emails,default --tries=5 --timeout=120
# Systemd unit: lerd-queue-my-app.service
# Logs: journalctl --user -u lerd-queue-my-app -f

Worker state in .lerd.yaml

Every start/stop command (queue:start, queue:stop, horizon:start, schedule:start, reverb:start, stripe:listen, worker start, etc.) automatically updates the workers list in .lerd.yaml when the file exists. This means:

  • Cloning a project and running lerd link or lerd setup restores all workers.
  • After an uninstall/reinstall cycle, lerd start reads .lerd.yaml and recreates missing worker units automatically, no need to re-run each start command manually.

The workers field is maintained automatically. You do not need to edit it by hand.


Auto-restart on config changes

The lerd watcher daemon monitors .env, composer.json, composer.lock, and .php-version for every registered site. When any of those files change it:

  • Signals php artisan queue:restart inside the PHP-FPM container (debounced to 2 seconds)
  • If .php-version changed: updates the site registry and regenerates the nginx vhost automatically, no manual reload needed

This ensures queue workers and nginx stay in sync after deploys or PHP version changes without manual intervention.


Failing and restarting workers

lerd status includes a Workers section that lists all active, restarting, or failed workers across sites. Paused sites are excluded from this list.

Workers that are crash-looping (repeatedly failing and restarting) are detected automatically. When you unlink a site, lerd stops any crash-looping workers for that site to prevent them from consuming resources after the site is gone.

In the web UI, a failing worker shows a pulsing red toggle and its log tab appears with a ! indicator so you can inspect the error output immediately.


Web UI control

Queue workers and Horizon are controllable from the Sites tab in the web UI:

  • For projects without Horizon: an amber Queue toggle starts or stops the queue worker.
  • For projects with laravel/horizon installed: the Queue toggle is replaced by a Horizon toggle (auto-detected from composer.json).

When a worker is running, a log tab (Queue or Horizon) appears in the site detail panel alongside PHP-FPM. The amber dot next to the site in the sidebar indicates a worker is active.

Released under the MIT License.