Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message
Agents
tonysm/sail-proxy
claude-code
cursor
laravel-zero
kamal-proxy
docker-compose
sail
cli-commands
pest-testing
env-files

This file provides guidance to AI agents when working with code in this repository.

Install
php artisan boost:add-skill tonysm/sail-proxy

Save this content to: AGENTS.md

# AGENTS.md

This file provides guidance to AI agents when working with code in this repository.

## What this is

A Laravel Zero CLI, distributed as a PHAR, that puts local Laravel Sail apps behind
[kamal-proxy](https://github.com/basecamp/kamal-proxy) so each one is served on its own
`*.localhost` hostname. `README.md` documents the user-facing commands and every
`SAIL_PROXY_*` environment variable; `CONTRIBUTING.md` documents the development
workflow in depth and is worth reading before changing anything non-trivial.

## Commands

```bash
composer install         # every runtime dep is in require-dev; --no-dev cannot boot
php sail-proxy <command> # run the app from source
vendor/bin/pest          # tests
vendor/bin/pest tests/Feature/ConfigCommandTest.php          # one file
vendor/bin/pest --filter='part of the test name'             # one test
vendor/bin/pint          # format
vendor/bin/pint --test   # check formatting (CI runs this)
```

CI runs Pest on PHP 8.3, 8.4 and 8.5.

## Architecture

Nine commands in `app/Commands/`, each driving thin wrappers in `app/Support/`:

- `Docker` — the `docker` CLI (containers, networks, volumes, labels). `labels()` reads a
  set of labels in one `inspect`, and returns null when the container is gone.
- `DockerCompose` — `docker compose` for a single project directory, bound as a singleton
  to `getcwd()`. Prefers `./vendor/bin/sail` over bare `docker compose` when present.
- `KamalProxy` — `kamal-proxy deploy|remove|list`, run via `docker exec` into the proxy
  container. `services()` parses the `list` table; `apps()` is the service column of it.
- `OverrideFile` — renders the `compose.override.yaml` that `config` writes.
- `EnvFile` — finds and rewrites `.env` lines that point at a service by its short name.

`install` creates the shared network and starts the `sail-proxy` (kamal-proxy) container;
nothing is pinned, so Docker assigns the network a subnet and the proxy an address from its
own pool. `config` rewrites a project's compose to join that network — under a network alias
matching its hostname — and registers it; `register`/`unregister` talk to kamal-proxy
directly; `uninstall` reverses `install`. `start`/`stop`/`restart` are lifecycle verbs over
the container `install` created and touch nothing else -- `restart` is `stop --brief` then
`start`, via `$this->call()`.

Container-to-container resolution is Docker's embedded resolver answering that alias; there
is no DNS container. Host-to-container resolution is the OS sending `*.localhost` to
`127.0.0.1`, where the proxy publishes port 80.

Each app gets **two** aliases -- `myapp.localhost` and `myapp.internal` -- because libcurl will
not resolve the first from inside a container. See the invariant below and the README's
"Container-to-container requests".

Everything configurable lives in `config/proxy.php`, each key backed by a `SAIL_PROXY_*`
env var read from the real environment (not a `.env`).

## Invariants

- **Always pass argv to `Process` as an array**, never a string, so container names and
  hostnames can't shell-inject.
- **The shared network must not be named `sail`** — Sail's own compose file defines a
  project-local network by that name, and overriding it drags mysql/redis/mailpit onto the
  shared network where projects collide on aliases.
- **`config` inspects the project with `-f <base compose file>`**, never the merged config,
  or a re-run feeds our previous override back in and accumulates networks.
- **Restart projects through `./vendor/bin/sail`** (`DockerCompose::command()`): it exports
  `WWWUSER`/`WWWGROUP`, without which the container runs as the wrong UID.
- **An explicit `docker stop` outranks `--restart unless-stopped`.** The proxy stays down
  across daemon and machine restarts until something starts it again, which is why `start`
  exists as its own verb -- and why `stop` says so in its output.
- **`docker rm -f` exits 0 for a missing container**, so check existence separately before
  reporting a removal.
- **`config` has to settle the hostname before it writes the override**, because the
  hostname goes in as a network alias. Prompting for it at registration time — after the
  file is written — would be too late.
- **Routes are settled before the override too**, for the same reason: a routed service's
  network and aliases go in the file.
- **Attaching a service to the shared network exposes its short name.** Compose always adds
  the service name as an alias, so two projects serving `mailpit` both answer to it there, and
  an app on that network can silently get the other project's. This is why only services the
  user routes are attached, and why `config` offers to move `.env` onto the `.internal` name.
- **`kamal-proxy deploy --force` does not skip health checks**, whatever its help says. It only
  stops waiting: a target that never passes a check serves 503s while `list` reports `running`.
  Use `--health-check-path` or `--health-check-port` for a target with no `/up`.
- **`kamal-proxy deploy` must not run under `Process`'s 60s default timeout.** It enforces
  `--deploy-timeout` itself, 120s by default, so a slow deploy would otherwise throw.
- **Aliases come in pairs, and the `.localhost` one is inert for libcurl.** libcurl resolves
  any `.localhost` name to `127.0.0.1` itself, so from inside a container that alias is never
  reached -- the request loops back to the caller. `ConfigCommand::containerHostname()` adds a
  second alias on `proxy.container_tld` for real container-to-container traffic. It keys off
  the literal `.localhost` suffix rather than `proxy.tld`, since that is what libcurl
  special-cases: a user running `SAIL_PROXY_TLD=test` already has a working name and gets no
  redundant second alias.
- **kamal-proxy colours its `list` output unconditionally.** `NO_COLOR`, `TERM=dumb` and
  the absence of a TTY make no difference, so anything reading that table has to strip
  `\e[...m` first — `KamalProxy::services()` does, and splits cells on two-or-more spaces
  since that is the padding kamal-proxy writes.
- **`docker inspect --format` label reads are positional.** `Docker::labels()` asks for
  several labels separated by `{{"\n"}}` and maps the lines back onto the names it was
  given, so only the *trailing* newline may be trimmed: an unset first label comes back as
  a leading blank line, and trimming that shifts every value up one.
- **Aliases force the map form of `networks:`.** A plain list cannot carry them, so
  `OverrideFile` switches to `network: {aliases: [...]}` and has to bring the service's
  other networks along as keys with a null value. It keeps the list form when there is no
  alias to add.

## Testing

Tests fake the process layer; nothing touches Docker. Use the helpers in `tests/Pest.php`
rather than `Process::fake()` directly — `Process::fake()`'s string patterns don't match our
argv arrays:

```php
fakeProcesses(['docker ps*' => Process::result('sail-proxy')]);
assertRanProcess('docker exec sail-proxy kamal-proxy remove myapp');
assertDidntRunProcess('docker network create*');
```

`Process::recorded()` does not exist in this version of `illuminate/process`; to assert
ordering, record commands inside a `Process::fake(closure)` (see `UninstallCommandTest`).
Two `expectsOutputToContain()` calls matching the same *write* will fail — a table or a
blob of JSON is one write, so only the first substring ever matches. Assert the whole line
as one substring, or run the command with `Artisan::call()` and assert against
`Artisan::output()` (see `StatusCommandTest`).

## Distribution

Composer serves the committed `builds/sail-proxy` archive (`bin` points at it, and `/builds`
is deliberately not gitignored). The same archive is the GitHub release asset installed by
the curl one-liner and by `mise use -g github:tonysm/sail-proxy` — its name is load-bearing:
keep it bare and unversioned (`sail-proxy`), or both break.

Releasing is one command; it validates, runs pint and pest, rebuilds the archive with the
tag baked in, commits it (Composer installs straight from the tag, so the build commit must
land first), then tags and pushes main and the tag in one `--atomic` push:

```bash
scripts/release.sh 1.2.3   # or 1.2.3-rc.1 for a prerelease, or --dry-run
```

Tags are bare semver (`1.2.3`); the release workflow triggers on numeric semver tags. It
re-runs the checks, fails if the version baked into the archive doesn't match the tag, and
publishes the archive plus `checksums.txt` with a build-provenance attestation.
`-`-suffixed tags ship as prereleases and never become Latest.

AGENTS.md

This file provides guidance to AI agents when working with code in this repository.

What this is

A Laravel Zero CLI, distributed as a PHAR, that puts local Laravel Sail apps behind kamal-proxy so each one is served on its own *.localhost hostname. README.md documents the user-facing commands and every SAIL_PROXY_* environment variable; CONTRIBUTING.md documents the development workflow in depth and is worth reading before changing anything non-trivial.

Commands

composer install         # every runtime dep is in require-dev; --no-dev cannot boot
php sail-proxy <command> # run the app from source
vendor/bin/pest          # tests
vendor/bin/pest tests/Feature/ConfigCommandTest.php          # one file
vendor/bin/pest --filter='part of the test name'             # one test
vendor/bin/pint          # format
vendor/bin/pint --test   # check formatting (CI runs this)

CI runs Pest on PHP 8.3, 8.4 and 8.5.

Architecture

Nine commands in app/Commands/, each driving thin wrappers in app/Support/:

  • Docker — the docker CLI (containers, networks, volumes, labels). labels() reads a set of labels in one inspect, and returns null when the container is gone.
  • DockerCompose — docker compose for a single project directory, bound as a singleton to getcwd(). Prefers ./vendor/bin/sail over bare docker compose when present.
  • KamalProxy — kamal-proxy deploy|remove|list, run via docker exec into the proxy container. services() parses the list table; apps() is the service column of it.
  • OverrideFile — renders the compose.override.yaml that config writes.
  • EnvFile — finds and rewrites .env lines that point at a service by its short name.

install creates the shared network and starts the sail-proxy (kamal-proxy) container; nothing is pinned, so Docker assigns the network a subnet and the proxy an address from its own pool. config rewrites a project's compose to join that network — under a network alias matching its hostname — and registers it; register/unregister talk to kamal-proxy directly; uninstall reverses install. start/stop/restart are lifecycle verbs over the container install created and touch nothing else -- restart is stop --brief then start, via $this->call().

Container-to-container resolution is Docker's embedded resolver answering that alias; there is no DNS container. Host-to-container resolution is the OS sending *.localhost to 127.0.0.1, where the proxy publishes port 80.

Each app gets two aliases -- myapp.localhost and myapp.internal -- because libcurl will not resolve the first from inside a container. See the invariant below and the README's "Container-to-container requests".

Everything configurable lives in config/proxy.php, each key backed by a SAIL_PROXY_* env var read from the real environment (not a .env).

Invariants

  • Always pass argv to Process as an array, never a string, so container names and hostnames can't shell-inject.
  • The shared network must not be named sail — Sail's own compose file defines a project-local network by that name, and overriding it drags mysql/redis/mailpit onto the shared network where projects collide on aliases.
  • config inspects the project with -f <base compose file>, never the merged config, or a re-run feeds our previous override back in and accumulates networks.
  • Restart projects through ./vendor/bin/sail (DockerCompose::command()): it exports WWWUSER/WWWGROUP, without which the container runs as the wrong UID.
  • An explicit docker stop outranks --restart unless-stopped. The proxy stays down across daemon and machine restarts until something starts it again, which is why start exists as its own verb -- and why stop says so in its output.
  • docker rm -f exits 0 for a missing container, so check existence separately before reporting a removal.
  • config has to settle the hostname before it writes the override, because the hostname goes in as a network alias. Prompting for it at registration time — after the file is written — would be too late.
  • Routes are settled before the override too, for the same reason: a routed service's network and aliases go in the file.
  • Attaching a service to the shared network exposes its short name. Compose always adds the service name as an alias, so two projects serving mailpit both answer to it there, and an app on that network can silently get the other project's. This is why only services the user routes are attached, and why config offers to move .env onto the .internal name.
  • kamal-proxy deploy --force does not skip health checks, whatever its help says. It only stops waiting: a target that never passes a check serves 503s while list reports running. Use --health-check-path or --health-check-port for a target with no /up.
  • kamal-proxy deploy must not run under Process's 60s default timeout. It enforces --deploy-timeout itself, 120s by default, so a slow deploy would otherwise throw.
  • Aliases come in pairs, and the .localhost one is inert for libcurl. libcurl resolves any .localhost name to 127.0.0.1 itself, so from inside a container that alias is never reached -- the request loops back to the caller. ConfigCommand::containerHostname() adds a second alias on proxy.container_tld for real container-to-container traffic. It keys off the literal .localhost suffix rather than proxy.tld, since that is what libcurl special-cases: a user running SAIL_PROXY_TLD=test already has a working name and gets no redundant second alias.
  • kamal-proxy colours its list output unconditionally. NO_COLOR, TERM=dumb and the absence of a TTY make no difference, so anything reading that table has to strip \e[...m first — KamalProxy::services() does, and splits cells on two-or-more spaces since that is the padding kamal-proxy writes.
  • docker inspect --format label reads are positional. Docker::labels() asks for several labels separated by {{"\n"}} and maps the lines back onto the names it was given, so only the trailing newline may be trimmed: an unset first label comes back as a leading blank line, and trimming that shifts every value up one.
  • Aliases force the map form of networks:. A plain list cannot carry them, so OverrideFile switches to network: {aliases: [...]} and has to bring the service's other networks along as keys with a null value. It keeps the list form when there is no alias to add.

Testing

Tests fake the process layer; nothing touches Docker. Use the helpers in tests/Pest.php rather than Process::fake() directly — Process::fake()'s string patterns don't match our argv arrays:

fakeProcesses(['docker ps*' => Process::result('sail-proxy')]);
assertRanProcess('docker exec sail-proxy kamal-proxy remove myapp');
assertDidntRunProcess('docker network create*');

Process::recorded() does not exist in this version of illuminate/process; to assert ordering, record commands inside a Process::fake(closure) (see UninstallCommandTest). Two expectsOutputToContain() calls matching the same write will fail — a table or a blob of JSON is one write, so only the first substring ever matches. Assert the whole line as one substring, or run the command with Artisan::call() and assert against Artisan::output() (see StatusCommandTest).

Distribution

Composer serves the committed builds/sail-proxy archive (bin points at it, and /builds is deliberately not gitignored). The same archive is the GitHub release asset installed by the curl one-liner and by mise use -g github:tonysm/sail-proxy — its name is load-bearing: keep it bare and unversioned (sail-proxy), or both break.

Releasing is one command; it validates, runs pint and pest, rebuilds the archive with the tag baked in, commits it (Composer installs straight from the tag, so the build commit must land first), then tags and pushes main and the tag in one --atomic push:

scripts/release.sh 1.2.3   # or 1.2.3-rc.1 for a prerelease, or --dry-run

Tags are bare semver (1.2.3); the release workflow triggers on numeric semver tags. It re-runs the checks, fails if the version baked into the archive doesn't match the tag, and publishes the archive plus checksums.txt with a build-provenance attestation. --suffixed tags ship as prereleases and never become Latest.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
andydefer/laravel-notification
adachsoft/cli-process-tester
jhaskell13/laraflow
sebastienheyd/boilerplate-media-manager
ccast/tagixo-primix
pretendtrue/laravel-builder
sawmainek/laravel-scout-tntsearch-driver
tomshaw/laravel-calendar-table
dibakar/laravel-ownership
steadrun/laravel-monitor
labyrinthe/connect-laravel
elmogy/layan
drewdan/sentinel-client
dskripchenko/laravel-llm
fatryst/nuonuo
stevenay/myanfont
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc