Resolves wayfinder tickets #2 and #3 on the calendar-backend groundwork map (#1): - smart-tablet-screen-calendar-webhooks.md: Nextcloud CalDAV push support (none usable — Mail-in-a-Box's bundled Nextcloud predates the Webhooks app) vs Microsoft Graph webhook subscriptions (supported, with renewal-job requirements). - smart-tablet-screen-frankenphp-docker.md: FrankenPHP-based dev/prod Docker image and compose setup for the Symfony backend. Also gitignore .scratch/, the repo-local scratchpad directory for throwaway working files. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QshNimeB3TVU2vMjN7tEuz
15 KiB
Smart Tablet Screen — FrankenPHP Docker Research
Research date: 2026-09-01. Scope: FrankenPHP Docker image setup for this project's Symfony backend, for the dev/prod split and PHP extensions this map needs (SQLite cache via pdo_sqlite, Symfony secrets vault via sodium). All claims sourced against primary sources only — frankenphp.dev's own docs, the php/frankenphp and dunglas/symfony-docker GitHub repos (the latter is FrankenPHP's own docs page's recommended reference implementation, see §2), Docker Hub, and one claim verified directly by running the real image (docker run … php -m, noted as such). This report does not redo smart-tablet-screen-stack.md or smart-tablet-screen-config-secrets.md, and does not modify them.
1. Recommended base image/tag
Per FrankenPHP's own Docker docs:
- The image is
dunglas/frankenphpon Docker Hub. Tags follow the pattern<frankenphp-version>-php<php-version>-<os>, e.g.dunglas/frankenphp:1.2.3-php8.4-bookworm. Supported PHP versions listed: 8.2, 8.3, 8.4, 8.5. OS variants:trixieandbookworm(Debian) oralpine. — Docker, frankenphp.dev/docs/docker/ - Debian variants are recommended over Alpine by the same page.
- FrankenPHP's own reference Symfony Dockerfile (§2) pins
dunglas/frankenphp:1-php8.5— the major-version-only FrankenPHP tag (1) paired with a specific PHP minor (php8.5), no explicit OS suffix (defaults to the image's default Debian base). Confirmed directly against the real image on Docker Hub (docker pull dunglas/frankenphp:1-php8.5succeeded, digestsha256:f92d81eb…).
For this project: dunglas/frankenphp:1-php8.5 (or pin a specific PHP version this project targets, e.g. 1-php8.4), Debian-based — matches both FrankenPHP's own recommendation and its own reference Symfony setup.
2. FrankenPHP's Symfony guide: Dockerfile structure and worker mode
Per FrankenPHP's own Symfony guide:
- "For Symfony projects, we recommend using Symfony Docker, the official Symfony Docker setup maintained by FrankenPHP's author." — Symfony, frankenphp.dev/docs/symfony/. This points at dunglas/symfony-docker on GitHub as the primary source for the actual Dockerfile/compose shape, rather than the guide page itself spelling one out inline.
- That repo's
Dockerfile(fetched directly,mainbranch) is a three-stage build:frankenphp_base— shared stage: installsinstall-php-extensions @composer apcu intl opcache zip, setsPHP_INI_SCAN_DIR, copies in the app'sphp.inioverrides, entrypoint script, and Caddyfile. Ends with aHEALTHCHECKhitting FrankenPHP's own/metricsadmin endpoint andCMD [ "frankenphp", "run", "--config", "/etc/frankenphp/Caddyfile" ].frankenphp_dev(extendsfrankenphp_base) — setsAPP_ENV=dev,FRANKENPHP_WORKER_CONFIG=watch, switches tophp.ini-development, installsxdebug, adds a non-root user, and runsfrankenphp run --config … --watch(file-watching hot reload).frankenphp_prod_builder(extendsfrankenphp_base) →frankenphp_prod— builder stage runscomposer install --no-dev,composer dump-autoload --classmap-authoritative --no-dev, asset-map compile, etc., then a separate final stageFROM debian:13-slimcopies over only the compiled FrankenPHP/PHP binaries, extensions, shared libraries (vialibtree), certs, and the built/app— producing a minimal prod image that doesn't carry build tooling. Runs aswww-data. —Dockerfile, dunglas/symfony-docker, raw.githubusercontent.com
- Worker mode: FrankenPHP's worker docs describe it as keeping the application booted in memory between requests — "Boot your application once and keep it in memory. FrankenPHP will handle incoming requests in a few milliseconds." It's enabled via the
FRANKENPHP_CONFIGenv var (worker /path/to/script.php) or a Caddyfileworkerdirective; by default 2 workers per CPU are started. — Worker Mode, frankenphp.dev/docs/worker/ - The Symfony guide states worker mode is natively supported since Symfony 7.4; for older Symfony versions it requires installing the
runtime/frankenphp-symfonyComposer package (APP_RUNTIME=Runtime\FrankenPhpSymfony\Runtime). — Symfony, frankenphp.dev/docs/symfony/ - Is it recommended for prod? The worker-mode docs don't blanket-recommend or discourage it — they frame it as an opt-in perf feature with a real cost: "many libraries and legacy code still leak memory" between requests since the app no longer boots fresh per request, and document mitigations (restarting the worker after N requests, graceful reloads via the Caddy admin API). Symfony-docker's own dev/prod split reflects this: dev sets
FRANKENPHP_WORKER_CONFIG=watch(worker mode + file-watch, for hot-reload DX) while prod'sCMDruns plainfrankenphp runwith no worker config set — i.e. the reference implementation does not turn worker mode on by default in prod; it's left as an explicit opt-in once the app has been audited for state leaks, not a default-on prod setting.
3. PHP extensions: pdo_sqlite and sodium
- FrankenPHP's own Dockerfile (builder stage,
php/frankenphprepo,mainbranch) pre-installs theinstall-php-extensionsscript (from mlocati/docker-php-extension-installer) into every FrankenPHP image at/usr/local/bin/install-php-extensions— confirmed by thecurl … -o /usr/local/bin/install-php-extensionsstep in the image's own build. —Dockerfile, php/frankenphp, raw.githubusercontent.com - FrankenPHP's docs document this as the standard way to add extensions:
RUN install-php-extensions pdo_mysql gd intl zip opcache— nodocker-php-ext-install/manual compile step needed; it's the same conventiondunglas/symfony-docker's own Dockerfile uses (install-php-extensions @composer apcu intl opcache zip, §2). — Docker, frankenphp.dev/docs/docker/ pdo_sqliteandsodiumare already enabled by default in this project's target image — verified directly, not inferred:docker run --rm dunglas/frankenphp:1-php8.5 php -mlistspdo_sqlite,sqlite3, andsodiumin[PHP Modules]with no extra install step. This matches the general official-PHP-image baseline documented in the extension installer's own README, which explicitly excludes "pre-installed PHP extensions" from its supported-extensions table and shows the samepdo_sqlite/sodiumpairing pre-enabled inphp:8.5-cli'sphp -moutput. —docker-php-extension-installerREADME, mlocati/docker-php-extension-installer, GitHub, and directdocker run dunglas/frankenphp:1-php8.5 php -moutput (this research).- This also lines up with FrankenPHP's own build:
php/frankenphp's builder stage installslibsqlite3-devandlibsodium-devas build dependencies for linking the FrankenPHP binary itself (its Caddy layer uses SQLite storage; libsodium is a common Go/Caddy TLS dependency), which is a separate concern from — but consistent with — PHP's ownpdo_sqlite/sodiummodules being pre-built in. —Dockerfile, php/frankenphp, raw.githubusercontent.com
For this project: no RUN install-php-extensions pdo_sqlite sodium line is strictly required — both are already on by default in dunglas/frankenphp:1-php8.5. Recommend still listing them explicitly (harmless — install-php-extensions no-ops on an already-enabled extension) as executable documentation of the requirement, alongside whatever extensions this project's own composer.json / symfony/requirements-checker actually demands (e.g. intl, opcache, zip, apcu per the symfony-docker baseline in §2) — don't rely on an undocumented default that could change across base-image updates.
4. Dev/prod docker-compose.yml + override structure (single service, no reverse proxy)
Per dunglas/symfony-docker's own compose files (fetched directly, main branch) — used here only for the base-vs-override responsibility split and the named var/ volume pattern; this repo's map has already ruled out the multi-service/reverse-proxy shape that repo also ships (Mercure, Postgres, Caddy ports), so only the single-service mechanics are carried over:
- Base
compose.yamlcarries the service's env vars (with${VAR:-default}shell-style defaults) and named volumes. In symfony-docker this iscaddy_data/caddy_config(TLS state) — for this project, the base file's equivalent responsibility is the namedvar/volume so the SQLite cache file (and Symfony'svar/cache,var/log) survive container rebuilds: a top-levelvolumes: app_var:declaration, mounted at/app/var(or wherever this app'svar/lives) on the service. —compose.yaml, dunglas/symfony-docker, raw.githubusercontent.com compose.override.yaml(Docker Compose auto-loads this file alongsidecompose.yamlfor localdocker compose up, per Compose's own merge-and-override docs) carries everything dev-specific:build: { context: ., target: frankenphp_dev }— pins the build to the dev stage of the multi-stage Dockerfile (§2), sodocker compose uplocally builds/runsfrankenphp_dev, never the prod image.volumes: - ./:/app— bind-mounts the whole source tree over the built image for hot-reload; the file adds- /app/varas an anonymous volume entry layered on top of that bind mount specifically sovar/(cache/log/the SQLite file) is not shadowed by the host bind-mount — this repo would instead point that at the namedapp_varvolume from the base file, per the map's requirement that the SQLite cache persist across rebuilds (an anonymous volume gets discarded ondocker compose down -v/ recreate; a named volume declared in the base file does not).environment:overrides —FRANKENPHP_WORKER_CONFIG: watch,APP_ENV: "${APP_ENV:-dev}",XDEBUG_MODE, etc. — i.e. dev-only env vars/toggles layered over whatever the base file / built image already sets. —compose.override.yaml, dunglas/symfony-docker, raw.githubusercontent.com
- Prod runs
compose.yamlalone (docker compose -f compose.yaml up, or any deploy tooling that skips the override), which builds without atarget:override — Docker's multi-stage build convention is that omittingtarget:builds the last stage in the Dockerfile, which in the §2 Dockerfile isfrankenphp_prod. — Compose filebuild.target, docs.docker.com/reference/compose-file/build/#target
For this project: same two-file split, minus everything reverse-proxy/multi-service (no Caddy TLS volumes, no Mercure/Postgres blocks) — single php/app service, named var/ volume in the base file for SQLite persistence, dev-only bind-mount + build target + env overrides in the override file.
Recommended approach
- Base image:
dunglas/frankenphp:1-php8.5(or pin the PHP minor this project settles on) — Debian-based per FrankenPHP's own recommendation (§1). - Dockerfile: three-stage, following
dunglas/symfony-docker's reference shape (§2) — a shared base stage (extensions, Caddyfile, entrypoint), adevstage (Xdebug, watch mode, non-root user), and aprod_builder→prodpair (Composer--no-devinstall, asset-map compile, copied into a minimal final stage). - Extensions:
pdo_sqliteandsodiumare already enabled in this base image (§3, verified by running it) — no extrainstall-php-extensionsstep is required, but list them explicitly anyway alongside the project's other needed extensions (intl,opcache,zip,apcuper the symfony-docker baseline) as documentation that doesn't rely on an implicit default. - Worker mode: leave off by default in prod (matches the reference implementation, §2) — it's a real perf win but requires auditing the app for per-request state leaks first; enable only after that audit, not as part of this ticket's baseline setup.
- Compose: base
docker-compose.yml(single service, no reverse proxy per the map's decision) +docker-compose.override.yamlfor dev, following the responsibility split in §4.
Illustrative shape only — not literal repo code:
# Dockerfile (illustrative)
FROM dunglas/frankenphp:1-php8.5 AS frankenphp_base
WORKDIR /app
RUN install-php-extensions \
pdo_sqlite \
sodium \
intl \
opcache \
zip \
apcu
ENV PHP_INI_SCAN_DIR=":$PHP_INI_DIR/app.conf.d"
CMD ["frankenphp", "run", "--config", "/etc/frankenphp/Caddyfile"]
FROM frankenphp_base AS frankenphp_dev
ENV APP_ENV=dev
RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"
CMD ["frankenphp", "run", "--config", "/etc/frankenphp/Caddyfile", "--watch"]
FROM frankenphp_base AS frankenphp_prod_builder
ENV APP_ENV=prod
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY . .
RUN composer install --no-dev --optimize-autoloader
FROM dunglas/frankenphp:1-php8.5 AS frankenphp_prod
ENV APP_ENV=prod
COPY --from=frankenphp_prod_builder /app /app
WORKDIR /app
USER www-data
# docker-compose.yml (illustrative) — base: prod-shaped, no target: override picks the last stage (frankenphp_prod)
services:
app:
build:
context: .
environment:
APP_ENV: prod
volumes:
- app_var:/app/var # named volume: SQLite cache + var/cache + var/log survive rebuilds
volumes:
app_var:
# docker-compose.override.yaml (illustrative) — auto-loaded by `docker compose up`, dev only
services:
app:
build:
target: frankenphp_dev
volumes:
- ./:/app # bind-mount source for hot reload
- app_var:/app/var # keep the same named volume so var/ isn't shadowed by the bind mount above
environment:
APP_ENV: dev
FRANKENPHP_WORKER_CONFIG: watch
Why: dunglas/frankenphp:1-php8.5 plus install-php-extensions is FrankenPHP's own documented convention (§1, §3); the three-stage dev/prod split and the base/override compose responsibility split both mirror FrankenPHP's own recommended reference implementation for Symfony (dunglas/symfony-docker, §2, §4) minus the reverse-proxy/multi-service pieces this project's map already decided against; the named app_var volume (rather than symfony-docker's anonymous /app/var volume) is the one deliberate deviation, needed specifically so the SQLite cache this map is designing persists across rebuilds rather than being discarded with the container.