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.
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.
.lerd.yaml still sit above both.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.
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.
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.
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.
# 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`
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
| What landed | Where | The problem it answers |
|---|---|---|
| PHP 8.6, as a prerelease tier #1584 | lerd fetch 8.6 | There 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 #1592 | lerd shell 8.4 | Looking at what a version actually holds meant finding a site on it first, or reaching for podman by hand |
| A pinned command #1575 | site control row | The 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 #1363 | lerd tui | Someone 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 override | custom.d/…location.conf | A 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 #1543 | host shim | Running 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 #1542 | lerd mcp:inject | The one client sharing neither JSON shape lerd already writes: an mcp key, a transport named local, command and arguments in one array |
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.
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.
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.
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.
composer audit, npm audit and the response-time lookup are not things to pay for once per site on a doctor run.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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.