Stacks, PHP and HTTPS
devkit does not ship a database or a web server of its own. It uses the Docker Compose stacks you already have and the PHP versions installed on your machine. This page explains how it finds them, how to tune the defaults, how to debug with Xdebug, and how to serve sites over HTTPS.
Stacks
devkit stack add ~/Project/infra/local-stack
devkit stack list
devkit stack up local-stack # docker compose up -d
devkit stack up local-stack mysql redis # only these services
devkit stack restart local-stack redis
devkit stack logs local-stack mysql --tail 100
devkit stack down local-stack # docker compose down
devkit stack down local-stack mailpit # docker compose stop mailpit
A stack is a folder with a compose file. devkit reads docker compose config (with ${VAR:-default} expanded) and docker compose ps, and never edits the folder. stack down of a whole stack removes its containers but keeps its volumes, so your data stays; with service names it only stops those containers.
A site uses the stack you set with devkit site set <site> --stack <name>, or the first stack you added. site up starts any stopped service the site needs (the database and Redis; for caddy also Caddy and the PHP container) and waits for their health checks, so you rarely start a stack by hand.
Service roles
Each service gets a role from its image name: the last part of the image, without registry, namespace or tag (bitnami/redis:7 is redis), matched exactly:
| Image | Role | What devkit reads |
|---|---|---|
mysql, mariadb, percona, percona-server | mysql | the root password (MYSQL_ROOT_PASSWORD / MARIADB_ROOT_PASSWORD) and the published port |
redis, valkey | redis | the published port, REDIS_PASSWORD |
caddy | caddy | the admin API address |
php with an fpm tag, or a name with php and fpm in it | php | the PHP version from the tag (php:8.3-fpm → 8.3) |
minio, localstack, garage | s3 | the API port and keys |
mailpit, mailhog | the SMTP port (container 1025) and the inbox (container 8025), as published |
An image you build yourself says nothing about its role, so label it. Labels always win over the image name:
services:
php83:
build: ./php83
labels:
vn.webpress.devkit.role: php # mysql | redis | caddy | php | s3 | mail | none
vn.webpress.devkit.php-version: "8.3"
caddy:
image: caddy:2
labels:
vn.webpress.devkit.caddy-admin: "localhost:2019" # where the admin API is
vn.webpress.devkit.caddy-server: "srv0" # which Caddy server gets devkit's routes
adminer:
image: adminer
labels:
vn.webpress.devkit.hidden: "true" # leave it out of devkit's lists
In the app, Stacks shows each folder with its services (role, image, published ports, health, state), Start, Restart, Stop…, container logs and the mail Inbox ↗. Stop… says which running sites may use the stack's database or Redis before it stops anything.
Defaults: engine, ports, domain suffix
devkit settings
default engine artisan
default PHP (the php on PATH)
domain suffix localhost
port range 8100-8999
devkit settings set --port-min 9000 --port-max 9499 --dry-run
devkit settings set --port-min 9000 --port-max 9499
| Flag | Default | What it changes |
|---|---|---|
--default-engine artisan|caddy | artisan | the engine of sites that set none. Running sites that follow it are stopped first; start them again with the new engine |
--default-php X.Y | "" (the php on PATH) | the PHP of sites that set none; the version must be installed. A running site switches at its next start |
--port-min N, --port-max N | 8100, 8999 | where new ports for artisan sites, lanes and Vite come from. A site keeps a port outside the new range (a warning says so) |
--domain-suffix s | localhost | caddy host names end in it, from each site's next start. Anything but localhost needs /etc/hosts entries (below) |
Always try --dry-run first: it says what the settings would become, which running sites would stop and what it would warn about, and changes nothing. The app does exactly this: Settings asks devkit with --dry-run, shows the answer in a sheet (No running site stops. or the list under These running sites stop), asks again right before applying, and the button reads Stop them and apply when sites will stop. The default PHP is set directly from the PHP section.
PHP on the host
The artisan engine runs PHP from your machine. devkit finds every version on its scan locations, paths or globs with * and ~:
| macOS defaults | Linux defaults |
|---|---|
/opt/homebrew/opt/php@*/bin/php/opt/homebrew/bin/php/usr/local/opt/php@*/bin/php~/.config/herd-lite/bin/php~/.phpenv/versions/*/bin/php (off) |
/usr/bin/php*/usr/local/bin/php*~/.phpenv/versions/*/bin/php |
devkit php list # every binary found
devkit php info 8.3 # php.ini, extensions, Xdebug, the sites on it
devkit php scan-paths # each location and what it finds
devkit php scan-paths add '~/tools/php/*/bin/php'
devkit php scan-paths disable '~/.phpenv/versions/*/bin/php'
devkit php scan-paths enable '~/.phpenv/versions/*/bin/php'
devkit php scan-paths rm '~/tools/php/*/bin/php'
devkit php scan-paths reset # back to this system's defaults
devkit settings set --default-php 8.3 # sites without their own version use 8.3
A binary found twice (a symlink and its target) is listed once. Quote globs so your shell does not expand them.
A missing version
A site set to a version no scan location finds fails with PHP_VERSION_MISSING and the command that installs it: brew install php@8.2 on macOS; on Linux sudo apt install php8.2-cli (Debian and Ubuntu ship one version, the others come from the ondrej/php repository), sudo dnf install php82-php-cli (Fedora, from Remi; it installs /usr/bin/php82) or yay -S php82 (Arch, from the AUR). After installing, the PHP section's Rescan (or any devkit command) finds it.
composer's platform check
devkit php check 8.4 shop
shop with PHP 8.4: ok
37 platform requirements met by PHP 8.4.19
php check runs composer check-platform-reqs in the site's folder with that PHP first on PATH and lists what fails (a missing extension, a version constraint) with the package that asks for it. It changes nothing, so it answers "could this site run on 8.4?" before you switch. devkit doctor <site> runs the same check for the site's current PHP.
In the app
The PHP section shows the Default PHP control (php on PATH or a version, applied at once), each host binary as a card (version, sites, Xdebug, extensions) and, for the selected one, its php.ini (Open php.ini, Copy path), Xdebug or how to install it, the sites on it, the loaded extensions, and the platform check for each of those sites. Check another site runs it for any site, marked (if switched). A version a site needs that is not installed appears as a not installed card with its install command and Copy command; devkit never runs it. Rescan, Add binary… and the Scan locations list (on/off, remove, Add location, Reset to defaults…) edit where devkit looks.
Xdebug
Modes: off (no overhead), debug (step debugging), coverage (for phpunit --coverage), profile (cachegrind files in Xdebug's output_dir). Xdebug must be installed for that PHP; otherwise devkit says XDEBUG_MISSING with the install command (pecl install xdebug next to the binary, or php8.3-xdebug with apt, php83-php-pecl-xdebug3 with dnf, xdebug with pacman).
artisan sites: per site and per lane
devkit xdebug status shop
devkit xdebug on shop # debug
devkit xdebug on shop --mode coverage
devkit xdebug off shop
devkit xdebug on shop--checkout-fix # a lane has its own setting
devkit writes a small ini file (in ~/.config/devkit/xdebug/) with xdebug.mode, xdebug.start_with_request=trigger and xdebug.client_host=127.0.0.1, adds it to the PHP scan directory of the site's server and PHP processes only, and restarts them. Your php.ini is not touched, and other sites on the same PHP are not affected.
caddy sites: per PHP container
PHP-FPM reads the mode once for the whole container, so under caddy Xdebug belongs to the container and affects every site and lane on it. The compose service must pass the variable through:
environment:
XDEBUG_MODE: ${XDEBUG_MODE:-off}
devkit xdebug on shop # recreates the php83 container with XDEBUG_MODE=debug
devkit xdebug container local-stack php83 --mode off
devkit php containers # each php service, its live mode, the sites on it
devkit recreates the service with the new XDEBUG_MODE, reads the mode back from the running container, and restarts the PHP processes of every site on it (the result lists them). A plain docker compose up -d of that stack later recreates it with Xdebug off again. Where Xdebug connects (xdebug.client_host) is the image's own setting; for a container it is usually host.docker.internal.
One command only
devkit exec shop --xdebug -- php vendor/bin/phpunit --filter CheckoutTest
XDEBUG_TRIGGER=1 devkit exec shop --xdebug -- php artisan orders:sync
--xdebug runs this command's PHP with xdebug.mode=debug,develop without changing the site's setting. With the trigger set, the run connects to your IDE.
Connecting your IDE
devkit's Xdebug starts a session on trigger: a request is debugged only when it carries the trigger. Use a browser extension such as Xdebug Helper (it sets the XDEBUG_SESSION cookie), add ?XDEBUG_TRIGGER=1 to a URL, or set XDEBUG_TRIGGER=1 in the environment of a CLI command. Xdebug connects to port 9003.
PhpStorm
- Settings → PHP → Debug: Xdebug port
9003(the default). - Press Start Listening for PHP Debug Connections in the toolbar.
- Set a breakpoint, turn the trigger on in the browser and load the page. On the first connection PhpStorm asks which project the files belong to; accept.
VS Code: install the PHP Debug extension (xdebug.php-debug) and add to .vscode/launch.json:
{
"version": "0.2.0",
"configurations": [
{ "name": "Listen for Xdebug", "type": "php", "request": "launch", "port": 9003 }
]
}
Path mapping: not needed. Under artisan PHP runs on your machine with the same paths as your editor. Under caddy devkit requires the containers to mount your sites at the same absolute path as on the host, so the paths match there too.
host.docker.internal does not exist by default. Add extra_hosts: ["host.docker.internal:host-gateway"] to the PHP service so Xdebug in the container can reach your IDE.In the app: the site's … menu → Xdebug… lists the four modes, shows how to start a session (trigger, client host, port 9003, path mapping) for debug, says which sites a container change affects, and applies with Apply or Apply and restart. The PHP section's Container PHP table changes a container's mode directly, after a sheet saying which sites it affects.
HTTPS with the caddy engine
A caddy site is served by a Caddy container from one of your stacks, with PHP running in a PHP-FPM container of the site's version. You need:
- mkcert installed (
brew install mkcertor your distribution's package). - Trust its CA once:
devkit trustrunsmkcert -install, which may ask for your password. It does nothing when the CA is already trusted. In the app: Settings → Certificates → Trust… opens it in a terminal. - A stack with Caddy and PHP-FPM that mount your sites at the same path. A minimal one, next to your database stack:
services:
caddy:
image: caddy:2
environment:
CADDY_ADMIN: 0.0.0.0:2019 # the admin API, reachable through the published port
ports:
- "443:443" # Docker Desktop without privileged ports: "8443:443"
- "127.0.0.1:2019:2019" # loopback only: devkit sends certificates through it
volumes:
- ${HOME}/Project:${HOME}/Project # your sites, at the same path
networks: [default, webpress]
php83:
build: ./php83 # FROM php:8.3-fpm with pdo_mysql, redis, intl, gd, zip, xdebug…
labels:
vn.webpress.devkit.role: php
vn.webpress.devkit.php-version: "8.3"
environment:
XDEBUG_MODE: ${XDEBUG_MODE:-off}
volumes:
- ${HOME}/Project:${HOME}/Project
networks: [default, webpress]
networks:
webpress:
external: true # the network your database is on
with php83/Dockerfile:
FROM php:8.3-fpm
ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/download/2.7.24/install-php-extensions /usr/local/bin/
RUN install-php-extensions pdo_mysql mysqli bcmath intl gd zip gmp pcntl sockets exif redis xdebug \
&& echo "xdebug.client_host=host.docker.internal" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
Then:
devkit stack add ~/Project/infra/web-stack
devkit site set shop --engine caddy --php 8.3
devkit site up shop # https://shop.localhost
devkit doctor shop
What happens on site up: devkit issues a certificate for the host, loads it into Caddy through the admin API, and adds a route for the host at the front of the server listening on :443 (or the one named by the caddy-server label; without one devkit creates a server of its own), forwarding PHP to <php-service>:9000. site down, hiding, removing a lane or deleting the site remove the route and certificate again.
- Caddy and the PHP service can live in another stack than the database. devkit looks in the site's stack first, then in the others.
- Both must mount the site's folder at the same absolute path, else
SITE_NOT_MOUNTED. The PHP service must reach the database by its service name (a shared network). - Restarting Caddy forgets devkit's routes. The sites then show as stopped;
site upputs them back. phpcommands of processes, quick actions andexecrun in the PHP container at the same path;pnpmand Vite run on the host.- The admin API is found from the
caddy-adminlabel, else the published port 2019. Thecaddy:2image needsCADDY_ADMIN: 0.0.0.0:2019, or devkit reportsCADDY_UNREACHABLE.
Certificates
devkit certs
Lists mkcert's CA (is it trusted?) and every certificate devkit issued, with its host and expiry. It changes nothing. Certificates are kept in ~/.config/devkit/certs/<host>.pem and <host>-key.pem, one per site and lane host (a wildcard cannot cover crm-fix.core-development.localhost), and are issued again when missing or within 30 days of expiry. In the app: Settings → Certificates, with Trust… and Check again.
Domains and /etc/hosts
.localhost(the default) needs nothing: browsers and curl resolve every*.localhostname to your own machine. Prefer it..testor any other suffix needs one line per host in/etc/hosts. devkit keeps them in a marked block (# devkit begin…# devkit end) and only writes it when you ask:
devkit settings set --domain-suffix test
devkit hosts # what the block should hold, and what differs
devkit hosts sync # rewrites only devkit's block, with sudo (one password prompt)
site upnever edits/etc/hosts: it warns about a missing name, and doctor does too. Going back tolocalhostand syncing removes the block..localis resolved through mDNS (Bonjour on macOS, Avahi on Linux) and can add seconds to every lookup. Avoid it.- In the app: Settings → Hosts shows whether the block is current, with Sync in Terminal… (macOS) or Sync in a terminal… (Linux) and Check again. The app never runs the command itself; the terminal asks for your password.