L lerd lerd-env/lerd v1.34.3…v1.35.0 ↗
ENGINEERING DIGEST · 2026-09-07 → 2026-09-16

v1.35.0

WHERE PHP ACTUALLY RUNS · and a dashboard that stopped being one colour

On macOS a project is bind-mounted from the host into the Podman VM, so a containerised PHP crosses that boundary on every file it touches. The native runtime removes the boundary: PHP-FPM, the CLI, composer and the workers run on the host, nginx and the services stay where they are. It ships as beta and nothing switches without being asked. Beside it, the accent that was one bright red reached by around 260 utilities became twelve themes kept in the config, and the disk figures stopped charging a shared layer once per image that sits on it.

175
commits
70
merged PRs
680
files
+54,839
insertions
−2,125
deletions
5
contributors
cli 19% web UI 18% config 6% podman 5% rest 26% license notices 26%
01

Native

FEATBETA

On macOS a project lives on the host and Podman runs in a VM, so a containerised PHP reaches every file across a bind mount. The cost is not subtle. The native runtime removes the boundary rather than tuning it: PHP-FPM, the CLI, composer and the workers run on the host, nginx and the services stay in containers. Linux never had the boundary and is unchanged.

THE SAME 2000 FILES, TWO SIDES OF A BIND MOUNTmeasured on the same machine, same project
stat
108ms
in a container
include
320ms
in a container
──►
stat
4ms
on the host
include
31ms
on the host
62 requests per second became 238 under ten concurrent clients — a Laravel app with Horizon and Filament, caches warm on both sides. The runtime is install-wide rather than per site, because the FPM container is shared by every site on a version and half a machine cannot be moved off it.
FEATBETA

The switch, and what it tears down last #1699

Switching rewrites each site's env for the service addresses the new runtime can reach, regenerates the vhosts, drops the framework config caches and restarts the workers, and tears the old runtime down only once nginx is serving from the new one. It carries the beta label on purpose: the runtime is the ground every site stands on, and the container runtime remains the default.

lerd php:runtime
FEAT

Services over loopback #1699

A host PHP cannot resolve lerd-mysql, so sites reach their services over loopback and the published host ports, reusing the rewrite host-proxy sites already used. It now covers every known service rather than only the ones a .lerd.yaml declares, and understands the dotted keys frameworks use when their configuration is a PHP array.

internal/siteops
FEAT

What it refuses, up front #1699

Native builds exist for 8.1 and newer: below that static PHP carries no OPcache, and 7.4 and 8.0 do not compile against current libxml2 and ICU. The switch refuses before it moves anything and names every site standing in the way. The extension set is fixed at build time, so php:ext and php:pkg refuse under this runtime and lerd shell says there is no container to enter rather than starting one.

FIX

Everything written when PHP could only be in a container #1745 #1749

Much of what followed was the native runtime meeting the rest of lerd, and each case read the host as broken. Composer writes vendor/bin/wp as a POSIX shell script rather than a PHP file, and the vendor binary fallback always ran lerd php vendor/bin/<name>; feeding a shell script to php echoes it back, so lerd wp printed its own source and exited clean, and drush was broken identically. The fallback reads the shebang now and execs a non-PHP binary directly, carrying the environment lerd php builds. Separately, a doctor fix clicked in the dashboard came back as exit 127 with lerd: command not found while the same line typed in a terminal worked: launchd hands a child the system PATH and nothing else, so ~/.local/bin was not on it while lerd's shim dir was in front. That PATH carries the running binary's own directory now.

internal/cli/vendor_bin.go
the switch, and what it will not do quietlybash
lerd php:runtime
runtime: container (default) · native available for 8.1+

lerd php:runtime native
✗ 2 sites are on a version with no native build
    legacy-shop.test    php 8.0    no OPcache below 8.1
    old-portal.test     php 7.4    libxml2 and ICU do not compile

# nothing moved, nothing torn down
02

Themes

FEATUI

The accent was one bright red, declared once and reached by around 260 utilities, and enough people said it was too sharp that the colour had to become a choice rather than a brand decision. Making it a choice meant deciding where the choice lives, and a browser's storage was the wrong answer.

ONE RED, REACHED 260 WAYS→ twelve themes, resolved once
--lerd-red
×260
utilities reaching one literal
──►
theme
×12
light and dark tone each
──►
config
every open tab
and the app's own chrome
Each theme carries a light tone and a dark one for its accent — a colour picked for a dark editor rarely reads as link text on white, so one value per theme would have made half the set unusable in the other mode.
FEATUI

Twelve, and where they come from #1718

The default keeps the brand red and muted softens it. Ocean, Solarized Dark, Monokai, Cobalt, Dracula, Nord and Gruvbox Dark bring the schemes people already read code in. Breeze, Adwaita and macOS take their values from what those desktops ship rather than from an approximation, so a dashboard beside a KDE window is the same blue rather than a near miss.

internal/ui/web/src/stores/palettes.ts
FEAT

In the config, not in one browser #1718

Local storage would have made the theme a property of the tab that set it. It lives in the global config instead, so it follows you to another machine, paints the installed app's own chrome, and reaches every open dashboard the moment it changes rather than on the next reload.

FEATUI

A theme is the thing worth sharing #1754

The lerd card has long asked for a star on GitHub, and that was the only thing lerd ever asked for. The Theme card carries a quiet line offering a prefilled post on X, Bluesky or Reddit, about the theme rather than the dashboard on purpose: a dashboard screenshot shows the whole list of someone's local sites, and that is not something to invite people to post.

03

Store

FEATREFACTOR

Everything a framework knows lives in YAML that ships within a day of being written, so the useful question is never what lerd can do, it is what a definition is allowed to say. Four new things it can say this cycle, and one file that can now overrule all of them for one machine.

FEAT

An untracked file that overrides the committed one #1752

A .lerd.yaml is committed, so the settings that belong to one machine or one temporary worktree, an extra domain, an isolated database, a PHP version for a single branch, all ended up in the repository, and on a team working with AI assistants they ended up there without anyone deciding to put them there. .lerd.local.yaml overrides it key by key, the merge happening where every reader already goes, and a save from any command keeps the local file's keys out of the committed one rather than baking a temporary choice into the repo. Changing a key the local file owns says so instead of pretending it took. The PHP and Node detectors were reading .lerd.yaml themselves rather than through the loader, so a version pinned locally was applied on link and then reported as the committed one; they agree again.

internal/config/project_local.go
What a definition can now sayWhereThe problem it answers
The dev server this worker starts, and the port it holds #1721 #1722 #1727worker definitionA worker that runs a dev server needed the port reserved, carried into the container and released again when it stops, and none of that could be declared
This package installs a binary globally #1724packages/*.yamlA globally installed CLI has to land where the host can run it, found in whichever composer home actually holds it, which nothing could express
Serve me on my own .test domain #1704 #1711service presetThe address the application used and the address you paste into a browser were two different ones, and preflights had nowhere to be answered
Copy this into a fresh worktree #1679worktree_includeWhat a worktree needs that git does not carry had to be copied in by hand every time one was provisioned
Run this with these arguments in front #1745vendor_bin_argswp-cli refuses to start as root, which every lerd container is, and the flag that lifts it cannot come from a config file or an environment variable
Answers to my own flags, committed #1721worker_optionsA project running several queues retyped the same values on every start, and a value equal to the framework default was stored, freezing a default that later moved
FEAT

Rolling backups of the registry #1712 #1713

A registry that comes back short is the worst thing lerd can do to someone, because the only way out is linking every project again by hand. sites.yaml is copied aside before every change that rewrites it, keeping the last ten in sites.bkp, with lerd sites:restore to read them back.

lerd sites:restore
FEAT

A save that changes nothing is skipped #1712

That matters more than it sounds. The daemon rewrites the registry constantly for idle state and worker toggles, and that churn would push the last real edit out of the window within minutes. It also means a wipe that keeps rewriting an empty registry takes one slot, so the good state stays at the top instead of being flushed out by the failure repeating itself.

FIX

A re-link kept what only the registry knew #1762

The resolver built a fresh entry out of what it could read in the directory and the registry replaced the whole record, so the approved host commands went, and so did the pinned ports, the share ports, the group, the idle bookkeeping and the paused state. A re-link is the only way to make lerd re-check which framework a site uses, so the one action that picks up a new definition was also the one that quietly deleted your consents. It starts from the entry that is already there now.

internal/siteops
04

Disk

FEATFIX

Cleanup only ever reaped images whose repo the service catalog recognised, so the base layer a custom container pulled was invisible to it forever. Answering that exposed something worse underneath: the numbers lerd had been reporting about disk were not true.

WHY THE OLD NUMBER WAS ALWAYS TOO BIGpodman charges an image its whole layer chain
php:8.4 base
×4
counted once per build on it
──►
reported
~1.5 GiB
what the run claimed it freed
──►
actual
~0.3 GB
measured against the image store
The preview says “at least” rather than “about” now — a shared layer is credited to neither target that frees it, so the honest estimate is a floor. What a run reports is measured against the image store on both sides rather than added up from the plan.
FEAT

The base layer nothing could see #1795

A site whose Containerfile says FROM golang:1.25 pulls that once, carries it across every rebuild, and strands it the moment the Containerfile picks a different base. The interactive tier reaps any tagged image no container holds and nothing lerd references; the unattended tiers stay catalog-only so the daily sweep keeps off anything you own.

internal/cleanup
FIX

Cleaning up no longer costs a rebuild #1795

Widening what can be reaped is only safe if what is in use is known exactly, so every installed quadlet's image counts as protected, PHP-FPM included. Cleaning up while a site is stopped used to take its image with it, because nothing was holding it at that moment.

FEATUI

What the install cost, not only what it can give back #1795

The widget could say what was reclaimable without ever saying what the install occupies in the first place. There is a disk figure beside CPU and memory now, what lerd's own images take whether or not anything can be reclaimed, and clicking it opens the breakdown heaviest first.

FEAT

The VM disk that only grows #1795

On macOS the blocks free inside the Podman Machine VM, whose sparse disk image never shrinks on its own. lerd machine reclaim caps the guest journal, vacuums what it had already grown past, trims the filesystem and reports how much host disk came back, touching no container, image, volume or site data.

lerd machine reclaim
FEAT

62 round trips, one at a time #1792

The store publishes 62 definitions and every refresh pulled them sequentially, so a plain lerd framework update spent around fourteen seconds waiting. Eight at a time through a bounded pool now, with the shared transport keeping that many idle connections per host so each wave reuses what the last one opened. Around two seconds. --check keeps its sequential fetches, since a diff per definition only reads in order.

internal/store
FEAT

S3 from the binary, not a tool image #1815

Docker Hub stopped answering for minio/mc on every tag, and nothing says the registry it moved to keeps it either. The bucket a site gets when it links to rustfs, the Buckets panel and the S3 mirror in the Sail import all speak the same API, so they share one client compiled into lerd: no image to pull, no hop through the lerd network, and bucket handling that keeps working offline.

05

Updates

FIXFEAT

An update re-execs lerd install to reapply the infrastructure, and then did a second pass of what that install had already done. One update wrote every AI skill file twice over and bounced every daemon twice, which is exactly what the output showed and nobody had read closely enough to question.

WHO OWNS THE RESTARTinstall already did it, update did it again
lerd update
re-execs install
skills written, daemons bounced
…then update
again
skills written, daemons bounced
──►
install alone
once
and only what was running
The restarts belong to install now, which also covers the case update was there to catch — with autostart off, install used to leave the units alone; it restarts one that is already running and leaves a stopped one stopped.
FIX

A prerelease was never told about the next one #1757

The notice and the update both looked at the stable line alone, so a prerelease you had taken went quiet until the stable release of that cycle overtook it. The running version already says which line an install is on, so a prerelease is offered the next one, and the stable release of that cycle outranks the prereleases it supersedes, which is what ends the run.

internal/update
FEAT

Leaving stable is a choice, so it gets a switch #1757

update.beta in the config, lerd update:beta on and off at the terminal, and a card on the dashboard's Lerd page. With no argument the command reports where the install sits, and says so plainly when the running build is a prerelease and follows that line whatever the flag says.

FIX

Two commands, one install, opposite answers #1757

lerd whatsnew asked for the latest stable, so on a prerelease install it compared against the older stable, decided nothing was newer and said you were up to date, while lerd status seconds earlier offered the next prerelease. The command meant to explain the update was denying there was one. It resolves the target the same way status and update do.

FIX

A reinstall dropped HTTPS from every site #1735

A project's .lerd.yaml records whether the site is served over HTTPS and link never read it. Day to day that is invisible, because the registry carries the same flag; it surfaces the moment the registry is gone. After an uninstall and a fresh install every site came back as plain HTTP with https refusing outright. The file that survives the wipe is now the one that decides.

FIX

A notification deep link landed on the plain dashboard #1747

A session that starts on 127.0.0.1:7073 is moved to lerd.localhost as soon as nginx answers, and the handover pointed the browser at the vhost root and nothing else. The route of every deep link lives in the hash it threw away, so clicking a captured email focused the app on the dashboard rather than the message. The handover carries the hash across.

FIX

A host php in front of lerd's shim #1782

lerd puts its shims on PATH once, at install, so anything appending its own PHP below that line takes php back. What follows looks nothing like a PATH problem: the site's env names the service container, which is why the browser keeps working, but a host php cannot resolve that name, so every console command dies on the lookup. Doctor resolves php and composer the way a shell does now and says what leads.

06

Fixes

FIX

The fixed list covers bugs that shipped in 1.34.3 or earlier. Regressions introduced and fixed inside this cycle never reached anyone, so they are folded into the feature that carried them. The release closed on a full pass of the test plan across the distro guests and a Mac, which is where the first two came from, and they are the same defect wearing different clothes: a command returning before the thing it changed was actually in service.

REPORTED DONE, NOT YET SERVINGordered by how long the gap lasted
1
A PHP pool restart took the site down for up to a minute #1823 #1817

Restarting an FPM container gives it a new address on the lerd network, and nginx went on connecting to the previous one until its own timeout expired. The command returned a tick in about a third of a second and the next request hung for between thirty nine and sixty seconds before answering 502 or 504. Measured over ten toggles, about half failed that way. Nothing about it was specific to xdebug: php:ext, php:rebuild, php:ini and a runtime switch all recreate the container the same way. A restart now waits for the pool to accept and then drops the address nginx had resolved, and the vhosts bound fastcgi_connect_timeout to five seconds because nginx's default of sixty is what turned a brief miss into a minute.

2
A domain change was reported before every worker had it #1826 #1828

nginx -s reload signals the master and returns, and the master lets the previous generation of workers drain, so for a moment both generations are serving and a request can land on either. A domain just added answered 404 over http and failed TLS outright; one just removed went on answering. It came from the commands and from the Manage Domains modal alike, which has its own handlers. Both wait for the old generation to retire now, with a ceiling rather than a promise: a worker holding a long connection can outlast any budget, and failing the command over that would be worse than the window it closes.

3
A confirm with no terminal to read blocked instead of taking its default #1823

Reading stdin blindly does not return promptly on a pipe that stays open, it waits for a line that is never coming, so lerd doctor --fix --yes over ssh sat with the question on screen until it was killed. It takes the default now and says which way it went, which also means a destructive prompt piped from a script answers no rather than hanging.

4
A start queued behind a stop was given the wrong budget #1823

systemd does not run a start while a stop of the same unit is in flight, it queues it, and only the stop read the window the unit declares. On MySQL, which asks for seventy five seconds and uses most of them, any db command issued during a restart failed with a thirty second start timeout while the server came up a second after its turn arrived.

5
A pin was judged on what the project declares rather than what it installed #1823

Laravel 13 declares php: ^8.3 and then resolves Symfony 8 components that each require 8.4.1, so lerd isolate 8.3 was accepted on a fresh site and the first request answered 500. The floor composer records in vendor/composer/platform_check.php counts as a constraint now, alongside the framework range and the manifest.

FIX

Database commands blamed a SQLite file #1823

They drive a database service container and SQLite has none, so DB_DATABASE being a file path was handed to the service layer and rejected as an invalid database name. Laravel defaults to SQLite, so this was the out-of-the-box path. It answers about SQLite now, names the file it found and points at --service and lerd db:move.

FIX

lerd isolate took the site down #1733

Pinning a site to a version the machine had never built repointed it at a runtime that could not start: the switch wrote everything, reloaded nginx, and only then suggested php:rebuild. By that point the FPM unit was failing on an image reference resolving to nothing and the site was serving 502 behind a command that reported success. The image is built first now, through the same disclosed build the install path uses.

FIX

Worker mode accepted with no Octane #1734

FrankenPHP worker mode serves through artisan octane, so a project without the package had nothing to run, and the switch was taken anyway: recorded, reported successful, and the container exited on its first line because the octane namespace holds no commands. It refuses now, reading what composer installed rather than what the manifest names.

FIX

db:restore could not find the snapshot #1736

Creating a snapshot stamps its name with a timestamp so a label can be reused, and restore and delete looked the name up literally, so the obvious round trip failed moments after the snapshot was reported created. A label resolves to the most recent snapshot carrying it now, with an exact name still winning.

FIX

The app logs tab froze on what was there when it opened #1793 #1790

The tab has no stream of its own and refreshed only because every websocket snapshot invalidated the effect reading the site's domain off the prop, so stopping that churn left the list frozen. It polls every five seconds now, only while it is the selected source and not at all for a suspended site, swapping entries in without touching the loading flag or the reader's scroll position.

FIX

A dev server resolved plugins through the wrong module format #1742

The generated config imported the project's own, which handed the generated file's module format authority over how every plugin that config imports gets resolved. For most plugins the two builds are interchangeable; for one that is not, the dev server fails under lerd while the same command run by hand works. The wrapper hands the path to the tool's own config loader now.