Compare commits

Author SHA1 Message Date
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
haylanandClaude-Bot 299343b83b chore: add Context7 MCP server for library docs
Checked docs/research for more useful MCP servers beyond leankg/
codebase-memory-mcp/playwright. Added Context7 (@upstash/context7-mcp,
61.5K stars, official Upstash) via npx — live docs lookup for the
libraries this project actually pulls in (sabre/dav, sabre/vobject,
laminas-feed, endroid/qr-code, FullCalendar, Vite, Symfony), none of
which have an offline index the way msgraph does for Graph.

Evaluated and rejected CalDAV/Nextcloud MCP servers (caldav-mcp 98
stars, aiquila-mcp 34 stars — real but too young, and would need a
live Nextcloud app-password before any account is even provisioned)
and Gitea MCP servers (0-1 stars, redundant with the tea CLI already
standardized on via the gitea-tea skill). No Docker MCP found as an
installable package — Docker's own MCP tooling lives inside Docker
Desktop's MCP Toolkit, not npm/pip, and there's no Dockerfile/CI yet
to make it relevant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tu1c13iVNdj1iWL633yazG
2026-09-01 21:13:40 +02:00
haylanandClaude-Bot b51b832bbd chore: add repo-level .mcp.json for leankg, codebase-memory-mcp, playwright
These were only registered in the user's global ~/.claude.json (with
machine-specific absolute paths), so they weren't available to anyone
else working in this repo. Added a repo-root .mcp.json using bare
command names (leankg, codebase-memory-mcp) so it's portable across
machines once those binaries are on PATH, plus the official Playwright
MCP server via npx (no install needed) — closing the "no separate
Playwright MCP server configured" gap noted in
docs/research/smart-tablet-screen-stack.md §5.

Also gitignore .leankg/ (leankg's local runtime pid/index state,
regenerated on start, not source).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tu1c13iVNdj1iWL633yazG
2026-09-01 21:10:40 +02:00
haylanandClaude-Bot 25f9e0e735 chore: pin Symfony and Microsoft Graph skills for this repo
Research in docs/research flagged missing tooling for the project's
actual stack: Symfony backend work and Microsoft Graph calendar sync
had no matching local skill. Searched skills.sh and installed
project-level:

- dev-toolings/superpowers-symfony (11 of 44 skills, matching what
  stack.md/config-secrets.md already decided on: config/secrets vault,
  bootstrap check, PHPUnit TDD + functional tests + mocking, controller
  cleanup, quality checks, DI/autowiring, Cache component, Doctrine
  migrations). Skipped the API Platform, Messenger/CQRS, hexagonal-
  architecture, Twig-component, and Pest skills — none of those are
  part of the decided stack (plain MVC JSON API, no API Platform, TS
  frontend not Twig, PHPUnit not Pest).
- merill/msgraph (offline-indexed Graph API search, no network calls)
  for the Outlook/Exchange calendar sync decided in stack.md.

No CalDAV/Nextcloud skill was installed: every candidate on skills.sh
is either a personal-calendar CLI wrapper (vdirsyncer+khal) or gated
behind a third-party SaaS account (Membrane) — neither fits writing a
PHP backend integration against sabre/dav, which the research doc
already speccs out directly.

skills-lock.json is tracked; .claude/skills/ payloads are gitignored
(msgraph alone is 112MB of multi-platform binaries + SQLite indexes)
and restored per-clone via `npx skills experimental_install -y`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tu1c13iVNdj1iWL633yazG
2026-09-01 21:07:19 +02:00
6 changed files with 293 additions and 0 deletions
+11
View File
@@ -52,3 +52,14 @@
# Embedded web-server pid file
/.web-server-pid
# Claude Code skill payloads (restorable via `npx skills experimental_install`
# from skills-lock.json, which IS tracked) and agent worktree scratch space
/.claude/skills/
/.claude/worktrees/
# leankg's local runtime state (pid file, index) — regenerated on start
/.leankg/
# Agent scratchpad — throwaway working files, never repo content
/.scratch/
+19
View File
@@ -0,0 +1,19 @@
{
"mcpServers": {
"leankg": {
"command": "leankg",
"args": ["mcp-stdio", "--watch"]
},
"codebase-memory-mcp": {
"command": "codebase-memory-mcp"
},
"playwright": {
"command": "npx",
"args": ["-y", "@playwright/mcp@latest"]
},
"context7": {
"command": "npx",
"args": ["-y", "@upstash/context7-mcp@latest"]
}
}
}
+23
View File
@@ -7,3 +7,26 @@ Issues live as Gitea issues on git.arthurerlich.de/haylan/Smart-Tablet-Screen, m
### Domain docs
Single-context: `CONTEXT.md` + `docs/adr/` at the repo root. See `docs/agents/domain.md`.
### Project skills
`skills-lock.json` at the repo root pins the extra Claude Code skills this project relies on (Symfony, Microsoft Graph). Their payloads aren't committed (large binaries/indexes — see `.gitignore`); restore them once per clone/worktree with:
```
npx skills experimental_install -y
```
No CalDAV/Nextcloud skill is pinned — none found on skills.sh fit writing a PHP backend integration (candidates were CLI personal-calendar tools or SaaS-gated); see `docs/research/smart-tablet-screen-nextcloud-todos.md` for the sabre/dav-based approach instead.
### MCP servers
`.mcp.json` at the repo root wires up this project's MCP servers — Claude Code loads it automatically for anyone working in this repo, no per-machine registration needed:
- **`leankg`** — code-graph/knowledge-graph tooling (architecture queries, call graphs, dead-code/impact analysis). Requires the `leankg` binary on `PATH`: `cargo install leankg`.
- **`codebase-memory-mcp`** — code-graph search and tracing (`search_graph`, `trace_path`, `get_architecture`) used per the Code Discovery Protocol below. Requires the `codebase-memory-mcp` binary on `PATH`: `pipx install codebase-memory-mcp` or `npm install -g codebase-memory-mcp`.
- **`playwright`** — official Microsoft Playwright MCP server, run via `npx` (no install needed). Pairs with the `playwright-e2e-testing` skill and the E2E decision in `docs/research/smart-tablet-screen-stack.md` §5.
- **`context7`** — official Upstash Context7 MCP server, run via `npx` (no install needed). Live, up-to-date docs lookup for the libraries this project actually pulls in — `sabre/dav`/`sabre/vobject`, `laminas-feed`, `endroid/qr-code`, FullCalendar, Vite, Symfony components — none of which have an offline index the way `msgraph` does.
`.mcp.json` uses bare command names (not absolute paths) so it works across machines once `leankg`/`codebase-memory-mcp` are installed; `playwright` and `context7` need nothing preinstalled (`npx` fetches them).
No CalDAV/Nextcloud or Gitea MCP server is pinned. CalDAV/Nextcloud candidates (`caldav-mcp`, `aiquila-mcp`) are real but too young (under 100 GitHub stars) and would need a live Nextcloud app-password wired in before there's even an account provisioned (see `smart-tablet-screen-config-secrets.md`) — revisit once an account exists. Gitea MCP candidates are essentially unused (01 stars) and redundant with the `tea` CLI this repo already standardized on via the `gitea-tea` skill.
@@ -0,0 +1,36 @@
# Smart Tablet Screen — Calendar Webhook vs Polling Research
Research date: 2026-09-01. All claims below are sourced against primary/official documentation, source code, or first-party API references — not third-party summaries. Each claim links its own source. Companion to `docs/research/smart-tablet-screen-stack.md` (§12 cover the base CalDAV/Graph decision this doc extends for change-detection strategy) — written for issue #2, feeds issue #5 ("Cache refresh trigger mechanism").
---
## 1. Nextcloud CalDAV (Mail-in-a-Box bundled): webhook vs sync-token
- **Mail-in-a-Box pins an old Nextcloud version.** Its own setup script hardcodes `nextcloud_ver=27.1.11` (with a matching SHA1 for the downloaded tarball) — this is the version actually installed on any Mail-in-a-Box box today, not whatever is "latest" upstream. — [mail-in-a-box/mailinabox `setup/nextcloud.sh`, GitHub](https://github.com/mail-in-a-box/mailinabox/blob/main/setup/nextcloud.sh)
- **Nextcloud's own "Webhooks" capability (`webhook_listeners` app) only exists from Nextcloud 30.0.0 onward.** It ships as an app inside the `nextcloud/server` monorepo itself (`apps/webhook_listeners/`, added by [PR #45475](https://github.com/nextcloud/server/pull/45475)) — it is not a separately-maintained GitHub repo (`nextcloud/webhook_listeners` does not exist as an independent project; the app lives at [`nextcloud/server/apps/webhook_listeners`](https://github.com/nextcloud/server/tree/master/apps/webhook_listeners)). — [nextcloud/server PR #45475, GitHub](https://github.com/nextcloud/server/pull/45475), [nextcloud/server `apps/webhook_listeners`, GitHub](https://github.com/nextcloud/server/tree/master/apps/webhook_listeners)
- **Consequence: Mail-in-a-Box's Nextcloud 27.1.11 predates `webhook_listeners` by three major versions (27 → 30).** The app cannot be present or enabled on a Mail-in-a-Box-bundled Nextcloud instance today — it isn't a matter of the household needing to install anything extra; the underlying Nextcloud version doesn't ship the feature at all. This directly answers the ticket's "is it realistic to assume it's present" question: no.
- **Even where present (Nextcloud ≥30), `webhook_listeners` does support calendar/event change events.** Nextcloud's own admin manual lists supported event categories including calendar object creation, update, deletion, and movement between calendars (`CalendarObjectCreatedEvent`, `CalendarObjectUpdatedEvent`, `CalendarObjectDeletedEvent`, `CalendarObjectMovedEvent`), alongside file, tag, Forms, and Tables events. It comes bundled with core Nextcloud but is **not enabled by default** — an admin must run `occ app:enable webhook_listeners`, and registering a webhook additionally requires an admin (or delegated-admin) account via the OCS API. Delivery itself runs through a background job, triggered roughly every 5 minutes by default rather than instantly. — [Webhook Listeners, Nextcloud Administration Manual, docs.nextcloud.com](https://docs.nextcloud.com/server/stable/admin_manual/webhook_listeners/index.html)
- **Absent (or unusable) webhooks, Nextcloud's CalDAV implementation supports WebDAV Collection Sync per RFC 6578** — the standard, protocol-level alternative to full re-fetch. The client requests a sync-token via a `sync-collection` REPORT; the server replies only with items created/modified/deleted since that token, plus a new token to store for the next round. This is a generic CalDAV/WebDAV mechanism (not Nextcloud-specific), defined by the IETF standard itself. — [RFC 6578 — Collection Synchronization for WebDAV, datatracker.ietf.org](https://datatracker.ietf.org/doc/html/rfc6578)
- **This sync-token mechanism still requires a client-initiated poll** — RFC 6578 makes each poll cheap (only the delta is returned) but does not give the server a way to push to the client; the client must periodically re-issue the `sync-collection` REPORT to learn whether anything changed. There is no server-initiated notification path in the CalDAV/WebDAV spec itself, and (per the point above) Mail-in-a-Box's pinned Nextcloud version has no bundled app that adds one.
## 2. Microsoft Graph webhook change notifications for calendar events
- **Graph's `/subscriptions` API explicitly supports the `event` resource.** The Outlook `event` resource is listed among subscribable resources with resource path `/me/events` / `/users/{id}/events`, supporting both delegated (personal Microsoft account, via `Calendars.Read`) and application permissions — confirming free Outlook.com/personal-account calendars are covered, consistent with §2 of the base stack research. — [Create subscription, Microsoft Graph v1.0, learn.microsoft.com](https://learn.microsoft.com/en-us/graph/api/subscription-post-subscriptions?view=graph-rest-1.0), [Set up notifications for changes in resource data — Supported resources, learn.microsoft.com](https://learn.microsoft.com/en-us/graph/change-notifications-overview#supported-resources)
- **Creating a subscription**: `POST https://graph.microsoft.com/v1.0/subscriptions` with a JSON body containing `changeType` (`created`, `updated`, `deleted`, comma-separated as needed), `notificationUrl` (the app's public HTTPS webhook endpoint), `resource` (e.g. `/me/events`), `expirationDateTime`, and a required `clientState` secret the app later uses to confirm notifications genuinely came from Graph. A successful call returns `201 Created` with the subscription object including its `id`. — [Create subscription, Microsoft Graph v1.0, learn.microsoft.com](https://learn.microsoft.com/en-us/graph/api/subscription-post-subscriptions?view=graph-rest-1.0)
- **Validation-token handshake (mandatory, happens synchronously during subscription creation)**: Graph sends `POST https://{notificationUrl}?validationToken={opaqueToken}` with `Content-Type: text/plain`. The app's endpoint must URL-decode the token and respond within **10 seconds** with HTTP `200 OK`, `Content-Type: text/plain`, and a body containing exactly the plain-text (decoded) validation token — an HTML/JS-encoded response fails validation. If this handshake fails, Graph does not create the subscription (`400 Bad Request`) at all. — [Receive change notifications through webhooks — notificationUrl validation, learn.microsoft.com](https://learn.microsoft.com/en-us/graph/change-notifications-delivery-webhooks#notificationurl-validation)
- **Notification payload shape delivered on change**: a `POST` to `notificationUrl` carrying a `changeNotificationCollection` — a JSON object with a `value` array, where each entry has `id`, `subscriptionId`, `subscriptionExpirationDateTime`, `clientState` (must be checked against the value set at creation to reject spoofed notifications), `changeType`, `resource` (path to the changed item), `tenantId`, and a `resourceData` block (`@odata.type`, `@odata.id`, `@odata.etag`, `id`) identifying the changed resource — by default this is a **basic notification** (no event body/fields, just enough to know something changed and re-fetch it). A single `POST` may bundle multiple notifications across subscriptions. The app must return `2xx` within 3 seconds (`200` if processed inline, `202` if merely queued) or Graph begins retrying for up to 4 hours with exponential backoff before giving up. — [Receive change notifications through webhooks, learn.microsoft.com](https://learn.microsoft.com/en-us/graph/change-notifications-delivery-webhooks)
- **Maximum subscription duration for `event` (and `message`, `contact`) is under 7 days: 10,080 minutes.** (Rich/resource-data subscriptions on these resources are capped much lower, at under 1 day — 1,440 minutes — but this project would use basic notifications and re-fetch via the API, so the 10,080-minute ceiling applies.) Graph subscriptions are explicitly **not indefinite** — the docs state apps "need to renew their subscriptions before the expiration time; Otherwise, they need to create a new subscription." — [Set up notifications for changes in resource data — Subscription lifetime, learn.microsoft.com](https://learn.microsoft.com/en-us/graph/change-notifications-overview#subscription-lifetime)
- **Renewal**: `PATCH https://graph.microsoft.com/v1.0/subscriptions/{id}` with a JSON body containing only the new `expirationDateTime` (must still respect the same per-resource max-duration ceiling from the point above — a single renewal cannot push the expiry further than ~7 days out from the renewal call). A successful renewal returns `200 OK` with the updated subscription object. Each change notification also carries the current `subscriptionExpirationDateTime`, which the docs recommend using as a cue for when to renew. Optionally, a `lifecycleNotificationUrl` can be registered alongside `notificationUrl` to receive an explicit "subscription about to expire" / "reauthorization required" push rather than relying solely on tracking dates client-side. — [Receive change notifications through webhooks — Subscription lifecycle / Renew a subscription, learn.microsoft.com](https://learn.microsoft.com/en-us/graph/change-notifications-delivery-webhooks#subscription-lifecycle)
---
## Recommended approach
For issue #5 ("Cache refresh trigger mechanism"), use two different strategies per backend, since only one of the two sources has a usable push mechanism:
- **Nextcloud/Mail-in-a-Box (CalDAV)**: **poll with WebDAV Collection Sync (RFC 6578 sync-token)**, not webhooks. Mail-in-a-Box's pinned Nextcloud (27.1.11) predates the `webhook_listeners` app (Nextcloud ≥30) entirely, so no webhook path exists on this backend without the household upgrading Nextcloud out-of-band from Mail-in-a-Box's own installer — not something to build a dependency on. Implement the Symfony CalDAV client to store each calendar's sync-token, issue a periodic `sync-collection` REPORT (e.g. on the cache-refresh job's normal cadence), and only re-parse/update the SQLite cache for the returned deltas rather than re-fetching the whole calendar every cycle.
- **Microsoft Graph**: **use webhook change notifications** (`/subscriptions` on `/me/events`), since Graph explicitly supports it end-to-end for both personal and work/school accounts. Implementation shape:
1. Symfony backend exposes a public HTTPS `notificationUrl` endpoint that (a) handles the synchronous `validationToken` handshake (echo it back as `text/plain`, `200 OK`, within 10s) and (b) accepts subsequent `POST` change-notification payloads, checks `clientState`, and — since notifications are basic (no event data) — triggers a re-fetch of the changed event/calendar into the SQLite cache.
2. Create the subscription with `changeType: "created,updated,deleted"`, `resource: "/me/events"`, and an `expirationDateTime` no more than ~7 days out (10,080-minute cap).
3. **Renewal is mandatory, not optional** — schedule a recurring job (well inside the 7-day window, e.g. daily) that `PATCH`es the subscription with a fresh `expirationDateTime`; a lapsed subscription silently stops delivering notifications. Track `subscriptionExpirationDateTime` from incoming notifications, or subscribe to `lifecycleNotificationUrl` events, as a second signal so a missed renewal is self-detected rather than silently going stale.
4. Keep a **low-frequency polling fallback** even with the webhook wired up — Graph notifications can be delayed, dropped after throttling, or silently lapse if renewal fails; a periodic full-resync (e.g. once daily) against `/me/events` guards against any single missed/expired subscription leaving the dashboard's cache stale indefinitely.
@@ -0,0 +1,127 @@
# 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/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/](https://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/](https://frankenphp.dev/docs/symfony/). This points at [dunglas/symfony-docker](https://github.com/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](https://raw.githubusercontent.com/dunglas/symfony-docker/main/Dockerfile)
- **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/](https://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/](https://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](https://github.com/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](https://raw.githubusercontent.com/php/frankenphp/main/Dockerfile)
- 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/](https://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](https://github.com/mlocati/docker-php-extension-installer), 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](https://raw.githubusercontent.com/php/frankenphp/main/Dockerfile)
**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](https://raw.githubusercontent.com/dunglas/symfony-docker/main/compose.yaml)
- **`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](https://docs.docker.com/compose/how-tos/multiple-compose-files/merge/)) 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](https://raw.githubusercontent.com/dunglas/symfony-docker/main/compose.override.yaml)
- **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](https://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
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_builder``prod` 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
# 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
```
```yaml
# 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:
```
```yaml
# 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.
+77
View File
@@ -0,0 +1,77 @@
{
"version": 1,
"skills": {
"msgraph": {
"source": "merill/msgraph",
"sourceType": "github",
"skillPath": "skills/msgraph/SKILL.md",
"computedHash": "f5a3d61621226c55a9c4e62831da4176e58239c71e9f8067937dba5d6d86c3f7"
},
"symfony:bootstrap-check": {
"source": "dev-toolings/superpowers-symfony",
"sourceType": "github",
"skillPath": "skills/bootstrap-check/SKILL.md",
"computedHash": "b0770d00e974d3077f0dbab3aaab5b644a67cce72f099a9363fd68a6e7262b43"
},
"symfony:config-env-parameters": {
"source": "dev-toolings/superpowers-symfony",
"sourceType": "github",
"skillPath": "skills/config-env-parameters/SKILL.md",
"computedHash": "29c30ab73cf591f6940991e1e297836499e1760d390d0fea4faffbf9bc0d42d0"
},
"symfony:controller-cleanup": {
"source": "dev-toolings/superpowers-symfony",
"sourceType": "github",
"skillPath": "skills/controller-cleanup/SKILL.md",
"computedHash": "e581ac4fa9f7e59035e17a96cdb306ae6fb4c1fe2bb2ddea75fbc8b3c61b7a7f"
},
"symfony:doctrine-migrations": {
"source": "dev-toolings/superpowers-symfony",
"sourceType": "github",
"skillPath": "skills/doctrine-migrations/SKILL.md",
"computedHash": "f567cc35a98fe8ac6e6632d4da5990f974d4028c9517672586c42a3528861425"
},
"symfony:functional-tests": {
"source": "dev-toolings/superpowers-symfony",
"sourceType": "github",
"skillPath": "skills/functional-tests/SKILL.md",
"computedHash": "fe487c7a86acc172e4d2d9130d04f7377f3950ce654d095f64a25829b9811435"
},
"symfony:interfaces-and-autowiring": {
"source": "dev-toolings/superpowers-symfony",
"sourceType": "github",
"skillPath": "skills/interfaces-and-autowiring/SKILL.md",
"computedHash": "18efd79e2b99806aaf7e3311391191e3041fede2e347d474a3c592af5b6e43e7"
},
"symfony:quality-checks": {
"source": "dev-toolings/superpowers-symfony",
"sourceType": "github",
"skillPath": "skills/quality-checks/SKILL.md",
"computedHash": "603a2a61a4515727f2de2a93d5ef7fcc6bb24da6b3d096f58724b81f18b437a4"
},
"symfony:symfony-cache": {
"source": "dev-toolings/superpowers-symfony",
"sourceType": "github",
"skillPath": "skills/symfony-cache/SKILL.md",
"computedHash": "92bfade421c2190100ba603b9d0d05d260594be3b4725ba98a9c8963433d7a97"
},
"symfony:tdd-with-phpunit": {
"source": "dev-toolings/superpowers-symfony",
"sourceType": "github",
"skillPath": "skills/tdd-with-phpunit/SKILL.md",
"computedHash": "484f05f7a16bf1e161eb4d2770b693526eb156f2a7502ea5833f64f5ef83e233"
},
"symfony:test-doubles-mocking": {
"source": "dev-toolings/superpowers-symfony",
"sourceType": "github",
"skillPath": "skills/test-doubles-mocking/SKILL.md",
"computedHash": "943d6a81a333d1a3a454510cf972c38181428a6d6186d1a19cfbe99ea7ffe3e1"
},
"symfony:using-symfony-superpowers": {
"source": "dev-toolings/superpowers-symfony",
"sourceType": "github",
"skillPath": "skills/using-symfony-superpowers/SKILL.md",
"computedHash": "8666a5b730f5cd8d1b66d07bfdf2afee3bdf0b41acc2a3d699a2bcff55c4151b"
}
}
}