Lerd local PHP development environment — use the lerd MCP tools to manage sites, services, workers, and PHP/Node runtimes, and to diagnose slow sites (N+1 and slow queries, request timing, slow routes, profiling) from real traffic.
php artisan boost:add-skill liberusoftware/automation-laravel
Save this content to: .cursor/rules/lerd.mdc
## Lerd, a local PHP development environment
This project runs on **lerd**, a Podman-based PHP development environment. It is framework-agnostic: Laravel, Symfony, WordPress, Drupal, Magento, CakePHP and any custom framework are all driven by a framework definition (YAML), never by lerd hardcoding a framework's name. The `lerd` MCP server is available — use it to manage the environment without leaving the chat.
The MCP surface is **twelve grouped tools**, each driven by an `action` argument: `site`, `service`, `db`, `env`, `runtime`, `worker`, `exec`, `framework`, `diag`, `logs`, `worktree`, `workspace`. Always pass `action`. Most actions also accept an optional `path` that defaults to the directory the assistant was opened in (then `LERD_SITE_PATH` if set), so you can usually omit it. Start by calling `site` with `action: "list"` to discover sites.
### Architecture
- PHP runs in Podman containers named `lerd-php<version>-fpm` (e.g. `lerd-php84-fpm`); each container includes composer and node/npm; the PHP version is resolved from `.lerd.yaml` → `.php-version` → `composer.json` `require.php` constraint (matched against installed versions) → global default
- Nginx routes `*.test` domains to the correct PHP-FPM container
- Services (MySQL, Redis, PostgreSQL, etc.) and custom services run as Podman containers via systemd quadlets
- Node.js versions run through a version manager, fnm (bundled, fetched on demand) or user nvm (declining managed Node picks nvm; `node.manager` + `node.nvm_dir`; `node:manager`/dashboard; nvm keeps PATH, fnm uses shims); per-project version via a `.node-version` file. The **package manager** is the project's, not lerd's: a `packageManager` pin in `package.json` wins, then the lockfile (`pnpm-lock.yaml` → pnpm, `yarn.lock` → yarn, `bun.lock*` → bun, else npm). pnpm and yarn run through corepack, and installs use the manager's frozen-lockfile mode (`pnpm install --frozen-lockfile`, `yarn install --immutable`, `npm ci`). Never assume `npm run dev`/`npm ci` — the worker command and the setup steps follow the detected manager
- Framework workers (queue, schedule, reverb, horizon, messenger, vite, etc.) run as systemd user services named `lerd-<worker>-<sitename>`; commands are defined per-framework in YAML; Laravel Horizon is auto-detected from `composer.json` and replaces the queue toggle when installed; Laravel ships with a `vite` host worker that runs the project's dev script on the host for HMR, through whichever package manager the project pins; workers and setup commands support optional `check` (`file` or `composer`) for conditional visibility; workers with `conflicts_with` auto-stop conflicting workers on start. Per-worker flags: `host: true` (run on host via the version manager instead of in FPM container — HMR-sensitive Node tools), `per_worktree: true` (worker runs independently per worktree under `lerd-<worker>-<site>-<branch>`), `replaces_build: true` (worker provides asset manifest while running, so a worktree add skips the static `npm run build` step when this worker is opted in)
- Custom workers can be added per-project (`.lerd.yaml` `custom_workers`) or globally (`~/.config/lerd/frameworks/<name>.yaml`); use the `worker` tool's `add`/`remove` actions — both survive framework store updates
- Framework setup commands (one-off bootstrap steps like migrations, storage links) are defined in the framework YAML and shown by `framework` `action: "setup"`; Laravel has built-in storage:link/migrate/db:seed; custom frameworks can define their own
- Service version placeholders (`{{mysql_version}}`, `{{postgres_version}}`, `{{redis_version}}`, `{{meilisearch_version}}`) are available in framework env vars and resolved from the service image tag at env-setup time
- **Custom containers**: non-PHP sites (Node.js, Python, Go, etc.) can define a `Containerfile.lerd` and a `container:` section in `.lerd.yaml` with a port; lerd builds a per-project image, runs it as `lerd-custom-<sitename>`, and nginx reverse-proxies to it; the project directory is volume-mounted at its host path with `--workdir` set automatically — do NOT add `WORKDIR` or `COPY` to the Containerfile; workers exec into the custom container; services are accessible by name on the shared `lerd` Podman network; **hot-reload file watchers must use polling on macOS** (inotify does not fire across Podman Machine's virtiofs mount) — nodemon: `--legacy-watch`, Vite: `server.watch.usePolling: true`, webpack: `watchOptions: { poll: 1000 }`
- **Custom-image PHP sites (custom-FPM)**: a PHP project can define a `Containerfile.lerd` (must build `FROM lerd-php<ver>-fpm:local`) plus a `container:` section with **no port**; lerd builds a per-site image (`lerd-custom-<site>:local`), runs a dedicated FPM container `lerd-cfpm-<site>`, and serves it by fastcgi instead of the shared `lerd-php<ver>-fpm`. It is a normal PHP site otherwise (xdebug, dumps, profiler, `lerd shell`, `php`/`artisan`/`composer`/`tinker`, queue/horizon all run in the per-site container). The PHP version is fixed by the `FROM` line (the UI version selector is read-only); `lerd rebuild` rebuilds the image. Same key as custom containers, the port is the discriminator: with a port it is a reverse-proxied non-PHP app, without a port it is a fastcgi PHP image. `runtime` for these reports `fpm-custom`.
- Git worktrees automatically get a `<branch>.<site>.test` subdomain (deep `*.<branch>.<site>.test` wildcard cert + nginx `server_name` on secured sites); `vendor/`, `node_modules/`, `.env` are seeded from the main checkout. `.lerd.yaml` `env_overrides` declares templated env vars (`{{domain}}`, `{{scheme}}`, `{{site}}`) layered on the default `APP_URL` rewrite — for multi-tenant apps (per-branch cookies, signed-URL hosts, tenant routing)
### DNS modes
Lerd has two install-time DNS modes recorded in `~/.config/lerd/config.yaml`:
- **Managed (default)**: `dns.enabled: true`, `dns.tld: test`. Sites at `*.test` via lerd-dns + mkcert; `site` `tls_enable` works.
- **Disabled**: `dns.enabled: false`, `dns.tld: localhost`. Sites at `*.localhost` via RFC 6761; no mkcert CA, TLS toggling unavailable.
Read `diag` `action: "status"` for `dns.tld` and `dns.enabled` instead of assuming `.test`; do not propose `tls_enable` when `dns.enabled` is false.
### MCP tools
Twelve grouped tools, each selecting behaviour via `action`.
#### `site` — sites and their configuration
Actions: `list` (discover sites — CALL FIRST), `link`, `unlink`, `domain_add`, `domain_remove`, `group_assign`, `group_unassign`, `group_label`, `group_db`, `group_list`, `tls_enable`, `tls_disable`, `tls_renew`, `php`, `node`, `pause`, `unpause`, `restart`, `rebuild`, `runtime`, `nginx_read`, `nginx_write`, `nginx_reset`, `park`, `unpark`.
- `link` registers a directory; non-PHP sites need `.lerd.yaml` `container.port` + a Containerfile first, or they register as PHP (wrong)
- `link` runs `lerd link` and returns its output verbatim, so read the reply. It will NOT start a `proxy.command` dev server (`command not approved`); ask the user to run `lerd link --yes`
- `domain_*` take a domain without the `.test` TLD; you can't remove the last domain
- `group_*` nest a secondary site under a main's subdomain (one level deep): they identify the secondary by `path` (defaults to cwd), not by `site`; `group_assign` with `main` + `label` (+ optional `share_db`), `group_db` = share|separate
- a group secondary follows its main's HTTPS: `group_assign` under a secured main secures the secondary, `tls_enable` on a main secures its secondaries too, and `tls_disable` on a secondary is refused while its main is secured (the main's `*.<main>` wildcard would answer the subdomain and serve the main's app). Disable the main's TLS first
- certificates renew themselves before they expire; `tls_renew` forces it by hand for one site
- `php`/`node` take `version`; pass `branch` to pin the override on a worktree's checkout
- `runtime` switches `fpm` ↔ `frankenphp` (`worker: true` enables frankenphp worker mode)
- `nginx_write` saves a custom override (runs `nginx -t`, backs up, reloads); `branch` targets a worktree
- `park` registers a parent dir and auto-registers every PHP project under it; `unpark` reverses it (project files kept)
#### `service` — built-in & custom services
Actions: `start`, `stop`, `restart`, `pin`, `unpin`, `update`, `rollback`, `migrate`, `remove`, `reinstall`, `add`, `expose`, `port`, `env`, `config_read`, `config_write`, `config_restore`, `config_reset`, `config_list_backups`, `preset_list`, `preset_search`, `preset_install`, `check_updates`, `entities`, `entity_action`.
- `update` pulls a newer image (safe, in-strategy); `migrate` dumps + restores across a cross-strategy upgrade; `reinstall` with `reset_data: true` wipes data and reprovisions; `remove` with `remove_data: true` renames the data dir aside
- `stop` marks the service paused — `lerd start` skips it until started again; `pin` keeps it always running
- `add` registers a custom OCI service (`depends_on` wires dependencies, `init: true` for mysql/mariadb); prefer `preset_install` for anything in `preset_list` (phpmyadmin, pgadmin, mongo, mongo-express, selenium, stripe-mock, mysql, mariadb…)
- `preset_list` returns the installable presets with the metadata each one declares: `category` (the discovery heading), `icon`, and `admin_for` — the services this preset's admin UI administers, which is **not** `depends_on`. phpMyAdmin depends on mysql but administers mariadb too, and RedisInsight administers valkey without depending on it. To answer "which dashboard administers this database", read `admin_for`, not `depends_on`. `preset_search` queries the store by `name` for presets that are not bundled locally
- `env` returns the recommended `.env` connection keys; `expose` publishes an extra `host:container` port
- `port` moves the service's primary published host port (`published_port`, or `reset: true` for the default); it stays bound to 127.0.0.1, the container-internal port is unchanged, and a host-proxy site that points at the old port is realigned automatically
- `entities` lists what a service holds that is not a database: the kinds its preset declares (RustFS buckets today, more later), each kind's rows and the actions it supports. Databases have their own tool, so they are not repeated here. `entity_action` runs one of those declared actions (`kind`, `entity`, `entity_action`); export and import stream a file and stay on the CLI and the dashboard
- `config_*` read/write/restore/reset a service's runtime tuning override
#### `db` — databases
Actions: `list`, `set`, `move`, `create`, `export`, `import`, `snapshot`, `snapshots`, `restore`, `snapshot_delete`, `extension_list`, `extension_add`.
- `list` reports an engine's databases with sizes; `service` picks the engine, else it resolves from the project. No introspect command, nothing to report
- `set` picks the project DB (`database`: sqlite, mysql, postgres, or a family alternate like mariadb / postgres-pgvector / postgres-timescaledb / mysql-5-7); persists to `.lerd.yaml`, rewrites `DB_` keys, starts the service, creates the DB + `_testing`
- `move` migrates sites between two installed same-family services (`from`/`to`, `sites: [...]` or `all: true`) and repoints each `.env`; source data is left intact
- `create`/`export`/`import` auto-detect service and database; pass `service` to override. `import` drops a hosted provider's ownership/DEFINER statements (which can never apply here) and creates any extension the dump's types need; pass `fresh: true` to empty the database first so a dump replaces what is there instead of colliding with it. What an engine can list and act on is declared in its preset, so this is not a mysql/postgres-only set
- `extension_list`/`extension_add` are postgres-only. An `import` already creates whatever extension the dump's types reach for, so use these to see what the engine offers and what the database has, or to add one (`extension: postgis`) before any dump arrives
- `snapshot`/`snapshots`/`restore`/`snapshot_delete` are named, restorable snapshots (MySQL/MariaDB/PostgreSQL); `restore` is destructive; `all_databases` covers the whole service
#### `env` — .env management
Actions: `setup`, `check`, `override`.
- `setup` configures services, DBs, APP_KEY and APP_URL; on a fresh Laravel clone call `db` `set` first to move off sqlite, then `env setup`, then ALWAYS `framework setup` or migrations never run
- `check` compares `.env` against `.env.example`
- `override` manages the personal, gitignored `.env.lerd_override` (its `set` KEY=VALUE win over lerd defaults; `LERD_EXTERNAL_SERVICES=<svc,svc>` marks vars lerd writes but won't start)
#### `runtime` — PHP/Node versions & extensions
Actions: `versions`, `node_install`, `node_uninstall`, `node_manager`, `php_list`, `ext_list`, `ext_add`, `ext_remove`, `ports_list`, `ports_add`, `ports_remove`, `ini_read`, `ini_write`, `ini_reset`.
- `ext_add`/`ext_remove` change one declared set applying to EVERY PHP version, so a site keeps its extensions across a version change. They rebuild one version's FPM container now (slow); others rebuild on next use. `ext_add` accepts `apk_deps` for extra Alpine build packages
- `ext_list` reports the declared set plus, per version: has it, predates the set (rebuild fixes), or cannot load it (rebuild won't). Never assume a declared ext is present: `mongodb` needs 8.1+, 7.4/8.0 are Alpine 3.16
- `node_manager` with no argument reports the version manager lerd drives, whether nvm is present and whether lerd manages Node at all; with `manager: fnm|nvm` it switches, which also rewrites the PATH shims and regenerates host workers
- `php_list` sets `base_update` when the published base image a version was built from has been republished (an upstream PHP or Alpine fix); `lerd php:rebuild <version>` picks it up
- `ports_add`/`ports_remove`/`ports_list` publish extra host ports on a PHP version's shell (FPM) container so a process started in `lerd shell` is reachable at `localhost:PORT`. `ports_add` takes `host` and optional `container` (defaults to `host`); a busy host port shifts to the next free one. Per version and independent, loopback-bound (follows `lan:expose`), restarts that version's FPM. Prefer host-proxy or a worker+proxy for a single site. CLI: `lerd php:ports add/remove/list [--php version]`
- `ini_read`/`ini_write`/`ini_reset` edit php.ini: pass `version` for a per-version file, or `shared: true` for the shared file applied to every version. The shared file loads below the per-version one, so a per-version key still wins and an unknown key on some version is ignored (not fatal). Prefer shared for a setting you want everywhere, so a version change never drops it. `ini_write` takes full `content`, backs up, and restarts the affected FPM containers. CLI: `lerd php:ini [version|shared]`
- **extra Alpine packages**: `lerd php:pkg add/remove/list <packages>` (CLI) bakes runtime apk packages (CLI tools, libs) into every FPM image, saved in config under `php.packages` and re-applied on every rebuild, so they survive `php:rebuild` and base image updates. One declared set applies to every PHP version. Layered onto the shared image, not the published base.
- **Pest browser testing (CLI-only)**: `lerd pest:browser install|doctor|remove [version]` sets up `pestphp/pest-plugin-browser` inside the FPM container by baking Alpine's musl chromium into the images. Chromium only, and current PHP versions only (the 7.4/8.0 legacy tier is rejected). Needs the `playwright` npm package first; re-run install after bumping it. Tests then run through the normal `lerd test`/`lerd pest`.
- **bun**: lerd never installs or version-manages bun. On the host, JS install/dev/build run through bun when the project is a bun project (`bun.lockb`/`bun.lock`/`bunfig.toml`/`packageManager: bun`) or when Node is unmanaged, no system Node exists, and bun is present. CLI-only: `lerd node:manage`/`node:unmanage` opt in or out of lerd-managed Node (unmanage drops fnm versions, never a user's nvm ones), `lerd js:runtime [bun|node|auto]` pins one site's runtime, and `lerd php:bun install|update|version` manages an in-container bun for `lerd shell`. These are host operations, not container exec actions.
#### `worker` — background workers
Actions: `list` (CALL FIRST), `start`, `stop`, `add`, `remove`, `health`, `heal`, `mode_get`, `mode_set`, and the framework workers `queue_start`, `queue_stop`, `horizon_start`, `horizon_stop`, `reverb_start`, `reverb_stop`, `schedule_start`, `schedule_stop`, `stripe_start`, `stripe_stop`, `stripe_config`.
- call `list` to discover a site's workers before `start`; pass `branch` to target a per-worktree unit
- use `horizon_*` instead of `queue_*` when laravel/horizon is installed (mutually exclusive); `queue_start` needs Redis running when `QUEUE_CONNECTION=redis`
- `add` saves a custom worker to `.lerd.yaml` (or the user overlay with `global: true`); does not auto-start
- `health` reports unhealthy units (read-only); `heal` resets and restarts them (`unit` for one, omit for all); `mode_get` reports the macOS worker runtime, `mode_set` switches it (`mode`: exec|container)
- a worker's health `state` is one of `failed` (the unit died), `expected-but-stopped` (it should be running and isn't), or `unreachable` — the unit is happily active but the server it publishes no longer accepts connections, a dev server that wedged without exiting. All three are heal-able; `heal` restarts the unit. Per-worktree units (`lerd-<worker>-<site>-<branch>`) are covered by the same pass, so a dead per-worktree Vite heals like any other. A worker with a `schedule` is a oneshot driven by a timer and is idle between ticks by design, which is healthy
- Stripe secret is read from `.env` (STRIPE_SECRET / STRIPE_SECRET_KEY / STRIPE_API_KEY); `stripe_config` sets webhook_path / secret_env_key in `.lerd.yaml`
- **Auto-reload (CLI-only)**: `lerd horizon:reload [on|off]` and `lerd octane:reload [on|off]` (FrankenPHP worker mode) restart workers on file changes; both need the project's `chokidar` npm package
- **Idle-suspend (CLI-only)**: `lerd idle on/off` toggles activity-driven suspension globally; suspended workers stop after the idle timeout (`lerd idle timeout <dur>`) and resume on the next request/CLI/MCP/file-save. `lerd idle pin/unpin <site>` exempts a site; `lerd idle status` reports policy and last-active. A worker shown as suspended is healthy, not failed, so do not `heal` it
#### `exec` — run tooling in the PHP-FPM container
Actions: `artisan` (Laravel), `console` (other frameworks), `composer`, `vendor_bins`, `vendor_run`, `commands_list`, `commands_run`, `command_add`, `command_remove`.
- `artisan`/`console`/`composer` take `args` (array); tinker must use `--execute=<code>` for non-interactive use
- `vendor_run` is the right way to run project tooling (pest, phpunit, pint, phpstan, rector) — call `vendor_bins` first to discover what's installed, then `vendor_run` with `bin` + `args`; prefer it over `composer exec`
- `commands_*`/`command_*` list, run, add and remove the on-demand commands in a site's `.lerd.yaml` `commands:` block; `commands_run` needs `force: true` for confirm-gated commands
- **composer over git SSH (CLI-only)**: when `composer` needs a private repo reachable only over SSH, `lerd auth ssh` starts a shared ssh-agent container and loads the host's `~/.ssh/id_*` (or named keys) so passphrase-protected keys work in the FPM container; `lerd auth ssh --list` shows loaded keys, `--remove` flushes them. Keys live only in agent memory and clear on machine restart
#### `framework` — framework definitions & scaffolding
Actions: `list`, `add`, `remove`, `prune`, `search`, `update`, `project_new`, `setup`.
- `add` with `name: "laravel"` merges custom workers/setup into the built-in framework
- `remove` refuses to drop a definition a linked site still uses (pass `force: true` to override); `prune` removes every framework definition no site uses
- `search`/`update` use the community store; definitions auto-fetch on link, so `update` is the manual refresh (no `name` refreshes the catalogue and all installed definitions; with `name` it fetches that one, auto-detecting version from `composer.lock`)
- `project_new` scaffolds a new project (requires absolute `path`, default framework laravel); follow with `site` `link` + `env` `setup`
- `setup` runs the framework's post-install steps (migrations, storage:link…) — MANDATORY after `env setup` on new/cloned projects; idempotent
#### `diag` — diagnostics & observability
Actions: `status`, `doctor`, `doctor_fix`, `site_doctor`, `which`, `check`, `dns_diagnose`, `bug_report`, `analyze_queries`, `route_timing`, `optimize_route`, `dumps_recent`, `dumps_status`, `dumps_clear`, `dumps_toggle`, `profiler_toggle`, `profiler_status`, `profiler_clear`, `profiler_report`, `xdebug_on`, `xdebug_off`, `xdebug_status`.
- `status` (DNS/nginx/FPM/watcher/tools health) and `doctor` (JSON findings, each tagged with a fix tier) are the first stops when something is broken; `dns_diagnose` walks the DNS chain
- `doctor_fix` applies the safe (non-heavy, non-sudo) automatic repairs for environment findings; package installs, `lerd install`, and `lerd cleanup` stay manual
- `site_doctor` runs framework-agnostic app-level checks for one site (env file, env drift, app key, composer/node dependency install + lock, `composer audit`/`npm audit`, PHP range, a `slow_routes` warning for routes whose p95 runs well above the site's typical time or over a second, plus the framework's own checks); pass `site` (name or domain) or `path`, defaults to cwd. A failing check carries a `severity` and, when one applies, a `fix` naming the command that resolves it — run that yourself (`exec`, or the named lerd command); site_doctor itself is read-only. `slow_routes` is the exception: it reads the watcher's request-timing snapshot and has no command fix, the remedy is to profile the route (`profiler_toggle`)
- reading logs lives in the `logs` tool (below), not here
- `which` shows resolved PHP/Node/docroot/nginx for a site; `check` validates `.lerd.yaml`
- debug bridge loop: `dumps_toggle` (enable) → `dumps_clear` → hit the page → `analyze_queries` (N+1 / slow-query report with file:line) or `dumps_recent` (filter by site/branch/ctx/kind/since/limit)
- `route_timing` returns the per-site response-time table: the typical (median) time and the routes whose p95 runs well above it (method, example path, p95, multiplier, samples), read from the watcher's request-timing snapshot. Works from recorded traffic alone, no capture needed. `site` accepts either the site name or its domain, as do `analyze_queries`, `optimize_route`, and `dumps_recent`
- `optimize_route` is the join: each slow route paired with the N+1 and slow-query findings captured against that same route (with the caller file:line), so you get the symptom and its cause in one call. Needs the query capture on (`dumps_toggle` enable) plus a few real hits. When the SPX profiler was on for the route's traffic, each slow route also carries a `profile` block, the top functions by exclusive wall time from the freshest capture, distilled to a few outliers (not the raw trace), so a CPU-bound route shows where its time went next to its queries
- **optimizing a slow site**: don't read controllers to guess at N+1s, drive it from real traffic. `route_timing` to see which routes are slow → `dumps_toggle` (enable) and `profiler_toggle` (enable) → hit the slow route a few times → `optimize_route` to get its N+1/slow queries with file:line and, from the profiler, the top CPU functions behind it → fix the caller (eager-load, index, cache) or the hot function
- **stay ahead of regressions**: when you change request handling or database code on a PHP site, verify you did not introduce a slowdown before moving on, enable dumps, hit the affected route, run `optimize_route`, and treat a new N+1 or a `slow_routes` doctor warning as work to finish rather than noise. On an unfamiliar site, `route_timing` shows the slow routes worth attention first. The timing panel is a live signal (recent traffic only, in memory), so a route you fixed clears once it stops being slow, but the durable catch is the `slow_route` push notification that fires the moment a route crosses into slow. Periodic checking belongs to the user's own scheduler (a routine, a cron job), not to lerd.
- `profiler_*` toggle the global SPX profiler and surface the flame-graph UI; `profiler_report` (site + `args`, e.g. `["artisan","app:heavy-report"]`) runs that command under SPX and returns a text flat profile, the top functions by wall time and call count, the CPU-bound analog of `analyze_queries` for when a slow route's cost is not in its queries (a reproducible CLI command, not a live HTTP request); `xdebug_*` control Xdebug on port 9003 (`mode` defaults to debug)
- `bug_report` writes an anonymised diagnostic report for a GitHub issue
- **disk cleanup (CLI-only)**: `lerd cleanup` reclaims podman disk from orphaned lerd images (`--dry-run` to preview, `--deep` for the aggressive tier); a daily safe-tier sweep plus post-rebuild/service-change reaping runs automatically, toggled with `lerd cleanup auto on|off|status`
#### `logs` — read logs from any source, filtered
Actions: `sources`, `fetch`. Debug without opening files by hand.
- `sources` lists every queryable source for the site plus shared infra: `app:<file>` (framework log files), `fpm`, `worker:<name>` (queue/horizon/schedule/custom), and the globals `nginx`, `dns`, `watcher`, `ui`, services, `php<ver>`. Call it first to learn the names
- `fetch source=<name>` reads one source. Filter with `grep` (regex, falls back to literal substring), `since`/`until` (relative like `15m`/`1h`/`2h30m`, or a timestamp), `level` (app logs only: error/warning/info/debug), and `lines` (default 50)
- streaming is polling: every `fetch` returns an opaque `cursor`; call again with `since=<cursor>` (or `cursor=<cursor>`) to get only the new lines. The cursor format differs per backend, so treat it as opaque and echo it back
- entries come back chronological (oldest first). Raw logs with no timestamps ignore `since`/`level` and just return the last N; a not-running container returns partial output, not an error
#### `worktree` — git worktrees
Actions: `list`, `add`, `remove`, `wait`, `db_isolate`, `db_share`.
- `add` installs deps and offers an asset-worker / build-step prompt; secured sites get `*.<branch>.<site>.test` wildcard cert SANs + nginx `server_name` automatically. It waits for setup and reports `provisioned` (`false` + note means still running, not failed; `timeout_seconds` default 300)
- `wait` is that readiness check alone, for a worktree made with plain `git worktree add`. **Never** judge readiness from the tree: `node_modules/` exists from the first extracted package and composer fills *existing* `vendor/<org>/` dirs, so both read as finished mid-install, and racing the watcher is how `vendor/` ends up with no `autoload.php`
- `db_isolate` gives a worktree its own database (seed via `source`: empty|main|<branch>); `db_share` points it back at the main; `remove` keeps an isolated DB unless `keep_db: false`
- a framework definition can declare what its worktrees need (an isolated database, what it is cloned from, console commands to run once it is in place), so `add` does that work rather than leaving it to be run by hand
- request timing is recorded per worktree; pass `branch` to `route_timing`, `optimize_route` and `dumps_recent` to read one branch's traffic
#### `workspace` — group sites for display
Actions: `list`, `create`, `rename`, `delete`, `assign`, `move`.
- a workspace is a **display-only** bucket of sites, shown in the dashboard sidebar and the TUI. It never touches nginx, domains, certificates or `.env`. This is not the same thing as the `site` tool's `group_*` actions, which nest a real site under another's subdomain and regenerate vhosts and certs — reach for `group_*` when a site should be served at `<label>.<main>.test`, and for `workspace` when the user just wants their site list organised
- `assign` takes `sites` (names or domains) and a `workspace`, creating it if new; `workspace: "none"` ungroups them. `move` reorders a workspace with a zero-based `position`
- `delete` drops the workspace and ungroups its members; no site is touched
### Key conventions
- Pass `action` on every tool; `path` is optional on most and defaults to the directory the assistant was opened in
- Discover before acting: `site` `list` for sites, `worker` `list` for a site's workers, `service` `preset_list` before `preset_install`, `exec` `vendor_bins` before `vendor_run`
- On a fresh Laravel clone (DB_CONNECTION=sqlite), call `db` `set` before `env` `setup` to choose a database deliberately, then run `framework` `setup`
- **Domain conflicts on link**: a link drops a domain another site owns (reported in `warnings`) and registers the survivors, falling back to `<dirname>.<tld>`; `.lerd.yaml` is untouched. `domain_add` still hard-errors
- **Custom APP_URL**: `env` `setup` writes `<scheme>://<primary-domain>`; override via `app_url` in `.lerd.yaml` (committed) or the per-machine `sites.yaml` entry, then re-run `env setup`
- Built-in service hosts follow `lerd-<name>` (e.g. `lerd-mysql`, `lerd-redis`, `lerd-postgres`); default DB credentials are username `root`, password `lerd`
- **Custom container sites** (Node.js, Python, Go, …) — mandatory order: (1) write a Containerfile (default `Containerfile.lerd`); (2) write `.lerd.yaml` with `container: {port: <N>}` (plus optional `domains`, `services`, `secured`); (3) configure the project's `.env` with service hosts (`lerd-mysql`, etc.) and start needed services via `service` `start`; (4) call `site` `link`. Never link before steps 1–3 or the site registers as PHP-FPM; if that happens, `site` `unlink`, write the files, then link again
- Worker unit names follow `lerd-<worker>-<site>` (per-worktree: `lerd-<worker>-<site>-<branch>`)
- **Host tools (CLI-only)**: `diag` `status` reports Composer, fnm and mkcert against the versions lerd pins, and flags any that differ. Applying an update is `lerd tools:update`, which has no tool here, so tell the user to run it rather than looking for an action
- **Sharing a site is CLI-only and deliberate**: `lerd share` (ngrok, cloudflared, Expose, serveo, localhost.run, Pinggy) and the dashboard's share menu put a site on the public internet, the same menu's public share serves it through the user's own reverse proxy on a base domain they control instead of a tunnel service, and `lerd lan:expose` puts it on the local network. None is exposed here, so never claim you can share a site; hand the user the command and let them decide
This project runs on lerd, a Podman-based PHP development environment. It is framework-agnostic: Laravel, Symfony, WordPress, Drupal, Magento, CakePHP and any custom framework are all driven by a framework definition (YAML), never by lerd hardcoding a framework's name. The lerd MCP server is available — use it to manage the environment without leaving the chat.
The MCP surface is twelve grouped tools, each driven by an action argument: site, service, db, env, runtime, worker, exec, framework, diag, logs, worktree, workspace. Always pass action. Most actions also accept an optional path that defaults to the directory the assistant was opened in (then LERD_SITE_PATH if set), so you can usually omit it. Start by calling site with action: "list" to discover sites.
lerd-php<version>-fpm (e.g. lerd-php84-fpm); each container includes composer and node/npm; the PHP version is resolved from .lerd.yaml → .php-version → composer.json require.php constraint (matched against installed versions) → global default*.test domains to the correct PHP-FPM containernode.manager + node.nvm_dir; node:manager/dashboard; nvm keeps PATH, fnm uses shims); per-project version via a .node-version file. The package manager is the project's, not lerd's: a packageManager pin in package.json wins, then the lockfile (pnpm-lock.yaml → pnpm, yarn.lock → yarn, bun.lock* → bun, else npm). pnpm and yarn run through corepack, and installs use the manager's frozen-lockfile mode (pnpm install --frozen-lockfile, yarn install --immutable, npm ci). Never assume npm run dev/npm ci — the worker command and the setup steps follow the detected managerlerd-<worker>-<sitename>; commands are defined per-framework in YAML; Laravel Horizon is auto-detected from composer.json and replaces the queue toggle when installed; Laravel ships with a vite host worker that runs the project's dev script on the host for HMR, through whichever package manager the project pins; workers and setup commands support optional check (file or composer) for conditional visibility; workers with conflicts_with auto-stop conflicting workers on start. Per-worker flags: host: true (run on host via the version manager instead of in FPM container — HMR-sensitive Node tools), per_worktree: true (worker runs independently per worktree under lerd-<worker>-<site>-<branch>), replaces_build: true (worker provides asset manifest while running, so a worktree add skips the static npm run build step when this worker is opted in).lerd.yaml custom_workers) or globally (~/.config/lerd/frameworks/<name>.yaml); use the worker tool's add/remove actions — both survive framework store updatesframework action: "setup"; Laravel has built-in storage:link/migrate/db:seed; custom frameworks can define their own{{mysql_version}}, {{postgres_version}}, {{redis_version}}, {{meilisearch_version}}) are available in framework env vars and resolved from the service image tag at env-setup timeContainerfile.lerd and a container: section in .lerd.yaml with a port; lerd builds a per-project image, runs it as lerd-custom-<sitename>, and nginx reverse-proxies to it; the project directory is volume-mounted at its host path with --workdir set automatically — do NOT add WORKDIR or COPY to the Containerfile; workers exec into the custom container; services are accessible by name on the shared lerd Podman network; hot-reload file watchers must use polling on macOS (inotify does not fire across Podman Machine's virtiofs mount) — nodemon: --legacy-watch, Vite: server.watch.usePolling: true, webpack: watchOptions: { poll: 1000 }Containerfile.lerd (must build FROM lerd-php<ver>-fpm:local) plus a container: section with no port; lerd builds a per-site image (lerd-custom-<site>:local), runs a dedicated FPM container lerd-cfpm-<site>, and serves it by fastcgi instead of the shared lerd-php<ver>-fpm. It is a normal PHP site otherwise (xdebug, dumps, profiler, lerd shell, php/artisan/composer/tinker, queue/horizon all run in the per-site container). The PHP version is fixed by the FROM line (the UI version selector is read-only); lerd rebuild rebuilds the image. Same key as custom containers, the port is the discriminator: with a port it is a reverse-proxied non-PHP app, without a port it is a fastcgi PHP image. runtime for these reports fpm-custom.<branch>.<site>.test subdomain (deep *.<branch>.<site>.test wildcard cert + nginx server_name on secured sites); vendor/, node_modules/, .env are seeded from the main checkout. .lerd.yaml env_overrides declares templated env vars ({{domain}}, {{scheme}}, {{site}}) layered on the default APP_URL rewrite — for multi-tenant apps (per-branch cookies, signed-URL hosts, tenant routing)Lerd has two install-time DNS modes recorded in ~/.config/lerd/config.yaml:
dns.enabled: true, dns.tld: test. Sites at *.test via lerd-dns + mkcert; site tls_enable works.dns.enabled: false, dns.tld: localhost. Sites at *.localhost via RFC 6761; no mkcert CA, TLS toggling unavailable.Read diag action: "status" for dns.tld and dns.enabled instead of assuming .test; do not propose tls_enable when dns.enabled is false.
Twelve grouped tools, each selecting behaviour via action.
site — sites and their configurationActions: list (discover sites — CALL FIRST), link, unlink, domain_add, domain_remove, group_assign, group_unassign, group_label, group_db, group_list, tls_enable, tls_disable, tls_renew, php, node, pause, unpause, restart, rebuild, runtime, nginx_read, nginx_write, nginx_reset, park, unpark.
link registers a directory; non-PHP sites need .lerd.yaml container.port + a Containerfile first, or they register as PHP (wrong)link runs lerd link and returns its output verbatim, so read the reply. It will NOT start a proxy.command dev server (command not approved); ask the user to run lerd link --yesdomain_* take a domain without the .test TLD; you can't remove the last domaingroup_* nest a secondary site under a main's subdomain (one level deep): they identify the secondary by path (defaults to cwd), not by site; group_assign with main + label (+ optional share_db), group_db = share|separategroup_assign under a secured main secures the secondary, tls_enable on a main secures its secondaries too, and tls_disable on a secondary is refused while its main is secured (the main's *.<main> wildcard would answer the subdomain and serve the main's app). Disable the main's TLS firsttls_renew forces it by hand for one sitephp/node take version; pass branch to pin the override on a worktree's checkoutruntime switches fpm ↔ frankenphp (worker: true enables frankenphp worker mode)nginx_write saves a custom override (runs nginx -t, backs up, reloads); branch targets a worktreepark registers a parent dir and auto-registers every PHP project under it; unpark reverses it (project files kept)service — built-in & custom servicesActions: start, stop, restart, pin, unpin, update, rollback, migrate, remove, reinstall, add, expose, port, env, config_read, config_write, config_restore, config_reset, config_list_backups, preset_list, preset_search, preset_install, check_updates, entities, entity_action.
update pulls a newer image (safe, in-strategy); migrate dumps + restores across a cross-strategy upgrade; reinstall with reset_data: true wipes data and reprovisions; remove with remove_data: true renames the data dir asidestop marks the service paused — lerd start skips it until started again; pin keeps it always runningadd registers a custom OCI service (depends_on wires dependencies, init: true for mysql/mariadb); prefer preset_install for anything in preset_list (phpmyadmin, pgadmin, mongo, mongo-express, selenium, stripe-mock, mysql, mariadb…)preset_list returns the installable presets with the metadata each one declares: category (the discovery heading), icon, and admin_for — the services this preset's admin UI administers, which is not depends_on. phpMyAdmin depends on mysql but administers mariadb too, and RedisInsight administers valkey without depending on it. To answer "which dashboard administers this database", read admin_for, not depends_on. preset_search queries the store by name for presets that are not bundled locallyenv returns the recommended .env connection keys; expose publishes an extra host:container portport moves the service's primary published host port (published_port, or reset: true for the default); it stays bound to 127.0.0.1, the container-internal port is unchanged, and a host-proxy site that points at the old port is realigned automaticallyentities lists what a service holds that is not a database: the kinds its preset declares (RustFS buckets today, more later), each kind's rows and the actions it supports. Databases have their own tool, so they are not repeated here. entity_action runs one of those declared actions (kind, entity, entity_action); export and import stream a file and stay on the CLI and the dashboardconfig_* read/write/restore/reset a service's runtime tuning overridedb — databasesActions: list, set, move, create, export, import, snapshot, snapshots, restore, snapshot_delete, extension_list, extension_add.
list reports an engine's databases with sizes; service picks the engine, else it resolves from the project. No introspect command, nothing to reportset picks the project DB (database: sqlite, mysql, postgres, or a family alternate like mariadb / postgres-pgvector / postgres-timescaledb / mysql-5-7); persists to .lerd.yaml, rewrites DB_ keys, starts the service, creates the DB + _testingmove migrates sites between two installed same-family services (from/to, sites: [...] or all: true) and repoints each .env; source data is left intactcreate/export/import auto-detect service and database; pass service to override. import drops a hosted provider's ownership/DEFINER statements (which can never apply here) and creates any extension the dump's types need; pass fresh: true to empty the database first so a dump replaces what is there instead of colliding with it. What an engine can list and act on is declared in its preset, so this is not a mysql/postgres-only setextension_list/extension_add are postgres-only. An import already creates whatever extension the dump's types reach for, so use these to see what the engine offers and what the database has, or to add one (extension: postgis) before any dump arrivessnapshot/snapshots/restore/snapshot_delete are named, restorable snapshots (MySQL/MariaDB/PostgreSQL); restore is destructive; all_databases covers the whole serviceenv — .env managementActions: setup, check, override.
setup configures services, DBs, APP_KEY and APP_URL; on a fresh Laravel clone call db set first to move off sqlite, then env setup, then ALWAYS framework setup or migrations never runcheck compares .env against .env.exampleoverride manages the personal, gitignored .env.lerd_override (its set KEY=VALUE win over lerd defaults; LERD_EXTERNAL_SERVICES=<svc,svc> marks vars lerd writes but won't start)runtime — PHP/Node versions & extensionsActions: versions, node_install, node_uninstall, node_manager, php_list, ext_list, ext_add, ext_remove, ports_list, ports_add, ports_remove, ini_read, ini_write, ini_reset.
ext_add/ext_remove change one declared set applying to EVERY PHP version, so a site keeps its extensions across a version change. They rebuild one version's FPM container now (slow); others rebuild on next use. ext_add accepts apk_deps for extra Alpine build packagesext_list reports the declared set plus, per version: has it, predates the set (rebuild fixes), or cannot load it (rebuild won't). Never assume a declared ext is present: mongodb needs 8.1+, 7.4/8.0 are Alpine 3.16node_manager with no argument reports the version manager lerd drives, whether nvm is present and whether lerd manages Node at all; with manager: fnm|nvm it switches, which also rewrites the PATH shims and regenerates host workersphp_list sets base_update when the published base image a version was built from has been republished (an upstream PHP or Alpine fix); lerd php:rebuild <version> picks it upports_add/ports_remove/ports_list publish extra host ports on a PHP version's shell (FPM) container so a process started in lerd shell is reachable at localhost:PORT. ports_add takes host and optional container (defaults to host); a busy host port shifts to the next free one. Per version and independent, loopback-bound (follows lan:expose), restarts that version's FPM. Prefer host-proxy or a worker+proxy for a single site. CLI: lerd php:ports add/remove/list [--php version]ini_read/ini_write/ini_reset edit php.ini: pass version for a per-version file, or shared: true for the shared file applied to every version. The shared file loads below the per-version one, so a per-version key still wins and an unknown key on some version is ignored (not fatal). Prefer shared for a setting you want everywhere, so a version change never drops it. ini_write takes full content, backs up, and restarts the affected FPM containers. CLI: lerd php:ini [version|shared]lerd php:pkg add/remove/list <packages> (CLI) bakes runtime apk packages (CLI tools, libs) into every FPM image, saved in config under php.packages and re-applied on every rebuild, so they survive php:rebuild and base image updates. One declared set applies to every PHP version. Layered onto the shared image, not the published base.lerd pest:browser install|doctor|remove [version] sets up pestphp/pest-plugin-browser inside the FPM container by baking Alpine's musl chromium into the images. Chromium only, and current PHP versions only (the 7.4/8.0 legacy tier is rejected). Needs the playwright npm package first; re-run install after bumping it. Tests then run through the normal lerd test/lerd pest.bun.lockb/bun.lock/bunfig.toml/packageManager: bun) or when Node is unmanaged, no system Node exists, and bun is present. CLI-only: lerd node:manage/node:unmanage opt in or out of lerd-managed Node (unmanage drops fnm versions, never a user's nvm ones), lerd js:runtime [bun|node|auto] pins one site's runtime, and lerd php:bun install|update|version manages an in-container bun for lerd shell. These are host operations, not container exec actions.worker — background workersActions: list (CALL FIRST), start, stop, add, remove, health, heal, mode_get, mode_set, and the framework workers queue_start, queue_stop, horizon_start, horizon_stop, reverb_start, reverb_stop, schedule_start, schedule_stop, stripe_start, stripe_stop, stripe_config.
list to discover a site's workers before start; pass branch to target a per-worktree unithorizon_* instead of queue_* when laravel/horizon is installed (mutually exclusive); queue_start needs Redis running when QUEUE_CONNECTION=redisadd saves a custom worker to .lerd.yaml (or the user overlay with global: true); does not auto-starthealth reports unhealthy units (read-only); heal resets and restarts them (unit for one, omit for all); mode_get reports the macOS worker runtime, mode_set switches it (mode: exec|container)state is one of failed (the unit died), expected-but-stopped (it should be running and isn't), or unreachable — the unit is happily active but the server it publishes no longer accepts connections, a dev server that wedged without exiting. All three are heal-able; heal restarts the unit. Per-worktree units (lerd-<worker>-<site>-<branch>) are covered by the same pass, so a dead per-worktree Vite heals like any other. A worker with a schedule is a oneshot driven by a timer and is idle between ticks by design, which is healthy.env (STRIPE_SECRET / STRIPE_SECRET_KEY / STRIPE_API_KEY); stripe_config sets webhook_path / secret_env_key in .lerd.yamllerd horizon:reload [on|off] and lerd octane:reload [on|off] (FrankenPHP worker mode) restart workers on file changes; both need the project's chokidar npm packagelerd idle on/off toggles activity-driven suspension globally; suspended workers stop after the idle timeout (lerd idle timeout <dur>) and resume on the next request/CLI/MCP/file-save. lerd idle pin/unpin <site> exempts a site; lerd idle status reports policy and last-active. A worker shown as suspended is healthy, not failed, so do not heal itexec — run tooling in the PHP-FPM containerActions: artisan (Laravel), console (other frameworks), composer, vendor_bins, vendor_run, commands_list, commands_run, command_add, command_remove.
artisan/console/composer take args (array); tinker must use --execute=<code> for non-interactive usevendor_run is the right way to run project tooling (pest, phpunit, pint, phpstan, rector) — call vendor_bins first to discover what's installed, then vendor_run with bin + args; prefer it over composer execcommands_*/command_* list, run, add and remove the on-demand commands in a site's .lerd.yaml commands: block; commands_run needs force: true for confirm-gated commandscomposer needs a private repo reachable only over SSH, lerd auth ssh starts a shared ssh-agent container and loads the host's ~/.ssh/id_* (or named keys) so passphrase-protected keys work in the FPM container; lerd auth ssh --list shows loaded keys, --remove flushes them. Keys live only in agent memory and clear on machine restartframework — framework definitions & scaffoldingActions: list, add, remove, prune, search, update, project_new, setup.
add with name: "laravel" merges custom workers/setup into the built-in frameworkremove refuses to drop a definition a linked site still uses (pass force: true to override); prune removes every framework definition no site usessearch/update use the community store; definitions auto-fetch on link, so update is the manual refresh (no name refreshes the catalogue and all installed definitions; with name it fetches that one, auto-detecting version from composer.lock)project_new scaffolds a new project (requires absolute path, default framework laravel); follow with site link + env setupsetup runs the framework's post-install steps (migrations, storage:link…) — MANDATORY after env setup on new/cloned projects; idempotentdiag — diagnostics & observabilityActions: status, doctor, doctor_fix, site_doctor, which, check, dns_diagnose, bug_report, analyze_queries, route_timing, optimize_route, dumps_recent, dumps_status, dumps_clear, dumps_toggle, profiler_toggle, profiler_status, profiler_clear, profiler_report, xdebug_on, xdebug_off, xdebug_status.
status (DNS/nginx/FPM/watcher/tools health) and doctor (JSON findings, each tagged with a fix tier) are the first stops when something is broken; dns_diagnose walks the DNS chaindoctor_fix applies the safe (non-heavy, non-sudo) automatic repairs for environment findings; package installs, lerd install, and lerd cleanup stay manualsite_doctor runs framework-agnostic app-level checks for one site (env file, env drift, app key, composer/node dependency install + lock, composer audit/npm audit, PHP range, a slow_routes warning for routes whose p95 runs well above the site's typical time or over a second, plus the framework's own checks); pass site (name or domain) or path, defaults to cwd. A failing check carries a severity and, when one applies, a fix naming the command that resolves it — run that yourself (exec, or the named lerd command); site_doctor itself is read-only. slow_routes is the exception: it reads the watcher's request-timing snapshot and has no command fix, the remedy is to profile the route (profiler_toggle)logs tool (below), not herewhich shows resolved PHP/Node/docroot/nginx for a site; check validates .lerd.yamldumps_toggle (enable) → dumps_clear → hit the page → analyze_queries (N+1 / slow-query report with file:line) or dumps_recent (filter by site/branch/ctx/kind/since/limit)route_timing returns the per-site response-time table: the typical (median) time and the routes whose p95 runs well above it (method, example path, p95, multiplier, samples), read from the watcher's request-timing snapshot. Works from recorded traffic alone, no capture needed. site accepts either the site name or its domain, as do analyze_queries, optimize_route, and dumps_recentoptimize_route is the join: each slow route paired with the N+1 and slow-query findings captured against that same route (with the caller file:line), so you get the symptom and its cause in one call. Needs the query capture on (dumps_toggle enable) plus a few real hits. When the SPX profiler was on for the route's traffic, each slow route also carries a profile block, the top functions by exclusive wall time from the freshest capture, distilled to a few outliers (not the raw trace), so a CPU-bound route shows where its time went next to its queriesroute_timing to see which routes are slow → dumps_toggle (enable) and profiler_toggle (enable) → hit the slow route a few times → optimize_route to get its N+1/slow queries with file:line and, from the profiler, the top CPU functions behind it → fix the caller (eager-load, index, cache) or the hot functionoptimize_route, and treat a new N+1 or a slow_routes doctor warning as work to finish rather than noise. On an unfamiliar site, route_timing shows the slow routes worth attention first. The timing panel is a live signal (recent traffic only, in memory), so a route you fixed clears once it stops being slow, but the durable catch is the slow_route push notification that fires the moment a route crosses into slow. Periodic checking belongs to the user's own scheduler (a routine, a cron job), not to lerd.profiler_* toggle the global SPX profiler and surface the flame-graph UI; profiler_report (site + args, e.g. ["artisan","app:heavy-report"]) runs that command under SPX and returns a text flat profile, the top functions by wall time and call count, the CPU-bound analog of analyze_queries for when a slow route's cost is not in its queries (a reproducible CLI command, not a live HTTP request); xdebug_* control Xdebug on port 9003 (mode defaults to debug)bug_report writes an anonymised diagnostic report for a GitHub issuelerd cleanup reclaims podman disk from orphaned lerd images (--dry-run to preview, --deep for the aggressive tier); a daily safe-tier sweep plus post-rebuild/service-change reaping runs automatically, toggled with lerd cleanup auto on|off|statuslogs — read logs from any source, filteredActions: sources, fetch. Debug without opening files by hand.
sources lists every queryable source for the site plus shared infra: app:<file> (framework log files), fpm, worker:<name> (queue/horizon/schedule/custom), and the globals nginx, dns, watcher, ui, services, php<ver>. Call it first to learn the namesfetch source=<name> reads one source. Filter with grep (regex, falls back to literal substring), since/until (relative like 15m/1h/2h30m, or a timestamp), level (app logs only: error/warning/info/debug), and lines (default 50)fetch returns an opaque cursor; call again with since=<cursor> (or cursor=<cursor>) to get only the new lines. The cursor format differs per backend, so treat it as opaque and echo it backsince/level and just return the last N; a not-running container returns partial output, not an errorworktree — git worktreesActions: list, add, remove, wait, db_isolate, db_share.
add installs deps and offers an asset-worker / build-step prompt; secured sites get *.<branch>.<site>.test wildcard cert SANs + nginx server_name automatically. It waits for setup and reports provisioned (false + note means still running, not failed; timeout_seconds default 300)wait is that readiness check alone, for a worktree made with plain git worktree add. Never judge readiness from the tree: node_modules/ exists from the first extracted package and composer fills existing vendor/<org>/ dirs, so both read as finished mid-install, and racing the watcher is how vendor/ ends up with no autoload.phpdb_isolate gives a worktree its own database (seed via source: empty|main|); db_share points it back at the main; remove keeps an isolated DB unless keep_db: falseadd does that work rather than leaving it to be run by handbranch to route_timing, optimize_route and dumps_recent to read one branch's trafficworkspace — group sites for displayActions: list, create, rename, delete, assign, move.
.env. This is not the same thing as the site tool's group_* actions, which nest a real site under another's subdomain and regenerate vhosts and certs — reach for group_* when a site should be served at <label>.<main>.test, and for workspace when the user just wants their site list organisedassign takes sites (names or domains) and a workspace, creating it if new; workspace: "none" ungroups them. move reorders a workspace with a zero-based positiondelete drops the workspace and ungroups its members; no site is touchedaction on every tool; path is optional on most and defaults to the directory the assistant was opened insite list for sites, worker list for a site's workers, service preset_list before preset_install, exec vendor_bins before vendor_rundb set before env setup to choose a database deliberately, then run framework setupwarnings) and registers the survivors, falling back to <dirname>.<tld>; .lerd.yaml is untouched. domain_add still hard-errorsenv setup writes <scheme>://<primary-domain>; override via app_url in .lerd.yaml (committed) or the per-machine sites.yaml entry, then re-run env setuplerd-<name> (e.g. lerd-mysql, lerd-redis, lerd-postgres); default DB credentials are username root, password lerdContainerfile.lerd); (2) write .lerd.yaml with container: {port: <N>} (plus optional domains, services, secured); (3) configure the project's .env with service hosts (lerd-mysql, etc.) and start needed services via service start; (4) call site link. Never link before steps 1–3 or the site registers as PHP-FPM; if that happens, site unlink, write the files, then link againlerd-<worker>-<site> (per-worktree: lerd-<worker>-<site>-<branch>)diag status reports Composer, fnm and mkcert against the versions lerd pins, and flags any that differ. Applying an update is lerd tools:update, which has no tool here, so tell the user to run it rather than looking for an actionlerd share (ngrok, cloudflared, Expose, serveo, localhost.run, Pinggy) and the dashboard's share menu put a site on the public internet, the same menu's public share serves it through the user's own reverse proxy on a base domain they control instead of a tunnel service, and lerd lan:expose puts it on the local network. None is exposed here, so never claim you can share a site; hand the user the command and let them decideHow can I help you explore Laravel packages today?