Custom extensions and packages stop belonging to a PHP version and become one declared set applied to every image lerd builds, with a realised set read back off each build so an extension a version cannot carry is reported absent rather than falsely present. php.ini follows with a shared scope that fills the gaps under each version's own file, so a site changing version no longer silently loses a setting. The dashboard reaches all the way down to the data: an engine's databases are managed from its service page, with snapshots, export, import and one click into an admin tool, on the back of a single store-declared introspect query. lerd doctor stops merely reporting and learns to repair what it safely can, notifications grow a native desktop sink and a bell that keeps them, logs render in colour and hand themselves to a terminal, and git worktrees finally resolve their own PHP version, image, pin and database from wherever a command is run.
Extensions, packages and php.ini were all stored per PHP version, which quietly punished the one thing lerd makes easy. A site built under 8.4 with chromium dropped it the moment the site moved to 8.3, and a directive set on one version simply did not exist on the next. Both now sit at a scope above the version: one declared set of extensions and packages applied to every image lerd builds, and a shared 95-lerd-shared.ini mounted below each version's own file. Neither is a merge, and a per-version value still wins.
lerd php:ext add redis # declared for every version, not just the current one lerd php:ext list # the declared set, with per-version reality beside it lerd php:ini shared # the scope that fills the gaps under 98-lerd-user.ini
One declared set across every image, a realised set read back per build, lazy fingerprinted rebuilds, and one shared path for a version change so the CLI, dashboard and MCP stay in step. The dashboard gains a PHP Extensions tab.
internal/phpsetsconf.d loads alphabetically, so 95-lerd-shared.ini fills the gaps under the per-version 98-lerd-user.ini and a per-version key still wins. Additive, nothing merged, back-filled on the next quadlet write. An unknown directive on one version is a notice, not a fatal.
php:ext add failed for every extension on the default path: the fast path layers onto the runtime image, which carries no compilers, so pecl install stopped at phpize. Compilers now install, build and purge inside one layer. A || true per step had been absorbing the total failure as if it were a capability boundary.
Every database-level operation was reachable only from the CLI and the MCP db tool, so there was no way to see your databases at all. Each engine's detail page now opens on a Databases tab: what is actually inside the running container, as cards with their sizes, each one able to create, drop, export, import, snapshot, copy a connection string, or open in an installed admin tool. The one new capability underneath is introspection, and it is declared in the service preset as introspect.list_databases and run generically, so nothing in Go branches on the engine name and a new engine works as soon as its preset ships the query.
template1, so an empty database now reads as empty, matching what MySQL always reported.Create, drop, export to a plain SQL dump, import one, copy a per-database connection string, open an admin tool. Snapshots live on the card of the database they belong to, named ones stamped with a UTC timestamp so they never collide.
internal/ui · internal/serviceopsThe MCP db tool could create, export and snapshot but never list. A list action resolves the engine the way the snapshot actions do, so naming a service picks it and omitting it falls back to the project's own database.
psql exits 0 whether a dump loaded cleanly or every statement failed. Imports now count what the engine wrote and keep its errors in order, folding the COPY cascade that follows a missing table into one line so it cannot crowd out the cause.
os.RemoveAll on a real directory, with directory creation and arbitrary dump reads available the same way. Names are checked against a strict pattern now, at the HTTP boundary and inside the snapshot entry points, so MCP and CLI callers share one guard. The MySQL and MariaDB existence lookup also escaped the quote but not the backslash, and MySQL treats a backslash as an escape unless NO_BACKSLASH_ESCAPES is set, so a backslash escaped the doubled quote and the rest ran as a second statement. Separately the whole subtree answered the LAN with remote-control credentials, letting an authenticated client on the same network export a database in full or drop it; it now sits behind the same loopback gate as a site's raw .env, and the tab is not offered at all on a LAN-exposed dashboard. Both arrived with the Databases tab during the 1.30 betas and were fixed before this release, so no stable version carried either. #1008 #1082A service no site uses is auto-stopped, which is the normal state to press Migrate from, but the migrate went straight to dumping and failed on "no such container". It brings the unit up and waits on the family's readiness probe first.
The mysql-family migrate spelled its dump as a literal mysqldump, and the MariaDB images carry only the mariadb-named binaries, so a MariaDB migrate died on the dump before the data dir was touched. The three in-container commands resolve their tool at runtime now.
Doctor told you what was wrong and left the fixing to you. Every finding now carries a fix in one of three tiers, and the tier is the whole contract: auto is lerd's to run and needs no privilege, manual needs sudo so lerd prints the exact command and never runs it, and the rest is external state it leaves alone with a hint. Fixes confirm one at a time, the heavy ones confirm even under --yes, and --dry-run shows what would happen without touching anything.
create a missing directory, enable linger, install the network-online drop-in, rebuild a missing PHP image, repair the DNS wiring, and after confirmation the heavier reinstalls and disk reclaims #958
lerd prints the command and stops. Two fixes had been shelling into paths that write the resolver and the podman config through sudo, so under --yes an assistant could have rewritten systemd-resolved unattended #1010
state lerd does not own. The MCP diag tool gains a doctor_fix action and reads the same structured report through a new doctor --json, so the checks and their tiers live in one place
Every finding carries a tiered fix, with --fix, --yes and --dry-run, plus a doctor_fix MCP action over a new doctor --json.
The rungs treated 127.0.0.1 as the only acceptable answer, so a healthy lan:expose host where dnsmasq legitimately answers the LAN IP was reported broken while every site served fine.
On a host-proxy running Python, Ruby or Go there is no framework and no lockfile, so every check self-skipped and the modal opened empty. It keys on whether any check applies now, which still catches a Node host-proxy's package.json.
Notifications only reached browsers, so a user with no dashboard open and no push subscription got nothing, even though the daemon runs the whole time. lerd-ui can now post straight to org.freedesktop.Notifications with no new dependency, as a mutually exclusive sink so it never doubles up. The other half is that a notification raised while you were working inside lerd used to vanish: everything now lands in the page as a toast and in a sidebar bell that keeps the last fifty across reloads.
Chosen by notifications.target, unset resolving to browser so every upgrade is unchanged. Clicking one opens the Lerd desktop app through its lerd:// scheme, or an installed PWA, falling back to the dashboard.
Lives at lerd-env/lerd-desktop and ships as a Flatpak, served from lerd.sh so flatpak install --user https://lerd.sh/lerd.flatpakref resolves and flatpak update keeps it current.
Fifty entries with an unread count, each linking back to what it was about. A failure stays until dismissed and is shown even when notifications are muted, since an operation you started failing is not something to swallow.
Worker units restart themselves after five seconds, and the watcher's five second tick plus a five second batch put the notification right in the middle of the window systemd uses to fix the thing itself. A new failure is held for thirty seconds and health re-read before dispatch, so recovered workers drop out and the rest are described as they actually are. Arriving at a green dashboard teaches you to ignore the ones that matter.
Every call used the zero flag, so a wedged notification daemon blocked the caller forever: one stuck goroutine per event through the mailpit webhook and the service handlers, and a held lock that left a PHP version unable to be reinstalled without restarting lerd-ui. All calls give up after three seconds, and discovery no longer autostarts a dbus-daemon that outlives lerd over SSH.
Log panes have always been monochrome, and the renderer was never the reason. Workers and UI commands write to a pipe, a launchd file or the journal, so artisan, composer, vite and pest see no terminal and strip their own colour before lerd ever reads the line. One definition of the force-colour environment now travels with every path that starts a worker or a command, and NO_COLOR turns all of it back off. On the debug side, a test suite could flush every genuine dump out of the buffer, and the only lever was disabling the bridge entirely, before the run rather than after noticing.
podman logs, tail or journalctl, so a long tail survives closing the tab. Loopback only, like every other host-spawning action.Backgrounds, dim, italic, underline, the 256 colour cube and 24-bit colour, with carriage-return progress frames collapsed the way a terminal does instead of concatenating every frame npm emits.
internal/logcolorKeyed on PHPUnit's own bootstrap constant, so it holds for any framework and any runner built on it, set at all three capture seams. A checkbox brings them back and says how many are held back. Dumping inside a failing test is arguably when dd() earns its keep.
The devtools extension reads LERD_SITE and lerd php never passed it, so every query, job and event from a terminal command arrived unattributed while dumps quietly guessed one from the directory name.
| Debug view | Before | After | PR |
|---|---|---|---|
| Lens rendering | Entire filtered array painted at once, up to 10,000 events held and 3,000 replayed on connect | Newest 100 rows, next 100 on scroll, across all eight tabs and both the system window and per-site tab | #1039 |
| Stream updates | Every event rewrote the store and changed its identity, so one request's 7–18 events rebuilt every mounted lens | One update per animation frame; haystacks and SQL fingerprints cached weakly by event identity | #1052 |
| Measured | 13s blocking time, 5 fps (300 requests, Queries lens open) | No blocking time, steady 60 fps | Laravel site |
On systemd-resolved hosts the resolver refuses to answer anything once no link is routable, over glibc NSS as well as resolvectl, so a fresh .test lookup failed with no network at all even though lerd-dns kept answering on loopback. The tell was a site that stayed reachable while the browser held a cached answer and failed the moment it was reopened. lerd now owns an always-up dummy link carrying the routing domain, so resolved keeps forwarding .test to lerd-dns offline.
An always-up lerd0 dummy link carries the routing domain. It removes the global drop-in it supersedes rather than leaving a catch-all that hung every ordinary offline lookup on an upstream that was not there, and it is best effort: a host that cannot carry it loses offline resolution and nothing else #936
The watcher logged "DNS resolution broken" every thirty seconds without being able to act, because its repair only rewrote the host resolver, which cannot help when the daemon it points at is gone. It asks dnsmasq directly now and restarts the unit, ahead of the privilege gate. The dispatcher's restart-per-interface also blew the default start rate limit on a wake and parked the unit in failed #1088
Uninstall left every root-owned piece behind and said nothing: the link unit stayed enabled so every boot recreated lerd0 pointing at a dnsmasq that no longer existed, FallbackDNS stayed empty for good, and a passwordless sudoers grant survived the tool it was for. Once the binary was gone nothing could undo any of it #1022
lerd0 and later lost it kept the drop-in from the run that worked, leaving fallbacks off and no link, which is precisely the state the guard exists to prevent. The failure path hands the fallback servers back now.Setting client_max_body_size in the global http override made nginx refuse the whole config with "directive is duplicate", since the file is included inside http {} where lerd already sets it and nginx fails on a repeated simple directive rather than letting the later one win. The render now yields the field for any directive the override declares, name-driven rather than a list of special cases.
log_format and access_log are repeatable and nginx is happy to carry both, so commenting lerd's log_format out left its access_log naming a format nginx no longer knew. The config check failed and every site went down. Both are exempt now, which also keeps the access feed that idle-suspend and request timing read.
A git worktree checked out inside its parent site's directory matches that site by path prefix, and four separate lookups stopped there. Each failure was invisible on its own and the combination was worse: the browser served the branch from one PHP version and one image while the terminal in the same directory ran another. All four now resolve the checkout the same way, and the rules live in one place that the CLI and the MCP server both delegate to.
| Lookup | What it resolved to | The symptom | PR |
|---|---|---|---|
| PHP version | Parent's registry version, while the vhost was generated from the worktree's pin | Browser served the branch on one version, lerd php and lerd composer ran another | #999 |
| Container | Shared per-version container for a sibling checkout | A site running its own image from a project Containerfile had nothing that image adds present in the branch | #1001 |
| Version pin | Parent site root | Declining an install and picking another version wrote .php-version at the parent and left the branch with no pin at all | #1021 |
| Database shim | Parent's DB_HOST | A branch with an isolated database could be dumped from the parent's data; a branch on a different engine hit the wrong container | #1024 |
Five background costs ran at full rate whether or not anyone was looking, and on macOS each one crosses into the podman VM, which is where the cost actually lands. None of this is visible as a feature; it is the difference between a daemon you leave running and one you notice.
| What | Was | Now | Measured | PR |
|---|---|---|---|---|
| Worker health | One launchctl fork per unit every 5s, forever | 60s while nothing is visible, 5s while a tab is | −83% idle CPU (25 workers) | #1073 |
| Stats poll | 5s poll against a 3s cache, so every poll missed and paid a 2s podman stats stream | One TTL named beside the data, honoured by dashboard and TUI alike | 31% → 17% of wall clock | #1074 |
| Container cache | Polled podman ps forever after lerd stop, for containers deliberately down | Settles on the post-teardown state, then quiet until the marker clears | 7 polls → 1 (150s window) | #1079 |
| Unit-state sweep | One podman inspect subprocess per unit, cold from every CLI and MCP call | One container snapshot up front, every unit answered from it | 930ms → 155ms (MCP, 25 workers) | #1075 |
| Reload watcher | chokidar re-stat'ing every watched file 10× a second across virtiofs | Once a second, doubling on battery and again in low power mode | ~30% → 11.8% host VM (reported at 161%) | #1078 |
platform_profile exists only where the ACPI driver does, so on an AMD desktop or an older Intel laptop the low-power tier was unreachable no matter what the user selected.There was no way to see where lerd-ui spends its time on a user's machine, so every CPU report above was an inference until it was a measurement. The standard Go endpoints now sit behind two gates: a marker file read per request, since the process worth profiling is usually one misbehaving right now and restarting it would discard the state being chased, and loopback, since these hand out goroutine stacks and heap contents. Both answer 404 rather than 403.
The services list had grown long once a project pulled in databases, caches, search and mail side by side, and a service a project asks for but never had looked identical to one that is simply stopped. Both are driven by the preset, not a name in Go: grouping reads each service's declared category, and the install offer resolves the bundled preset that matches.
Labelled sections in the same order the discovery grid uses, per-site worker groups still listed below. Driven entirely by each service's category as resolved from its preset.
A service in .lerd.yaml that was never installed fell back to "Stopped" and clicked through to nothing. It shows an amber Not installed and offers to install the preset inline. Reinstalling a removed built-in like redis works again too.
A host-proxy site's dev server is its whole runtime. The restart waits for the unit to go inactive and the port to come free, because a server that drains queues on shutdown holds its port and re-execing gets the app killed by address already in use.
phpantom_lsp 0.9.0 colours each token from what it resolved to rather than from the static PHP grammar, so a resolved method reads differently from an unresolved one and framework magic is coloured like the symbol behind it.
bubbletea's new renderer redraws differentially, adds synchronized updates that stop the flicker, and handles wide characters and emoji correctly. lipgloss dropped render-time adaptive colour, so the palette starts dark and flips once the terminal background is reported.
internal/tuiThree stacked buttons labelled L, A and D became a single button showing the mode in effect. On auto the tooltip names what the system resolved to, since the icon alone cannot say whether the page is currently light or dark.
The long tail: a missing directory that took every site down, a crash-looping agent, an install that hung with no output, and a round of translation work that had drifted without anything looking wrong from the outside.
| Fix | What was happening | PR |
|---|---|---|
| A missing bind source took down every site | Podman refuses to start a container whose bind source is gone, so a project subdirectory removed by a branch checkout aborted the start, and because nginx serves every site nothing was reachable. A start-time preflight sweeps the quadlets and names the path and the site it belongs to | #1084 |
Projects outside $HOME were unreachable | The mount was written into the quadlets but the containers kept the mounts they booted with, and the rewrite that would have restarted them diffed against the file it had just written, found no change, and did nothing. Both ask the running container what it is actually mounted now | #1054 |
| The shared ssh-agent crash-looped | ssh-agent refuses to bind a socket that exists and only unlinks it on SIGINT/SIGTERM/SIGHUP, never on the image's SIGQUIT, so the socket survived every stop and every start failed instantly under Restart=always | #1067 |
pest:browser install hung forever | Playwright's Node zip extractor deadlocks writing into the bind-mounted cache inside the FPM container: every archive downloaded to 100% then sat there with no error and no timeout. lerd fetches with curl and unpacks with the image's unzip, keeping Playwright's mirror fallback and registry link | #1009 |
| Idle-suspend slept proxy-only sites | There is no process to stop for a site that only forwards to a dev server you start yourself, so suspending achieved nothing while still presenting it as asleep. lerd idle status reads "proxy only" now | #1086 |
| Every slow route offered to profile itself | SPX ships in the FPM image, so on FrankenPHP, a custom container, a host-proxy or a static site the click armed the profiler and left you in front of one that would never receive a capture | #1060 |
| Unlink left request and idle state behind | Unlink tore down the vhost, certs, workers and registry entry but left the per-site state the watcher writes, so an unlinked site kept its snapshot entries and recorded requests indefinitely. Nothing broke; it was unbounded growth | #948 |
cleanup left several GB unreclaimed | Only ledger-recorded images counted as reclaimable, but a service quadlet starting for the first time has podman auto-pull without going through lerd's wrapper. Cleanup reported tens of MiB while GB of old service versions sat there, visible only to podman system df | #981 |
| Values reached the browser unescaped | Translated messages render through {@html} and paraglide compiles to a template literal that escapes nothing, so the remote-control username, LAN address, setup code and configured TLD all travelled verbatim | #1082 |
| The dashboard rendered English anyway | Every failed API call fell back to a raw English string, so a non-English user hit English the moment anything went wrong. Key parity held so nothing looked wrong from outside, but a large number of values still sat on their English source text across all thirteen non-English catalogs | #1081 |
mounts key #954, lerd php refuses an unreachable working directory instead of leaking a crun chdir error #954, the auto theme follows a live system light/dark switch #971, and the dashboard cards fill a tall window instead of leaving a band of dead space under the second row #983.