L lerd lerd-env/lerd v1.33.1…v1.34.0 ↗
ENGINEERING DIGEST · 2026-08-14 → 2026-09-02

v1.34.0

NATIVEPHP, DESKTOP AND MOBILE · and a worker the binary no longer knows about

NativePHP runs end to end, desktop and mobile, and everything it declares is written once in the store instead of into every Laravel major, because a worker belongs to a composer package and the store finally has a way to say so. The named worker commands are generated from the definition now, which took the last of the Laravel knowledge out of Go. Alongside it, two things lerd never had: a way in without a terminal, and a rule that nothing downloads before you have seen its size.

117
commits
68
merged PRs
456
files
+21,738
insertions
−2,981
deletions
4
contributors
cli 26% web UI 18% config 10% docs 8% podman 6% rest 32%
01

Workers

FEATREFACTOR

A worker belongs to a composer package, not to the framework that happens to require it, and the store had no way to say so. Everything gated on a package was repeated in every version file of every framework that might install it, which is how adding NativePHP meant putting the same worker, two commands and three checks into three files and then correcting all three twice in one afternoon.

ONE PACKAGE, WRITTEN ONCEinstead of once per framework major
laravel/horizon
×8
one Laravel version file each
drush/drush
×4
one Drupal version file each
──►
packages/
×1
merged onto the resolved definition
The package wins a name collision with a version file — which is what lets a declaration move out of the framework file without being shadowed by the copy it leaves behind. A user overlay and a project's own .lerd.yaml still sit above both.
FEAT

The package layer #1574

One file per composer package, beside the definitions rather than inside them, narrowed by an optional framework list and major range, so a queue driver stays unscoped while nativephp/electron says it is Laravel's alone. Only packages the store index lists are looked up, so a project's dependency list never turns into a request for a file that does not exist, and an install that cannot reach a version it has never fetched falls back to the newest cached file below it.

internal/config/framework_package.go
FEATREFACTOR

The named commands are generated #1356

Four hand-written cobra commands that each ended up doing what lerd worker start already does from the definition, so a worker added to the store could never get a start command without a binary release. The flags come from the tune_command placeholders rather than a fixed queue, tries and timeout set, and each default is read back out of the plain command so the value is declared once.

internal/cli/worker_cmds.go
FEAT

Options live in the project #1569

A worker's tunable values came from the store but nothing kept them, so a project running several queues spelled them on every start and the dashboard toggle, a restore after a reinstall and a fresh clone all fell back to the defaults. They live under worker_options now. A value equal to the framework's default is not stored, so a later store change to that default still lands.

.lerd.yaml
FEAT

A command that has to leave the container #1651

A console command that opens a desktop window cannot run where lerd runs every other one. NativePHP's native:run starts Electron and shows a window, and the PHP-FPM container has neither. What made it worse than a plain failure is that nobody could see it coming: upstream documents php artisan native:run, and on a lerd machine php is the shim on PATH ahead of /usr/bin, so the documented command quietly re-entered the container, ran npm in there and died two seconds later saying only that the dev script failed. A definition names an argument pattern and the binary that runs it, matched against the arguments as typed so one entry covers lerd php artisan native:run and lerd artisan native:run alike. A declared binary that is not installed is an error naming the install command, never a fall back into the container, since falling back is precisely the failure the declaration exists to prevent.

internal/cli/host_command.go
what a start resolves through, and where each piece comes fromyaml
# the framework definition: the command, and the same command with its knobs named
workers:
  queue:
    command: php artisan queue:work --queue=default --tries=3 --timeout=60
    tune_command: php artisan queue:work --queue={queue} --tries={tries} --timeout={timeout}
    requires_service:
      name: redis
      when_env: QUEUE_CONNECTION=redis   # the unit orders after it instead of racing it at boot

# the project's own .lerd.yaml: what this project committed to
worker_options:
  queue:
    queue: emails,default
    tries: 5

# and the command nobody had to write
lerd queue:start   # one flag per placeholder, each default read back out of `command`
02

No Terminal

FEATUI

Opening the dashboard on a stopped lerd left you looking at a page you could not act on, and the only way back was a terminal. That is a strange thing to require of a tool whose whole point is that you stop thinking about the infrastructure. Three ways in now, and all of them report the start rather than hanging on it.

THREE WAYS IN, ONE STARTeach streaming the same progress back
dashboard
Start Lerd
unit by unit in the banner
macOS
~/Applications
splash with a progress bar
Linux
desktop entry
notification rewritten in place
──►
served
lerd.localhost
nothing typed
The splash waits for the start to report itself finished, not for nginx to answer — the vhost is up well before the databases behind it, so the earlier signal would have handed you a dashboard whose services were still coming up.
FEATUI

Start from the banner #1529

The core services banner runs the same start the CLI does and streams its progress back, counting units as they come up and naming the stage it is in rather than hanging on a request that takes the better part of a minute. lerd dashboard and the tray's Open Dashboard start lerd first, and the offline page offers the same button whenever the daemon still answers on its own port.

internal/ui/server.go
FEAT

In the app list, on both platforms #1536

macOS gets a Lerd app in Launchpad and Finder; Linux gets a desktop entry written by lerd install and removed by lerd uninstall, reporting its cold start over the session bus rather than through a dialog tool the desktop may not ship. It is named Lerd on its own, and steps aside as Start Lerd when the desktop app is already listed, rather than shadowing the entry that carries the lerd:// association.

internal/desktopapp
FIX

The terminal you actually chose #1535

Open in terminal walked a fixed list, so an emulator that merely happened to be on PATH won over the one you had picked, and Warp never appeared at all. macOS reads the LaunchServices handler for shell scripts; Linux asks the freedesktop launcher, then alternatives, then what KDE or GNOME records. A chosen terminal is launched with its own flags, since the generic cd wrapper is not something every emulator accepts.

FEAT

The tray is optional #1525

A desktop that already carries lerd's running state elsewhere, the Omarchy bar widget being the case that prompted this, got the applet as a second copy of the same information with no supported way to turn it off: masking the unit did not work, because start enabled it again or launched the helper directly. There is an on/off preference in the global config now, checked by both start and install before they touch the applet, and turning it off quits the running one and takes the unit out of the autostart set. It is reachable from all three places someone might look, lerd tray on and off, the dashboard's system settings, and an item in the tray menu itself, which is deliberately one way since a gone applet has no menu left to bring it back from.

internal/tray
03

Bandwidth

FEATSAFETY

Several commands quietly committed to pulling or rebuilding container images, and the PHP base images are republished often enough that a rebuild is a whole base download. On a metered connection, or a hotel wifi, or a phone tether, that is a decision somebody should get to make before it is made for them.

said before the first byte moves, and three ways to refuse itbash
lerd start --dry-run
lerd would download 2 images (~486 MB total); nothing was downloaded
  rebuild  PHP 8.4 image                 ~412 MB  missing, needed by php84-fpm
  pull     docker.io/library/mysql:8.4    ~74 MB  missing, needed by mysql

lerd start --no-pull              # skip it, unless the image is missing outright
LERD_OFFLINE=1                     # the dashboard, the watcher and the MCP server
FEAT

Sizes that cost nothing to ask for #1524

Read from the registry manifest over the OCI distribution API, so working out what a pull costs downloads nothing at all, and a registry that does not answer in time leaves the line without a number rather than holding the command up. The disclosure covers start, install, fetch, php:rebuild, the whole service install and update path, the implicit pull mid-start and the FrankenPHP switch.

internal/imagepull
FEATUI

A confirmation, not a spinner #1524

The dashboard turns the same estimate into a dialog naming the image and roughly how much it will download. An image already in the local store costs nothing to use, so it still runs with no extra click; the point is to interrupt a download, not to add a step to everything.

FEAT

An assistant has to come back with your answer #1618

An assistant installing a preset or adding an extension started the same download on a connection belonging to someone who never typed the command. The MCP actions that can pull answer with the image and its size instead of fetching it, so the assistant relays the cost and returns with confirm: true once the person paying for it has agreed.

internal/mcp/download.go
04

Surfaces

FEATUI

The most asked-for thing this cycle was NativePHP, and it is supported end to end, desktop and mobile. The rest is spread across the places lerd is met from: a PHP version nothing picks for you, a shell into any image, one command promoted out of a dropdown, and the two panes the TUI was missing.

FEAT

NativePHP, desktop and mobile #1545

nativephp/electron runs the desktop app as a host worker and nativephp/mobile builds onto an emulator, a simulator or a device, with a walkthrough for the parts that catch people out because neither toolchain reports itself clearly: an Xcode shipping an SDK without the matching simulator runtime, a Gradle picking up whichever JDK leads PATH when JAVA_HOME is unset. Several things had to become framework-agnostic to carry it. A doctor check can take the gate rule workers and commands already use, so a check about an optional package is dropped rather than drawing a permanently green row on every project that never installed it. A worker declares whether its command outlives what it started, so a native:serve that ends with the window the user just closed is no longer reported as drift asking to be started again, which was never NativePHP's alone since vite ends with its dev server the same way. A start refuses when the command names a project-relative program that is not there and says which one, instead of launching a unit that dies on "Can't spawn program" and flaps every few seconds. And a host worker's teardown reaps the whole process group, since launchd signals only the job leader and a worker that hands off to a launcher was leaving the Electron window on screen throwing EPIPE dialogs once its parent's stdio had gone.

packages/nativephp-*.yaml
What landedWhereThe problem it answers
PHP 8.6, as a prerelease tier #1584lerd fetch 8.6There was nowhere to run a suite against the next PHP, and adding it as an ordinary version offers a beta to people who read a version list as supported. A bare lerd fetch leaves it alone, FrankenPHP does not offer it, and release day is one edit
A shell in any PHP image #1592lerd shell 8.4Looking at what a version actually holds meant finding a site on it first, or reaching for podman by hand
A pinned command #1575site control rowThe command a project runs twenty times a day cost the same two clicks as the one it runs twice a year
Databases and service facets #1362 #1363lerd tuiSomeone debugging in the terminal could not see that a database existed, how large it had grown or whether it had a snapshot to fall back on
A location-scope nginx overridecustom.d/…location.confA server-scope override can never win against what the generated vhost sets inside a location, so the forwarded host wiring could not be changed for one site
lerd cpx #1543host shimRunning a Composer package's binary without adding it to the project, on the PHP the site is registered on rather than the host's
OpenCode #1542lerd mcp:injectThe one client sharing neither JSON shape lerd already writes: an mcp key, a transport named local, command and arguments in one array
FEAT

A tier, not a version #1584

The prerelease tier decides everything that follows: the image builds from the 8.6-rc tag until release, and every place a version is picked marks it, so it is never chosen in the belief that it gets security updates. 8.6 removed PEAR, so pecl is gone from the upstream image and every PECL install goes through one wrapper that falls back to the release tarball.

internal/php
FEATUI

Two per site, enforced by the API #1575

Not only by the UI, because the control row folds its secondary actions into a menu on a narrow panel and an unbounded set would land on that fold first. A definition can ship a command pinned for the one everyone on a project reaches for, and the user's own choice overrides that default, so a preference stays personal instead of turning into a diff every teammate carries.

UI

Informative, plus what is reversible #1362 #1363

Taking a snapshot adds a file and takes nothing away, so it runs under n; a client shim toggle writes or removes a file and flipping it back undoes it exactly, so it sits under space. Restore, drop, import and export destroy data and stay in the CLI, with the pane saying where to find them.

internal/tui
05

Doctor

FEATFIX

The environment doctor reported on the host and stopped there, so a machine where every check passed could still be serving nothing. It ends with a sites section now, and two of the checks underneath it stopped ending in a command you had to go and type somewhere else.

ONE RUN, THE WHOLE ENVIRONMENTand one engine's database list reused across it
host
[Podman] [DNS] [Ports]
what lerd itself depends on
──►
every linked site
[Sites]
one line each, first thing wrong
──►
the detail
lerd site:doctor
audits, commands, timings
The cheap half runs for every site; the expensive half stays on the per-site command — framework command checks, composer audit, npm audit and the response-time lookup are not things to pay for once per site on a doctor run.
FEAT

The finding creates the database

A site pointing at a schema its engine does not hold was told to go and run lerd db:create, the one finding on the panel ending in a command to type somewhere else, while the button it did carry ran migrate, which has nowhere to run until the database exists. It creates it now, resolving the missing set again on the way in so it can only ever create what the check reported, and a database that does not exist suppresses the migration check so the two steps stay in the order they have to happen in.

internal/sitedoctor
FEAT

Can a container resolve a name? #1522

Every DNS check was about .test resolving on the host, and aardvark-dns answers container names from its own records, so those keep working while every other lookup goes to the forwarders the network was handed. A stale one there stayed invisible until composer timed out mid-download with a clean bill of health. The check asks the running nginx container to resolve the store's own hostname, capped on wall clock because the failure it exists to catch hangs rather than answers.

FIX

A fix undone by its own cache #1649

The fix created the schema, said so, and the re-check underneath it reported the same database as still missing. An engine's contents are read through a list memoised for five seconds so a sweep does not pay a container exec per site, and the re-run is always inside that window. Creating a database drops that engine's entry now, scoped to the engine that changed, and the dashboard's copy gets the same call where it matters more, since lerd-ui outlives the request.

06

Fixes

FIXSEC

The fixed list covers bugs that shipped in 1.33.1 or earlier. Regressions introduced and fixed inside this cycle never reached anyone, so they are folded into the feature that carried them. Five of these left nothing working, so they lead.

THE ONES THAT LEFT NOTHING WORKINGordered by how much of the environment they took with them
1
Every container exited on a host with IPv6 off #1634

Every service quadlet paired a [::1] publish onto its 127.0.0.1 one, and rootlessport cannot bind an address the kernel does not have. A host booted with ipv6.disable=1, or one whose VPN client turns IPv6 off while connected, had podman treat that bind failure as fatal: nginx, mysql, redis and everything else exited with status 126, and there was no working environment left to diagnose it from. Units written by an earlier build are repaired on the next start, and the host is read every time, so a machine that regains IPv6 gets its dual-stack pairs back with it.

2
An uninstall that reported keeping the data had already emptied it #1635

The Linux desktop entry's icon lives inside lerd's own data directory rather than an icon directory of its own, and the launcher removal cleared the icon's parent. That step ran whatever the answer to the data prompt was, so an uninstall that went on to say the data was kept had taken the site registry, the vhosts, the certificates and the request stats, with nothing in the output connecting the two. Only the icon file goes now.

3
Re-running the installer flipped a .localhost machine onto managed DNS #1647

The DNS mode is settled once, and lerd install honours the saved choice on every later run. install.sh did not know that, asking on every run and passing the answer on as an explicit flag, the one input that wins ahead of the saved choice. Where there was no terminal the question was never shown, since it reads from /dev/tty, so it silently resolved to managed: dnsmasq came up, the sudoers grant was written and every site domain was rewritten, on nothing more than a reinstall. Only a first install asks now.

4
nginx never bound the ports it was configured with #1544

nginx.http_port and nginx.https_port were read by the share commands, the profiler probe, the watcher and the install-time port check, but never by the thing that actually binds: both writers rendered a template publishing 80:80 and 443:443. Doctor was the visible half of the same problem, reporting on the literal 80 and 443 without probing at all when nginx was up, so a host demonstrably on 10080 was told about port 80 either way.

5
A stop returned before the service had finished stopping #1510

Every stop waited a fixed thirty seconds, so a service declaring a longer graceful window was guaranteed to outlast the wait: mysql and postgres declare sixty, which reaches the unit as TimeoutStopSec=75. The uninstall is where it left a mark, deleting the units and resetting their failed state while podman killed mysql a minute later, so systemd recorded a failure after the reset that would have cleared it. The wait is read off the unit now.

FIX

ftp_ssl_connect existed in no image #1576

ext/ftp only compiles its SSL half when OpenSSL is configured in the same run, and installing it through phpize against an already built PHP never sets that. php -m cannot see this class of bug, because the module loads either way, so the base image workflow probes the function on the image it just built. A declared extension naming a bundled one then replaced it with a worse build of itself, which is how ftp lost the flag a second time, immediately after the fix.

FIX

stripe:listen could stop the listener #1609

The stop command was declared as stripe:listen stop and registered on the root beside the start command, and cobra takes a name from the first word, so the root carried two commands both called stripe:listen. The plain form resolved to whichever cobra reached first, which on a current build is the stopper. This had been there since the listener became a systemd service in 0.6.0.

FIX

A worker that went down stopped coming back #1627

The workers list in .lerd.yaml was rewritten after every worker action as the set running at that moment, so a worker merely down when an unrelated one was touched was written out of the file with nothing to put it back. A crash-looping worker is down by definition, which is how one disappeared for good. The list is a declaration, so it follows the units on disk now.

FIX

mysql died on a missing auth plugin

MySQL 8.4 authenticates root with caching_sha2_password and Alpine's mysql-client is the MariaDB one, which carries no such plugin, so artisan migrate on any project with a stored schema dump went down with it. Naming mysql_native_password in the shared config had been the old answer, and MySQL 9.x removed that plugin entirely and refused to initialise. The image installs the plugin from mariadb-connector-c instead.

FIX

A service could take the Xdebug port #1555

The port guard knew nothing about 9003, so a service whose own ports were busy could be published there. Nothing then failed: Xdebug connected back exactly as configured, reached the container that took the port, and the session died against a peer that does not speak DBGp, so the IDE simply never saw a breakpoint. A request stopped at a breakpoint also outlived the vhost's 60s read timeout, answering a debugging session with a 504.

SEC

DOMPurify GHSA-55q2-fjhq-7xh7

The editor pulls DOMPurify in through monaco-editor, and 3.4.12 and earlier leave a detached subtree executable when an IN_PLACE hook removes an element. Nothing in the dashboard calls the sanitizer that way, so no shipped build was exploitable, but the pin moves to 3.4.14 so the vulnerable code is not in the bundle at all.