- name
- spin-laravel-development
- description
- Develops, tests, and deploys Laravel applications using Spin and serversideup/php Docker images. Covers Docker Compose configuration, development environment setup, container commands, running Laravel tests (PHPUnit and Pest), SPIN_ENV selection and CI parity, parallel Compose environments, Laravel service configuration (databases, queues, Horizon, Reverb), server provisioning, and deployment workflows. Use when working with Spin, Docker Compose files, serversideup/php images, spin commands, running artisan test, configuring Laravel services in Docker, or deploying Laravel apps to production servers.
# Spin Laravel Development
## Table of contents
- [Safety guardrails](#safety-guardrails)
- [Laravel Boost MCP](#laravel-boost-mcp)
- [How Spin works](#how-spin-works)
- [Project structure](#project-structure)
- [Dockerfile pattern](#dockerfile-pattern)
- [Development workflow](#development-workflow)
- [Running tests](#running-tests)
- [serversideup/php images](#serversideupphp-images)
- [Laravel services](#laravel-services)
- [Deployment](#deployment)
- [Server provisioning](#server-provisioning)
- [Common pitfalls](#common-pitfalls)
- [Reference files](#reference-files)
## Safety guardrails
- **NEVER** run commands that could destroy data without explicitly confirming with the user first. This includes `docker system prune`, `docker volume rm`, dropping databases, removing services, or any destructive operation.
- Avoid `spin kill` — it kills **all containers on the machine** (not just this project) and requires interactive confirmation, which hangs in non-interactive contexts. Use `spin stop` or `spin down` for the project's stack.
- If Spin fails to run, it is likely because Docker Desktop is not started. Check with `docker info`. If Docker is not running, tell the user to start Docker Desktop and offer to retry before continuing.
- Use `spin exec` (running stack) or `spin run` (stopped stack), with `-T` in AI/CI/subprocess contexts — see [Running commands](#running-commands).
- Projects start from templates but are free to restructure. Treat the project's own compose files, Dockerfile, and `.env` as the source of truth for service names, versions, ports, and paths — the examples in this skill show typical template defaults, not guarantees.
## Laravel Boost MCP
Spin runs PHP inside Docker, not on the host machine. Laravel Boost's MCP server needs a bridge script (`spin-mcp-wait.sh`) that retries until Docker is ready and filters Docker's stdout noise to preserve the JSON-RPC stdio protocol.
The required `.env` configuration:
```env
BOOST_PHP_EXECUTABLE_PATH="./vendor/bin/spin-mcp-wait.sh ./vendor/bin/spin run -T php php"
BOOST_COMPOSER_EXECUTABLE_PATH="./vendor/bin/spin run php composer"
BOOST_NPM_EXECUTABLE_PATH="./vendor/bin/spin run node npm"
```
**NEVER use `spin-mcp-wait.sh` to run commands.** It is exclusively for MCP server startup. The script will refuse non-MCP invocations with this error:
```
Error: spin-mcp-wait.sh is only for starting the MCP server. Use 'spin' directly instead.
```
If that error appears, drop `spin-mcp-wait.sh` and invoke `spin` directly:
```bash
./vendor/bin/spin exec -T php php artisan test # Correct
./vendor/bin/spin run -T php php artisan migrate # Correct
./vendor/bin/spin-mcp-wait.sh ./vendor/bin/spin run php # WRONG — never do this
```
## How Spin works
Spin wraps Docker Compose and follows its syntax exactly. The compose-wrapping commands (`up`, `down`, `stop`, `build`, `logs`, `ps`, `pull`, `run`, `exec`) forward any additional flags straight to the wrapped `docker compose` subcommand, so every official Docker Compose option works (see [COMMANDS.md](COMMANDS.md) for per-command exceptions).
The core pattern is **Docker Compose overrides**: a base `docker-compose.yml` is merged with an environment-specific override file. Spin sets `COMPOSE_FILE=docker-compose.yml:docker-compose.$SPIN_ENV.yml` automatically.
**`SPIN_ENV` defaults to `dev`**, so `spin up` is equivalent to:
```bash
COMPOSE_FILE=docker-compose.yml:docker-compose.dev.yml docker compose up
```
Override with `SPIN_ENV=testing spin up` to use `docker-compose.testing.yml` instead.
The base file defines shared service structure. Override files add environment-specific settings. Docker merges them intelligently — override values replace or extend base values. (Image versions and service names below are illustrative — mirror the project's actual files.)
```yaml
# docker-compose.yml (base — shared across all environments)
services:
traefik:
image: traefik:v3
php:
depends_on:
- traefik
```
```yaml
# docker-compose.dev.yml (development overrides)
services:
traefik:
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./.infrastructure/conf/traefik/dev/traefik.yml:/traefik.yml:ro
php:
build:
target: development
args:
USER_ID: ${SPIN_USER_ID}
GROUP_ID: ${SPIN_GROUP_ID}
volumes:
- .:/var/www/html/
labels:
- "traefik.enable=true"
- "traefik.http.routers.laravel.rule=HostRegexp(`localhost`)"
- "traefik.http.routers.laravel.entrypoints=web"
- "traefik.http.services.laravel.loadbalancer.server.port=8080"
- "traefik.http.services.laravel.loadbalancer.server.scheme=http"
node:
image: node:24
volumes:
- .:/usr/src/app/
working_dir: /usr/src/app/
```
```yaml
# docker-compose.prod.yml (production overrides — Docker Swarm)
services:
php:
image: ${SPIN_IMAGE_DOCKERFILE}
environment:
AUTORUN_ENABLED: "true"
SSL_MODE: full
deploy:
replicas: 1
update_config:
failure_action: rollback
order: start-first
labels:
- "traefik.http.routers.my-php-app.rule=Host(`${SPIN_APP_DOMAIN}`)"
- "traefik.http.routers.my-php-app.tls.certresolver=letsencryptresolver"
```
## Project structure
Typical layout from the Laravel templates (individual projects may differ):
```
docker-compose.yml # Base (shared services)
docker-compose.dev.yml # Dev overrides (ports, volumes, build target, labels)
docker-compose.prod.yml # Prod overrides (Swarm deploy, TLS, health checks)
Dockerfile # Multi-stage: base → development → ci → deploy
.env # Read by Docker Compose AND Laravel (dual-use)
.spin.yml # Server inventory for provisioning (optional)
.infrastructure/
conf/
traefik/dev/ # Dev Traefik config + local certs (committed)
traefik/prod/ # Prod Traefik config (committed)
volume_data/
sqlite/ # SQLite data (gitignored)
redis/ # Redis data (gitignored)
```
The `.infrastructure/` folder is flexible. `conf/` stores committed configuration. `volume_data/` stores gitignored persistent data for any service. This keeps everything portable — moving the project folder moves all data with it. Check the project's `.gitignore` to determine what is tracked.
## Dockerfile pattern
Multi-stage build using `serversideup/php` (match the PHP version and variant to the project):
```dockerfile
FROM serversideup/php:8.5-fpm-nginx-alpine AS base
# Uncomment to add PHP extensions:
# USER root
# RUN install-php-extensions bcmath gd
FROM base AS development
ARG USER_ID
ARG GROUP_ID
USER root
RUN docker-php-serversideup-set-id www-data $USER_ID:$GROUP_ID && \
docker-php-serversideup-set-file-permissions --owner $USER_ID:$GROUP_ID
USER www-data
FROM base AS ci
USER root
FROM base AS deploy
COPY --chown=www-data:www-data . /var/www/html
USER www-data
```
Stages: **base** (production image), **development** (matches host UID/GID via `SPIN_USER_ID`/`SPIN_GROUP_ID`), **ci** (root for CI pipelines), **deploy** (copies app code, sets permissions). The dev compose file targets the `development` stage via `build.target`.
## Development workflow
This is where 90% of Spin usage happens.
### Starting the environment
```bash
spin up --build # Recommended: builds and starts all services (foreground)
spin up -d # Detached mode (background)
```
`spin up` runs in the foreground — open a second terminal for other commands. Press `Ctrl+C` to stop.
### Running commands
Syntax: `spin <exec|run> [-T] <service> <command>` — the service argument is the **service name** from the project's `docker-compose.yml` (verify there; not every project names its services `php` and `node`).
```bash
spin exec php composer install
spin exec php php artisan migrate # First "php" = service, second "php" = binary
spin run php php artisan make:model Post
spin exec node npm install
spin exec node npm run dev
```
**`exec` vs `run`**:
- **`spin exec`** reuses the live container from an already-running stack. Near-instant. Default for `artisan`, `composer`, `npm`, and ad-hoc commands during development.
- **`spin run`** creates a new container each invocation. Use when the stack is not running, or when isolated state / different env vars are needed.
**The `-T` flag**: Compose auto-detects TTY, so interactive terminal usage works without `-T`. Add `-T` as a defensive default when invoking from an AI agent, CI pipeline, or wrapper script — auto-detection can misfire in those contexts, causing hangs or ANSI-garbled output:
```bash
./vendor/bin/spin exec -T php php artisan test # AI/CI-safe
./vendor/bin/spin exec php artisan tinker # Interactive — omit -T
```
### Volume mounting
In development, the project directory is mounted at `.:/var/www/html/`, so code changes are reflected instantly — no rebuild needed for code changes.
### When to rebuild
Use `spin up --build` when:
- Starting a development environment (recommended as default habit)
- After Dockerfile changes (adding PHP extensions, changing base image)
- After modifying build-related compose settings
Code-only changes never need a rebuild — volume mounts handle it.
### `.env` dual-use
The `.env` file is read by **both** Docker Compose (for variable substitution like `${SPIN_APP_DOMAIN}`) and Laravel inside the container (since the project is volume-mounted). Example:
```env
DB_HOST=mysql
DB_DATABASE=laravel
SPIN_APP_DOMAIN=laravel.dev.test
```
### Which compose file to edit
| Change type | File |
|---|---|
| Shared service definitions (image, depends_on) | `docker-compose.yml` |
| Dev-only (ports, volume mounts, build target, Traefik labels) | `docker-compose.dev.yml` |
| Prod-only (deploy config, Swarm labels, named volumes, TLS) | `docker-compose.prod.yml` |
### Essential commands
| Command | What it does |
|---------|-------------|
| `spin up` | Start all services (`docker compose up`) |
| `spin up --build` | Start and rebuild |
| `spin stop` | Stop containers without removing them |
| `spin down` | Stop and remove containers |
| `spin run <svc> <cmd>` | One-off command in new container |
| `spin exec <svc> <cmd>` | Command in running container |
| `spin logs` | View container logs |
| `spin ps` | List running containers |
| `spin build` | Build images without starting |
See [COMMANDS.md](COMMANDS.md) for the complete command reference.
## Running tests
Prefer the **already-running dev stack** — it's faster than spinning up a parallel CI stack:
```bash
./vendor/bin/spin exec -T php php artisan test
./vendor/bin/spin exec -T php php artisan test --filter=ExampleTest
./vendor/bin/spin exec -T php composer test # If the project exposes a composer test script
```
`php artisan test` works for both PHPUnit and Pest.
Inspect the project's `phpunit.xml` before choosing a stack: if tests are self-contained (sqlite `:memory:`, `array`/`sync` drivers), the dev stack is plenty; otherwise it usually still works. Reach for `SPIN_ENV=ci` only when CI parity is explicitly needed.
See [TESTING.md](TESTING.md) for the stack decision tree, the full CI-parity workflow, parallel Compose environments, and the override-network gotcha.
## serversideup/php images
Choose the right variant:
| Variant | Use case |
|---------|---------|
| `fpm-nginx` | Most Laravel apps (PHP-FPM + NGINX) |
| `fpm-apache` | Apps needing `.htaccess` support |
| `frankenphp` | Laravel Octane with FrankenPHP |
| `cli` | Artisan commands, queue workers, schedulers |
Tag pattern: `serversideup/php:<php-version>-<variant>[-alpine]`
Example: `serversideup/php:8.5-fpm-nginx-alpine`
Key environment variables for production:
```yaml
environment:
AUTORUN_ENABLED: "true" # Enable Laravel automations (migrations, caching)
PHP_OPCACHE_ENABLE: "1" # Enable OPcache
SSL_MODE: "full" # SSL termination mode
HEALTHCHECK_PATH: "/up" # Laravel's built-in health route
```
See [DOCKER-IMAGES.md](DOCKER-IMAGES.md) for the full image configuration reference.
## Laravel services
In Docker, services connect via **container name as hostname**. This is critical when generating `.env` files:
```env
DB_HOST=mysql # NOT localhost
REDIS_HOST=redis # NOT 127.0.0.1
MAIL_HOST=mailpit
```
Available services: MySQL, PostgreSQL, MariaDB, SQLite, Redis, Laravel Queues, Horizon, Reverb, Task Scheduler, Octane, Vite, Mailpit, Meilisearch.
Avoid special characters in database passwords — use long (20+) alphanumeric passwords instead.
See [LARAVEL-SERVICES.md](LARAVEL-SERVICES.md) for complete Docker Compose configurations and `.env` settings for each service.
## Deployment
Two strategies:
### `spin deploy` (solo developers)
Builds locally, pushes via SSH tunnel, deploys to Docker Swarm — all in one command:
```bash
spin deploy production
```
Requires a provisioned server (`spin provision`) and `.spin.yml` configuration.
### GitHub Actions (teams)
Use open source actions for CI/CD:
- [`serversideup/docker-build-action`](https://github.com/marketplace/actions/docker-build-action) — Build and publish Docker images
- [`serversideup/docker-swarm-deploy-github-action`](https://github.com/marketplace/actions/docker-swarm-deploy-github-action) — Deploy to Docker Swarm
See [DEPLOYMENT.md](DEPLOYMENT.md) for detailed deployment workflows.
## Server provisioning
`spin provision` uses Ansible to configure servers for Docker Swarm:
```bash
spin provision # Provision all servers in .spin.yml
spin provision production # Provision only production
```
Server requirements: Ubuntu 22.04+ LTS, x86_64, ports 22/80/443 open, fresh install.
The `.spin.yml` file defines users, providers, servers, and environments. Supports Hetzner, DigitalOcean, Vultr, or any host with SSH access.
## Common pitfalls
| Problem | Solution |
|---------|---------|
View on GitHub