Схема з двох контейнерів існує тому, що php-fpm не є вебсервером. Він слухає FastCGI (типово порт 9000) і чекає, поки хтось передасть йому вже розібраний запит. Цим займається nginx: статику з public/ він віддає сам, а .php проксує директивою fastcgi_pass app:9000. Звідси головна пастка новачка. root у конфізі nginx має вказувати на шлях, за яким код лежить у контейнері app: nginx лише передає php-fpm рядок SCRIPT_FILENAME, а відкриває файл уже сам php-fpm. Якщо код змонтований лише в один із контейнерів або шляхи різні, отримуєте порожню сторінку чи File not found без жодного натяку на причину. Імена сервісів усередині compose-мережі резолвляться автоматично, тому в конфігах і .env фігурують app, db, redis, а не localhost.
FrankenPHP прибирає цю пару: Caddy з PHP усередині, один процес, один контейнер, HTTPS без зайвих рухів, HTTP/2 і HTTP/3 з коробки. Виграш дає передусім worker mode, коли застосунок завантажується один раз і далі крутиться в циклі, обробляючи запити без повторного бутстрапу фреймворку. Laravel підключає це через Octane, Symfony має свій runtime. Плата очевидна: у worker mode стан переживає запит, і будь-яка статична властивість із даними користувача перетворюється на витік між сесіями. Для локального стенду розумний компроміс такий: якщо продакшен на php-fpm, тримайте php-fpm і локально; якщо на FrankenPHP, беріть FrankenPHP, але вмикайте воркери й на розробці, інакше специфічні баги ви побачите вперше на бойовому сервері.
З volumes усе впирається в операційну систему. На Linux контейнер працює в тому самому ядрі, тому bind mount не коштує нічого. На macOS і Windows Docker крутить Linux-VM, і кожне звернення до змонтованої хостової теки йде через прошарок: VirtioFS у Docker Desktop (доступний з 4.6, типовий з 4.15) або 9p чи gRPC-FUSE у старіших конфігураціях. Один stat() дешевий, але composer-автозавантаження на холодний запит відкриває тисячі файлів у vendor/, і затримка складається в секунди. Прапорці :cached і :delegated з часів osxfs у нинішньому Docker Desktop нічого не роблять. Працюють два підходи: винести vendor/, node_modules/ і теку кешів у named volumes, які лежать усередині VM, або взагалі відмовитися від монтування на користь docker compose watch (Compose 2.22 і новіші), який односторонньо синхронізує файли всередину контейнера. Під WSL2 діє простіше правило: тримайте репозиторій у файловій системі самого WSL, а не в /mnt/c, інакше проєкт ходить через міст до NTFS і гальмує так само.
Xdebug ставлять в образ, але не вмикають. Починаючи з версії 3 його поведінкою керує xdebug.mode, і в режимі off розширення практично не впливає на швидкість, бо не вішає хуки на виконання опкодів. Значення читається зі змінної середовища XDEBUG_MODE, тому в compose пишуть XDEBUG_MODE: "${XDEBUG_MODE:-off}" і піднімають сервіс з XDEBUG_MODE=debug тоді, коли справді треба ставити брейкпоінти. Другий рівень контролю дає xdebug.start_with_request=trigger: навіть у режимі debug сесія стартує лише за наявності cookie, параметра запиту або змінної XDEBUG_TRIGGER, тож фонові команди й черги не намагаються достукатися до IDE. Адресу для зворотного підключення задають через client_host=host.docker.internal, і на Linux до цього обов'язково потрібен extra_hosts із host-gateway, бо там такого імені за замовчуванням не існує. Окремо тримайте в голові покриття тестами: воно вимагає XDEBUG_MODE=coverage, і саме тому CI з увімкненим Xdebug на всіх кроках повільний. Альтернатива для покриття - PCOV, який робить одну річ, але швидко.
Останнє про межі. Compose-файл для розробки і продакшен-образ вирішують різні задачі: локально потрібні bind mount, dev-залежності, відкриті порти БД і людські помилки на екрані, у релізі - копія коду всередині образу, --no-dev, прогріті кеші. Збігатися вони мають в одному: версія PHP і набір розширень. Різниця тут повертається як «локально працює, на сервері ні», і жоден compose цього не врятує. Практично це роблять multi-stage збіркою, де dev і prod виходять з однієї базової стадії. Ще одна річ, яку зазвичай згадують запізно: стенд має підійматися з нуля однією командою, разом із міграціями і сідерами, інакше новий розробник витрачає перший день на з'ясування, чиї саме дампи вважаються актуальними.
# compose.yaml - стенд для розробки. Продакшен-образ збирається окремо.
services:
app: # php-fpm: слухає FastCGI на 9000, HTTP не вміє
build:
context: .
target: dev # окремий stage із Xdebug і dev-залежностями
volumes:
- .:/app # код правимо на хості, бачимо в контейнері
- vendor:/app/vendor # named volume: тисячі файлів не ходять через bind mount
environment:
XDEBUG_MODE: "${XDEBUG_MODE:-off}" # типово вимкнено, накладних витрат майже нема
XDEBUG_CONFIG: "client_host=host.docker.internal"
extra_hosts:
- "host.docker.internal:host-gateway" # на Linux цього імені немає, додаємо руками
web:
image: nginx:1.27-alpine
ports: ["8080:80"]
volumes:
- ./public:/app/public:ro # статику nginx віддає сам, PHP не турбує
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on: [app] # у конфізі: fastcgi_pass app:9000;
# root /app/public - шлях однаковий в обох контейнерах
db:
image: postgres:17-alpine
environment:
POSTGRES_PASSWORD: secret
volumes:
- dbdata:/var/lib/postgresql/data # datadir лише в named volume
ports: ["5432:5432"] # щоб підключитися клієнтом з хоста
volumes:
vendor:
dbdata:
# docker compose run --rm app composer install
# XDEBUG_MODE=debug docker compose up -d app # рестарт лише сервісу app на час дебагу
Скажіть, що compose-файл ви оцінюєте за трьома речами: чи однакова версія PHP з продакшеном, що саме змонтовано через bind mount, і чи можна запустити стенд без Xdebug. Далі покажіть на пальцях, що `vendor` у named volume, і поясніть, звідки береться різниця в швидкості між вашим Mac і CI на Linux. Це відповідь людини, яка чинила чужий стенд, а не копіювала compose.yaml зі статті.