Skip to content

Framework Workers

Every framework can define long-running workers (queue consumers, schedulers, WebSocket servers). This page covers the worker commands, conditional rules, conflicts, proxy wiring, project-specific custom workers, and orphan cleanup.

Each framework can define workers: long-running processes managed as systemd user services inside the PHP-FPM container.

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

Every worker also gets commands under its own name, generated from the framework definition rather than written by hand: lerd queue:start, lerd horizon:stop, lerd reverb start and so on, in both the name:verb and name verb spellings. They run the same implementation lerd worker start does, so a worker added to the store arrives with its own commands and no lerd release. Outside a linked project there is no framework to read them from, so they aren't there.

A worker whose definition has a reload_command also gets lerd <name>:reload [on|off], which toggles restart-on-file-change for the current project and restarts the worker when it is already running. With no argument it prints the current state.

Tuning flags: a worker with a tune_command gets a flag per placeholder on its start command, so Laravel's --queue={queue} --tries={tries} --timeout={timeout} becomes lerd queue:start --queue emails --tries 5. Each default is read back out of the plain command, so nothing is duplicated, and a framework that spells its queue differently (CodeIgniter takes it positionally and has no timeout flag) gets exactly the flags it declares. Passing no flags runs the declared command verbatim.

Required services: a worker can name a service it cannot run without, optionally scoped to sites whose .env carries a given key. Laravel's queue worker requires Redis on QUEUE_CONNECTION=redis, so starting it with lerd-redis down says which service to start instead of leaving the worker to crash-loop on a DNS error:

yaml
workers:
  queue:
    command: php artisan queue:work
    requires_service:
      name: redis
      when_env: QUEUE_CONNECTION=redis

Worker features

Conditional workers: Workers with a check rule only appear when the condition passes (e.g. laravel/horizon is in composer.json):

yaml
workers:
  horizon:
    command: php artisan horizon
    check:
      composer: laravel/horizon

Conflict resolution: Workers can declare conflicts. When a conflicting worker starts, the other is stopped automatically and hidden from the UI:

yaml
workers:
  horizon:
    command: php artisan horizon
    conflicts_with:
      - queue      # stops queue before starting horizon; hides queue toggle in UI

WebSocket/HTTP proxy: Workers that need an nginx proxy block define a proxy config. Lerd auto-assigns a collision-free port and regenerates the nginx vhost:

yaml
workers:
  reverb:
    command: php artisan reverb:start
    proxy:
      path: /app                    # URL path for the proxy location block
      paths:                        # every path the server answers on (optional)
        - /app
        - /apps
      port_env_key: REVERB_SERVER_PORT  # env key holding the port
      default_port: 8080            # starting port for auto-assignment
      upstream: container           # where the server listens: container (default) or host
      port: pinned                  # optional: lerd owns the port instead of .env

A server that answers on more than one path lists them all under paths, and each gets its own location block on the same port. Reverb is the case in point: the WebSocket connection lands on /app while the HTTP broadcasting API a server-side ShouldBroadcast event posts to lives on /apps/{app_id}/events, and a path left out falls through to PHP and answers 404. Where both are set, paths is the list that gets proxied and path is ignored, so a definition keeps path alongside it and still proxies on lerd versions released before paths existed.

Every worker that declares a proxy gets its own locations, so an asset server and a websocket server run side by side on the same site rather than the first one declared taking it.

upstream names where the server actually listens. The default, container, proxies to the site's own PHP-FPM container, which is where a worker without host: true runs. A worker marked host: true runs on your machine instead and is unreachable from inside that container, so its proxy needs upstream: host to be routed to the host address the vhost already knows.

The port comes from one of two places. Naming a port_env_key suits a server configured from the site's .env: lerd assigns a free port on first start, writes it to that key, and appends --port to the command. port: pinned suits everything else: lerd owns the port, keeps it clear of every other site's pinned ports and dev servers, records it on the site so it survives restarts, and hands it to the worker as KEY=port in front of its command, where KEY is the port_env_key the definition names. The project's own config reads it from the environment, and nothing is written to .env.

The environment reaches the process lerd starts and everything it spawns, including a command that re-enters the container on its way: lerd names the key for passthrough, so the php shim carries it into the site's runtime and a tool started through php artisan something binds the same port the vhost proxies to.

Stopping a worker clears what it left inside the container. A command that re-enters the runtime leaves the real process there when its unit stops, holding whatever port it bound, so lerd sweeps the site's container for processes matching that worker's command and working directory and signals the process group, which is what catches the tool a console command started.

Port assignment scans all proxy port env keys across all sites to prevent collisions between different workers and frameworks.

The generated nginx location anchors on path, so /app proxies /app and everything under it without also swallowing an unrelated route that merely starts with the same letters (/appstore, say). Write path as a literal URL path and lerd normalises it, so /app, /app/ and app all anchor identically; a path of / mounts the worker at the site root and proxies everything. Regex-special characters are escaped (a literal . in something like /socket.io, for instance) before the path reaches nginx's location block, so write it exactly as the URL reads. A proxy block with no path names nothing to proxy and is ignored.

Anchoring also turns the location into a regex, and nginx runs regex locations in file order ahead of the prefix location it would otherwise have picked, so the proxy takes precedence over lerd's own PHP handling and dotfile deny for anything under path. That's the right call for a worker mounted on its own path, but it means a .php file or dotfile living under path is proxied rather than served by lerd.

Server health probe: A worker whose process can outlive its server (a Vite dev server that dies under npm while the Node process lingers) declares a health block, so lerd probes reachability rather than mere process liveness:

yaml
workers:
  vite:
    command: npm run dev
    host: true
    health:
      url_file: public/hot   # a file the server writes on boot, holding its URL

url_file names a file the dev server writes when it binds (Vite's public/hot holds a URL like http://[::1]:5173). While the process is up, lerd reads that file and makes a short TCP dial to its host and port; if nothing is accepting, the worker reports unreachable instead of running and worker-heal restarts it. A worker with no health block keeps the process-only liveness check.

A missing url_file is never itself a failure, only a signal lerd cannot use: the worker keeps the process-only check. Plenty of healthy setups never write one, from a Vite config with a custom hotFile to vite build --watch, and idle-suspend clears public/hot while the unit is briefly still up. The failure the probe exists to catch is a stale file whose advertised port refuses a connection, which is what a dev server that died behind a live unit leaves behind. A file older than the unit's last activation is a leftover from a previous run and is not dialled.

Host workers: Workers that need to run on the host instead of inside the PHP-FPM container set host: true. The command runs via fnm at the project's pinned Node.js version. This is used for tools like Vite that need direct filesystem access for HMR:

yaml
workers:
  vite:
    label: Vite
    command: npm run dev
    restart: on-failure
    host: true
    check:
      file: vite.config.js

The command is wrapped in /bin/sh -c so shell features (&&, |, env-var expansion, redirects) work as written. A composite command like npm run build && npm run preview runs end-to-end without quoting tricks.

Host workers auto-start in three places:

  • when a worktree is created, with per-worktree units (lerd-vite-<site>-<branch>, supervised by systemd on Linux and launchd on macOS) so multiple Vite instances can run simultaneously with auto-incremented ports.
  • at daemon boot, so worktree units recover after a host reboot or lerd stop && lerd start even when fsnotify hasn't fired.
  • on lerd worktree remove, the matching unit is stopped and its file removed; without this the unit would restart-loop against the deleted WorkingDirectory.

Host workers run with lerd's bin dir prepended to PATH, so subprocesses spawned by npm run dev (for example Inertia's wayfinder Vite plugin shelling out to php artisan) reach lerd's php, composer and laravel shims and route into the containerised runtime. Stopping a host worker via the UI or lerd worker stop is now sticky: a HEAD-write event (commit, checkout, rebase, branch rename) inside a worktree no longer resurrects it, and on macOS the heal loop respects a missing plist as a user-stop signal instead of recreating it.

On macOS the unit is a launchd plist (~/Library/LaunchAgents/lerd-<worker>-<site>[-<branch>].plist) backed by a guard script under ~/.local/share/lerd/run/workers/ that cds into the site/worktree and fnm execs the command. The guard records its own pid, which is the process group leader, and stopping the worker signals that whole group: launchd only signals the leader, so a worker that hands off to a launcher (npm to electron-vite to Electron) would otherwise leave the app running, reparented to init, with no unit left to stop it. The watcher self-heals the unit independently of the worker exec mode, host workers always need launchd-level supervision because they aren't behind podman's --restart=always. Scheduled workers (schedule != "") still aren't supported on macOS; launchd's StartCalendarInterval isn't wired through the unit translator yet.

Declaring the dev server a worker starts: lerd recognises a dev server by reading the worker's command, following one level of npm run. A framework that starts the same tool through its own console command is invisible to that, so the worker can say so instead:

yaml
workers:
  vite:
    command: php artisan vite:watch theme-vampire
    host: true
    dev_server:
      tool: vite

A declaration is believed whatever the command looks like, and it is what opts a framework in rather than lerd inferring it. Naming a tool lerd has no integration for changes nothing.

Where the command starts the tool itself, lerd hands it a generated config and everything below applies unchanged. Where it does not, there is no flag to put that config on and the tool loads whatever the console command decides, so lerd writes the values instead, to node_modules/.lerd/dev-server.mjs, and the project imports them into its own config:

js
import lerd from '../../node_modules/.lerd/dev-server.mjs';

export default defineConfig({
    server: { ...lerd.server },
    // the rest of the project's config
});

That file carries the site's origin, the hosts the server may answer for, the origins allowed to fetch from it, and the port, which is the worker's pinned proxy port when it declares one. lerd rewrites it and restarts the server whenever those addresses move, which is what lerd secure, lerd domain add and grouping all do, so the one thing a project cannot keep current by hand stops going stale.

Dev servers on the site's own domain: A dev server normally advertises its own address, so a Vite app renders asset URLs pointing at localhost:5173. That address means nothing to anyone else, so the page arrives unstyled over a share tunnel, over LAN sharing, or on any host other than the one that started it.

lerd puts a supported dev server behind the site's own domain instead. Everything the tool serves lives under one prefix (/@lerd-vite/), which the site's vhost proxies to it, so the assets and the hot-reload websocket both travel on whatever hostname the visitor actually used. Nothing needs rewriting, because the client derives its host, port and protocol from the URL it was loaded from.

This needs no configuration and no framework definition. A host worker qualifies when the project has the tool installed and the worker command starts it directly, following one level of npm run indirection. A command that only reaches the tool through a runner such as concurrently is left alone, since the flags lerd appends would land on the wrong process.

Nothing in the project is edited. lerd writes a generated config to node_modules/.lerd/ that merges in the base, origin and allowed hosts for serve only, then starts the tool against it. The project's own config is not imported there, it is handed back to the tool's own loader: a config's module format decides which build of every plugin it pulls in, and importing it into a generated file would resolve those plugins differently than a plain run of the same command does. A plugin whose ESM and CommonJS builds are not interchangeable would otherwise fail under lerd alone. That file is rewritten on every start, since a worktree seeds node_modules from its parent and would otherwise inherit the parent's domain. A project with no config file for the tool, or one that tracks the generated path in git rather than ignoring it, keeps its dev server exactly as it was.

Framework plugins released before Vite grew server.origin ignore it and publish whatever address the server bound to, writing it to the file the app reads to find its dev server. That address is a wildcard nothing can route to, and on a secured site the browser blocks the plain-HTTP request as mixed content and drops the padlock, so the page arrives unstyled. The generated config catches that one value as it is written and stores the site's own URL instead, which is what a current plugin writes there anyway. Upgrading the plugin remains worthwhile, but an old one no longer breaks the page.

The port is pinned, because the vhost proxies to it and the tool would otherwise drift to the next free one whenever several sites run. It is kept clear of other sites, of the site's own worktrees, and of whatever else the machine is holding, and a pin something has since taken is re-picked rather than left to fail. Each worktree pins its own port and takes its origin from its own subdomain.

The tool reads those addresses once, when it starts, so lerd writes them back and restarts the dev server whenever they move: lerd secure and lerd unsecure, lerd domain add and lerd domain remove, and grouping a site under a main. A dev server that is not running is left down, and a change that leaves the addresses exactly as they were restarts nothing.

A site with more than one domain serves its assets from the primary one, since a dev server can advertise only a single origin. The generated config lists every domain, both as a host the server answers for (along with its subdomains, matching the vhost's wildcard) and as an origin allowed to fetch from it, so a page opened on a second domain loads normally instead of having its assets refused.

Some plugin middleware registers itself ahead of the tool's own base handling and only answers unprefixed, which would 404 on URLs it advertised itself. nginx retries any 404 under the prefix once with the prefix removed, so those routes work without anything having to name them.

When idle-suspend is enabled it stops every one of a site's workers once the site has been idle, so workers carry no special configuration for it. A worker marked per_worktree: true (Vite is the only one by default) is suspended per worktree, on each worktree's own idle timer.

Workers a package brings

A worker gated on a composer package belongs to the package, not to the framework major it happens to be written in, so the store lets it be declared once in packages/<vendor>-<name>.yaml and merges it onto whatever definition the project resolved. It behaves like any other framework worker from there: same commands, same tuning flags, same lifecycle. See package definitions for the schema and how a package narrows itself to a framework and a range of its majors.

Project-specific custom workers

The workers: list is what lerd start brings back, and it follows the worker units lerd has written rather than what happens to be running at the moment you touch another worker. Starting a worker adds it, stopping one by name removes it, and a worker that is merely down, crash-looping or stopped for a rebuild, stays on the list and starts again with the rest.

Add workers to .lerd.yaml for project-specific needs that don't belong in the framework definition:

yaml
# .lerd.yaml
framework: symfony
framework_version: "8"
workers:
  - messenger
  - pdf-generator
custom_workers:
  pdf-generator:
    label: PDF Generator
    command: php bin/console app:generate-pdfs --daemon
    restart: always

Custom workers with proxy support:

yaml
custom_workers:
  mercure:
    label: Mercure Hub
    command: php bin/console mercure:run
    restart: always
    proxy:
      path: /.well-known/mercure
      port_env_key: MERCURE_PORT
      default_port: 3000

Custom workers are merged with the framework's workers at runtime. They are committed to git so teammates get the same setup.

Worker icons

A worker declares how the dashboard draws it, so a new worker gets an identity from the store with no binary release:

yaml
workers:
  queue:
    label: Queue Worker
    icon: queue                 # built-in glyph, inked in the framework's colour
    command: php artisan queue:work
  vite:
    label: Vite
    icon: vite                  # a mark the store ships at workers/vite.svg
    color: "#9135ff"            # the mark's own tone, not the framework's
    command: npm run dev

icon names either one of the built-in glyphs (queue, clock, bolt, broadcast, card, gear, …) or a mark the framework store carries under workers/<icon>.svg. Marks are keyed by icon name rather than by framework, so every framework that runs Vite shares one drawing. lerd caches a mark beside the definition that named it, sanitises it on the way in, and serves it to the dashboard from its own copy, so a worker keeps its icon offline and over remote access.

color is optional. A worker without one takes its framework's brand colour, which is what tells two schedulers from different products apart; a worker whose mark has a tone of its own (Vite, Horizon) declares it, because inking Vite in Laravel red would read as a different product. A worker that declares neither falls back to its framework's own mark, and then to a plain gear.

Both keys are optional and ignored by older binaries, so a store update carrying them is safe for installs that have not updated yet.

Worker logs

bash
journalctl --user -u lerd-messenger-myapp -f

In the dashboard, a worker keeps its Logs tab whatever state it is in, drawn muted while it is stopped. The journal outlives the unit, so the tab is still the place to read why a worker died after it has gone down, or after the health banner stopped it.

Each worker's toggle carries a shortcut straight to that journal: the log-lines button on the right of the toggle opens the Logs tab with the worker's source already selected, without hunting through the tab strip.

Managing custom workers

Use lerd worker add to add project-specific or global custom workers without manually editing YAML:

bash
# Add a project-specific worker (saved to .lerd.yaml)
lerd worker add pulse --command "php artisan pulse:work" --label "Pulse" --check-composer laravel/pulse

# Add a worker that conflicts with another (stops it on start, hides it in UI)
lerd worker add custom-queue --command "php artisan queue:work --queue=emails" --conflicts-with queue

# Add a global worker (saved to ~/.config/lerd/frameworks/<name>.yaml)
lerd worker add pulse --command "php artisan pulse:work" --global

# Remove a custom worker (stops it if running)
lerd worker remove pulse
lerd worker remove pulse --global

Project workers (.lerd.yaml) apply to a single project and are committed to git. Global workers (user overlay) apply to all projects using that framework. Both survive framework store updates.

The resulting .lerd.yaml looks like:

yaml
framework: laravel
custom_workers:
  pulse:
    label: Pulse
    command: php artisan pulse:work
    check:
      composer: laravel/pulse
  custom-queue:
    command: php artisan queue:work --queue=emails
    conflicts_with:
      - queue

After adding, start the worker with lerd worker start pulse.

When running lerd init --fresh, existing custom workers are shown in a multi-select step before the workers step. Deselecting a custom worker removes it from .lerd.yaml and excludes it from the workers selection. If the removed worker had conflicts_with, those workers become available again.

Orphaned workers

A worker becomes orphaned when its systemd unit is still running but its definition has been removed from .lerd.yaml (e.g. after a git pull or manual edit). Orphaned workers are detected and surfaced in several places:

  • lerd worker list: shows orphaned workers with a stop hint
  • lerd worker stop <name>: can stop orphaned workers even without a definition
  • lerd setup: offers orphaned workers as pre-selected stop steps before framework worker starts
  • UI: the stop button works for orphaned workers directly

Stale worker units

A worker that leaves the definition entirely is a different case. A framework definition reaches every install within a day with no binary release, so a worker retired upstream is retired on every machine at once: it stops appearing in lerd worker list, and the dashboard stops drawing a toggle for it. Its unit file stays where it was written, though, still linked into default.target.wants and still armed for boot.

Nothing used to reconcile the two, so a unit that answered to nothing kept being walked by worker-heal and reported as a worker that needed healing. Two things changed. Worker-heal now leaves a unit whose worker the site no longer declares alone, so it no longer counts towards the failing-worker banner. And lerd site:doctor reports it once, under Worker Units, with a fix that disables the unit, deletes it, and reloads the daemon:

bash
lerd site:doctor           # names the units that answer to nothing
lerd site:doctor --fix     # disables and removes them

The unit is never removed silently, because it may still be running something you want. A worker whose check rule simply stopped matching is not stale: the definition still names it, and the next branch checkout brings it back.

Web UI (worker toggles)

Framework workers appear as toggles in the Sites panel. Workers with a check rule only appear when the condition passes. Workers with conflicts_with suppress each other (e.g. when Horizon is available, the queue toggle is hidden).

Custom framework workers from .lerd.yaml also appear as toggles alongside the framework's standard workers.


See also: Frameworks for the framework store and Laravel definition; Framework definitions for the YAML schema.

Released under the MIT License.