Files
Smart-Tablet-Screen/docs/research/smart-tablet-screen-frankenphp-docker.md
T
haylanandClaude-Bot 07670c5f44 docs(research): calendar webhook support + FrankenPHP Docker setup
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
2026-09-01 21:37:53 +02:00

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.


Per FrankenPHP's own Docker docs:

  • The image is dunglas/frankenphp on 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: trixie and bookworm (Debian) or alpine. — 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.5 succeeded, digest sha256: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, main branch) is a three-stage build:
    • frankenphp_base — shared stage: installs install-php-extensions @composer apcu intl opcache zip, sets PHP_INI_SCAN_DIR, copies in the app's php.ini overrides, entrypoint script, and Caddyfile. Ends with a HEALTHCHECK hitting FrankenPHP's own /metrics admin endpoint and CMD [ "frankenphp", "run", "--config", "/etc/frankenphp/Caddyfile" ].
    • frankenphp_dev (extends frankenphp_base) — sets APP_ENV=dev, FRANKENPHP_WORKER_CONFIG=watch, switches to php.ini-development, installs xdebug, adds a non-root user, and runs frankenphp run --config … --watch (file-watching hot reload).
    • frankenphp_prod_builder (extends frankenphp_base) → frankenphp_prod — builder stage runs composer install --no-dev, composer dump-autoload --classmap-authoritative --no-dev, asset-map compile, etc., then a separate final stage FROM debian:13-slim copies over only the compiled FrankenPHP/PHP binaries, extensions, shared libraries (via libtree), certs, and the built /app — producing a minimal prod image that doesn't carry build tooling. Runs as www-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_CONFIG env var (worker /path/to/script.php) or a Caddyfile worker directive; 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-symfony Composer 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's CMD runs plain frankenphp run with 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/frankenphp repo, main branch) pre-installs the install-php-extensions script (from mlocati/docker-php-extension-installer) into every FrankenPHP image at /usr/local/bin/install-php-extensions — confirmed by the curl … -o /usr/local/bin/install-php-extensions step 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 — no docker-php-ext-install/manual compile step needed; it's the same convention dunglas/symfony-docker's own Dockerfile uses (install-php-extensions @composer apcu intl opcache zip, §2). — Docker, frankenphp.dev/docs/docker/
  • pdo_sqlite and sodium are already enabled by default in this project's target image — verified directly, not inferred: docker run --rm dunglas/frankenphp:1-php8.5 php -m lists pdo_sqlite, sqlite3, and sodium in [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 same pdo_sqlite/sodium pairing pre-enabled in php:8.5-cli's php -m output. — docker-php-extension-installer README, mlocati/docker-php-extension-installer, GitHub, and direct docker run dunglas/frankenphp:1-php8.5 php -m output (this research).
  • This also lines up with FrankenPHP's own build: php/frankenphp's builder stage installs libsqlite3-dev and libsodium-dev as 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 own pdo_sqlite/sodium modules 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.yaml carries the service's env vars (with ${VAR:-default} shell-style defaults) and named volumes. In symfony-docker this is caddy_data/caddy_config (TLS state) — for this project, the base file's equivalent responsibility is the named var/ volume so the SQLite cache file (and Symfony's var/cache, var/log) survive container rebuilds: a top-level volumes: app_var: declaration, mounted at /app/var (or wherever this app's var/ lives) on the service. — compose.yaml, dunglas/symfony-docker, raw.githubusercontent.com
  • compose.override.yaml (Docker Compose auto-loads this file alongside compose.yaml for local docker 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), so docker compose up locally builds/runs frankenphp_dev, never the prod image.
    • volumes: - ./:/app — bind-mounts the whole source tree over the built image for hot-reload; the file adds - /app/var as an anonymous volume entry layered on top of that bind mount specifically so var/ (cache/log/the SQLite file) is not shadowed by the host bind-mount — this repo would instead point that at the named app_var volume from the base file, per the map's requirement that the SQLite cache persist across rebuilds (an anonymous volume gets discarded on docker 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.yaml alone (docker compose -f compose.yaml up, or any deploy tooling that skips the override), which builds without a target: override — Docker's multi-stage build convention is that omitting target: builds the last stage in the Dockerfile, which in the §2 Dockerfile is frankenphp_prod. — Compose file build.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.


  1. Base image: dunglas/frankenphp:1-php8.5 (or pin the PHP minor this project settles on) — Debian-based per FrankenPHP's own recommendation (§1).
  2. Dockerfile: three-stage, following dunglas/symfony-docker's reference shape (§2) — a shared base stage (extensions, Caddyfile, entrypoint), a dev stage (Xdebug, watch mode, non-root user), and a prod_builderprod pair (Composer --no-dev install, asset-map compile, copied into a minimal final stage).
  3. Extensions: pdo_sqlite and sodium are already enabled in this base image (§3, verified by running it) — no extra install-php-extensions step is required, but list them explicitly anyway alongside the project's other needed extensions (intl, opcache, zip, apcu per the symfony-docker baseline) as documentation that doesn't rely on an implicit default.
  4. 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.
  5. Compose: base docker-compose.yml (single service, no reverse proxy per the map's decision) + docker-compose.override.yaml for 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.