From aba62cc63c00b2cc4a6eb17eab6ed5cd256adcbe Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:04:05 +0200 Subject: [PATCH 01/10] feat(api): enable HTTP cache with Souin via FrankenPHP/Caddy Compile Souin (RFC 7234 HTTP cache) + Otter (L1 in-memory) + Redis (L2 distributed) into the FrankenPHP binary. The cache is active in production only (controlled via CADDY_GLOBAL_OPTIONS and CADDY_SERVER_CACHE env vars) so the dev experience is unchanged. API Platform's SouinPurger handles automatic cache invalidation via surrogate keys on every write operation (only in prod, via config/packages/prod/api_platform.yaml). Add Redis service to Docker Compose and Helm chart to serve as the shared L2 cache store across pods. Document the decision in ADR #5. Closes #357 Co-Authored-By: Claude Opus 4.6 (1M context) --- api/.env | 1 + api/Dockerfile | 16 +++++++ api/config/packages/prod/api_platform.yaml | 7 +++ api/frankenphp/Caddyfile | 2 + compose.override.yaml | 1 + compose.prod.yaml | 15 +++++++ compose.yaml | 15 +++++++ docs/adr/0005-http-cache-with-souin.md | 31 +++++++++++++ helm/api-platform/templates/configmap.yaml | 4 +- helm/api-platform/templates/deployment.yaml | 10 +++++ .../templates/redis-deployment.yaml | 45 +++++++++++++++++++ .../api-platform/templates/redis-service.yaml | 19 ++++++++ helm/api-platform/values.yaml | 33 +++++++++++--- 13 files changed, 192 insertions(+), 7 deletions(-) create mode 100644 api/config/packages/prod/api_platform.yaml create mode 100644 docs/adr/0005-http-cache-with-souin.md create mode 100644 helm/api-platform/templates/redis-deployment.yaml create mode 100644 helm/api-platform/templates/redis-service.yaml diff --git a/api/.env b/api/.env index 10629fa72..6a95c7983 100644 --- a/api/.env +++ b/api/.env @@ -59,3 +59,4 @@ MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!" # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands DEFAULT_URI=https://localhost ###< symfony/routing ### +CACHE_INVALIDATION_URL=http://localhost:2019/souin-api/souin diff --git a/api/Dockerfile b/api/Dockerfile index 4320f33c7..38a11faa1 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -12,9 +12,25 @@ FROM docker.io/dunglas/frankenphp:1-php8.5 AS frankenphp_upstream # https://docs.docker.com/compose/compose-file/#target +# Build FrankenPHP with Souin cache module +FROM docker.io/dunglas/frankenphp:1-builder AS builder + +WORKDIR /go/src/app/caddy + +# Add Souin cache module + storage backends to FrankenPHP +RUN go get github.com/darkweak/souin/plugins/caddy@latest \ + github.com/darkweak/storages/otter/caddy@latest \ + github.com/darkweak/storages/redis/caddy@latest \ + && sed -i '/_ "github.com\/dunglas\/vulcain\/caddy"/a\\t_ "github.com/darkweak/souin/plugins/caddy"\n\t_ "github.com/darkweak/storages/otter/caddy"\n\t_ "github.com/darkweak/storages/redis/caddy"' frankenphp/main.go \ + && CGO_CFLAGS="$(php-config --includes) $CGO_CFLAGS" \ + go build -ldflags "-w -s" -tags nobadger,nomysql,nopgx -o /usr/local/bin/frankenphp ./frankenphp + + # Base FrankenPHP image FROM frankenphp_upstream AS frankenphp_base +COPY --from=builder /usr/local/bin/frankenphp /usr/local/bin/frankenphp + WORKDIR /app # persistent / runtime deps diff --git a/api/config/packages/prod/api_platform.yaml b/api/config/packages/prod/api_platform.yaml new file mode 100644 index 000000000..911138ab0 --- /dev/null +++ b/api/config/packages/prod/api_platform.yaml @@ -0,0 +1,7 @@ +api_platform: + http_cache: + invalidation: + enabled: true + purger: 'api_platform.http_cache.purger.souin' + urls: + - '%env(CACHE_INVALIDATION_URL)%' diff --git a/api/frankenphp/Caddyfile b/api/frankenphp/Caddyfile index 96bc936d6..beb89428e 100644 --- a/api/frankenphp/Caddyfile +++ b/api/frankenphp/Caddyfile @@ -52,6 +52,8 @@ header ?Permissions-Policy "browsing-topics=()" route { + {$CADDY_SERVER_CACHE} + # Matches requests for OIDC routes @oidc expression path('/oidc/*') diff --git a/compose.override.yaml b/compose.override.yaml index c3a3eeace..322235387 100644 --- a/compose.override.yaml +++ b/compose.override.yaml @@ -13,6 +13,7 @@ services: # from the bind-mount for better performance by enabling the next line: #- /app/vendor environment: + CADDY_SERVER_CACHE: "" FRANKENPHP_WORKER_CONFIG: watch MERCURE_EXTRA_DIRECTIVES: demo # See https://xdebug.org/docs/all_settings#mode diff --git a/compose.prod.yaml b/compose.prod.yaml index 5455a3db3..46d00ff44 100644 --- a/compose.prod.yaml +++ b/compose.prod.yaml @@ -10,6 +10,21 @@ services: APP_SECRET: ${APP_SECRET} MERCURE_PUBLISHER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET} MERCURE_SUBSCRIBER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET} + CADDY_GLOBAL_OPTIONS: | + order cache before rewrite + cache { + ttl 3600s + stale 86400s + default_cache_control "public, s-maxage=3600" + redis { + url redis:6379 + } + otter + api { + souin + } + } + CADDY_SERVER_CACHE: "cache" pwa: image: ${PWA_DOCKER_IMAGE} diff --git a/compose.yaml b/compose.yaml index 33faa6720..78b78317e 100644 --- a/compose.yaml +++ b/compose.yaml @@ -8,8 +8,11 @@ services: condition: service_started keycloak: condition: service_started + redis: + condition: service_healthy restart: unless-stopped environment: + CACHE_INVALIDATION_URL: "http://localhost:2019/souin-api/souin" PWA_UPSTREAM: pwa:3000 OIDC_UPSTREAM: keycloak:8080 SERVER_NAME: ${SERVER_NAME:-localhost}, php:80 @@ -91,6 +94,17 @@ services: ###> symfony/mercure-bundle ### ###< symfony/mercure-bundle ### + redis: + image: docker.io/redis:7-alpine + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + volumes: + - redis_data:/data + keycloak-database: image: docker.io/postgres:${KEYCLOAK_POSTGRES_VERSION:-16}-alpine environment: @@ -144,4 +158,5 @@ volumes: ###< doctrine/doctrine-bundle ### ###> symfony/mercure-bundle ### ###< symfony/mercure-bundle ### + redis_data: keycloak_db_data: diff --git a/docs/adr/0005-http-cache-with-souin.md b/docs/adr/0005-http-cache-with-souin.md new file mode 100644 index 000000000..1a3fe2d6a --- /dev/null +++ b/docs/adr/0005-http-cache-with-souin.md @@ -0,0 +1,31 @@ +# HTTP Cache with Souin + +* Status: accepted +* Deciders: @vincentchalamon + +## Context and Problem Statement + +The demo application exposes public HTTP cache headers (`Cache-Control: public`) on API responses, but no cache layer actually stores and serves cached responses. Every request hits the PHP backend, even for unchanged resources. The demo needs an HTTP cache that integrates natively with FrankenPHP (Caddy) and API Platform's automatic cache invalidation, while also serving as an example for distributed deployments. + +## Considered Options + +**Varnish** is the traditional HTTP cache for API Platform projects. It is battle-tested and well-documented. However, it requires a separate service (reverse proxy) in front of the application, adding operational complexity. It does not integrate natively with Caddy/FrankenPHP — the two processes must be orchestrated separately, and Varnish uses its own configuration language (VCL). + +**Souin** is an HTTP cache module written in Go that compiles directly into Caddy as a plugin. Since FrankenPHP is built on Caddy, Souin runs in the same process — no additional service, no network hop for cache lookups. It is RFC 7234 compliant, supports surrogate key invalidation (which API Platform uses natively via `SouinPurger`), and offers pluggable storage backends. The trade-off is that it requires a custom FrankenPHP build via `xcaddy`. + +**CloudFlare / CDN** would offload caching entirely to an external provider. This works well in production but cannot be demonstrated locally, and ties the demo to a specific vendor. + +## Decision Outcome + +We chose **Souin** because it integrates natively with the existing FrankenPHP/Caddy stack — no extra service to manage, no network hop, and built-in support in API Platform via `api_platform.http_cache.purger.souin`. This makes the demo a realistic showcase of HTTP caching with automatic invalidation. + +For storage, we use a **two-tier architecture: Otter (L1) + Redis (L2)**. Otter is an in-memory cache local to each process, providing the fastest possible read path. Redis is a shared, persistent store that serves two purposes: distributing the cache across multiple pods in a Kubernetes deployment, and surviving process restarts so the cache is not lost on every deploy. This combination demonstrates a production-ready pattern for distributed projects, which is a key goal of the demo application. + +The cache is compiled into all images (dev and prod) but **only activated in production** via environment variables. This keeps the developer experience unchanged while ensuring the production setup is always tested with the same binary. + +## Links + +* [Souin — HTTP cache module for Caddy](https://github.com/darkweak/souin) +* [Souin Caddy plugin documentation](https://docs.souin.io/docs/middlewares/caddy/) +* [API Platform HTTP Cache documentation](https://api-platform.com/docs/core/performance/) +* [FrankenPHP custom builds](https://frankenphp.dev/docs/compile/) diff --git a/helm/api-platform/templates/configmap.yaml b/helm/api-platform/templates/configmap.yaml index 172447865..afb5e2379 100644 --- a/helm/api-platform/templates/configmap.yaml +++ b/helm/api-platform/templates/configmap.yaml @@ -13,7 +13,9 @@ data: mercure-url: "http://{{ include "api-platform.fullname" . }}/.well-known/mercure" mercure-public-url: {{ .Values.mercure.publicUrl | default "http://127.0.0.1/.well-known/mercure" | quote }} mercure-extra-directives: {{ .Values.mercure.extraDirectives | quote }} - caddy-global-options: {{ .Values.php.caddyGlobalOptions | quote }} + caddy-global-options: {{ tpl .Values.php.caddyGlobalOptions . | quote }} + caddy-server-cache: {{ .Values.php.caddyServerCache | default "" | quote }} + cache-invalidation-url: "http://localhost:2019/souin-api/souin" oidc-server-url: "https://{{ (first .Values.ingress.hosts).host }}/oidc/realms/demo" oidc-server-url-internal: "http://{{ include "api-platform.fullname" . }}-keycloak/oidc/realms/demo" better-auth-url: "https://{{ (first .Values.ingress.hosts).host }}/api/auth" diff --git a/helm/api-platform/templates/deployment.yaml b/helm/api-platform/templates/deployment.yaml index 88700128e..1c31b2988 100644 --- a/helm/api-platform/templates/deployment.yaml +++ b/helm/api-platform/templates/deployment.yaml @@ -120,6 +120,16 @@ spec: secretKeyRef: name: {{ include "api-platform.fullname" . }} key: mercure-jwt-secret + - name: CADDY_SERVER_CACHE + valueFrom: + configMapKeyRef: + name: {{ include "api-platform.fullname" . }} + key: caddy-server-cache + - name: CACHE_INVALIDATION_URL + valueFrom: + configMapKeyRef: + name: {{ include "api-platform.fullname" . }} + key: cache-invalidation-url ports: - name: http containerPort: 80 diff --git a/helm/api-platform/templates/redis-deployment.yaml b/helm/api-platform/templates/redis-deployment.yaml new file mode 100644 index 000000000..e8ca41b9d --- /dev/null +++ b/helm/api-platform/templates/redis-deployment.yaml @@ -0,0 +1,45 @@ +{{- if .Values.redis.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "api-platform.fullname" . }}-redis + labels: + {{- include "api-platform.labels" . | nindent 4 }} + app.kubernetes.io/component: redis +spec: + replicas: 1 + selector: + matchLabels: + {{- include "api-platform.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: redis + template: + metadata: + labels: + {{- include "api-platform.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: redis + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: redis + image: "{{ .Values.redis.image.repository }}:{{ .Values.redis.image.tag }}" + imagePullPolicy: {{ .Values.redis.image.pullPolicy }} + ports: + - name: redis + containerPort: 6379 + protocol: TCP + readinessProbe: + exec: + command: ["redis-cli", "ping"] + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + exec: + command: ["redis-cli", "ping"] + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + {{- toYaml .Values.redis.resources | nindent 12 }} +{{- end }} diff --git a/helm/api-platform/templates/redis-service.yaml b/helm/api-platform/templates/redis-service.yaml new file mode 100644 index 000000000..a63f81c27 --- /dev/null +++ b/helm/api-platform/templates/redis-service.yaml @@ -0,0 +1,19 @@ +{{- if .Values.redis.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "api-platform.fullname" . }}-redis + labels: + {{- include "api-platform.labels" . | nindent 4 }} + app.kubernetes.io/component: redis +spec: + type: ClusterIP + ports: + - port: 6379 + targetPort: redis + protocol: TCP + name: redis + selector: + {{- include "api-platform.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: redis +{{- end }} diff --git a/helm/api-platform/values.yaml b/helm/api-platform/values.yaml index 7c9234d0f..8104002b4 100644 --- a/helm/api-platform/values.yaml +++ b/helm/api-platform/values.yaml @@ -19,18 +19,26 @@ php: - "10.0.0.0/8" - "172.16.0.0/12" - "192.168.0.0/16" + caddyServerCache: "cache" caddyGlobalOptions: | debug servers { metrics trusted_proxies static private_ranges } -# order cache before rewrite -# cache { -# api { -# souin -# } -# } + order cache before rewrite + cache { + ttl 3600s + stale 86400s + default_cache_control "public, s-maxage=3600" + redis { + url {{ include "api-platform.fullname" . }}-redis:6379 + } + otter + api { + souin + } + } resources: requests: memory: 100Mi @@ -89,6 +97,19 @@ postgresql: memory: 50Mi cpu: 1m +redis: + enabled: true + image: + repository: "docker.io/redis" + tag: "7-alpine" + pullPolicy: IfNotPresent + resources: + requests: + memory: 50Mi + cpu: 1m + limits: + memory: 128Mi + keycloak: enabled: true image: From 0934d427d2ea4e5e2635ede42a1466b34416f2b7 Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:35:49 +0200 Subject: [PATCH 02/10] fix(api): ignore Souin purge failures in CLI context and fix Dockerfile lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a FaultTolerantSouinPurger decorator that catches transport exceptions thrown when the Caddy admin API (port 2019) is unreachable — this happens when running CLI commands such as doctrine:fixtures:load in a container that does not have FrankenPHP running (e.g. `docker compose run --rm php`). Also suppress Hadolint DL3062 on the go get line in the Dockerfile: using @latest is intentional to always pick up security patches in the builder stage, and pinning to a specific commit hash would make maintenance harder. Co-Authored-By: Claude Opus 4.6 (1M context) --- api/Dockerfile | 1 + api/config/packages/prod/api_platform.yaml | 9 ++++- .../HttpCache/FaultTolerantSouinPurger.php | 40 +++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 api/src/HttpCache/FaultTolerantSouinPurger.php diff --git a/api/Dockerfile b/api/Dockerfile index 38a11faa1..a6867ff6c 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -18,6 +18,7 @@ FROM docker.io/dunglas/frankenphp:1-builder AS builder WORKDIR /go/src/app/caddy # Add Souin cache module + storage backends to FrankenPHP +# hadolint ignore=DL3062 RUN go get github.com/darkweak/souin/plugins/caddy@latest \ github.com/darkweak/storages/otter/caddy@latest \ github.com/darkweak/storages/redis/caddy@latest \ diff --git a/api/config/packages/prod/api_platform.yaml b/api/config/packages/prod/api_platform.yaml index 911138ab0..e9b168022 100644 --- a/api/config/packages/prod/api_platform.yaml +++ b/api/config/packages/prod/api_platform.yaml @@ -2,6 +2,13 @@ api_platform: http_cache: invalidation: enabled: true - purger: 'api_platform.http_cache.purger.souin' + purger: 'app.http_cache.purger.fault_tolerant_souin' urls: - '%env(CACHE_INVALIDATION_URL)%' + +services: + app.http_cache.purger.fault_tolerant_souin: + class: App\HttpCache\FaultTolerantSouinPurger + arguments: + $inner: '@api_platform.http_cache.purger.souin' + $logger: '@logger' diff --git a/api/src/HttpCache/FaultTolerantSouinPurger.php b/api/src/HttpCache/FaultTolerantSouinPurger.php new file mode 100644 index 000000000..96ccac51a --- /dev/null +++ b/api/src/HttpCache/FaultTolerantSouinPurger.php @@ -0,0 +1,40 @@ +inner->purge($iris); + } catch (\Throwable $e) { + $this->logger->warning('Failed to purge HTTP cache: {message}', [ + 'message' => $e->getMessage(), + 'exception' => $e, + ]); + } + } + + public function getResponseHeaders(array $iris): array + { + return $this->inner->getResponseHeaders($iris); + } +} From 64d8c4aad83b593ce40c45e9b3dda6d94109455e Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:54:56 +0200 Subject: [PATCH 03/10] fix(api): apply Rector suggestions and disable cache in E2E tests - readonly class removes implicit readonly on promoted properties - Rename catch variable $e to $throwable (CatchExceptionNameMatchingTypeRector) - Disable CADDY_SERVER_CACHE in compose.e2e.yaml: Souin would cache empty API responses during service startup (before fixtures load) and serve them as stale data during Playwright tests Co-Authored-By: Claude Sonnet 4.6 --- api/src/HttpCache/FaultTolerantSouinPurger.php | 12 ++++++------ compose.e2e.yaml | 4 ++++ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/api/src/HttpCache/FaultTolerantSouinPurger.php b/api/src/HttpCache/FaultTolerantSouinPurger.php index 96ccac51a..9e1bcefb8 100644 --- a/api/src/HttpCache/FaultTolerantSouinPurger.php +++ b/api/src/HttpCache/FaultTolerantSouinPurger.php @@ -13,11 +13,11 @@ * When the Caddy admin API is unreachable (e.g. during CLI commands like doctrine:fixtures:load), * exceptions are caught and logged as warnings instead of propagating. */ -final class FaultTolerantSouinPurger implements PurgerInterface +final readonly class FaultTolerantSouinPurger implements PurgerInterface { public function __construct( - private readonly PurgerInterface $inner, - private readonly LoggerInterface $logger, + private PurgerInterface $inner, + private LoggerInterface $logger, ) { } @@ -25,10 +25,10 @@ public function purge(array $iris): void { try { $this->inner->purge($iris); - } catch (\Throwable $e) { + } catch (\Throwable $throwable) { $this->logger->warning('Failed to purge HTTP cache: {message}', [ - 'message' => $e->getMessage(), - 'exception' => $e, + 'message' => $throwable->getMessage(), + 'exception' => $throwable, ]); } } diff --git a/compose.e2e.yaml b/compose.e2e.yaml index 78a6d9465..e77c76968 100644 --- a/compose.e2e.yaml +++ b/compose.e2e.yaml @@ -13,6 +13,10 @@ services: - gutendex.com php: + environment: + # Disable HTTP cache during E2E tests: Souin would cache empty API responses + # during service startup (before fixtures are loaded) and serve them during tests + CADDY_SERVER_CACHE: "" volumes: - ./e2e/mock-server/cert.pem:/usr/local/share/ca-certificates/mock-server.crt:ro depends_on: From 368609af3e9d24be72f74fbf8a4c43bdcfa255fb Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:36:38 +0200 Subject: [PATCH 04/10] refactor(api): address review comments on PR #619 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace sed-based main.go patching with an explicit frankenphp/main.go that declares all Caddy plugins: FrankenPHP, Mercure, Vulcain, Souin, Otter and Redis — making the binary composition fully transparent - Use when@prod syntax instead of config/packages/prod/ subdirectory - Add blank line before CACHE_INVALIDATION_URL in .env - Remove CADDY_SERVER_CACHE override from compose.override.yaml (useless in dev: compose.prod.yaml is required to activate Souin globally) - Upgrade Redis image from 7-alpine to 8-alpine Co-Authored-By: Claude Sonnet 4.6 --- api/.env | 1 + api/Dockerfile | 3 ++- api/config/packages/api_platform.yaml | 16 ++++++++++++++++ api/config/packages/prod/api_platform.yaml | 14 -------------- api/frankenphp/main.go | 18 ++++++++++++++++++ compose.override.yaml | 1 - compose.yaml | 2 +- helm/api-platform/values.yaml | 2 +- 8 files changed, 39 insertions(+), 18 deletions(-) delete mode 100644 api/config/packages/prod/api_platform.yaml create mode 100644 api/frankenphp/main.go diff --git a/api/.env b/api/.env index 6a95c7983..b156e4d3b 100644 --- a/api/.env +++ b/api/.env @@ -59,4 +59,5 @@ MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!" # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands DEFAULT_URI=https://localhost ###< symfony/routing ### + CACHE_INVALIDATION_URL=http://localhost:2019/souin-api/souin diff --git a/api/Dockerfile b/api/Dockerfile index a6867ff6c..3ef72a186 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -17,12 +17,13 @@ FROM docker.io/dunglas/frankenphp:1-builder AS builder WORKDIR /go/src/app/caddy +COPY --link frankenphp/main.go frankenphp/main.go + # Add Souin cache module + storage backends to FrankenPHP # hadolint ignore=DL3062 RUN go get github.com/darkweak/souin/plugins/caddy@latest \ github.com/darkweak/storages/otter/caddy@latest \ github.com/darkweak/storages/redis/caddy@latest \ - && sed -i '/_ "github.com\/dunglas\/vulcain\/caddy"/a\\t_ "github.com/darkweak/souin/plugins/caddy"\n\t_ "github.com/darkweak/storages/otter/caddy"\n\t_ "github.com/darkweak/storages/redis/caddy"' frankenphp/main.go \ && CGO_CFLAGS="$(php-config --includes) $CGO_CFLAGS" \ go build -ldflags "-w -s" -tags nobadger,nomysql,nopgx -o /usr/local/bin/frankenphp ./frankenphp diff --git a/api/config/packages/api_platform.yaml b/api/config/packages/api_platform.yaml index 769d460a0..0f61ce71c 100644 --- a/api/config/packages/api_platform.yaml +++ b/api/config/packages/api_platform.yaml @@ -18,6 +18,22 @@ api_platform: html: ['text/html'] http_cache: public: true + +when@prod: + api_platform: + http_cache: + invalidation: + enabled: true + purger: 'app.http_cache.purger.fault_tolerant_souin' + urls: + - '%env(CACHE_INVALIDATION_URL)%' + + services: + app.http_cache.purger.fault_tolerant_souin: + class: App\HttpCache\FaultTolerantSouinPurger + arguments: + $inner: '@api_platform.http_cache.purger.souin' + $logger: '@logger' # Good defaults for REST APIs defaults: stateless: true diff --git a/api/config/packages/prod/api_platform.yaml b/api/config/packages/prod/api_platform.yaml deleted file mode 100644 index e9b168022..000000000 --- a/api/config/packages/prod/api_platform.yaml +++ /dev/null @@ -1,14 +0,0 @@ -api_platform: - http_cache: - invalidation: - enabled: true - purger: 'app.http_cache.purger.fault_tolerant_souin' - urls: - - '%env(CACHE_INVALIDATION_URL)%' - -services: - app.http_cache.purger.fault_tolerant_souin: - class: App\HttpCache\FaultTolerantSouinPurger - arguments: - $inner: '@api_platform.http_cache.purger.souin' - $logger: '@logger' diff --git a/api/frankenphp/main.go b/api/frankenphp/main.go new file mode 100644 index 000000000..7c32bdde5 --- /dev/null +++ b/api/frankenphp/main.go @@ -0,0 +1,18 @@ +package main + +import ( + caddycmd "github.com/caddyserver/caddy/v2/cmd" + + // plug in Caddy modules here. + _ "github.com/caddyserver/caddy/v2/modules/standard" + _ "github.com/darkweak/souin/plugins/caddy" + _ "github.com/darkweak/storages/otter/caddy" + _ "github.com/darkweak/storages/redis/caddy" + _ "github.com/dunglas/frankenphp/caddy" + _ "github.com/dunglas/mercure/caddy" + _ "github.com/dunglas/vulcain/caddy" +) + +func main() { + caddycmd.Main() +} diff --git a/compose.override.yaml b/compose.override.yaml index 322235387..c3a3eeace 100644 --- a/compose.override.yaml +++ b/compose.override.yaml @@ -13,7 +13,6 @@ services: # from the bind-mount for better performance by enabling the next line: #- /app/vendor environment: - CADDY_SERVER_CACHE: "" FRANKENPHP_WORKER_CONFIG: watch MERCURE_EXTRA_DIRECTIVES: demo # See https://xdebug.org/docs/all_settings#mode diff --git a/compose.yaml b/compose.yaml index 78b78317e..1851d8452 100644 --- a/compose.yaml +++ b/compose.yaml @@ -95,7 +95,7 @@ services: ###< symfony/mercure-bundle ### redis: - image: docker.io/redis:7-alpine + image: docker.io/redis:8-alpine restart: unless-stopped healthcheck: test: ["CMD", "redis-cli", "ping"] diff --git a/helm/api-platform/values.yaml b/helm/api-platform/values.yaml index 8104002b4..80654c87c 100644 --- a/helm/api-platform/values.yaml +++ b/helm/api-platform/values.yaml @@ -101,7 +101,7 @@ redis: enabled: true image: repository: "docker.io/redis" - tag: "7-alpine" + tag: "8-alpine" pullPolicy: IfNotPresent resources: requests: From f6f9d5b775648f2fb3762ac678b8ca41630f18f9 Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:48:02 +0200 Subject: [PATCH 05/10] refactor(api): switch to xcaddy to build FrankenPHP with custom modules Replace manual go build with xcaddy, the method recommended by the FrankenPHP documentation, so all Caddy plugins (FrankenPHP, Mercure, Vulcain, Souin, Otter, Redis) are declared explicitly via --with flags. Remove the hand-written main.go since xcaddy generates its own. Co-Authored-By: Claude Sonnet 4.6 --- api/Dockerfile | 26 ++++++++++++++++---------- api/frankenphp/main.go | 18 ------------------ 2 files changed, 16 insertions(+), 28 deletions(-) delete mode 100644 api/frankenphp/main.go diff --git a/api/Dockerfile b/api/Dockerfile index 3ef72a186..ebe793bf7 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -12,20 +12,26 @@ FROM docker.io/dunglas/frankenphp:1-php8.5 AS frankenphp_upstream # https://docs.docker.com/compose/compose-file/#target -# Build FrankenPHP with Souin cache module +# Build FrankenPHP with Mercure, Vulcain and Souin (HTTP cache) modules FROM docker.io/dunglas/frankenphp:1-builder AS builder -WORKDIR /go/src/app/caddy - -COPY --link frankenphp/main.go frankenphp/main.go +# Install xcaddy to build a custom FrankenPHP binary with additional Caddy modules +# hadolint ignore=DL3062 +RUN go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest -# Add Souin cache module + storage backends to FrankenPHP # hadolint ignore=DL3062 -RUN go get github.com/darkweak/souin/plugins/caddy@latest \ - github.com/darkweak/storages/otter/caddy@latest \ - github.com/darkweak/storages/redis/caddy@latest \ - && CGO_CFLAGS="$(php-config --includes) $CGO_CFLAGS" \ - go build -ldflags "-w -s" -tags nobadger,nomysql,nopgx -o /usr/local/bin/frankenphp ./frankenphp +RUN CGO_ENABLED=1 \ + CGO_CFLAGS="$(php-config --includes)" \ + CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \ + XCADDY_GO_BUILD_FLAGS="-tags=nobadger,nomysql,nopgx" \ + xcaddy build \ + --output /usr/local/bin/frankenphp \ + --with github.com/dunglas/frankenphp/caddy \ + --with github.com/dunglas/mercure/caddy \ + --with github.com/dunglas/vulcain/caddy \ + --with github.com/darkweak/souin/plugins/caddy \ + --with github.com/darkweak/storages/otter/caddy \ + --with github.com/darkweak/storages/redis/caddy # Base FrankenPHP image diff --git a/api/frankenphp/main.go b/api/frankenphp/main.go deleted file mode 100644 index 7c32bdde5..000000000 --- a/api/frankenphp/main.go +++ /dev/null @@ -1,18 +0,0 @@ -package main - -import ( - caddycmd "github.com/caddyserver/caddy/v2/cmd" - - // plug in Caddy modules here. - _ "github.com/caddyserver/caddy/v2/modules/standard" - _ "github.com/darkweak/souin/plugins/caddy" - _ "github.com/darkweak/storages/otter/caddy" - _ "github.com/darkweak/storages/redis/caddy" - _ "github.com/dunglas/frankenphp/caddy" - _ "github.com/dunglas/mercure/caddy" - _ "github.com/dunglas/vulcain/caddy" -) - -func main() { - caddycmd.Main() -} From 336b91ce7ec7f11297c1ec8effb1588fea6c7ae7 Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:10:49 +0200 Subject: [PATCH 06/10] fix(api): install xcaddy with CGO_ENABLED=0 The frankenphp:1-builder image sets CGO_ENABLED=1 globally. xcaddy is a pure Go tool and fails to compile with CGO in this environment (runtime/cgo: unknown symbol stderr in pcrel). Explicitly disable CGO for the xcaddy install step; CGO_ENABLED=1 is still set for the actual FrankenPHP build. Co-Authored-By: Claude Sonnet 4.6 --- api/Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/Dockerfile b/api/Dockerfile index ebe793bf7..3716cc631 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -16,8 +16,9 @@ FROM docker.io/dunglas/frankenphp:1-php8.5 AS frankenphp_upstream FROM docker.io/dunglas/frankenphp:1-builder AS builder # Install xcaddy to build a custom FrankenPHP binary with additional Caddy modules +# CGO_ENABLED=0: xcaddy is a pure Go tool and must not be compiled with CGO # hadolint ignore=DL3062 -RUN go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest +RUN CGO_ENABLED=0 go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest # hadolint ignore=DL3062 RUN CGO_ENABLED=1 \ From 4fea88de486ab1208af3214b1818f0e1891d467e Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:19:10 +0200 Subject: [PATCH 07/10] fix(api): use full path for xcaddy binary go install places binaries in $GOPATH/bin which is not in $PATH in the frankenphp builder image. Use $(go env GOPATH)/bin/xcaddy to invoke it. Co-Authored-By: Claude Sonnet 4.6 --- api/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/Dockerfile b/api/Dockerfile index 3716cc631..1fb1d9d56 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -25,7 +25,7 @@ RUN CGO_ENABLED=1 \ CGO_CFLAGS="$(php-config --includes)" \ CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \ XCADDY_GO_BUILD_FLAGS="-tags=nobadger,nomysql,nopgx" \ - xcaddy build \ + "$(go env GOPATH)/bin/xcaddy" build \ --output /usr/local/bin/frankenphp \ --with github.com/dunglas/frankenphp/caddy \ --with github.com/dunglas/mercure/caddy \ From 8256f59419977cb55244d17aa81542f664dbb0e1 Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:28:32 +0200 Subject: [PATCH 08/10] fix(api): move when@prod block to end of api_platform.yaml Per review comment: the when@prod section must appear at the end of the file. Also fix indentation of defaults and oauth which belong under api_platform, not as direct keys of when@prod. Co-Authored-By: Claude Sonnet 4.6 --- api/config/packages/api_platform.yaml | 32 +++++++++++++-------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/api/config/packages/api_platform.yaml b/api/config/packages/api_platform.yaml index 0f61ce71c..a2a574aab 100644 --- a/api/config/packages/api_platform.yaml +++ b/api/config/packages/api_platform.yaml @@ -18,22 +18,6 @@ api_platform: html: ['text/html'] http_cache: public: true - -when@prod: - api_platform: - http_cache: - invalidation: - enabled: true - purger: 'app.http_cache.purger.fault_tolerant_souin' - urls: - - '%env(CACHE_INVALIDATION_URL)%' - - services: - app.http_cache.purger.fault_tolerant_souin: - class: App\HttpCache\FaultTolerantSouinPurger - arguments: - $inner: '@api_platform.http_cache.purger.souin' - $logger: '@logger' # Good defaults for REST APIs defaults: stateless: true @@ -87,3 +71,19 @@ services: $nameConverter: '@?api_platform.name_converter' $properties: { name: 'ipartial' } tags: [ 'api_platform.filter' ] + +when@prod: + api_platform: + http_cache: + invalidation: + enabled: true + purger: 'app.http_cache.purger.fault_tolerant_souin' + urls: + - '%env(CACHE_INVALIDATION_URL)%' + + services: + app.http_cache.purger.fault_tolerant_souin: + class: App\HttpCache\FaultTolerantSouinPurger + arguments: + $inner: '@api_platform.http_cache.purger.souin' + $logger: '@logger' From f9f4bc219f12cf9d0736fa1370eae233502384c9 Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:45:13 +0200 Subject: [PATCH 09/10] fix(api): fix br encoder --- api/Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/api/Dockerfile b/api/Dockerfile index 1fb1d9d56..001c5a1e0 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -27,6 +27,7 @@ RUN CGO_ENABLED=1 \ XCADDY_GO_BUILD_FLAGS="-tags=nobadger,nomysql,nopgx" \ "$(go env GOPATH)/bin/xcaddy" build \ --output /usr/local/bin/frankenphp \ + --with github.com/caddyserver/brotli \ --with github.com/dunglas/frankenphp/caddy \ --with github.com/dunglas/mercure/caddy \ --with github.com/dunglas/vulcain/caddy \ From 7b4db428051bbe48f88e04f77441048ea4b20390 Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Tue, 31 Mar 2026 17:06:21 +0200 Subject: [PATCH 10/10] fix(api): use github.com/dunglas/caddy-cbrotli for brotli encoder github.com/caddyserver/brotli does not exist. The correct module is github.com/dunglas/caddy-cbrotli, a CGO-based brotli implementation maintained by the FrankenPHP author, compatible with CGO_ENABLED=1. Co-Authored-By: Claude Sonnet 4.6 --- api/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/Dockerfile b/api/Dockerfile index 001c5a1e0..4e71929a8 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -27,7 +27,7 @@ RUN CGO_ENABLED=1 \ XCADDY_GO_BUILD_FLAGS="-tags=nobadger,nomysql,nopgx" \ "$(go env GOPATH)/bin/xcaddy" build \ --output /usr/local/bin/frankenphp \ - --with github.com/caddyserver/brotli \ + --with github.com/dunglas/caddy-cbrotli \ --with github.com/dunglas/frankenphp/caddy \ --with github.com/dunglas/mercure/caddy \ --with github.com/dunglas/vulcain/caddy \