Sail連携
Docker連携の構造と動作。
Technical reference synchronized with Yard’s source. File names and development commands refer to the native app repository.
How Yard runs a Laravel Sail project (Docker Compose) without asking the user to learn anything new:
myapp.test keeps working, with HTTPS, Studio, yard php, the dev server and Insights, while PHP and the
databases run in Sail's containers.
Status: implemented and tested end to end (see §8). Workspaces for Sail projects are out of scope (§7).
1. Why Yard and Sail collide
Yard is "native binaries + nginx in front + *.test". Sail is "Docker Compose + everything in containers +
localhost:80". Used side by side without integration:
| Collision | Effect |
|---|---|
| Ports | Sail publishes 80, 3306, 6379, 5432, 7700, 9000, 8108, 5173… on 0.0.0.0 — the same defaults as Yard's nginx and services. |
| Domain | Sail serves http://localhost; there is no myapp.test or HTTPS with Yard's CA. |
| Commands | The dev server, queue, Tinker, Studio terminal, agents, yard php run the host PHP: a different PHP, extensions and database host than the container. |
| Yard features | Insights (auto_prepend_file), Xdebug routing and the profiler live in Yard's FPM pools, not in the container. |
2. Experiments (Docker Desktop 29.9.2, Apple Silicon, Sail from a real project)
| # | Question | Result | Consequence |
|---|---|---|---|
| E1 | Can a container reach a host service bound to 127.0.0.1 via host.docker.internal? |
Yes (HTTP 200). | Yard Mail (2525) and the Insights ingest (2304) are reachable from Sail without rebinding them. |
| E2 | What happens when Docker publishes 0.0.0.0:P while a host process holds 127.0.0.1:P? |
No error. The container starts; 127.0.0.1:P keeps going to the host process. |
Collisions are silent: sail up "works", but localhost shows Yard's 404 and TablePlus on 127.0.0.1:3306 talks to Yard's MySQL. Port checks must probe with connect() (YardControl.isPortInUse), not rely on bind errors. |
| E3 | Does the sail script honour SAIL_FILES from .env, with a path containing a space (Application Support)? |
Yes, when quoted. SAIL_FILES replaces the default file, so it must list compose.yaml too. |
Yard can add an override compose file that also applies when the user runs ./vendor/bin/sail in a terminal. |
| E4 | Does SIGTERM to a foreground sail up stop the containers? |
No. The script exits, containers keep running. | Don't supervise sail up as a long-running process. Use sail up -d / sail stop and poll docker compose ps. |
| E5 | Does killing docker compose exec … (or the sail wrapper) stop the process in the container? |
No — it keeps running in the container, and killing the wrapper also orphans the host-side compose client. | Long-running commands in the container (Vite, queue:listen) are started as setsid -w sh -c 'echo $$ > /tmp/yard-<tag>.pid; exec <cmd>' and stopped with kill -TERM -<pgid> inside the container. Verified: all children die and the host client exits. |
| E6 | Can a GUI app find docker? |
On this Mac /usr/local/bin/docker was a dangling link to an uninstalled OrbStack, and ~/.docker/bin was not on a usable PATH. |
Yard validates Docker at known locations and skips dangling links, while preserving the invocation path and docker basename for multicall providers such as OrbStack. Its invocation directory comes first on PATH, followed by the resolved target directory for credential helpers. |
| E7 | Do container environment variables reach the web requests? | No. Sail's web server is php -d variables_order=EGPCS artisan serve, which starts its php -S workers without any variable outside its allow-list (PHP_INI_SCAN_DIR, YARD_* are dropped). They do reach sail artisan …. |
Yard's ini is mounted straight into the image's conf.d (/etc/php/<v>/cli/conf.d/99-yard.ini), and the values live in a mounted env.php that sail-prepend.php reads. |
| E8 | Does Insights capture anything? | It didn't — on any site, native included. dump-loader.php started with if (class_exists('YardInsights', false)) return;, but PHP binds that top-level class at compile time, so the guard always returned before boot(). Reproduced with Yard's PHP 8.4 and php -S. |
Fixed: the guard is a constant (YARD_INSIGHTS_LOADED). Verified on PHP 8.4 (host) and 8.5 (container); including the file twice is still safe. |
Also verified in the sources:
vendor/bin/sailsources.env, exportsWWWUSER/WWWGROUP(used by the image build) and defaultsAPP_SERVICEtolaravel.test.sail shareuseshttp://host.docker.internal:$APP_PORT, soAPP_PORTmust stay a plain number (no127.0.0.1:prefix trick).- Sail's
compose.stubalready addsextra_hosts: host.docker.internal:host-gatewayand readsSAIL_XDEBUG_MODE. laravel-vite-plugin(1.x) binds0.0.0.0:${VITE_PORT}and writeshttp://localhost:${VITE_PORT}topublic/hotwhenLARAVEL_SAILis set.php artisan serve(Sail's web server) runs PHP as SAPIcli-server, whichdump-loader.phptreats as an HTTP request.
3. Design principles (ease of use first)
- Zero configuration. Yard detects a Sail project (
laravel/sailincomposer.json+ a compose file). The inspector shows a Sail section with one switch: Run with Sail. Yard never switches a project on its own. - One click. Turning it on assigns free ports, updates
.env, creates the.testproxy (HTTPS if the site is secured) and starts the containers. - Start on visit. If the containers are stopped,
myapp.testshows a "Starting Sail…" page that starts them and reloads when the app answers — the same idea as the existing 502 "Yard is fixing this site" page. If Docker Desktop / OrbStack is installed but not running, Yard opens it first. - No lock-in. Ports are pinned in
.env, so./vendor/bin/sail upin a terminal gives the same result andmyapp.testkeeps working. Turning the switch off restores every.envvalue Yard changed. - Yard never installs Docker (no Homebrew, nothing compiled by Yard). It explains what's missing.
- Same commands.
yard php …/yard composer …, Studio's terminal, Tinker, queue and dev server, and the coding agents run inside the container for Sail sites, so docs and agent prompts stay the same.
4. Architecture
Browser ─► myapp.test (Yard DNS) ─► nginx (Yard CA cert)
├─ /yard-studio → Studio (4421)
└─ / → proxy_pass http://127.0.0.1:<APP_PORT> ─► laravel.test container
502 → /yard-error/sail.html → POST /api/sail/:site/up
container ─► host.docker.internal:2525 (Yard Mail), :2304 (Insights ingest)
| Piece | File | Responsibility |
|---|---|---|
DockerEnvironment |
YardKit/Sail/Docker.swift |
Find a working docker (skips dangling links), status (missing / stopped / running / failed), open Docker Desktop/OrbStack, PATH for child processes. |
SailProject |
YardKit/Sail/SailProject.swift |
Detection, compose files, published port variables (${VAR:-default} on the host side), app service, command builders (sail …, docker compose exec with a pid file, stop). |
SailManager |
YardKit/Sail/SailManager.swift |
Enable/disable (ports, .env, backup, override file, registry, nginx), status (docker compose ps --format json), up/stop. |
SiteRegistry.sail |
YardKit/Sites/Site.swift |
name → SailSite (app port, assigned ports, original .env values). In sites.json, like isolation. |
| nginx | NginxConfigWriter.sailServer |
Proxy with Studio location + badge, error_page 502 → sail.html. |
| API | YardApp/Sail/SailModule.swift |
GET /api/sail, GET /api/sail/:site, POST /api/sail/:site/:action (enable, disable, up, stop, restart). |
| UI | YardApp/Sail/SailSection.swift |
Inspector section: Docker state, switch, start/stop/restart, every service with its state, health, ports and live logs (docker compose logs --follow), last start output. The Environment section shows the container's PHP and Node versions. |
| CLI | yard/Commands/SailCommands.swift |
yard sail:enable, sail:disable, sail:status, sail:up, sail:stop; yard php/composer delegate to Sail. |
4.1 Ports
On enable Yard reads every published port variable from the compose files (APP_PORT, VITE_PORT,
FORWARD_DB_PORT, FORWARD_REDIS_PORT, …). A value is reassigned when it:
- is one of Yard's own ports (HTTP/HTTPS, API, Studio, dump, mail, DNS) or a Yard service port, or
- is used by another Sail site registered in Yard, or
- answers a TCP
connect()on127.0.0.1while this project's containers are not running (E2).
New ports are searched from 8100 for APP_PORT and from default + 10000 otherwise (3306 → 13306),
skipping ports in use. Ports that are free are left as they are, so projects that already set their own
ports (e.g. APP_PORT=8081, FORWARD_DB_PORT=33060) are not touched.
4.2 .env changes (all reverted on disable)
| Key | Value |
|---|---|
| Port variables that collide | Free ports (§4.1) |
APP_URL |
https://myapp.test (or http://) |
SAIL_FILES |
"compose.yaml:<Yard>/sail/<site>.yml" (quoted, E3) |
The original values (or "unset") are stored in sites.json (SiteRegistry.sail[name].originalEnv), not in a
file in the project. If the user edited one of those keys after Yard did, Yard keeps the user's value.
4.3 Override compose file (<Yard>/sail/<site>.yml)
Adds Yard's features to the app service without touching the project's compose file. Only read-only mounts —
no environment variables, because artisan serve drops them (E7):
| Host | Container | Purpose |
|---|---|---|
runtime/php |
/opt/yard/php |
sail-prepend.php, dump-loader.php |
sail/home/config |
/opt/yard/config |
Only insights.json (mirrored by InsightsFlag.write); Yard's real config dir holds integration tokens and is never mounted |
sail/sites/<site> |
/opt/yard/site |
env.php: YARD_HOME, YARD_SITE, YARD_SITE_PATH=/var/www/html, YARD_HOST_SITE_PATH, YARD_DUMP_HOST=host.docker.internal |
sail/sites/<site>/yard.ini |
/etc/php/<v>/cli/conf.d/99-yard.ini |
auto_prepend_file = /opt/yard/php/sail-prepend.php; <v> from image: sail-<v>/app or runtimes/<v>, else every version 8.0–8.6 |
sail-prepend.php exports env.php into $_SERVER/getenv(), sets HTTPS=on for requests that came through
Yard's proxy over HTTPS (X-Yard-Proxy + X-Forwarded-Proto: https; Laravel doesn't trust proxies by default, so URLs
would otherwise be http:// — mixed content), then loads the dump loader, which sends to YARD_DUMP_HOST
with Host: 127.0.0.1 (accepted by Yard's DNS-rebinding check).
4.4 Commands
| Yard feature | Native site | Sail site |
|---|---|---|
yard php … / yard composer … |
Yard PHP | vendor/bin/sail php … / sail composer … |
| Tinker (Studio) | php artisan tinker --execute |
docker compose exec -T -u sail <app> php artisan tinker --execute |
| Queue (Studio) | php artisan queue:listen |
same, through the pid-file wrapper (E5) |
| Dev server | php artisan dev or npm run dev |
npm run dev in the container (Vite only; artisan serve already runs there), pid-file wrapper |
| Studio terminal | host shell, YARD_SITE_PHP = Yard PHP |
host shell, YARD_SITE_PHP = vendor/bin/sail php |
| Agents | system prompt says yard php artisan … |
unchanged — yard php delegates to Sail; the prompt says the app runs in Sail |
5. Decisions
| Decision | Choice | Why |
|---|---|---|
| Databases/Redis: Sail's or Yard's? | Sail's containers. Yard only moves their forwarded ports. | Keeps the project portable and identical for teammates; using Yard services would mean editing DB_HOST and diverging from compose.yaml. |
| Stop containers when Yard quits? | No. | Matches Docker semantics (E4) and the user's terminal workflow; the inspector and yard sail:stop stop them. |
| HTTPS | Uses the site's existing HTTPS switch; TLS ends at nginx. | Nothing changes in the container. |
| Vite on an HTTPS site | http://localhost:<VITE_PORT> from public/hot. |
Chrome and Firefox treat localhost as potentially trustworthy (no mixed-content block). Safari may block it; workaround: npm run build, or open the site over HTTP. |
First sail up builds the image (minutes) |
Output streamed to the inspector and the "Starting Sail…" page. | Avoids "it's frozen". It's the project's image, not a Yard binary, so it doesn't conflict with "nothing compiled by Yard". |
yard.yml |
sail: true enables Sail mode on yard init. |
Shareable with the team. |
Supervised queue, scheduler and broadcasting workers pass the complete dockerCommandLine to the shell.
That command already puts Docker's environment assignments before its own exec; prepending another
exec would make the shell treat PATH=… as the executable. SailProjectRuntimeTests launches all three
roles through a disposable recording Docker client, including restart, environment/quoting and tagged stop.
6. Failure states shown to the user
| State | Message / action |
|---|---|
| No Docker found | "Sail needs Docker Desktop or OrbStack." + download link |
| Docker client works but the engine cannot be reached | Connection diagnostic + Open Docker (Yard also opens it on start) |
| Docker executable fails, times out or returns unusable engine information | failed status with diagnostic + Retry; Yard does not open an app for a broken executable. |
No compose file (laravel/sail required, sail:install not run) |
"Run php artisan sail:install first." |
vendor/ missing |
"Run composer install first." |
| Build/start failed | Last lines of the output + Show Output |
7. Out of scope / next steps
- Workspaces for Sail projects (each clone needs its own
COMPOSE_PROJECT_NAMEand ports). - New Application → Sail (run
artisan sail:install --with=…with Yard's PHP). - Xdebug switch mapped to
SAIL_XDEBUG_MODE. - Yard Mail as an opt-in mailer for Sail sites (
MAIL_HOST=host.docker.internal,MAIL_PORT=2525). - Vite over HTTPS with Yard's certificate inside the container.
8. Implementation status
See §4 for the files. Unit tests: Tests/YardKitTests/SailTests.swift (detection, port parsing and allocation,
.env round trip incl. a file without a final newline, user SAIL_FILES, Yard service ports, override file,
PHP version detection, compose output parsing, warning filter, nginx config incl. nginx -t).
End-to-end (2026-11-05, Docker Desktop 29.9.2): a copy of a real Laravel 12 + MySQL + Redis + Mailpit Sail project,
with an isolated YARD_HOME (path with a space), the app on its own ports and a real nginx:
| Check | Result |
|---|---|
yard sail:enable |
APP_PORT 80 → 8100 (Yard's nginx), Redis 6379 → 16379 (another project's container); free ports untouched |
yard php -r … / yard php artisan migrate |
Ran in the container (PHP 8.5.12, Linux) |
https://sailshop.test through Yard's nginx |
200, certificate from Yard's CA, Studio badge injected, asset URLs https:// |
yard secure |
APP_URL switched to https:// automatically |
| Containers stopped → open the site | "Starting Sail…" page → POST /api/sail/:site/up → serving → 200 |
| Studio Tinker / queue | Ran in the container; stopping the queue killed the worker in the container and the host client |
| Insights | Batches from the container reached the ingest port with the right site and host path (after the E8 fix) |
Untrusted Origin on the Sail API |
403 |
yard sail:disable |
Containers stopped, .env byte-identical to the original, override files removed |
| Inspector | "Laravel Sail" section (switch, state, Restart/Stop, ports) and a "Sail" chip on the card |
Not covered end to end: Vite in the container (same exec/stop mechanism as the queue, which was verified) and Safari
with HMR on an HTTPS site (§5). Seen once: right after Yard's PHP runtime files were replaced under a running
container, one request got a 502 (upstream prematurely closed connection); the next requests were fine.
DockerEnvironmentTests uses a compiled multicall fixture with two symlink levels and paths containing
spaces. It checks invocation identity, credential-helper PATHs, dangling links, launch failures, daemon
connection errors, timeouts and old status JSON. YARD_DOCKER_CLIENT_PROBE=/path/to/docker adds a
read-only --version probe of an installed CLI; it does not contact the engine or manage containers.