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
haylanandClaude-Bot f045d1bc93 chore: set up agent skills config (issue tracker, domain docs)
Add AGENTS.md/CLAUDE.md agent-skills block, docs/agents/issue-tracker.md
(Gitea via tea CLI) and docs/agents/domain.md (single-context layout).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 20:58:56 +02:00
haylanandClaude-Bot a6fd372123 docs(research): add YAML account config and secrets vault research
Cover Symfony Config component typed-list patterns (Doctrine/Messenger
precedents) and the native secrets vault vs. plaintext YAML/SOPS for
encrypting account credentials.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 20:54:07 +02:00
10 changed files with 654 additions and 0 deletions
+11
View File
@@ -52,3 +52,14 @@
# Embedded web-server pid file # Embedded web-server pid file
/.web-server-pid /.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"]
}
}
}
+32
View File
@@ -0,0 +1,32 @@
## Agent skills
### Issue tracker
Issues live as Gitea issues on git.arthurerlich.de/haylan/Smart-Tablet-Screen, managed via the `tea` CLI. See `docs/agents/issue-tracker.md`.
### 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.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+51
View File
@@ -0,0 +1,51 @@
# Domain Docs
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
## Before exploring, read these
- **`CONTEXT.md`** at the repo root, or
- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved.
## File structure
Single-context repo (most repos):
```
/
├── CONTEXT.md
├── docs/adr/
│ ├── 0001-event-sourced-orders.md
│ └── 0002-postgres-for-write-model.md
└── src/
```
Multi-context repo (presence of `CONTEXT-MAP.md` at the root):
```
/
├── CONTEXT-MAP.md
├── docs/adr/ ← system-wide decisions
└── src/
├── ordering/
│ ├── CONTEXT.md
│ └── docs/adr/ ← context-specific decisions
└── billing/
├── CONTEXT.md
└── docs/adr/
```
## Use the glossary's vocabulary
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`).
## Flag ADR conflicts
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_
+39
View File
@@ -0,0 +1,39 @@
# Issue tracker: Gitea
Issues and specs for this repo live as Gitea issues on `git.arthurerlich.de/haylan/Smart-Tablet-Screen`. Use the `tea` CLI for all operations.
## Conventions
- **Create an issue**: `tea issues create -r haylan/Smart-Tablet-Screen --title "..." --description "..."`
- **Read an issue**: `tea issues view -r haylan/Smart-Tablet-Screen <number>`
- **List issues**: `tea issues list -r haylan/Smart-Tablet-Screen`
- **Comment on an issue**: `tea comment -r haylan/Smart-Tablet-Screen <number> "..."`
- **Apply / remove labels, assignees, milestone**: `tea issues edit -r haylan/Smart-Tablet-Screen --add-labels "..." --add-assignees "..." <number>` (use `--remove-labels`/`--remove-assignees` to unset)
- **Close / reopen**: `tea issues close -r haylan/Smart-Tablet-Screen <number>` / `tea issues reopen -r haylan/Smart-Tablet-Screen <number>`
Repo is inferred from `git remote -v` when running `tea` from the repo root; pass `-r haylan/Smart-Tablet-Screen` explicitly if inference fails.
## Pull requests as a triage surface
**PRs as a request surface: no.** _(Set to `yes` if this repo treats external PRs as feature requests; `/triage` reads this flag — not relevant yet since `triage` isn't installed.)_
When set to `yes`, use the `tea pulls` equivalents: `tea pulls view`, `tea pulls create`, `tea comment <number> "..."`, `tea pulls merge`.
## When a skill says "publish to the issue tracker"
Create a Gitea issue with `tea issues create`.
## When a skill says "fetch the relevant ticket"
Run `tea issues view -r haylan/Smart-Tablet-Screen <number>`.
## Wayfinding operations
Gitea has no native issue-dependency/blocking API (unlike GitHub/GitLab), so fall back to plain-text conventions:
- **Map**: a single issue labelled `wayfinder:map`, holding the Notes / Decisions-so-far / Fog body.
- **Child ticket**: an issue with `Part of #<map>` at the top of its description, labelled `wayfinder:<type>` (`research`/`prototype`/`grilling`/`task`). Once claimed, assign it to the driving dev.
- **Blocking**: a `Blocked by: #<n>, #<n>` line at the top of the description — no native link available. A ticket is unblocked when every blocker listed there is closed.
- **Frontier query**: `tea issues list -r haylan/Smart-Tablet-Screen`, scoped to the map's children, drop any with an open blocker in its `Blocked by` line or an assignee; first in map order wins.
- **Claim**: `tea issues edit -r haylan/Smart-Tablet-Screen --add-assignees "@me" <n>` — the session's first write.
- **Resolve**: `tea comment -r haylan/Smart-Tablet-Screen <n> "<answer>"`, then `tea issues close -r haylan/Smart-Tablet-Screen <n>`, then append a context pointer to the map's Decisions-so-far.
@@ -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,261 @@
# Smart Tablet Screen — Multi-Account Config & Secrets Research
Research date: 2026-09-01. Scope: how to configure the project's multiple account "sources" (Microsoft/Exchange OAuth2, N Mail-in-a-Box CalDAV accounts, a Nextcloud CalDAV account, an RSS feed list, a Diun webhook secret) via YAML, and how to keep the secret values out of plaintext. All claims sourced against primary/official docs; each claim links its own source. This report does not redo the research in `smart-tablet-screen-stack.md`, `smart-tablet-screen-additions.md`, or `smart-tablet-screen-nextcloud-todos.md`, and does not modify them.
---
## 1. Modeling "N accounts, each with a type and connection details" in Symfony config
### 1.1 The Config component's `TreeBuilder` + `useAttributeAsKey()` + prototypes
Symfony's own Config component docs describe defining a config schema as a `Configuration` class returning a `TreeBuilder`:
```php
class DatabaseConfiguration implements ConfigurationInterface
{
public function getConfigTreeBuilder(): TreeBuilder
{
$treeBuilder = new TreeBuilder('database');
return $treeBuilder;
}
}
```
— [Defining and Processing Configuration Values — Symfony Config component, symfony.com/doc](https://symfony.com/doc/current/components/config/definition.html)
The relevant piece for "a list of named entries, each with a shared shape" is the combination of an array node's **prototype** (the shape repeated for every entry) with **`useAttributeAsKey()`** (which preserves the YAML map key — e.g. the account name — as an array key rather than discarding it, which is the array node's default behavior for list-like input):
```php
$node
->children()
->arrayNode('connections', 'connection')
->useAttributeAsKey('name') // Preserves keys
->arrayPrototype()
->children()
->scalarNode('table')->end()
->scalarNode('user')->end()
->scalarNode('password')->end()
->end()
->end()
->end()
->end()
;
```
Given
```yaml
connections:
sf_connection:
table: symfony
user: root
default:
table: foo
user: root
```
this produces a PHP array keyed by `sf_connection` and `default`, each holding its own validated sub-tree — [Config component — Defining Configuration Trees, symfony.com/doc/current/components/config/definition.html](https://symfony.com/doc/current/components/config/definition.html)
This is exactly the shape needed for "N Mail-in-a-Box accounts, each keyed by a name, each with its own `url`/`user`/`password` fields."
### 1.2 How a bundle exposes that schema as YAML
Symfony's bundle configuration docs confirm the two supported ways to wire a `Configuration` schema up to real `config/packages/*.yaml` input:
- **`AbstractBundle`** (current recommended style): define the tree directly in the bundle class via `configure(DefinitionConfigurator $definition)`, and read the merged/validated `$config` array in `loadExtension()`.
- **Traditional Extension + separate `Configuration` class**: a `src/DependencyInjection/Configuration.php` implementing `ConfigurationInterface`, whose `getConfigTreeBuilder()` is passed into `processConfiguration()` inside the bundle's `Extension::load()` — this is what actually validates and merges the YAML.
Either way, the **root key is derived from the bundle/extension name** and the user-facing config lives in `config/packages/<extension_name>.yaml`, e.g.:
```yaml
# config/packages/acme_social.yaml
acme_social:
twitter:
client_id: 123
client_secret: your_secret
```
— [How to Create Friendly Configuration for a Bundle, symfony.com/doc/current/bundles/configuration.html](https://symfony.com/doc/current/bundles/configuration.html)
The `config:dump-reference` console command (documented on the same page) dumps the effective schema for a given config root — useful for verifying a `smart_screen.yaml` schema once written, without hand-checking every node.
**Applied to this project**: a `SmartScreenBundle` (or its `Extension`+`Configuration` pair) defining a `smart_screen` root, with sub-trees for `accounts.exchange`, `accounts.caldav` (list, `useAttributeAsKey`), `accounts.nextcloud`, `rss.feeds` (list), and `diun.webhook`, is the idiomatic Symfony way to validate this project's `accounts.yaml`-shaped config rather than reading raw YAML by hand.
### 1.3 Real first-party precedent for "N named entries, each with a type/DSN discriminator"
Two of Symfony's own subsystems use exactly this "list of named, typed connections" pattern in their reference YAML, confirming it's an established, idiomatic pattern rather than something bespoke to invent for this project:
**Doctrine DBAL — multiple named connections:**
```yaml
doctrine:
dbal:
default_connection: default
connections:
default:
dbname: Symfony2
user: root
password: null
host: localhost
customer:
dbname: customer
user: root
password: null
host: localhost
```
Each connection is also exposed as its own service, `doctrine.dbal.[name]_connection`. — [DoctrineBundle Configuration Reference, symfony.com/bundles/DoctrineBundle/current/configuration.html](https://symfony.com/bundles/DoctrineBundle/current/configuration.html)
**Messenger — multiple named transports, each keyed by name with its own `dsn`/`options`:**
```yaml
# config/packages/messenger.yaml
framework:
messenger:
transports:
async_priority_high:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
queue_name: high
async_priority_low:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
queue_name: low
routing:
'App\Message\SmsNotification': async_priority_low
'App\Message\NewUserWelcomeEmail': async_priority_high
```
A transport can also be written as a bare DSN string (`async: "%env(MESSENGER_TRANSPORT_DSN)%"`) when no extra options are needed, which is the shorthand form of the same prototype. — [Messenger: Sync & Queued Message Handling, symfony.com/doc/current/messenger.html](https://symfony.com/doc/current/messenger.html)
**Relevance to this project**: Messenger's transport DSN string (`amqp://...`, `doctrine://...`, `sync://`) is effectively a "type" encoded in the URL scheme, dispatched by Messenger's transport factory chain to the right transport implementation. The same idea maps directly onto this project's account list: each entry can carry an explicit `type: exchange|caldav_miab|caldav_nextcloud` discriminator (clearer for four genuinely different shapes than trying to smuggle type into a URL scheme), with the Symfony `Configuration` tree validating that each type's required fields are present — while `%env(...)%` placeholders (see §2) fill in the actual secret values, exactly as Messenger does with `%env(MESSENGER_TRANSPORT_DSN)%`.
### 1.4 On the "known-good YAML for multiple external accounts with a type field" comparison target
The task asked to check a real OSS project (e.g. mbin/friendica) configuring multiple external accounts via YAML with a discriminated type field, as a second data point beyond Symfony's own subsystems. Symfony's own Messenger transports config (§1.3) already **is** that pattern, first-party, documented, and directly reusable by this project without adopting an unrelated project's schema conventions — so no separate third-party project was adopted as a model; Messenger's transports and Doctrine's connections together cover the "list of named, typed entries" precedent this project needs.
---
## 2. Encrypting the secrets
### 2.1 Confirm: no encryption in plain YAML
Symfony's Config/bundle-configuration docs contain no mechanism for encrypting values inside a YAML file — YAML itself is a plain serialization format with no such feature, and the fields Symfony's `Configuration` tree validates are read as literal strings unless the value is an `%env(...)%` placeholder (§2.3). The framework's own answer to "how do I keep a config value secret" is the dedicated secrets vault, not YAML: Symfony's configuration docs state plainly:
> "Instead of defining a real environment variable or adding it to a `.env` file, if the value of a variable is sensitive (e.g. an API key or a database password), you can encrypt the value using the [secrets management system]."
— [Configuring Symfony, symfony.com/doc/current/configuration.html](https://symfony.com/doc/current/configuration.html)
### 2.2 Symfony's native secrets vault
Per Symfony's own secrets docs — [Configuring Symfony — Secrets, symfony.com/doc/current/configuration/secrets.html](https://symfony.com/doc/current/configuration/secrets.html):
- The vault requires the **Sodium PHP extension**. Keys are generated per environment: `php bin/console secrets:generate-keys` produces `config/secrets/<env>/<env>.encrypt.public.php` (safe to commit — used only to encrypt/add secrets) and `config/secrets/<env>/<env>.decrypt.private.php` (must **not** be committed for `prod` — used to decrypt).
- **Algorithm: libsodium asymmetric sealed boxes** — public-key encryption where the public key encrypts and only the private key decrypts, giving confidentiality and integrity without needing the decryption key present during container compilation/cache warmup.
- Secrets are set via `php bin/console secrets:set SECRET_NAME` (interactively, from a file, from STDIN, or `--random`), listed via `secrets:list` (`--reveal` to show plaintext), and removed via `secrets:remove`.
- A secret is referenced in YAML exactly like a normal env var, via the `%env(...)%` syntax:
```yaml
# config/packages/doctrine.yaml
doctrine:
dbal:
password: '%env(DATABASE_PASSWORD)%'
```
- Production deployment options: copy `prod.decrypt.private.php` to the server, or set `SYMFONY_DECRYPTION_SECRET` (base64 of the private key) as a real env var, or run `secrets:decrypt-to-local` during deploy so the private key never needs to live on the server long-term.
### 2.3 `.env` vs. the vault — and whether they coexist
Symfony's configuration docs lay out the `.env` file hierarchy and its intended scope explicitly:
> "This file should be committed to your repository and (due to that fact) should only contain 'default' values that are good for local development. This file should not contain production values."
with `.env` (defaults), `.env.local` (uncommitted, machine-specific), `.env.<environment>` (committed, per-environment), and `.env.<environment>.local` (uncommitted, per-environment-and-machine) — [Configuring Symfony, symfony.com/doc/current/configuration.html](https://symfony.com/doc/current/configuration.html). Immediately after that, the same page hands sensitive values off to the secrets system quoted in §2.1.
**They coexist by design, not by accident**: the secrets vault docs show the vault has an explicit "local override" escape hatch — `php bin/console secrets:set SECRET_NAME --local` writes the value straight into `.env.<env>.local` as plaintext, and that local `.env` value takes precedence over the vault's encrypted value. This is the documented mechanism for "give a developer a working non-production secret locally without touching the committed vault." — [Configuring Symfony — Secrets, symfony.com/doc/current/configuration/secrets.html](https://symfony.com/doc/current/configuration/secrets.html)
**Conclusion for this project** (matching the plan already assumed in `smart-tablet-screen-stack.md`, which put the Graph client id/secret/refresh token in `.env`): keep genuinely non-sensitive, per-environment configuration (feature flags, base URLs that aren't secret, `APP_ENV`) in `.env`/`.env.local` as before, but move the four actual secret values this project has — Graph `client_secret` and refresh token, each Mail-in-a-Box account's app-password, the Nextcloud account's app-password, and the Diun webhook shared token — into the secrets vault, referenced from YAML via `%env(...)%`. This is a refinement of, not a contradiction to, the earlier `.env`-based plan: it only reclassifies which values live in which of the two documented buckets.
### 2.4 Can the vault hold structured/multiple values, or is the answer a YAML+vault hybrid?
The vault's own interface (`secrets:set NAME`, `secrets:list`, `%env(NAME)%`) is a **flat, name→string store** — each secret is one named string value, decrypted individually into one env var. There is no first-party "store a nested object/array as one secret" primitive in the commands themselves. However, Symfony's **env var processors** dock directly onto both plain env vars and vault-backed secrets (since a resolved secret becomes an ordinary env var to the container at runtime), and two of them make structured secret values workable:
- **`env(json:FOO)`** — decodes a JSON-encoded env var into an array:
```yaml
parameters:
env(ALLOWED_LANGUAGES): '["en","de","es"]'
app_allowed_languages: '%env(json:ALLOWED_LANGUAGES)%'
```
- **`env(resolve:FOO)`** — interpolates `%parameter_name%` container parameters *inside* an env var's value before it's used, useful for composing a value (e.g. a DSN) from one secret plus other non-secret parameters:
```yaml
parameters:
sentry_host: '10.0.0.1'
env(SENTRY_DSN): 'http://%sentry_host%/project'
sentry:
dsn: '%env(resolve:SENTRY_DSN)%'
```
- Processors chain (e.g. `%env(json:file:resolve:AUTH_FILE)%` — resolve a path, read the file, JSON-decode it), showing the mechanism is designed to compose.
— [Environment Variable Processors, symfony.com/doc/current/configuration/env_var_processors.html](https://symfony.com/doc/current/configuration/env_var_processors.html)
**Practical answer for this project**: don't try to cram the whole `accounts.yaml` account list into one vault secret (JSON-blob-in-a-vault-entry). The clean split, directly matching what Messenger/Doctrine already do (§1.3), is a **hybrid**:
- Keep the *structure* — the list of accounts, each account's `type`, non-secret connection fields (server URLs, usernames, feed URLs, account display names) — in plain committed YAML (`config/packages/smart_screen.yaml`), validated by a `Configuration` tree (§1).
- Put only the *actual secret strings* (app-passwords, client secret, refresh token, webhook token) into individually-named vault secrets, and reference each one from the YAML via a `%env(SECRET_NAME)%` placeholder in the relevant field — exactly the pattern Doctrine's own reference config uses for `password: '%env(DATABASE_PASSWORD)%'` (§2.2) and Messenger uses for `dsn: '%env(MESSENGER_TRANSPORT_DSN)%'` (§1.3).
- For N Mail-in-a-Box accounts each needing their own app-password, this means N vault secrets (e.g. `MIAB_HOME_APP_PASSWORD`, `MIAB_WORK_APP_PASSWORD`), one per account entry, each referenced by name from that account's YAML block — no processor trickery needed since each field is just one string.
---
## 3. SOPS as a comparison point
Per SOPS's own README:
> "SOPS is an editor of encrypted files that supports YAML, JSON, ENV, INI and BINARY formats."
SOPS encrypts values **inline, in place, inside the committed file itself** (rather than moving them to a separate vault/store), supporting age and PGP as well as cloud KMS providers (AWS KMS, GCP KMS, Azure Key Vault, HashiCorp Vault). Using it requires: the `sops` binary itself, and key management — either a cloud KMS the deploy environment has IAM access to, or a locally-managed age/PGP keypair whose private half must reach every machine/CI job that needs to decrypt. — [getsops/sops README, github.com/getsops/sops](https://github.com/getsops/sops)
**Trade-off vs. Symfony's native vault**: SOPS is format-agnostic and works outside any one framework, but it is an **external dependency** — a separate binary to install everywhere the file is decrypted (dev machines, CI, prod), plus a key (age/PGP/KMS) to provision and rotate outside of Symfony's own tooling, and no native `%env(...)%`-style resolution — some glue (`sops -d` piped into `.env` or an env-loading step) is needed to get decrypted values into the app at all. Symfony's vault, by contrast, needs **zero extra dependencies beyond the already-required PHP `sodium` extension**, ships its own CLI (`secrets:set`/`secrets:list`/`secrets:generate-keys`), and resolves straight into `%env(...)%` — the same mechanism this project already uses for ordinary env vars — with no extra decode step. For a single-team, self-hosted Symfony project (this one), the native vault is the lower-friction choice; SOPS would only earn its keep if the project already had multi-repo/multi-language secret sharing or an existing cloud-KMS setup to plug into, neither of which applies here.
---
## Recommended approach
Use Symfony's **native secrets vault + `.env`, referenced from a `Configuration`-validated `config/packages/smart_screen.yaml`** — no third-party secret-encryption tool, no bespoke YAML-parsing code.
1. Define a `Configuration` tree (per §1.1–§1.2) under a `smart_screen` root with a discriminated, `useAttributeAsKey()`-keyed `accounts` list (per §1.3's Messenger/Doctrine precedent) plus a plain `rss.feeds` list and a `diun` block.
2. Put every actual secret (OAuth client secret + refresh token, each CalDAV account's app-password, the Diun webhook token) into the vault via `bin/console secrets:set <NAME>`, and reference each one from the YAML with `%env(<NAME>)%` — never as a literal string in the file.
3. Non-secret connection details (server URLs, usernames, feed URLs, account display names, the `type` discriminator) stay as plain YAML values, committed normally.
Illustrative shape only — not real project code:
```yaml
# config/packages/smart_screen.yaml
smart_screen:
accounts:
work_outlook:
type: exchange
tenant: common
client_id: '11111111-2222-3333-4444-555555555555'
client_secret: '%env(EXCHANGE_WORK_CLIENT_SECRET)%'
refresh_token: '%env(EXCHANGE_WORK_REFRESH_TOKEN)%'
miab_home:
type: caldav_miab
url: 'https://mail.example.com/remote.php/dav/calendars/alice/'
user: 'alice'
app_password: '%env(MIAB_HOME_APP_PASSWORD)%'
miab_work:
type: caldav_miab
url: 'https://mail2.example.com/remote.php/dav/calendars/bob/'
user: 'bob'
app_password: '%env(MIAB_WORK_APP_PASSWORD)%'
nextcloud_tasks:
type: caldav_nextcloud
url: 'https://cloud.example.com/remote.php/dav/calendars/alice/'
user: 'alice'
app_password: '%env(NEXTCLOUD_APP_PASSWORD)%'
rss:
feeds:
- 'https://example.com/feed.xml'
- 'https://another-example.com/rss'
diun:
webhook_token: '%env(DIUN_WEBHOOK_TOKEN)%'
```
Why: this is Symfony's own idiomatic pattern end to end — `useAttributeAsKey()`-backed prototypes for the account list mirror Doctrine's `dbal.connections` and Messenger's `messenger.transports` reference config exactly (§1.3), the vault needs no dependency beyond the `sodium` extension PHP already requires, and `%env(SECRET_NAME)%` is the same placeholder mechanism this project's `.env`-based plan (per `smart-tablet-screen-stack.md`) already relies on for ordinary env vars — so adopting the vault only reclassifies which values are secret, it doesn't introduce a second configuration system.
@@ -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"
}
}
}