<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Symfony on Guillaume Delré</title><link>https://guillaumedelre.github.io/tags/symfony/</link><description>Recent content in Symfony on Guillaume Delré</description><generator>Hugo</generator><language>en</language><lastBuildDate>Sun, 17 May 2026 15:00:00 +0000</lastBuildDate><atom:link href="https://guillaumedelre.github.io/tags/symfony/index.xml" rel="self" type="application/rss+xml"/><item><title>Eleven Out of Twelve</title><link>https://guillaumedelre.github.io/2026/05/17/eleven-out-of-twelve/</link><pubDate>Sun, 17 May 2026 15:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2026/05/17/eleven-out-of-twelve/</guid><description>Part 8 of 8 in &amp;quot;Symfony to the Cloud: Twelve Factors, Thirteen Services&amp;quot;: Eleven factors resolved cleanly. The twelfth: Doctrine migrations in the entrypoint, waiting on a governance question that code alone can&amp;#39;t answer.</description><category>symfony-to-the-cloud</category><content:encoded><![CDATA[<p>The <code>composer.json</code> in each service had this in its <code>post-install-cmd</code> section:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span><span style="color:#e6db74">&#34;post-install-cmd&#34;</span><span style="color:#960050;background-color:#1e0010">:</span> [
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;bin/console cache:clear --env=prod&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;bin/console doctrine:migrations:migrate --no-interaction&#34;</span>
</span></span><span style="display:flex;"><span>]
</span></span></code></pre></div><p><code>post-install-cmd</code> runs during <code>composer install</code>, which in the production Dockerfile runs during the image build. There is no database available during a Docker build. The migration command either failed silently, or connected to nothing, or was skipped by Doctrine when it couldn&rsquo;t find a schema to compare against. In any case, it didn&rsquo;t migrate anything.</p>
<p>This is a clean violation of <a href="https://12factor.net/admin-processes" target="_blank" rel="noopener noreferrer">Factor XII</a>
: admin processes — migrations, one-off scripts, console tasks — should run in the same environment as the application, against the actual production data. Running them at build time inverts the relationship. The image shouldn&rsquo;t know about the database. The database should be there when the image needs it.</p>
<h2 id="the-move-to-the-entrypoint">The move to the entrypoint</h2>
<p>The migration command moved from <code>composer.json</code> to <code>docker-entrypoint.sh</code>. The shift looks small on a diff. The implications are not.</p>
<p>The entrypoint runs when the container starts, not when the image is built. The database is reachable. The entrypoint waits for it — up to 60 seconds, one attempt per second — before doing anything:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span>ATTEMPTS_LEFT_TO_REACH_DATABASE<span style="color:#f92672">=</span><span style="color:#ae81ff">60</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">until</span> <span style="color:#f92672">[</span> $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq <span style="color:#ae81ff">0</span> <span style="color:#f92672">]</span> <span style="color:#f92672">||</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  DATABASE_ERROR<span style="color:#f92672">=</span><span style="color:#66d9ef">$(</span>php bin/console dbal:run-sql -q <span style="color:#e6db74">&#34;SELECT 1&#34;</span> 2&gt;&amp;1<span style="color:#66d9ef">)</span>; <span style="color:#66d9ef">do</span>
</span></span><span style="display:flex;"><span>    sleep <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>    ATTEMPTS_LEFT_TO_REACH_DATABASE<span style="color:#f92672">=</span><span style="color:#66d9ef">$((</span>ATTEMPTS_LEFT_TO_REACH_DATABASE <span style="color:#f92672">-</span> <span style="color:#ae81ff">1</span><span style="color:#66d9ef">))</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">done</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> <span style="color:#f92672">[</span> $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq <span style="color:#ae81ff">0</span> <span style="color:#f92672">]</span>; <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>    echo <span style="color:#e6db74">&#34;</span>$DATABASE_ERROR<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>    exit <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">fi</span>
</span></span></code></pre></div><p>If the database doesn&rsquo;t respond within 60 seconds, the container exits with an error and Kubernetes restarts it. Once the database is ready, the migration runs:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span><span style="color:#66d9ef">if</span> <span style="color:#f92672">[</span> <span style="color:#e6db74">&#34;</span><span style="color:#66d9ef">$(</span> find ./migrations -iname <span style="color:#e6db74">&#39;*.php&#39;</span> -print -quit <span style="color:#66d9ef">)</span><span style="color:#e6db74">&#34;</span> <span style="color:#f92672">]</span>; <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>    php bin/console doctrine:migrations:migrate --no-interaction --all-or-nothing
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">fi</span>
</span></span></code></pre></div><p>Two changes from the original command: <code>--all-or-nothing</code> ensures that if any migration in a batch fails, the entire batch rolls back. And the <code>find</code> guard skips the command entirely if there are no migration files — useful for services that don&rsquo;t use Doctrine migrations at all.</p>
<p>This is genuinely better. The database is present. The migration runs in the real environment. The <code>--all-or-nothing</code> flag adds atomicity that the build-time version never had.</p>
<h2 id="what-it-doesnt-solve">What it doesn&rsquo;t solve</h2>
<p>Two pods redeploying simultaneously both run the entrypoint. Both reach the database. Both find pending migrations. Both call <code>doctrine:migrations:migrate</code>.</p>
<p>Doctrine has a locking mechanism: a <code>doctrine_migration_versions</code> table that records which migrations have run, and the command checks it before applying. Under normal conditions this is fine: the second pod finds the table up to date and exits cleanly. The real failure modes are more specific: a migration long enough that the database lock times out before it completes, letting a second runner start the same migration before the first has finished; or a pod that crashes mid-migration before recording the version in the table, leaving the schema in an applied-but-unregistered state that the next pod will try to apply again.</p>
<p>The team&rsquo;s position is explicit: a brief deployment downtime is acceptable. Application versions aren&rsquo;t necessarily forward-compatible with older schema versions, so running N and N+1 simultaneously against the same database isn&rsquo;t safe anyway. The deployment strategy is Recreate: all old pods are terminated before any new pods start. The migration runs on first startup, no overlap between versions. It works.</p>
<p>But &ldquo;it works&rdquo; and &ldquo;it&rsquo;s the right architecture&rdquo; are different answers.</p>
<h2 id="what-would-be-different">What would be different</h2>
<p><a href="https://12factor.net/admin-processes" target="_blank" rel="noopener noreferrer">Factor XII</a>
 says admin processes should run in &ldquo;one-off processes.&rdquo; A process that runs once, for a specific purpose, against the production environment. The entrypoint is not one-off — it runs every time a container starts, including restarts, scaling events, and Kubernetes node movements.</p>
<p>Three alternatives exist, each with a different answer to the question of ownership:</p>
<p><strong>A Kubernetes init container</strong> runs before the main container starts, in the same pod. It could run the migration, exit, and let the main container start only after it succeeds. The migration is isolated from the application runtime. The downside: the init container is another image to build and maintain, and it runs on every pod start — so a 14-service platform starting simultaneously still has a potential race.</p>
<p><strong>A Kubernetes Job</strong> runs once, on demand or triggered by a deployment pipeline. It can be made to run before any pods are updated — serial, isolated, with a clear success or failure signal. The race condition goes away. The complexity moves to the deployment process: the Job must complete before the Deployment rollout begins, and the CI pipeline must coordinate both.</p>
<p><strong>A Helm hook</strong> is the same concept expressed declaratively in the Helm chart. A <code>pre-upgrade</code> hook runs the migration before the application pods are updated. It&rsquo;s the most idiomatic Kubernetes answer. It also means the Helm chart is now responsible for running migrations — a decision that belongs to whoever owns the chart.</p>
<p>That last sentence is why the entrypoint hasn&rsquo;t changed. Moving migrations out of the application means deciding that the deployment infrastructure — not the application itself — is responsible for the schema. It&rsquo;s a governance question as much as a technical one, and governance questions take longer to resolve than code changes.</p>
<h2 id="the-honest-end">The honest end</h2>
<p>The migration block in the entrypoint is two lines. Literally: the <code>if [ &quot;$( find ./migrations... )&quot; ]</code> guard, and the <code>php bin/console doctrine:migrations:migrate</code> that follows. Eleven other factors have clean resolutions. The cache moved to Redis. The logs go to stdout. The filesystem is an S3 bucket. The CI assembles production images from the same commit it tests. The secrets don&rsquo;t travel in image layers.</p>
<p>Factor XII has an answer. It&rsquo;s just not the final one.</p>
<p>The migrations run at startup, with a real database, with atomicity, with a bounded retry window. That&rsquo;s better than running at build time against nothing. Whether they eventually move to a Job or a Helm hook is a conversation about who owns the schema — a question that a <code>kubectl apply</code> can&rsquo;t answer.</p>
]]></content:encoded></item><item><title>Ready Is Not the Same as Started</title><link>https://guillaumedelre.github.io/2026/05/17/ready-is-not-the-same-as-started/</link><pubDate>Sun, 17 May 2026 10:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2026/05/17/ready-is-not-the-same-as-started/</guid><description>Part 7 of 8 in &amp;quot;Symfony to the Cloud: Twelve Factors, Thirteen Services&amp;quot;: The entrypoint script that works perfectly in Docker Compose has five jobs. In Kubernetes, each of those jobs belongs somewhere else.</description><category>symfony-to-the-cloud</category><content:encoded><![CDATA[<p>The rolling deploy looked clean. A new pod started. Kubernetes saw the healthcheck pass — <code>php -v</code> returned zero — and began routing traffic to the new container.</p>
<p>For the next forty seconds — out of a possible sixty — that container was polling for the database.</p>
<p>Requests that landed on it during that window got errors. Not many — the window was short — but enough to show up as noise in the monitoring. The kind of noise that gets dismissed as a transient network issue and filed nowhere. The deploy succeeded. The pod eventually became ready. The mechanism that caused it was still there, waiting for the next deploy.</p>
<p>The entrypoint script does five things before FrankenPHP starts: copy a version file, verify the vendor directory, wait up to sixty seconds for the database, run pending migrations, install assets and set filesystem permissions. In Docker Compose, this is invisible. In Kubernetes, the gap becomes traffic.</p>
<h2 id="the-gap-between-started-and-ready">The gap between started and ready</h2>
<p>Kubernetes decides whether to send traffic to a pod by watching its readiness probe. A pod whose readiness probe passes receives requests. A pod whose readiness probe fails is removed from the load balancer rotation until it recovers. This is the mechanism that makes rolling deploys safe: Kubernetes doesn&rsquo;t cut over to a new pod until that pod says it&rsquo;s ready.</p>
<p>The compose.yaml defines a healthcheck on every service:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">healthcheck</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">test</span>: [ <span style="color:#e6db74">&#34;CMD&#34;</span>, <span style="color:#e6db74">&#34;php&#34;</span>, <span style="color:#e6db74">&#34;-v&#34;</span> ]
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">interval</span>: <span style="color:#ae81ff">30s</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">timeout</span>: <span style="color:#ae81ff">10s</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">retries</span>: <span style="color:#ae81ff">3</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">start_period</span>: <span style="color:#ae81ff">10s</span>
</span></span></code></pre></div><p><code>php -v</code> succeeds the moment the PHP binary is present — which is true from the first millisecond of container life. The <code>start_period: 10s</code> gives ten seconds before checks begin. But the entrypoint polling loop runs for up to sixty seconds before FrankenPHP even starts. At second ten, the healthcheck passes. The application is still waiting for the database.</p>
<p>The Dockerfile has a better signal:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-dockerfile" data-lang="dockerfile"><span style="display:flex;"><span><span style="color:#66d9ef">HEALTHCHECK</span> --start-period<span style="color:#f92672">=</span>60s CMD curl -f http://localhost:2019/metrics <span style="color:#f92672">||</span> exit <span style="color:#ae81ff">1</span><span style="color:#960050;background-color:#1e0010">
</span></span></span></code></pre></div><p>Port 2019 is Caddy&rsquo;s built-in metrics server, embedded directly in FrankenPHP. The endpoint is Prometheus-compatible and only responds once Caddy&rsquo;s HTTP stack is fully initialized and PHP workers are accepting connections. <code>php -v</code> exits in fifty milliseconds regardless of what the application is doing — it checks the binary, not the server. <code>:2019/metrics</code> only answers when the server is actually serving. It is also not an endpoint added just for the probe: every service in the platform already has it scraped by Prometheus, so the signal is live regardless of any healthcheck configuration.</p>
<p>That&rsquo;s closer. But in Kubernetes, the <code>HEALTHCHECK</code> instruction is ignored entirely. Kubernetes uses its own probe configuration. Without explicit probe definitions in the Kubernetes manifests, there are no readiness checks — and a pod is considered ready the moment its container starts.</p>
<p>Which means: pod starts, entrypoint begins polling, Kubernetes routes traffic, application is not yet serving. Requests arrive at a container that isn&rsquo;t ready to handle them.</p>
<h2 id="three-signals-three-questions">Three signals, three questions</h2>
<p>Kubernetes separates container lifecycle into three distinct questions, each with its own probe type:</p>
<p><strong>startupProbe</strong> — &ldquo;Has the application finished starting?&rdquo; Fires repeatedly until it passes, then hands off to liveness. Prevents the liveness probe from killing a container that&rsquo;s legitimately slow to initialize. For a container whose entrypoint can take sixty seconds, this is the right tool.</p>
<p><strong>readinessProbe</strong> — &ldquo;Is the application ready to handle requests?&rdquo; Fails and passes throughout the container&rsquo;s life. When it fails, the pod is removed from the load balancer. This is what makes a rolling deploy safe.</p>
<p><strong>livenessProbe</strong> — &ldquo;Is the application still alive?&rdquo; If it fails, Kubernetes restarts the container. Meant to catch hung processes, not slow startups.</p>
<p>The sixty-second polling loop belongs in the startupProbe&rsquo;s patience, not in application code:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">startupProbe</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">httpGet</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">path</span>: <span style="color:#ae81ff">/metrics</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">port</span>: <span style="color:#ae81ff">2019</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">failureThreshold</span>: <span style="color:#ae81ff">12</span>    <span style="color:#75715e"># 12 attempts × 5s = 60s max</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">periodSeconds</span>: <span style="color:#ae81ff">5</span>
</span></span></code></pre></div><p>Once the startupProbe passes, a readinessProbe on the same endpoint takes over — telling Kubernetes when the pod is safe to receive traffic — and a livenessProbe watches for hung processes. But the startupProbe is the one that absorbs the slow start. The entrypoint polling loop becomes redundant: its job was to keep the container alive while the database caught up. Without it, the application attempts to connect, fails, and the container exits — Kubernetes restarts the pod, and the startupProbe maintains its retry cycle until the database responds and the application starts cleanly. The retry responsibility moves from inside the entrypoint to the orchestrator, which is exactly where it belongs.</p>
<h2 id="the-migration-problem">The migration problem</h2>
<p>The polling loop is the most visible issue, but the migrations create a subtler one.</p>
<p>With a rolling deploy and two replicas, Kubernetes starts a new pod while the old one still serves traffic. Both pods run the same entrypoint. Both reach <code>doctrine:migrations:migrate</code>.</p>
<p>Doctrine&rsquo;s migration table tracks which migrations have already executed, so a completed migration won&rsquo;t run twice. But if two pods start simultaneously and both see a pending migration, both attempt to run it at the same time. Whether that&rsquo;s safe depends on the migration: additive schema changes are usually fine; destructive ones less so. And you don&rsquo;t get to choose which ones run on a deploy that didn&rsquo;t expect to coordinate. <code>--all-or-nothing</code> wraps migrations in a transaction and rolls back everything if one fails — it&rsquo;s about atomicity within a single run, not coordination across processes.</p>
<p>The cleaner approach separates the two concerns into two init containers: one that waits for the database, one that runs migrations. The main container starts only after both complete:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">initContainers</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">wait-for-db</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">image</span>: <span style="color:#ae81ff">authentication:latest</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">command</span>: [<span style="color:#e6db74">&#34;php&#34;</span>, <span style="color:#e6db74">&#34;bin/console&#34;</span>, <span style="color:#e6db74">&#34;dbal:run-sql&#34;</span>, <span style="color:#e6db74">&#34;-q&#34;</span>, <span style="color:#e6db74">&#34;SELECT 1&#34;</span>]
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">migrate</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">image</span>: <span style="color:#ae81ff">authentication:latest</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">command</span>: [<span style="color:#e6db74">&#34;php&#34;</span>, <span style="color:#e6db74">&#34;bin/console&#34;</span>, <span style="color:#e6db74">&#34;doctrine:migrations:migrate&#34;</span>, <span style="color:#e6db74">&#34;--no-interaction&#34;</span>, <span style="color:#e6db74">&#34;--all-or-nothing&#34;</span>]
</span></span></code></pre></div><p>Both init containers reuse the application image. That&rsquo;s not waste: they need the same PHP binary and the same environment wiring to reach the database and resolve the migration classes. A lighter purpose-built image would reduce startup overhead, but would require maintaining a separate PHP installation in sync with the main image.</p>
<p>Even with init containers, multiple pods starting simultaneously — initial deploy, after a node failure, or under autoscaling pressure — will each attempt to run migrations. Solving that properly — through a Helm pre-upgrade hook, a <code>maxSurge: 0</code> strategy, or a separate migration Job — is a topic in itself. What matters here is that the entrypoint is the wrong place to host that decision: it can&rsquo;t coordinate across pods, and it ties migration execution to application startup in a way that&rsquo;s hard to untangle later. The question of which approach fits this codebase — and why the entrypoint hasn&rsquo;t been replaced — gets its own treatment in <a href="/2026/05/17/eleven-out-of-twelve/">the next article in this series</a>
.</p>
<p>Factor XII of the <a href="https://12factor.net/admin-processes" target="_blank" rel="noopener noreferrer">twelve-factor methodology</a> — admin processes run in the same environment as the application — is satisfied either way. The question is whether &ldquo;same environment&rdquo; means &ldquo;same entrypoint script&rdquo; or &ldquo;same image, separate process&rdquo;. In Kubernetes, the latter is safer.</p>
<h2 id="what-the-entrypoints-real-job-is">What the entrypoint&rsquo;s real job is</h2>
<p>Strip out the database wait (now a startupProbe or init container), the migrations (now an init container or Job), and the assets install (a build-time operation that belongs in the Dockerfile), and the entrypoint has one remaining job: start the application.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span>exec docker-php-entrypoint <span style="color:#e6db74">&#34;</span>$@<span style="color:#e6db74">&#34;</span>
</span></span></code></pre></div><p>Factor IX of the twelve-factor app asks for fast startup and graceful shutdown. A container whose startup takes sixty seconds because it&rsquo;s waiting for external dependencies is not fast. It means rolling deploys are slow, recovery after a crash is slow, and horizontal scale-out creates a sixty-second gap before each new pod contributes.</p>
<p>Fast startup is not just a nice-to-have. It&rsquo;s what makes the rest of the cloud model work. When a pod can start in seconds, the orchestrator can scale aggressively and recover quickly. When it takes a minute, you add headroom everywhere — longer probe timeouts, larger deployment windows, more conservative scaling policies — and the system becomes rigid.</p>
<h2 id="the-docker-compose-tax">The Docker Compose tax</h2>
<p>The entrypoint accumulates these responsibilities for a reason. In Docker Compose, there is no init container concept. There is no startupProbe. Services declare <code>depends_on</code>, but without health conditions, that&rsquo;s just startup ordering — not readiness. The entrypoint fills the gap.</p>
<p>This is not a design flaw. It&rsquo;s a reasonable adaptation to the constraints of Docker Compose. The script works. It handles edge cases (the database timeout, unrecoverable errors, missing migrations directory). Someone tested it.</p>
<p>The issue is the assumption that the same script works equally well in Kubernetes. It runs. The application eventually starts. But it bypasses the probe system that makes Kubernetes deployments reliable, and it puts migration responsibility in a place where coordination across pods is difficult to reason about.</p>
<p>Several of the changes in this series — <a href="/2026/05/14/the-ghost-of-the-ci-runner/">media storage</a>
, <a href="/2026/05/14/what-survives-the-build/">secrets in image layers</a>
, <a href="/2026/05/15/no-witnesses/">log handlers</a>
, <a href="/2026/05/15/the-host-that-hid-the-graph/">service dependencies</a>
, <a href="/2026/05/16/fifteen-minutes-before-the-first-test/">CI environment parity</a>
, <a href="/2026/05/16/the-cache-that-was-lying-to-us/">cache adapters</a>
 — were changes to application code or configuration. This one is different. It requires the infrastructure to gain awareness of what &ldquo;ready&rdquo; means for this application, and it requires the entrypoint to give up responsibilities it currently owns.</p>
<p>That&rsquo;s a harder conversation. But the startupProbe is waiting for it.</p>
]]></content:encoded></item><item><title>The Cache That Was Lying to Us</title><link>https://guillaumedelre.github.io/2026/05/16/the-cache-that-was-lying-to-us/</link><pubDate>Sat, 16 May 2026 15:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2026/05/16/the-cache-that-was-lying-to-us/</guid><description>Part 6 of 8 in &amp;quot;Symfony to the Cloud: Twelve Factors, Thirteen Services&amp;quot;: How a single config line blocked horizontal scaling across 13 Symfony microservices, and what the twelve-factor app had to say about it.</description><category>symfony-to-the-cloud</category><content:encoded><![CDATA[<p>The first time we ran two replicas of the same Symfony service behind a load balancer, everything looked fine. Health checks passed. Traffic split cleanly. Response times were good.</p>
<p>Then someone noticed the rate limiter was acting strange. Hit the API five times, get blocked. Hit it five more times on the next request, get through. Depending on which pod answered, you were a different person.</p>
<p>That was the cache talking. One config line, replicated across thirteen services, was blocking horizontal scaling entirely.</p>
<h2 id="one-config-file-thirteen-times">One config file, thirteen times</h2>
<p>We were preparing a platform of thirteen Symfony microservices to move to Kubernetes. The stack was already in good shape: FrankenPHP for the HTTP server, multi-stage Dockerfiles, a GitLab CI that pushed tagged images to a cloud registry. The pieces were there. We just needed to verify nothing would break when we started scaling pods horizontally.</p>
<p>A good checklist for that kind of audit is the <a href="https://12factor.net" target="_blank" rel="noopener noreferrer">twelve-factor app methodology</a> — twelve principles for building software that runs cleanly in cloud environments. Most factors were already covered without us doing anything deliberate about it.</p>
<p>Factor VII (port binding) came for free. FrankenPHP embeds Caddy directly into the PHP process. The container exposes its own HTTP endpoint, no Apache or Nginx to bolt on. The image is self-contained, which is exactly what the factor requires:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-dockerfile" data-lang="dockerfile"><span style="display:flex;"><span><span style="color:#66d9ef">HEALTHCHECK</span> --start-period<span style="color:#f92672">=</span>60s CMD curl -f http://localhost:2019/metrics <span style="color:#f92672">||</span> exit <span style="color:#ae81ff">1</span><span style="color:#960050;background-color:#1e0010">
</span></span></span></code></pre></div><p>Factor II (dependencies) was handled by <code>composer.json</code> and the Dockerfile extensions. Factor X (dev/prod parity) was covered enough for our scope: same image, same backing services locally and in CI, which is the part that actually matters for what we were auditing.</p>
<p>Then I got to Factor VI.</p>
<h2 id="the-problem-with-it-works-on-one-server">The problem with &ldquo;it works on one server&rdquo;</h2>
<p>Factor VI says processes must share nothing. Nothing written to disk between requests, nothing in local memory that another instance can&rsquo;t see. If you need to persist state, put it in a backing service — a database, a cache cluster, a queue. The process itself stays disposable.</p>
<p>I opened <code>authentication/config/packages/cache.yaml</code>. Then <code>content/config/packages/cache.yaml</code>. Then <code>media/config/packages/cache.yaml</code>.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">framework</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">cache</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">app</span>: <span style="color:#ae81ff">cache.adapter.filesystem</span>
</span></span></code></pre></div><p>Thirteen services. Thirteen times, word for word.</p>
<p>Every instance of every service was writing its cache to the local filesystem. Which meant every pod had its own private cache, invisible to every other pod. When the load balancer sent a request to pod A, it got pod A&rsquo;s cached version of reality. Pod B had built its own. They might have been generated at different times, from different source data, or one of them might not have been built yet at all.</p>
<p>The rate limiter was the most visible symptom because it had a counter. But the same divergence affected every piece of data we were caching: serializer metadata, route collections, Doctrine result caches. Two users sending identical requests could get different responses depending on which node happened to pick up the connection.</p>
<h2 id="redis-was-already-there">Redis was already there</h2>
<p>This is the part that stings a little. Redis was already in the stack. Every service had it configured via SncRedisBundle:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># config/packages/snc_redis.yaml — present on all 13 services</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">snc_redis</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">clients</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">default</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">type</span>: <span style="color:#e6db74">&#39;phpredis&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">alias</span>: <span style="color:#e6db74">&#39;default&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">dsn</span>: <span style="color:#e6db74">&#39;%env(IN_MEM_STORE__URI)%&#39;</span>
</span></span></code></pre></div><p>Factor IV of the twelve-factor app says backing services should be attached resources, interchangeable through configuration. Redis was exactly that: reachable via an environment variable, ready to be swapped for a managed instance in the cloud. The plumbing was done. We just weren&rsquo;t using it for the application cache.</p>
<p>Some services even had it right for specific pools. The rate limiter in the authentication service:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">pools</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">rate_limiter.cache</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">adapter</span>: <span style="color:#ae81ff">cache.adapter.redis</span>
</span></span></code></pre></div><p>Which explains the inconsistency we saw first. The rate limit <em>count</em> went to Redis (shared across pods). The cache backing the rate limit <em>check</em> went to the filesystem (local to the pod). Two sources of truth, one invisible to the other.</p>
<p>The fix was one line per service:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">framework</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">cache</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">app</span>: <span style="color:#ae81ff">cache.adapter.redis</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">default_redis_provider</span>: <span style="color:#ae81ff">snc_redis.default</span>
</span></span></code></pre></div><p>Thirteen files. Thirteen identical changes. The kind of fix that makes you feel like you should have caught it earlier, except it&rsquo;s perfectly invisible when you&rsquo;re running a single instance.</p>
<h2 id="what-needs-to-move-to-redis">What needs to move to Redis</h2>
<p>The filesystem cache violated Factor VI (processes carry local state they shouldn&rsquo;t) and Factor VIII (you can&rsquo;t scale out without sharing that state). They&rsquo;re the same problem seen from two angles: VI describes what&rsquo;s wrong, VIII describes what you can&rsquo;t do because of it.</p>
<p>With a shared cache backend, a second pod is safe. The two pods build the same cache, see the same invalidations, agree on the same rate limits. You can add a third pod under load and remove it when traffic drops. The orchestrator handles it; the application doesn&rsquo;t need to know.</p>
<p>Without it, horizontal scaling is a liability. More pods means more divergence, more &ldquo;works on my machine&rdquo; bugs that are impossible to reproduce locally because local only runs one container.</p>
<p>Sessions had the same problem — and potentially a worse one. Twelve of the thirteen services were using <code>session.storage.factory.native</code> — which writes sessions to the filesystem by default. A user whose request lands on pod A gets a session tied to pod A. Their next request goes to pod B. Session gone, they&rsquo;re logged out. Only one service had <code>RedisSessionHandler</code> configured.</p>
<p>The partial mitigation is that most of the platform runs stateless JWT-based APIs, so session usage is limited. But &ldquo;limited&rdquo; isn&rsquo;t &ldquo;zero&rdquo;. The services that do create sessions — authentication flows, temporary state during OAuth handshakes — have a user-visible failure mode waiting for the second pod. Either those sessions get moved to Redis, or the code that creates them gets removed. Leaving them as-is is a decision that waits for the first user whose session disappears without explanation.</p>
<h2 id="the-other-kind-of-state">The other kind of state</h2>
<p>Redis fixes the cross-pod problem. FrankenPHP introduces a different one worth knowing about.</p>
<p>In the standard PHP-FPM model, each request forks a fresh process. Every in-memory object — every cached value, every computed result — dies with the response. The process is stateless by construction.</p>
<p>FrankenPHP has a worker mode that doesn&rsquo;t follow that model. In worker mode, a single PHP process boots once, loads the kernel, wires the container, and handles multiple successive requests without restarting. Request throughput improves: no autoloader cold start, no container rebuild per request, fewer allocations. The tradeoff is that the PHP process now has a lifecycle that spans requests.</p>
<p>For cache, this adds a wrinkle. An <code>array</code> adapter or APCu pool accumulates entries across requests on the same worker. A cache invalidation pushed to Redis reaches the other pods immediately — but doesn&rsquo;t clear what&rsquo;s sitting in a worker&rsquo;s in-process memory. Two requests on the same pod can see different things: one hits a warm in-memory entry, the next triggers a Redis fetch after the in-process entry expires.</p>
<p>The platform keeps worker mode disabled (<code>APP__WORKER_MODE__ENABLED=false</code>). It&rsquo;s available — the infrastructure is there, the flag is wired — but it&rsquo;s not active. The performance gain didn&rsquo;t justify the audit. Every cache pool would need to be verified against worker-mode semantics; every place where state leaks between requests would become a potential bug.</p>
<p>The conservative position: keep PHP stateless at the process level even when the runtime doesn&rsquo;t require it. Factor VI&rsquo;s shared-nothing principle applies not just to the filesystem — it applies to the process itself.</p>
<h2 id="what-was-already-working">What was already working</h2>
<p>To be fair to the codebase: the Symfony Scheduler was already using Redis for distributed locks:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span>$schedule<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">lock</span>($this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">lockFactory</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">createLock</span>(<span style="color:#e6db74">&#39;schedule_purge&#39;</span>));
</span></span></code></pre></div><p>In a multi-pod environment, you don&rsquo;t want five instances running the same purge job simultaneously. The lock prevents it. Redis makes the lock visible across pods. Whoever wrote the scheduler knew exactly what they were doing.</p>
<p>The same reasoning just hadn&rsquo;t propagated to the cache configuration — probably because when you&rsquo;re running a single instance, <code>cache.adapter.filesystem</code> is invisible. It works, it&rsquo;s fast, it requires zero configuration. The problem only appears at two.</p>
<h2 id="the-four-questions">The four questions</h2>
<p>Factor VI catches most applications off guard during a cloud migration. Not because developers don&rsquo;t know about stateless processes — they usually do — but because the filesystem is always there, and the problem stays hidden until you try to run a second instance.</p>
<p>Before scaling a Symfony service horizontally, four questions are worth answering:</p>
<ul>
<li>Where does the application cache go? (<code>cache.adapter.filesystem</code> needs to become <code>cache.adapter.redis</code>)</li>
<li>Where do sessions go? (<code>session.storage.factory.native</code> needs Redis — or remove sessions entirely if you&rsquo;re JWT-only)</li>
<li>Does anything write to <code>var/</code> at runtime that another pod would need to read?</li>
<li>Is anything in your code path that needs to be mutually exclusive across pods? (if yes, that&rsquo;s a job for the <a href="https://symfony.com/doc/current/components/lock.html" target="_blank" rel="noopener noreferrer">Symfony Lock component</a> backed by Redis, not a local mutex)</li>
</ul>
<p>If the answers all point to shared backing services, you&rsquo;re ready. If any of them points to the local filesystem, production will find the pod that built its cache three hours ago and serve it to the user who least expects it.</p>
]]></content:encoded></item><item><title>Fifteen Minutes Before the First Test</title><link>https://guillaumedelre.github.io/2026/05/16/fifteen-minutes-before-the-first-test/</link><pubDate>Sat, 16 May 2026 10:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2026/05/16/fifteen-minutes-before-the-first-test/</guid><description>Part 5 of 8 in &amp;quot;Symfony to the Cloud: Twelve Factors, Thirteen Services&amp;quot;: How a CI pipeline that provisioned an Azure VM per run — missing RabbitMQ, MinIO, and Varnish — became one that assembles the production environment from the same images it ships.</description><category>symfony-to-the-cloud</category><content:encoded><![CDATA[<p>The pipeline had two stages that had nothing to do with code: <code>provision</code> and <code>deprovision</code>. Between them, in sequence, came <code>phpunit</code>, <code>phpmetrics</code>, and <code>behat</code>.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">stages</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">build</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">provision</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">phpunit</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">phpmetrics</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">behat</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">deprovision</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">deploy</span>
</span></span></code></pre></div><p>Before the first assertion ran, fifteen minutes had passed. Terraform had cloned an infrastructure repository, authenticated to Azure, and applied a VM configuration. Ansible had connected to the new VM, installed PHP, configured the application, wired up a database and a Redis instance. Then the tests ran. Then Terraform destroyed what Ansible had built.</p>
<p>For every pipeline. From every branch. For every pull request, from open to merge.</p>
<h2 id="what-those-fifteen-minutes-were-missing">What those fifteen minutes were missing</h2>
<p>The <code>provision</code> stage set up two services: PostgreSQL and Redis. Three services that the application depended on in production were absent: RabbitMQ, MinIO, and Varnish.</p>
<p>RabbitMQ processed all asynchronous work — 56 consumers across 14 microservices. MinIO handled media storage. Varnish fronted the HTTP cache. In CI, none of them existed. Tests that exercised message queuing or file storage had two options: skip these paths, or leave them untested until staging. Varnish is a different case: tests hit the application directly and intentionally bypass the cache layer, so its absence in CI is a deliberate choice rather than a gap.</p>
<p>This is the problem <a href="https://12factor.net/dev-prod-parity" target="_blank" rel="noopener noreferrer">Factor X</a>
 describes as the environment gap. The gap here wasn&rsquo;t a matter of configuration — it was structural. The VM was built by Ansible from a script in a separate repository. It wasn&rsquo;t a container image. It wasn&rsquo;t versioned alongside the application. If a branch modified the RabbitMQ message topology, there was no way to test that modification in CI. The topology change and the code that relied on it would only meet in staging.</p>
<p>The Ansible provisioning script itself is part of the problem:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">launch_vm</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">stage</span>: <span style="color:#ae81ff">provision</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">script</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#ae81ff">git clone git@gitlab.internal/infra/ci-vm.git</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#ae81ff">cd ci-vm</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#ae81ff">az login --service-principal -u $ARM_CLIENT_ID ...</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#ae81ff">terraform apply -var &#34;prefix=${CI_PIPELINE_ID}-vm&#34; ...</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#ae81ff">sleep 45</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#ae81ff">ansible-playbook behat/test-env.yml ...</span>
</span></span></code></pre></div><p>The <code>sleep 45</code> is there because Ansible needs the VM to finish booting before it can connect. It&rsquo;s not an oversight — it&rsquo;s the minimum time a freshly provisioned VM needs before SSH works. It&rsquo;s baked into the process.</p>
<h2 id="what-replaced-it">What replaced it</h2>
<p>The new pipeline has no <code>provision</code> stage. It has no <code>deprovision</code> stage. The environment is the images, and the images exist before the tests begin.</p>
<p>Each test job declares its dependencies as Docker services:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">services</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">$REGISTRY_URL/platform/rabbitmq:$CI_COMMIT_REF_SLUG</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">alias</span>: <span style="color:#ae81ff">rabbitmq</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">$REGISTRY_URL/platform/minio:$CI_COMMIT_REF_SLUG</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">alias</span>: <span style="color:#ae81ff">minio</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">redis:7.4.1</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">alias</span>: <span style="color:#ae81ff">redis</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">$ARTIFACTORY_URL/postgresql:13</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">alias</span>: <span style="color:#ae81ff">postgresql</span>
</span></span></code></pre></div><p>The services start in parallel when the job begins. Before the test script runs, a <code>before_script</code> waits for all of them to be ready:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">before_script</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">$CI_PROJECT_DIR/dockerize</span>
</span></span><span style="display:flex;"><span>      -<span style="color:#ae81ff">wait tcp://postgresql:5432</span>
</span></span><span style="display:flex;"><span>      -<span style="color:#ae81ff">wait tcp://rabbitmq:5672</span>
</span></span><span style="display:flex;"><span>      -<span style="color:#ae81ff">wait tcp://minio:9000</span>
</span></span><span style="display:flex;"><span>      -<span style="color:#ae81ff">wait tcp://redis:6379</span>
</span></span><span style="display:flex;"><span>      -<span style="color:#ae81ff">timeout 120s</span>
</span></span></code></pre></div><p>From pipeline start to first assertion: ninety seconds — assuming images are already cached on the runner; a cold pull adds time, but becomes negligible once the pipeline has run once on a given branch.</p>
<h2 id="what-ci_commit_ref_slug-means">What <code>$CI_COMMIT_REF_SLUG</code> means</h2>
<p>The timing is the visible result. What produces it is more interesting: the image names.</p>
<p><code>$REGISTRY_URL/platform/rabbitmq:$CI_COMMIT_REF_SLUG</code> is not the official RabbitMQ image from Docker Hub. It&rsquo;s an image built by the same pipeline, from the same branch, at the same commit as the code being tested. The RabbitMQ image carries the topology: a <code>definitions.json</code> with every exchange, every queue, every binding, every dead-letter configuration — versioned in git alongside the application that depends on them.</p>
<p>If a branch modifies the messaging topology, the CI pipeline builds a new RabbitMQ image that includes those modifications, then runs the tests against it. The topology change and the code that relies on it are tested together, at the same commit, before anything reaches staging.</p>
<p>The same logic applies to MinIO, as described in the <a href="/2026/05/14/the-ghost-of-the-ci-runner/">first article in this series</a>
: the MinIO image carries preloaded test fixtures. The CI environment doesn&rsquo;t need a setup step to populate storage. The state is built in.</p>
<p>The test runner itself follows the same pattern. Each job uses a debug variant of the application image — built from the same branch, same commit — with the test dependencies included:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">image</span>: <span style="color:#ae81ff">$REGISTRY_URL/platform/$service:$CI_COMMIT_REF_SLUG-debug</span>
</span></span></code></pre></div><p>The whole environment assembles from artifacts built at the same point in the git history.</p>
<h2 id="what-this-required-dropping">What this required dropping</h2>
<p>Behat and the provisioned VM were coupled. The Behat test suite ran against an HTTP server on the VM; removing the VM meant removing Behat.</p>
<p>That turned out not to be the obstacle it looked like. The Behat suite lived in a separate repository, required the VM to run, and had accumulated significant maintenance overhead. PHPUnit, running inside the application container with Docker services, covered the same scenarios through a more direct path: functional tests exercising the HTTP layer, unit tests for individual components, suites organized per feature area and generated dynamically into parallel CI jobs.</p>
<p>The BDD layer went away. The test coverage stayed — and could now run against the actual services.</p>
<h2 id="factor-x-applied">Factor X, applied</h2>
<p><a href="https://12factor.net/dev-prod-parity" target="_blank" rel="noopener noreferrer">Factor X</a>
 is often read as &ldquo;use the same database locally as in production.&rdquo; That&rsquo;s the simplest version. The deeper version is about the gap between what you test and what you ship.</p>
<p>The gap in the old pipeline was wide: a manually configured VM, missing key services, rebuilt from scratch on every run. The gap in the new pipeline is narrow: the CI assembles the environment from the same images as production, built from the same commit as the code under test.</p>
<p>The fifteen minutes of Terraform and Ansible were not just slow. They were building something that wasn&rsquo;t what production ran, every time, before any test could begin. The ninety seconds of <code>docker pull</code> build exactly what production runs — and the tests that follow are testing that, not an approximation of it.</p>
]]></content:encoded></item><item><title>The Host That Hid the Graph</title><link>https://guillaumedelre.github.io/2026/05/15/the-host-that-hid-the-graph/</link><pubDate>Fri, 15 May 2026 15:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2026/05/15/the-host-that-hid-the-graph/</guid><description>Part 4 of 8 in &amp;quot;Symfony to the Cloud: Twelve Factors, Thirteen Services&amp;quot;: Thirteen services sharing six identical gateway variables. The config looked simple. The dependency graph was invisible — until Kubernetes asked where each service actually lived.</description><category>symfony-to-the-cloud</category><content:encoded><![CDATA[<p>Every service in the platform had these six variables:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>APP__GATEWAY__PRIVATE__HOST<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;platform.internal&#34;</span>
</span></span><span style="display:flex;"><span>APP__GATEWAY__PRIVATE__PORT<span style="color:#f92672">=</span><span style="color:#ae81ff">80</span>
</span></span><span style="display:flex;"><span>APP__GATEWAY__PRIVATE__SCHEME<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;http&#34;</span>
</span></span><span style="display:flex;"><span>APP__GATEWAY__PUBLIC__HOST<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;platform.internal&#34;</span>
</span></span><span style="display:flex;"><span>APP__GATEWAY__PUBLIC__PORT<span style="color:#f92672">=</span><span style="color:#ae81ff">80</span>
</span></span><span style="display:flex;"><span>APP__GATEWAY__PUBLIC__SCHEME<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;http&#34;</span>
</span></span></code></pre></div><p>Thirteen services, six variables each, one value. Reading any service&rsquo;s configuration, the architecture looked flat. Everything talked to the same host. That was the whole picture.</p>
<p>It wasn&rsquo;t.</p>
<h2 id="how-the-gateway-worked">How the gateway worked</h2>
<p>The gateway sat in front of every service and handled all inter-service traffic. A service calling the content API would construct a request to <code>http://platform.internal/content/api/</code> — the gateway received it, identified the target from the URL path, and forwarded it to the right backend. Every inter-service HTTP client in <code>framework.yaml</code> followed the same pattern:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">content.client</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">base_uri</span>: <span style="color:#e6db74">&#34;%http_client.gateway.base_uri%/content/api/&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">headers</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">Host</span>: <span style="color:#e6db74">&#34;%env(APP__GATEWAY__PRIVATE__HOST)%&#34;</span>
</span></span></code></pre></div><p>The <code>http_client.gateway.base_uri</code> parameter was assembled from the GATEWAY vars. The gateway knew where each service ran. The services didn&rsquo;t need to know. From their perspective, everything was <code>platform.internal</code>.</p>
<p>This worked. For years, it worked well. Adding a service meant adding one DNS alias in the gateway config, not touching thirteen <code>.env</code> files. The gateway abstracted the topology. The services stayed decoupled from the infrastructure detail of who ran where.</p>
<h2 id="what-the-gateway-was-absorbing">What the gateway was absorbing</h2>
<p>The abstraction had a cost that didn&rsquo;t show up until you tried to read the system.</p>
<p>Looking at <code>content</code>&rsquo;s env file, you saw six gateway variables and nothing else about inter-service communication. To find out that <code>content</code> called <code>conversion</code>, <code>shorty</code>, and <code>media</code>, you had to read <code>framework.yaml</code>. To find out that <code>pilot</code> called ten external services, you had to trace through the HTTP clients one by one and count.</p>
<p>The number was ten. Authentication, bam, config, content, conversion, media, product, shorty, sitemap, social. Ten of the platform&rsquo;s thirteen services that <code>pilot</code> depended on at runtime, none of them visible from its configuration. Six variables said: talk to the gateway. They said nothing about the shape of what lay behind it.</p>
<p>That information existed — in the code, in the framework config, in the heads of the people who had built those integrations. It just didn&rsquo;t live anywhere you could read at a glance.</p>
<h2 id="what-kubernetes-made-explicit">What Kubernetes made explicit</h2>
<p>On-premise, the gateway was a single resolvable hostname. One DNS record, one set of variables, one place to update. Kubernetes doesn&rsquo;t work that way. Each service gets its own DNS name inside the cluster — <code>content.namespace.svc.cluster.local</code>, <code>conversion.namespace.svc.cluster.local</code>. Inter-service traffic goes directly, service to service, not through a shared gateway.</p>
<p>Moving to Kubernetes meant the gateway abstraction had to give way. Each service needed to know, concretely, where each of its dependencies lived. The six generic variables couldn&rsquo;t express that.</p>
<p>The refactor replaced them with per-target HOST variables — one per service dependency, named for the target:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># content/.env — content calls these four services</span>
</span></span><span style="display:flex;"><span>APP__CONFIG__HOST<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;platform.internal&#34;</span>
</span></span><span style="display:flex;"><span>APP__CONVERSION__HOST<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;platform.internal&#34;</span>
</span></span><span style="display:flex;"><span>APP__MEDIA__HOST<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;platform.internal&#34;</span>
</span></span><span style="display:flex;"><span>APP__SHORTY__HOST<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;platform.internal&#34;</span>
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># pilot/.env — ten service dependencies</span>
</span></span><span style="display:flex;"><span>APP__AUTHENTICATION__HOST<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;platform.internal&#34;</span>
</span></span><span style="display:flex;"><span>APP__BAM__HOST<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;platform.internal&#34;</span>
</span></span><span style="display:flex;"><span>APP__CONFIG__HOST<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;platform.internal&#34;</span>
</span></span><span style="display:flex;"><span>APP__CONTENT__HOST<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;platform.internal&#34;</span>
</span></span><span style="display:flex;"><span>APP__CONVERSION__HOST<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;platform.internal&#34;</span>
</span></span><span style="display:flex;"><span>APP__MEDIA__HOST<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;platform.internal&#34;</span>
</span></span><span style="display:flex;"><span>APP__PRODUCT__HOST<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;platform.internal&#34;</span>
</span></span><span style="display:flex;"><span>APP__SHORTY__HOST<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;platform.internal&#34;</span>
</span></span><span style="display:flex;"><span>APP__SITEMAP__HOST<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;platform.internal&#34;</span>
</span></span><span style="display:flex;"><span>APP__SOCIAL__HOST<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;platform.internal&#34;</span>
</span></span></code></pre></div><p>Each HTTP client in <code>framework.yaml</code> got its own <code>base_uri</code> built from its target&rsquo;s HOST variable, and the <code>Host</code> header gave way to a <code>User-Agent</code> that identified the caller:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">content.client</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">base_uri</span>: <span style="color:#e6db74">&#34;%env(APP__HTTP__SCHEME)%://%env(APP__CONTENT__HOST)%:%env(APP__HTTP__PORT)%/content/api/&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">headers</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">User-Agent</span>: <span style="color:#e6db74">&#34;Platform Content - %semver%&#34;</span>
</span></span></code></pre></div><p>The change isn&rsquo;t cosmetic. In the old setup, the explicit <code>Host</code> header ensured requests reached the correct gateway virtual host regardless of URL resolution. In the new setup, each client points directly at its target&rsquo;s DNS name — the right <code>Host</code> is derived from the <code>base_uri</code> automatically. The header slot doesn&rsquo;t go empty: <code>User-Agent</code> now identifies the calling service, which surfaces in logs and distributed traces without any additional instrumentation.</p>
<h2 id="the-discomfort-of-legibility">The discomfort of legibility</h2>
<p><code>pilot</code>&rsquo;s env file went from nine gateway variables to ten service-specific HOST variables. The file got longer. The architecture didn&rsquo;t get simpler — the ten dependencies were there before and they&rsquo;re still there now. What changed is that they&rsquo;re readable.</p>
<p><a href="https://12factor.net/config" target="_blank" rel="noopener noreferrer">Factor III</a>
 says to store configuration in the environment. The old approach satisfied that literally: six variables, all in env files, none hardcoded. But variables that collapse the entire dependency graph into a single opaque hostname aren&rsquo;t really configuration — they&rsquo;re a shorthand that trades legibility for convenience. Factor III doesn&rsquo;t ask only that configuration be externalized — it implicitly assumes the externalized configuration remains informative.</p>
<p>The refactor didn&rsquo;t simplify anything. It made the complexity visible. <code>pilot</code>&rsquo;s ten HOST variables document, in the <code>.env</code> file itself, the ten services it depends on. A new team member reading that file learns something real about the architecture. The old file taught them that there was a gateway.</p>
<p>There&rsquo;s a version of this story where you read the final state and conclude the team did unnecessary work — they replaced six variables with ten, all pointing at the same host anyway. In local development, <code>platform.internal</code> still resolves to the same place. The functional behavior didn&rsquo;t change.</p>
<p>The change is in what the configuration communicates. In Kubernetes, the HOST values diverge: each target gets its own cluster-internal DNS name, different per environment. The variables now carry real information. The refactor prepared the config to be honest about a topology it had been quietly simplifying for years.</p>
]]></content:encoded></item><item><title>No Witnesses</title><link>https://guillaumedelre.github.io/2026/05/15/no-witnesses/</link><pubDate>Fri, 15 May 2026 10:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2026/05/15/no-witnesses/</guid><description>Part 3 of 8 in &amp;quot;Symfony to the Cloud: Twelve Factors, Thirteen Services&amp;quot;: A service crashed in production and left no logs behind. Here is why fingers_crossed and cloud deployments do not mix well.</description><category>symfony-to-the-cloud</category><content:encoded><![CDATA[<p>The service had crashed. We had the alert. We had the timestamp down to the second. We had <a href="https://grafana.com/oss/loki/" target="_blank" rel="noopener noreferrer">Loki</a> open and a query ready.</p>
<p>What we didn&rsquo;t have was any logs from the five minutes before the crash.</p>
<p>Promtail was running. It was healthy. It had been collecting logs from every other service without issue. But for this one, in the window that mattered, there was nothing. The service had crashed without leaving a trace.</p>
<h2 id="the-setup-that-looked-correct">The setup that looked correct</h2>
<p>The logging stack was reasonable. Each service wrote structured JSON to stdout using Monolog&rsquo;s logstash formatter:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">stdout</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">type</span>: <span style="color:#ae81ff">stream</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">path</span>: <span style="color:#e6db74">&#34;php://stdout&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">level</span>: <span style="color:#e6db74">&#34;%env(MONOLOG_LEVEL__DEFAULT)%&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">formatter</span>: <span style="color:#e6db74">&#39;monolog.formatter.logstash&#39;</span>
</span></span></code></pre></div><p><a href="https://grafana.com/docs/loki/latest/" target="_blank" rel="noopener noreferrer">Promtail</a> collected container output via the Docker socket, parsed the JSON, extracted labels, pushed to Loki:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">scrape_configs</span>:
</span></span><span style="display:flex;"><span>    -
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">job_name</span>: <span style="color:#ae81ff">docker</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">docker_sd_configs</span>:
</span></span><span style="display:flex;"><span>            -
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">host</span>: <span style="color:#ae81ff">unix:///var/run/docker.sock</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">refresh_interval</span>: <span style="color:#ae81ff">5s</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">pipeline_stages</span>:
</span></span><span style="display:flex;"><span>            -
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">drop</span>:
</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">older_than</span>: <span style="color:#ae81ff">168h</span>
</span></span><span style="display:flex;"><span>            -
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">json</span>:
</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">expressions</span>:
</span></span><span style="display:flex;"><span>                        <span style="color:#f92672">level</span>: <span style="color:#ae81ff">level</span>
</span></span><span style="display:flex;"><span>                        <span style="color:#f92672">msg</span>: <span style="color:#ae81ff">message</span>
</span></span><span style="display:flex;"><span>                        <span style="color:#f92672">service</span>: <span style="color:#ae81ff">service</span>
</span></span><span style="display:flex;"><span>            -
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">labels</span>:
</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">level</span>:
</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">service</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">relabel_configs</span>:
</span></span><span style="display:flex;"><span>            -
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">source_labels</span>: [ <span style="color:#e6db74">&#39;__meta_docker_container_log_stream&#39;</span> ]
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">target_label</span>: <span style="color:#ae81ff">stream</span>
</span></span></code></pre></div><p>Two stages in that pipeline do more work than the others. The <code>json</code> stage extracts <code>level</code> and <code>service</code> from each log line; the <code>labels</code> stage immediately following promotes them to Loki index labels, making <code>{service=&quot;content&quot;, level=&quot;error&quot;}</code> a direct index lookup rather than a full-text scan across stored lines. The <code>stream</code> relabeling preserves whether a line came from stdout or stderr — a distinction that becomes queryable once Monolog sends errors to stderr and everything else to stdout. The <code>drop older_than: 168h</code> stage is a safety valve: if Promtail restarts after a long gap and replays buffered lines, anything older than seven days is discarded before reaching Loki.</p>
<p>In theory: logs go to stdout, Promtail reads stdout, logs appear in Loki. The <a href="https://12factor.net/logs" target="_blank" rel="noopener noreferrer">twelve-factor app methodology</a> describes exactly this model for Factor XI — treat logs as event streams, write to stdout, let the environment handle collection and routing.</p>
<p>The application had stdout. Promtail was reading stdout. What could go wrong.</p>
<h2 id="what-fingers_crossed-takes-with-it">What fingers_crossed takes with it</h2>
<p>In production, the <code>when@prod</code> block replaced the simple <code>stream</code> handler with something more sophisticated:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">when@prod</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">monolog</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">handlers</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">main</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">type</span>: <span style="color:#ae81ff">fingers_crossed</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">action_level</span>: <span style="color:#ae81ff">error</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">handler</span>: <span style="color:#ae81ff">main_group</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">excluded_http_codes</span>: [<span style="color:#ae81ff">404</span>]
</span></span></code></pre></div><p>The <code>excluded_http_codes: [404]</code> line is itself a tell: without it, every 404 from a scanner or crawler triggers a full buffer flush, dumping megabytes of debug logs for malformed URLs. Someone had already learned that the hard way.</p>
<p><code>fingers_crossed</code> is a well-known Monolog pattern. The idea is elegant: don&rsquo;t flood production logs with debug noise, but if something goes wrong, retroactively show what happened before the error. The handler buffers every log record in memory. The moment it sees an <code>error</code>, it flushes the entire buffer to the nested handler — giving you the full context leading up to the failure.</p>
<p>The problem is what happens when the failure isn&rsquo;t a logged error. It&rsquo;s an OOM kill. A SIGKILL from the orchestrator. A segfault. A process that stops responding and gets forcibly terminated.</p>
<p>In those cases, <code>fingers_crossed</code> never reaches its <code>action_level</code>. The buffer exists, full of the last five minutes of activity, and it vanishes with the process. The logs were there. They were in memory. They died before reaching stdout.</p>
<p>Factor IX of the twelve-factor app talks about disposability: processes should start fast and stop gracefully. On a clean shutdown (SIGTERM), a well-behaved process finishes its current work and exits. But crashes are not clean shutdowns, and memory buffers are not crash-safe. The service had been disposable in the sense that we could restart it; it was not disposable in the sense that its exit was transparent.</p>
<h2 id="the-files-nobody-was-reading">The files nobody was reading</h2>
<p>There was a second problem, quieter but just as persistent.</p>
<p>Every service had a <code>main_group</code> handler that routed logs to two destinations in parallel:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">main_group</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">type</span>: <span style="color:#ae81ff">group</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">members</span>: [<span style="color:#ae81ff">main_file, stdout]</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">main_file</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">type</span>: <span style="color:#ae81ff">stream</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">path</span>: <span style="color:#e6db74">&#34;%kernel.logs_dir%/%kernel.environment%.log&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">formatter</span>: <span style="color:#e6db74">&#34;monolog.formatter.logstash&#34;</span>
</span></span></code></pre></div><p><code>var/log/prod.log</code> was being written on every service, in every environment, including production. The same content that went to stdout also went to a file inside the container. The file grew without rotation. The file was not accessible to Promtail (which read from the Docker socket, not from the container filesystem). The file consumed disk space. Nobody was reading it.</p>
<p>The audit channel was worse:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">audit_file</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">type</span>: <span style="color:#ae81ff">stream</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">path</span>: <span style="color:#e6db74">&#34;%kernel.logs_dir%/audit.log&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">formatter</span>: <span style="color:#e6db74">&#39;monolog.formatter.line&#39;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">audit</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">type</span>: <span style="color:#ae81ff">group</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">members</span>: [<span style="color:#ae81ff">audit_file, stderr]</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">channels</span>: [<span style="color:#e6db74">&#39;audit&#39;</span>]
</span></span></code></pre></div><p>Audit logs went to <code>stderr</code> (visible to Promtail) and to <code>audit.log</code> (not visible to Promtail). The format in the file was a plain line format, not the structured JSON that Promtail expected. In practice, the audit trail existed in two places: one queryable, one buried in a container directory that survived only as long as the container did.</p>
<h2 id="what-factor-xi-actually-requires">What Factor XI actually requires</h2>
<p>The eleventh factor is direct about this: an app should not concern itself with routing or storage of its output stream. It writes to stdout. Everything else is the environment&rsquo;s job.</p>
<p>That means no file handlers in production. Not as a backup. Not for audit trails. Not &ldquo;just in case&rdquo;. The moment an application starts managing files, it takes on responsibility for rotation, retention, disk space, and accessibility — none of which belong inside a container.</p>
<p>The fix for the file handlers is straightforward. In <code>when@prod</code>, remove every <code>*_file</code> handler and every group that includes one. The audit channel gets the same treatment: stderr only, structured JSON, no file:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">when@prod</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">monolog</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">handlers</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">stdout</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">type</span>: <span style="color:#ae81ff">stream</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">path</span>: <span style="color:#e6db74">&#34;php://stdout&#34;</span>
</span></span><span style="display:flex;"><span>                <span style="color:#75715e"># defaults to &#34;warning&#34; — overridable per-deploy via env var for targeted debugging</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">level</span>: <span style="color:#e6db74">&#34;%env(default:default_log_level:MONOLOG_LEVEL__DEFAULT)%&#34;</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">formatter</span>: <span style="color:#e6db74">&#39;monolog.formatter.logstash&#39;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">stderr</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">type</span>: <span style="color:#ae81ff">stream</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">path</span>: <span style="color:#e6db74">&#34;php://stderr&#34;</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">level</span>: <span style="color:#ae81ff">error</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">formatter</span>: <span style="color:#e6db74">&#39;monolog.formatter.logstash&#39;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">main</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">type</span>: <span style="color:#ae81ff">group</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">members</span>: [<span style="color:#ae81ff">stdout]</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">channels</span>: [<span style="color:#e6db74">&#39;!event&#39;</span>, <span style="color:#e6db74">&#39;!http_client&#39;</span>, <span style="color:#e6db74">&#39;!doctrine&#39;</span>, <span style="color:#e6db74">&#39;!deprecation&#39;</span>, <span style="color:#e6db74">&#39;!audit&#39;</span>]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">audit</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">type</span>: <span style="color:#ae81ff">stream</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">path</span>: <span style="color:#e6db74">&#34;php://stderr&#34;</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">level</span>: <span style="color:#ae81ff">debug</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">formatter</span>: <span style="color:#e6db74">&#39;monolog.formatter.logstash&#39;</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">channels</span>: [<span style="color:#e6db74">&#39;audit&#39;</span>]
</span></span></code></pre></div><p>stdout for the main channel. stderr for errors and audit. Nothing else. Promtail picks up both via the Docker socket. The container writes nothing to disk. And audit logs are now structured JSON, queryable in Loki alongside everything else.</p>
<h2 id="the-harder-question-about-fingers_crossed">The harder question about fingers_crossed</h2>
<p>The file handlers were easy. <code>fingers_crossed</code> is more nuanced.</p>
<p>The pattern solves a real problem: in a busy production service, logging everything at debug level creates noise and cost. <code>fingers_crossed</code> lets you capture context without paying for it unless something actually goes wrong. It is a reasonable tradeoff when the failure mode you&rsquo;re protecting against is an application-level error (an exception, a 500, a slow query).</p>
<p>It is not a reasonable tradeoff when the failure mode is a process crash. And in a Kubernetes environment, process crashes happen: OOM evictions, liveness probe failures, node pressure. Exactly the cases where you most need the logs.</p>
<p>One approach: keep <code>fingers_crossed</code> but reduce the buffer size. By default it keeps everything since the last reset. Set <code>buffer_size: 50</code> and you cap memory usage, which also limits what gets lost on crash. You won&rsquo;t have the full context, but you&rsquo;ll have the last fifty records. This patches the blast radius rather than removing the root cause: the opacity still depends on an error threshold that may never fire.</p>
<p>Another approach: accept that debug logs are expensive and raise the default log level in production. Then you don&rsquo;t need <code>fingers_crossed</code> at all — if info and above go directly to stdout, nothing is ever buffered.</p>
<p>The approach we landed on: drop <code>fingers_crossed</code>, raise the default level to <code>warning</code>, keep a debug override available via env var for targeted investigation. The logs we care about appear immediately. The ones we don&rsquo;t are never written. Nothing is buffered.</p>
<h2 id="crashes-dont-flush">Crashes don&rsquo;t flush</h2>
<p>Factor XI and Factor IX meet at the same point: a process dying mid-request. <a href="/2026/05/16/the-cache-that-was-lying-to-us/">another article in this series</a>
 described the illusion of a service that worked perfectly on one pod but quietly misbehaved on two. This is the same illusion, one layer up: a service that appeared to log correctly, until the moment it most needed to.</p>
<p>The rule for production Monolog is blunt: if it doesn&rsquo;t reach stdout or stderr before the process exits, it doesn&rsquo;t exist. A file handler inside a container is invisible to the log collector and dies with the pod. A <code>fingers_crossed</code> buffer is invisible to the log collector and dies with the process.</p>
<p>Production tends to create the conditions where you need logs the most — OOM pressure, cascading failures, bad deploys — and those are exactly the conditions where both of these patterns fail you simultaneously. Write to stdout, default to a level that doesn&rsquo;t require buffering, and make the override available for when you actually need to debug something. The logs will be there. They won&rsquo;t be waiting for an error threshold that never fires.</p>
]]></content:encoded></item><item><title>What Survives the Build</title><link>https://guillaumedelre.github.io/2026/05/14/what-survives-the-build/</link><pubDate>Thu, 14 May 2026 15:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2026/05/14/what-survives-the-build/</guid><description>Part 2 of 8 in &amp;quot;Symfony to the Cloud: Twelve Factors, Thirteen Services&amp;quot;: How committed .env files feed a build step that bakes credentials into Docker layers — and what it takes to empty the file down to four lines.</description><category>symfony-to-the-cloud</category><content:encoded><![CDATA[<p>At some point during a cloud migration audit, someone ran this:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker run --rm &lt;image&gt; php -r <span style="color:#e6db74">&#34;var_dump(require &#39;.env.local.php&#39;);&#34;</span>
</span></span></code></pre></div><p>The output showed everything that <code>composer dump-env prod</code> had compiled into the image at build time. Which meant it showed everything that had been in the <code>.env</code> file when the image was built. Which meant it showed these, among others:</p>
<pre tabindex="0"><code class="language-dotenv" data-lang="dotenv">INFLUXDB_INIT_ADMIN_TOKEN=&lt;influxdb-admin-token&gt;
GF_SECURITY_ADMIN_USER=admin
GF_SECURITY_ADMIN_PASSWORD=admin123
BLACKFIRE_CLIENT_ID=&lt;blackfire-client-id&gt;
BLACKFIRE_CLIENT_TOKEN=&lt;blackfire-client-token&gt;
BLACKFIRE_SERVER_ID=&lt;blackfire-server-id&gt;
BLACKFIRE_SERVER_TOKEN=&lt;blackfire-server-token&gt;
NGROK_AUTHTOKEN=replace-me-optionnal
</code></pre><p>Twenty-five variables in total. Every credential that had accumulated in the root <code>.env</code> over three years, now permanent in an image layer.</p>
<h2 id="how-dump-env-works">How <code>dump-env</code> works</h2>
<p><code>composer dump-env prod</code> is a legitimate Symfony optimization. Instead of parsing <code>.env</code> files on every request, the runtime loads a pre-compiled PHP array from <code>.env.local.php</code>. Faster and simpler.</p>
<p>The problem is what it reads. The Dockerfile copies the repository into the image with <code>COPY . ./</code>, <code>.env</code> included. Then <code>dump-env prod</code> reads that file and compiles every variable into <code>.env.local.php</code>. The image ships with a frozen snapshot of the credentials that were in <code>.env</code> at build time.</p>
<p>Docker layers are immutable archives. Even if a subsequent step removed <code>.env</code> from the container filesystem, the layer containing it would still exist inside the image. <code>docker save &lt;image&gt;</code> produces a tarball of every layer; extracting any file from any point in the build history is straightforward. The credentials are invisible at runtime. They are not gone.</p>
<p><a href="https://12factor.net/build-release-run" target="_blank" rel="noopener noreferrer">Factor V</a>
 calls this out directly: a build artifact should be environment-agnostic, with config arriving at the release step from outside. Once credentials are compiled in, the image is no longer portable. You can&rsquo;t promote it across environments. You build twice and hope the second build behaves like the first.</p>
<h2 id="how-twenty-five-variables-accumulate">How twenty-five variables accumulate</h2>
<p>Before tracing how this gets fixed, it&rsquo;s worth understanding how it happened.</p>
<p>The <code>BLACKFIRE_*</code> tokens are the easy case to understand. A team member sets up profiling, needs to share the configuration, and the repository is already open to everyone. One line in <code>.env</code> is the path of least resistance. The InfluxDB and Grafana credentials follow the same logic — shared tooling, shared repo, one commit.</p>
<p>Then there are the variables that reveal a different kind of drift. In some of the service-level <code>.env</code> files:</p>
<pre tabindex="0"><code class="language-dotenv" data-lang="dotenv">APP__RATINGS__SERIALS=&#39;{&#34;brand1&#34;:{&#34;fr&#34;:&#34;12345&#34;},...}&#39;  # ~40 lines of JSON
APP__YOUTUBE__CREDENTIALS=&#39;{&#34;brand1&#34;:{&#34;client_id&#34;:&#34;xxx&#34;,&#34;refresh_token&#34;:&#34;yyy&#34;},...}&#39;
</code></pre><p>Audience measurement serial numbers. YouTube API refresh tokens per brand. These aren&rsquo;t secrets in the Blackfire sense. They&rsquo;re business data — the kind of values that vary between brands and environments, that someone decided to version in <code>.env</code> because they behaved like configuration and <code>.env</code> was where configuration lived.</p>
<p>Twenty-five variables is the sum of incremental decisions, none of which felt wrong in isolation. The problem is structural: when <code>.env</code> is the only answer available, everything starts looking like it belongs there.</p>
<h2 id="where-things-actually-belong">Where things actually belong</h2>
<p>Emptying the file required answering one question for each variable: <em>where does this actually belong?</em></p>
<p>The answers revealed three categories that the team had never explicitly named:</p>
<p><strong>Static config</strong> lives in code. Business rules, routing logic, Symfony parameter files — anything that doesn&rsquo;t vary between deployments. A change requires a rebuild. The JSON blobs for audience measurement serials turned out not to be static config at all: they were queried from a dedicated Config service at runtime. They had no business being in a file.</p>
<p><strong>Environment config</strong> varies between deployments: hostnames, connection strings, third-party credentials. This is what <a href="https://12factor.net/config" target="_blank" rel="noopener noreferrer">Factor III</a>
 means by &ldquo;config in environment variables&rdquo; — real OS-level variables injected by the runtime, never files that travel with the code. In Kubernetes, this becomes a ConfigMap for non-sensitive values and a Kubernetes Secret for credentials. The choice for secrets management was SOPS — credentials are encrypted and committed to git, rather than stored in an external vault like Azure Key Vault or HashiCorp Vault. A vault trades simplicity for auditability: automatic rotation, centralized audit logs, workload identity-based access with no key to protect. SOPS trades those capabilities for a simpler operational model — no external service to query at deploy time, secrets travel through the normal code review process, git history serves as the audit trail. The accepted downsides are manual rotation and the responsibility of protecting the decryption key itself. For the team&rsquo;s scale, the tradeoff was deliberate.</p>
<p><strong>Dynamic config</strong> changes without a deployment: editorial parameters, per-brand thresholds, content moderation settings. It belongs in a database, managed through the application&rsquo;s Config service. Some of what had accumulated in <code>.env</code> files was this category all along, passing as static defaults because it changed rarely enough that nobody noticed.</p>
<p>Once the categories had names, the variables sorted themselves. The root <code>.env</code> ended at four lines:</p>
<pre tabindex="0"><code class="language-dotenv" data-lang="dotenv">DOMAIN=platform.127.0.0.1.sslip.io
XDEBUG_MODE=off
SERVER_NAME=:80
APP_ENV=dev
</code></pre><p>Safe defaults. Nothing sensitive. <code>dump-env prod</code> now compiles empty strings; real values arrive at runtime from Kubernetes.</p>
<h2 id="the-postgresql-image">The PostgreSQL image</h2>
<p>The PostgreSQL image used in CI has a hardcoded password:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-dockerfile" data-lang="dockerfile"><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> <span style="color:#e6db74">postgres:15</span><span style="color:#960050;background-color:#1e0010">
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">ENV</span> POSTGRES_PASSWORD<span style="color:#f92672">=</span>admin123
</span></span></code></pre></div><p>This looks like the same problem. It isn&rsquo;t, because the threat model is different. The CI database is ephemeral — it exists for the duration of a pipeline run, contains no real data, and runs in an isolated network. A hardcoded password on a throwaway test database is an acceptable risk, not a policy exception.</p>
<p>In production, the question doesn&rsquo;t arise: the platform uses Azure Flexible Server, a managed PostgreSQL service. There is no Docker image. Credentials arrive via Helm chart injection, never touching a layer.</p>
<h2 id="what-survives-the-build-now">What survives the build now</h2>
<p>The image that ships to production now contains a guarantee: <code>var_dump(require '.env.local.php')</code> returns only empty strings and safe defaults. The credentials aren&rsquo;t there because they were never put there — they arrive at runtime, from outside.</p>
<p>That&rsquo;s the responsibility boundary <code>dump-env</code> had been quietly erasing: the image is the application, the runtime is the environment. They should not know each other&rsquo;s secrets.</p>
]]></content:encoded></item><item><title>The Ghost of the CI Runner</title><link>https://guillaumedelre.github.io/2026/05/14/the-ghost-of-the-ci-runner/</link><pubDate>Thu, 14 May 2026 10:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2026/05/14/the-ghost-of-the-ci-runner/</guid><description>Part 1 of 8 in &amp;quot;Symfony to the Cloud: Twelve Factors, Thirteen Services&amp;quot;: How a CI runner&amp;#39;s home directory in production config revealed a storage architecture problem — and how Flysystem lazy adapters fixed it.</description><category>symfony-to-the-cloud</category><content:encoded><![CDATA[<pre tabindex="0"><code class="language-dotenv" data-lang="dotenv">APP__COLD_STORAGE__FILESYSTEM_PATH=&#34;/home/jenkins-slave/share_media/media&#34;
APP__COLD_STORAGE__FILESYSTEM_PATH_CACHE=&#34;/home/jenkins-slave/share_media/media/cache&#34;
APP__COLD_STORAGE__RAW_IMAGE_PATH=&#34;/home/jenkins-slave/share_media/media_raw&#34;
APP__SHARE_STORAGE__FILESYSTEM_PATH=&#34;/home/jenkins-slave/share_storage&#34;
</code></pre><p>These lines were in the production <code>.env</code> of the media service. Not staging. Not a local override. Production, committed to the repository, read on every startup.</p>
<p>The paths end where you&rsquo;d expect: <code>/media</code>, <code>/share_storage</code>. They start somewhere more surprising: <code>/home/jenkins-slave</code>, the home directory of a CI runner from an old Jenkins setup.</p>
<h2 id="how-a-runners-home-directory-ends-up-in-production-config">How a runner&rsquo;s home directory ends up in production config</h2>
<p>The platform had grown from a single machine. One server ran everything — the application, the CI runner, the database, the file storage. Files moved between the app and the CI system via NFS: a directory mounted on the same host, accessible to both the containers and the runner.</p>
<p>The path <code>/home/jenkins-slave/share_media</code> was where the NFS share landed on that machine. When the team migrated to Docker Compose, the containers inherited the NFS mount. The path made it into the <code>.env</code> because the application needed to know where to find files. Nobody changed it because it worked. The mount was still there. The path was valid. The application started. Files appeared where they should.</p>
<p>Three years later, nobody thought about it at all. It was just how the media path was configured.</p>
<h2 id="what-kubectl-apply-found">What kubectl apply found</h2>
<p>The first <code>kubectl apply</code> for the media service ended with a pod stuck in CrashLoopBackOff. The container started. The entrypoint ran. The application tried to access <code>/home/jenkins-slave/share_media/media</code>. No such file or directory. No NFS mount. No runner.</p>
<p>The path didn&rsquo;t document a design decision. It documented the machine that happened to be running at the time the <code>.env</code> was written.</p>
<p>This is what <a href="https://12factor.net/backing-services" target="_blank" rel="noopener noreferrer">Factor IV</a>
 of the twelve-factor app is warning against. Backing services — storage, queues, databases — should be attached resources, configured via URL or connection string, interchangeable between environments without code changes. A filesystem path on a shared host is not a backing service. It&rsquo;s a physical assumption about the machine. When the machine changes, the assumption fails.</p>
<h2 id="the-path-was-the-symptom">The path was the symptom</h2>
<p>The obvious first step was removing the runner reference:</p>
<pre tabindex="0"><code class="language-dotenv" data-lang="dotenv">APP__COLD_STORAGE__FILESYSTEM_PATH=&#34;/share_media/media&#34;
APP__SHARE_STORAGE__FILESYSTEM_PATH=&#34;/share_storage&#34;
</code></pre><p>Cleaner. No more CI references in a production config. Still not right. The application still assumed a POSIX filesystem — either a volume mount or a directory on the node. In Kubernetes, a volume shared between multiple pods requires a <code>ReadWriteMany</code> PersistentVolumeClaim. Most storage providers don&rsquo;t support it. Those that do tend to be slow and expensive. And even where it works, you&rsquo;ve replaced one shared filesystem assumption with another.</p>
<p>Renaming the path bought time. It didn&rsquo;t fix the problem.</p>
<p>The problem was that roughly twelve terabytes of images — originals and pre-generated derivatives in multiple formats — from multiple editorial brands — were treated as a directory. A directory can&rsquo;t be mounted cleanly across pods. A backing service can.</p>
<h2 id="flysystem-as-the-shape-of-the-solution">Flysystem as the shape of the solution</h2>
<p>The media service already had a Flysystem dependency. Three concrete adapters — local filesystem, AWS S3, Azure Blob — and one lazy adapter sitting on top:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># config/packages/flysystem.yaml</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">flysystem</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">storages</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">media.storage.local</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">adapter</span>: <span style="color:#e6db74">&#39;local&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">options</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">directory</span>: <span style="color:#e6db74">&#34;/&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">media.storage.aws</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">adapter</span>: <span style="color:#e6db74">&#39;aws&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">options</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">client</span>: <span style="color:#e6db74">&#39;aws_client_service&#39;</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">bucket</span>: <span style="color:#e6db74">&#39;media&#39;</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">streamReads</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">media.storage</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">adapter</span>: <span style="color:#e6db74">&#39;lazy&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">options</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">source</span>: <span style="color:#e6db74">&#39;%env(APP__FLYSYSTEM_MEDIA_STORAGE)%&#39;</span>
</span></span></code></pre></div><p>All application code depends on <code>media.storage</code>. It doesn&rsquo;t know whether files live on the filesystem or in a cloud bucket. One environment variable determines which backend is active:</p>
<pre tabindex="0"><code class="language-dotenv" data-lang="dotenv">APP__FLYSYSTEM_MEDIA_STORAGE=media.storage.aws   # production
APP__FLYSYSTEM_MEDIA_STORAGE=media.storage.local  # local fallback still available
</code></pre><p>The path is gone. The filesystem assumption is gone. What remains is a service name — an attached resource in the twelve-factor sense, configurable without rebuilding the image.</p>
<p>The same pattern extends to the thumbnail cache. <a href="https://github.com/liip/LiipImagineBundle" target="_blank" rel="noopener noreferrer">LiipImagine</a>
 generates resized images on demand; both the source originals and the generated cache go through separate Flysystem adapters:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">liip_imagine</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">loaders</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">default</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">flysystem</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">filesystem_service</span>: <span style="color:#e6db74">&#39;media.storage&#39;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">default_cache</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">flysystem</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">filesystem_service</span>: <span style="color:#e6db74">&#39;media.cache.storage&#39;</span>
</span></span></code></pre></div><p>Two environment variables, two buckets. The full pipeline — receive upload, store original, generate thumbnail, cache it — is cloud-portable without touching a line of PHP.</p>
<p>What this doesn&rsquo;t cover is moving the data. The lazy adapter changes one environment variable. Getting twelve terabytes from an NFS mount into an S3 bucket is a different project — a migration window, double-write during cutover, verification that nothing was missed.</p>
<h2 id="what-minio-makes-possible-in-ci">What Minio makes possible in CI</h2>
<p>Production uses S3. Local development uses <a href="https://min.io/" target="_blank" rel="noopener noreferrer">Minio</a>
, an S3-compatible object store that runs in a Docker container. The AWS adapter talks to Minio locally and to S3 in production. The application doesn&rsquo;t notice the difference:</p>
<pre tabindex="0"><code class="language-dotenv" data-lang="dotenv"># local/CI
APP__FLYSYSTEM_MEDIA_STORAGE=media.storage.aws
APP__MINIO_ENDPOINT=http://minio:9000
APP__MINIO_ACCESS_KEY=minioadmin
APP__MINIO_SECRET_KEY=minioadmin
</code></pre><p>The same code, the same adapter, a different endpoint. No mocking, no special test paths, no environment-specific branches.</p>
<p>But the CI configuration goes one step further. The Minio image used in the pipeline isn&rsquo;t the standard upstream one — it&rsquo;s a custom image built with test fixtures preloaded:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-dockerfile" data-lang="dockerfile"><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> <span style="color:#e6db74">minio/minio:latest</span><span style="color:#960050;background-color:#1e0010">
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">COPY</span> tests/fixtures/ /fixtures_media/<span style="color:#960050;background-color:#1e0010">
</span></span></span></code></pre></div><p>Every CI run starts with a Minio instance that already contains the data the test suite expects. No setup script, no seed command, no &ldquo;wait for fixtures to load&rdquo; step before tests begin. The initial state of the test environment is part of the build artifact.</p>
<p><a href="https://12factor.net/build-release-run" target="_blank" rel="noopener noreferrer">Factor V</a>
 applied to test infrastructure: the environment state is built, versioned, and immutable. The CI pipeline builds the Minio image from the same source and at the same commit as the application image. The test fixtures and the code that exercises them are always in sync.</p>
<h2 id="the-s3-tradeoff-honestly">The S3 tradeoff, honestly</h2>
<p>S3 introduces a latency cost that local storage doesn&rsquo;t have. The first bytes of a file take 10 to 30 milliseconds to arrive from S3 — that&rsquo;s the documented first-byte latency for the service, not a measurement on this specific workload.</p>
<p>At 300 requests per second, the reasoning for accepting it was this: most reads hit already-generated thumbnails in the S3-backed cache, not the original files. A freshly uploaded image pays the cold-miss penalty once, on the first thumbnail request. Everything after that is a cache hit. Whether the actual tail latency under load bore that reasoning out required performance testing that was tracked separately — the architecture decision and the validation were decoupled.</p>
<p>The tradeoff was accepted: predictable behavior across multiple pods, no shared-state problems, a storage layer that scales without coordination. The full measurement story belongs in the load test report, not here.</p>
<h2 id="the-ghost-leaves">The ghost leaves</h2>
<p>The path <code>/home/jenkins-slave</code> no longer appears in the configuration. But what it pointed to was a coupling that predated Docker, predated microservices, predated any conversation about cloud migration. The CI runner and the production application shared a filesystem because they lived on the same machine. Nobody designed it that way. It accumulated.</p>
<p>A <code>kubectl apply</code> error on a path that shouldn&rsquo;t have existed forced the question: why does this application assume a specific CI runner is present on the host? The answer was &ldquo;because it always has.&rdquo; That&rsquo;s not a reason. It&rsquo;s a history.</p>
<p>Renaming the path was a paper fix. Flysystem&rsquo;s lazy adapter was the actual answer — not because it&rsquo;s more elegant, but because it makes the storage backend a decision that belongs to the environment, not to the application. The container starts, reads one variable, connects to whatever is at the other end. It doesn&rsquo;t know whether that&rsquo;s a bucket in a data center or a container on a laptop.</p>
<p>The runner&rsquo;s home directory is gone from the config. What replaced it is a service name. That&rsquo;s the difference.</p>
]]></content:encoded></item><item><title>Symfony 8.0: PHP 8.4 minimum, native lazy objects, and FormFlow</title><link>https://guillaumedelre.github.io/2026/01/12/symfony-8.0-php-8.4-minimum-native-lazy-objects-and-formflow/</link><pubDate>Mon, 12 Jan 2026 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2026/01/12/symfony-8.0-php-8.4-minimum-native-lazy-objects-and-formflow/</guid><description>Part 11 of 11 in &amp;quot;Symfony Releases&amp;quot;: Symfony 8.0 requires PHP 8.4, replaces its proxy code generator with native lazy objects, and introduces FormFlow.</description><category>symfony-releases</category><content:encoded><![CDATA[<p>Symfony 8.0 shipped November 27, 2025, same day as 7.4. It requires PHP 8.4 and drops everything that was deprecated in 7.4. The two most interesting changes are what it stops doing and what it starts doing with PHP 8.4.</p>
<h2 id="native-lazy-objects">Native lazy objects</h2>
<p>Symfony&rsquo;s proxy system, used for lazy service initialization and Doctrine&rsquo;s entity proxies, has historically relied on code generation. The proxy classes were generated at cache warmup, stored as files, and loaded when needed. It worked, but it added real complexity: generated files to manage, cache to invalidate, code that looked nothing like the class it proxied.</p>
<p>PHP 8.4 added native lazy objects. Symfony 8.0 uses them. The <code>LazyGhostTrait</code> and <code>LazyProxyTrait</code> that powered the old system are removed. Proxy creation is now a runtime operation backed by the engine itself, not a code generation step.</p>
<p>For application developers the change is mostly invisible: lazy services still work. For framework and library authors, a significant surface of complexity just disappears.</p>
<h2 id="formflow">FormFlow</h2>
<p>Multi-step forms have always been a DIY exercise in Symfony. Session management, step tracking, partial validation, navigation between steps: every project rolled its own solution or pulled in a third-party bundle.</p>
<p>8.0 introduces FormFlow: a built-in mechanism for multi-step form wizards. Steps are defined as a sequence of form types, partial validation is scoped to the current step, and session management is handled automatically.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">#[AsFormFlow]
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">CheckoutFlow</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">AbstractFormFlow</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">protected</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">defineSteps</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">Steps</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">Steps</span><span style="color:#f92672">::</span><span style="color:#a6e22e">create</span>()
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">add</span>(<span style="color:#e6db74">&#39;shipping&#39;</span>, <span style="color:#a6e22e">ShippingType</span><span style="color:#f92672">::</span><span style="color:#a6e22e">class</span>)
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">add</span>(<span style="color:#e6db74">&#39;payment&#39;</span>, <span style="color:#a6e22e">PaymentType</span><span style="color:#f92672">::</span><span style="color:#a6e22e">class</span>)
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">add</span>(<span style="color:#e6db74">&#39;review&#39;</span>, <span style="color:#a6e22e">ReviewType</span><span style="color:#f92672">::</span><span style="color:#a6e22e">class</span>);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="xml-and-fluent-php-config-removed">XML and fluent PHP config removed</h2>
<p>The 7.4 deprecation of the fluent PHP configuration format becomes a hard removal in 8.0. XML configuration also exits as a first-class format. The supported formats for application configuration are now YAML and PHP arrays. The footprint shrinks, but what remains is genuinely better.</p>
<h2 id="what-else-is-gone">What else is gone</h2>
<ul>
<li>PHP 8.2 and 8.3 support (8.4 minimum)</li>
<li>The <code>ContainerAwareInterface</code> and <code>ContainerAwareTrait</code></li>
<li>Symfony&rsquo;s internal use of <code>LazyGhostTrait</code> and <code>LazyProxyTrait</code></li>
<li>HTTP method override for GET and HEAD (only POST makes sense semantically)</li>
</ul>
<p>Symfony 8.0 is a clean break, and that kind of break only becomes possible when the PHP floor rises. PHP 8.4&rsquo;s lazy objects are the clearest example: the feature now exists in the language, so the framework can just stop implementing it.</p>
<h2 id="console-becomes-more-ergonomic-for-invokable-commands">Console becomes more ergonomic for invokable commands</h2>
<p>Invokable commands get a significant upgrade. The <code>#[Input]</code> attribute turns a DTO into the command&rsquo;s argument/option bag. No more calling <code>$input-&gt;getArgument()</code> inside the handler:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">#[AsCommand(name: &#39;app:send-report&#39;)]
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">SendReportCommand</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">__invoke</span>(
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">#[Input] SendReportInput $input,
</span></span></span><span style="display:flex;"><span>    )<span style="color:#f92672">:</span> <span style="color:#a6e22e">int</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// $input-&gt;email, $input-&gt;dryRun, etc.
</span></span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">Command</span><span style="color:#f92672">::</span><span style="color:#a6e22e">SUCCESS</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>BackedEnum</code> is supported in invokable commands, so an option declared as a <code>Status</code> enum gets validated and cast automatically. Interactive commands get <code>#[Interact]</code> and <code>#[Ask]</code> attributes to declare question prompts inline. <code>CommandTester</code> works with invokable commands without any extra wiring.</p>
<h2 id="routing-finds-its-own-controllers">Routing finds its own controllers</h2>
<p>Routes defined via <code>#[Route]</code> on controller classes are auto-registered without needing an explicit <code>resource:</code> entry in <code>config/routes.yaml</code>. The tag <code>routing.controller</code> is applied automatically. You still control which directories are scanned, but your YAML config shrinks to a pointer at a directory rather than a manual file list.</p>
<p><code>#[Route]</code> also gains a <code>_query</code> parameter for setting query parameters at generation time, and multiple environments in the <code>env</code> option.</p>
<h2 id="security-csrf-and-oidc-get-better-tooling">Security: CSRF and OIDC get better tooling</h2>
<p><code>#[IsCsrfTokenValid]</code> gets a <code>$tokenSource</code> argument so you can specify where the token comes from (header, cookie, form field) rather than relying on a fixed convention. <code>SameOriginCsrfTokenManager</code> adds <code>Sec-Fetch-Site</code> header validation, a browser-native CSRF protection mechanism that doesn&rsquo;t need token injection at all.</p>
<p>The <code>security:oidc-token:generate</code> command creates tokens for testing OIDC-protected endpoints locally. Multiple OIDC discovery endpoints are supported now, useful in multi-tenant setups where each tenant has its own identity provider.</p>
<p>Two new Twig functions: <code>access_decision()</code> and <code>access_decision_for_user()</code> expose the authorization voter result in templates without going through the security facade. <code>#[IsGranted]</code> can be subclassed for repeated authorization patterns that deserve their own named attribute.</p>
<h2 id="objectmapper-and-jsonstreamer-leave-experimental">ObjectMapper and JsonStreamer leave experimental</h2>
<p>Both components introduced in 7.x graduate to stable in 8.0. <code>ObjectMapper</code> maps between objects without hand-written transformers, using attribute-based configuration. <code>JsonStreamer</code> reads and writes large JSON without loading the full document into memory, and it now supports synthetic properties: virtual fields computed at serialization time.</p>
<p><code>JsonStreamer</code> also drops its dependency on <code>nikic/php-parser</code>. The code generation for the reader/writer now uses a simpler internal mechanism, cutting a heavy dev dependency.</p>
<h2 id="uid-defaults-to-uuidv7">Uid defaults to UUIDv7</h2>
<p><code>UuidFactory</code> now generates UUIDv7 by default instead of UUIDv4. The difference: v7 is time-ordered, so generated UUIDs sort chronologically. That matters a lot for database index performance. <code>MockUuidFactory</code> provides deterministic UUID generation in tests.</p>
<h2 id="yaml-raises-an-error-on-duplicate-keys">Yaml raises an error on duplicate keys</h2>
<p>Previously, a YAML file with two identical keys silently kept the last one. 8.0 raises a parse error. This catches real bugs: duplicate keys in <code>services.yaml</code> or <code>config/packages/*.yaml</code> are almost always copy-paste mistakes and you definitely want to know about them.</p>
<h2 id="validator-video-constraint-and-wildcard-protocols">Validator: Video constraint and wildcard protocols</h2>
<p>A <code>Video</code> constraint joins the <code>Image</code> constraint for validating uploaded video files (MIME type, duration, codec). The <code>Url</code> constraint accepts <code>protocols: ['*']</code> to allow any RFC 3986-compliant scheme, useful when storing arbitrary URLs that include <code>git+ssh://</code>, <code>file://</code>, or custom app schemes.</p>
<h2 id="messenger-sqs-native-retry-and-new-events">Messenger: SQS native retry and new events</h2>
<p>SQS transport can now use its own native retry and dead-letter queue configuration instead of Symfony&rsquo;s retry middleware. For high-volume queues on AWS, this removes a round-trip through PHP for transient failures. A <code>MessageSentToTransportsEvent</code> fires after a message is dispatched, carrying information about which transports actually received it.</p>
<p><code>messenger:consume</code> gets <code>--exclude-receivers</code> to pair with <code>--all</code>.</p>
<h2 id="mailer-microsoft-graph-transport">Mailer: Microsoft Graph transport</h2>
<p>A new transport sends mail via the Microsoft Graph API, which is what Microsoft recommends for applications on Azure Active Directory these days. The other options (SMTP relay, Exchange EWS) still work, but Graph is the right choice for new Azure deployments.</p>
<h2 id="workflow-weighted-transitions">Workflow: weighted transitions</h2>
<p>Transitions can now declare weights. When multiple transitions are enabled from the same place, the highest-weight one wins. This lets you express priority directly in the workflow definition without adding a guard that reads an external counter.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">return</span> (<span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Definition</span>(<span style="color:#a6e22e">states</span><span style="color:#f92672">:</span> [<span style="color:#e6db74">&#39;draft&#39;</span>, <span style="color:#e6db74">&#39;review&#39;</span>, <span style="color:#e6db74">&#39;published&#39;</span>]))
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">addTransition</span>(<span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Transition</span>(<span style="color:#e6db74">&#39;publish&#39;</span>, <span style="color:#e6db74">&#39;review&#39;</span>, <span style="color:#e6db74">&#39;published&#39;</span>, <span style="color:#a6e22e">weight</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">10</span>))
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">addTransition</span>(<span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Transition</span>(<span style="color:#e6db74">&#39;reject&#39;</span>, <span style="color:#e6db74">&#39;review&#39;</span>, <span style="color:#e6db74">&#39;draft&#39;</span>, <span style="color:#a6e22e">weight</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">1</span>));
</span></span></code></pre></div><h2 id="lock-lockkeynormalizer">Lock: LockKeyNormalizer</h2>
<p><code>LockKeyNormalizer</code> normalizes a lock key to a consistent string before hashing. Useful when the key is derived from user input or external data that may vary in whitespace or casing: the normalizer makes sure the same logical key always maps to the same lock.</p>
<h2 id="httpfoundation-query-method-and-cleaner-body-parsing">HttpFoundation: QUERY method and cleaner body parsing</h2>
<p>The IETF <code>QUERY</code> method (a safe, idempotent method with a body, unlike <code>GET</code>) is now supported throughout the stack: <code>Request</code>, HTTP cache, WebProfiler, and HttpClient. If you build search APIs that need a structured request body and also want caching, <code>QUERY</code> is the right semantic choice.</p>
<p><code>Request::createFromGlobals()</code> now parses the body of <code>PUT</code>, <code>DELETE</code>, <code>PATCH</code>, and <code>QUERY</code> requests automatically.</p>
<h2 id="config-json-schema-for-yaml-validation">Config: JSON schema for YAML validation</h2>
<p>Symfony 8.0 auto-generates a JSON Schema file for each configuration section. IDEs that support JSON Schema for YAML files (VS Code, PhpStorm) can now validate <code>config/packages/*.yaml</code> against these schemas and provide autocompletion without any plugin. The schema is generated during cache warmup and placed at <code>config/reference.php</code>.</p>
<h2 id="runtime-frankenphp-auto-detection">Runtime: FrankenPHP auto-detection</h2>
<p>The Runtime component detects FrankenPHP automatically and activates worker mode without any extra package or environment variable. If <code>$_SERVER['APP_RUNTIME']</code> is set, that runtime class takes precedence. You can also pick the error renderer based on <code>APP_RUNTIME_MODE</code>, which is useful when running the same codebase in HTTP and CLI contexts with different error presentation needs.</p>
]]></content:encoded></item><item><title>Symfony 7.4 LTS: message signing, PHP config arrays, and the last 7.x</title><link>https://guillaumedelre.github.io/2026/01/10/symfony-7.4-lts-message-signing-php-config-arrays-and-the-last-7.x/</link><pubDate>Sat, 10 Jan 2026 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2026/01/10/symfony-7.4-lts-message-signing-php-config-arrays-and-the-last-7.x/</guid><description>Part 10 of 11 in &amp;quot;Symfony Releases&amp;quot;: Symfony 7.4 LTS adds Messenger message signing, PHP array-based configuration, and closes out the 7.x line.</description><category>symfony-releases</category><content:encoded><![CDATA[<p>Symfony 7.4 landed November 2025, alongside 8.0. It&rsquo;s the last LTS of the 7.x line: PHP 8.2 minimum, three years of bug fixes, four of security. For teams that can&rsquo;t or won&rsquo;t follow 8.0&rsquo;s PHP 8.4 requirement, 7.4 is where you land.</p>
<h2 id="message-signing-in-messenger">Message signing in Messenger</h2>
<p>Transport security in Messenger has always been the application&rsquo;s problem to solve. 7.4 adds message signing: a stamp-based mechanism that signs dispatched messages and validates signatures on reception.</p>
<p>The target use case is multi-tenant or external transport scenarios where you need cryptographic proof that a message wasn&rsquo;t tampered with or injected from outside. Configuration lives at the transport level:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">framework</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">messenger</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">transports</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">async</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">dsn</span>: <span style="color:#e6db74">&#39;%env(MESSENGER_TRANSPORT_DSN)%&#39;</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">options</span>:
</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">signing_key</span>: <span style="color:#e6db74">&#39;%env(MESSENGER_SIGNING_KEY)%&#39;</span>
</span></span></code></pre></div><h2 id="php-array-configuration">PHP array configuration</h2>
<p>Symfony&rsquo;s configuration formats have always been YAML (default), XML, and PHP. The PHP format existed but it was awkward: a fluent builder DSL that required method chaining and gave your IDE nothing useful to work with.</p>
<p>7.4 swaps the fluent format for standard PHP arrays. IDEs can now actually analyze it, <code>config/reference.php</code> is auto-generated as a type-annotated reference, and the result reads like data rather than code:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">return</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">function</span> (<span style="color:#a6e22e">FrameworkConfig</span> $framework)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span> {
</span></span><span style="display:flex;"><span>    $framework<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">router</span>()<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">strictRequirements</span>(<span style="color:#66d9ef">null</span>);
</span></span><span style="display:flex;"><span>    $framework<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">session</span>()<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">enabled</span>(<span style="color:#66d9ef">true</span>);
</span></span><span style="display:flex;"><span>};
</span></span></code></pre></div><p>The fluent format is deprecated. Arrays are the future, and honestly it&rsquo;s a better format.</p>
<h2 id="oidc-improvements">OIDC improvements</h2>
<p><code>#[IsSignatureValid]</code> validates signed URLs directly in controllers, cutting out the boilerplate of manual validation. OpenID Connect now supports multiple discovery endpoints, and a new <code>security:oidc-token:generate</code> command makes dev and testing a lot less painful.</p>
<h2 id="the-support-window">The support window</h2>
<p>7.4 LTS: bugs until November 2028, security fixes until November 2029. The path to 8.4 LTS (the next long-term target) goes through 7.4&rsquo;s deprecation notices and the PHP 8.4 upgrade. Fix the deprecations now and the jump to 8.x will be much less painful.</p>
<h2 id="attributes-get-more-precise">Attributes get more precise</h2>
<p><code>#[CurrentUser]</code> now accepts union types, which matters in practice when a route can be reached by more than one user class:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">index</span>(<span style="color:#75715e">#[CurrentUser] AdminUser|Customer $user): Response
</span></span></span></code></pre></div><p><code>#[Route]</code> accepts an array for the <code>env</code> option, so a debug route active only in <code>dev</code> and <code>test</code> no longer needs two separate definitions. <code>#[AsDecorator]</code> is now repeatable, meaning one class can decorate multiple services at once. <code>#[AsEventListener]</code> method signatures accept union event types. <code>#[IsGranted]</code> gets a <code>methods</code> option to scope an authorization check to specific HTTP verbs without duplicating the route.</p>
<h2 id="request-class-stops-doing-too-much">Request class stops doing too much</h2>
<p><code>Request::get()</code> is deprecated, and honestly good riddance. The method searched route attributes, then query parameters, then request body, in that order, silently returning whatever it found first. That ambiguity caused real bugs. It&rsquo;s gone in 8.0; in 7.4 it still works but triggers a deprecation. The replacements are explicit: <code>$request-&gt;attributes-&gt;get()</code>, <code>$request-&gt;query-&gt;get()</code>, <code>$request-&gt;request-&gt;get()</code>.</p>
<p>Body parsing for <code>PUT</code>, <code>PATCH</code>, <code>DELETE</code>, and <code>QUERY</code> requests arrives at the same time. Previously Symfony only parsed <code>application/x-www-form-urlencoded</code> and <code>multipart/form-data</code> for <code>POST</code>. Those same content types now get parsed for the other writable methods too, which kills a common REST API workaround.</p>
<p>HTTP method override for <code>GET</code>, <code>HEAD</code>, <code>CONNECT</code>, and <code>TRACE</code> is deprecated. Overriding a safe method with a header was always semantically broken anyway. You can now explicitly allow only the methods that make sense for your app:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#a6e22e">Request</span><span style="color:#f92672">::</span><span style="color:#a6e22e">setAllowedHttpMethodOverride</span>([<span style="color:#e6db74">&#39;PUT&#39;</span>, <span style="color:#e6db74">&#39;PATCH&#39;</span>, <span style="color:#e6db74">&#39;DELETE&#39;</span>]);
</span></span></code></pre></div><h2 id="workflows-accept-backedenums">Workflows accept BackedEnums</h2>
<p>Workflow places and transitions can now be defined with PHP backed enums, both in YAML (via the <code>!php/enum</code> tag) and in PHP config. The marking store works with enum values directly, so your domain model and your workflow definition finally use the same types:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">framework</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">workflows</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">blog_publishing</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">initial_marking</span>: !<span style="color:#ae81ff">php/enum App\Status\PostStatus::Draft</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">places</span>: !<span style="color:#ae81ff">php/enum App\Status\PostStatus</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">transitions</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">publish</span>:
</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">from</span>: !<span style="color:#ae81ff">php/enum App\Status\PostStatus::Review</span>
</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">to</span>: !<span style="color:#ae81ff">php/enum App\Status\PostStatus::Published</span>
</span></span></code></pre></div><h2 id="extending-validation-and-serialization-for-third-party-classes">Extending validation and serialization for third-party classes</h2>
<p>Ever needed to add validation or serialization metadata to a class from a bundle you don&rsquo;t own? 7.4 has <code>#[ExtendsValidationFor]</code> and <code>#[ExtendsSerializationFor]</code> for that. You write a companion class with your extra annotations, point the attribute at the target class, and Symfony merges the metadata at container compilation time:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">#[ExtendsValidationFor(UserRegistration::class)]
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">abstract</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">UserRegistrationValidation</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">#[Assert\NotBlank(groups: [&#39;my_app&#39;])]
</span></span></span><span style="display:flex;"><span>    <span style="color:#75715e">#[Assert\Length(min: 3, groups: [&#39;my_app&#39;])]
</span></span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#a6e22e">string</span> $name <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;&#39;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">#[Assert\Email(groups: [&#39;my_app&#39;])]
</span></span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#a6e22e">string</span> $email <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;&#39;</span>;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Symfony verifies at compile time that the declared properties actually exist on the target class. A rename won&rsquo;t silently break your validation.</p>
<h2 id="dx-the-things-that-dont-headline-but-matter">DX: the things that don&rsquo;t headline but matter</h2>
<p>The Question helper in Console accepts a timeout. Ask the user to confirm something, and if they don&rsquo;t respond in N seconds, the default answer kicks in. Very handy in deployment scripts that can&rsquo;t afford to wait forever for a human.</p>
<p><code>messenger:consume</code> gets <code>--exclude-receivers</code>. Combined with <code>--all</code>, it lets you consume from every transport except specific ones:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>bin/console messenger:consume --all --exclude-receivers<span style="color:#f92672">=</span>low_priority
</span></span></code></pre></div><p>FrankenPHP worker mode is now auto-detected. If the process is running inside FrankenPHP, Symfony switches to worker mode automatically. No extra package needed.</p>
<p>The <code>debug:router</code> command hides the <code>Scheme</code> and <code>Host</code> columns when all routes use <code>ANY</code>, which removes a lot of noise from the default output. HTTP methods are now color-coded too.</p>
<p>Functional tests get <code>$client-&gt;getSession()</code> before the first request. Previously you had to make at least one request to access the session, which was annoying. Now you can pre-seed CSRF tokens or A/B testing flags up front:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span>$session <span style="color:#f92672">=</span> $client<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getSession</span>();
</span></span><span style="display:flex;"><span>$session<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">set</span>(<span style="color:#e6db74">&#39;_csrf/checkout&#39;</span>, <span style="color:#e6db74">&#39;test-token&#39;</span>);
</span></span><span style="display:flex;"><span>$session<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">save</span>();
</span></span></code></pre></div><h2 id="lock-dynamodb-store">Lock: DynamoDB store</h2>
<p><code>DynamoDbStore</code> lands as a new Lock backend. Useful in AWS-native deployments where Redis isn&rsquo;t in the stack, and it works exactly like any other store:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span>$store <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">DynamoDbStore</span>(<span style="color:#e6db74">&#39;dynamodb://default/locks&#39;</span>);
</span></span><span style="display:flex;"><span>$factory <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">LockFactory</span>($store);
</span></span></code></pre></div><h2 id="doctrine-bridge-day-and-time-point-types">Doctrine bridge: day and time point types</h2>
<p>Two new Doctrine column types: <code>day_point</code> stores a date-only value (no time component) and <code>time_point</code> stores a time-only value, both mapping to <code>DatePoint</code>. Good when your domain genuinely separates date from time:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">#[ORM\Column(type: &#39;day_point&#39;)]
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#a6e22e">DatePoint</span> $birthDate;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#[ORM\Column(type: &#39;time_point&#39;)]
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#a6e22e">DatePoint</span> $openingTime;
</span></span></code></pre></div><h2 id="routing-explicit-query-parameters">Routing: explicit query parameters</h2>
<p>The <code>_query</code> key in URL generation lets you set query parameters explicitly, separate from route parameters. This matters when a route parameter and a query parameter share the same name:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span>$url <span style="color:#f92672">=</span> $urlGenerator<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">generate</span>(<span style="color:#e6db74">&#39;report&#39;</span>, [
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;site&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;fr&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;_query&#39;</span> <span style="color:#f92672">=&gt;</span> [<span style="color:#e6db74">&#39;site&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;us&#39;</span>],
</span></span><span style="display:flex;"><span>]);
</span></span><span style="display:flex;"><span><span style="color:#75715e">// /report/fr?site=us
</span></span></span></code></pre></div><h2 id="weblink-parsing-incoming-link-headers">WebLink: parsing incoming Link headers</h2>
<p><code>HttpHeaderParser</code> parses <code>Link</code> response headers into structured objects. Before this, parsing Link headers from API responses meant either pulling in a third-party library or writing regex. The use case is HTTP APIs that advertise related resources or pagination via Link headers, like GitHub&rsquo;s API does.</p>
<h2 id="html5-parsing-gets-faster-on-php-84">HTML5 parsing gets faster on PHP 8.4</h2>
<p>DomCrawler and HtmlSanitizer switch to PHP 8.4&rsquo;s native HTML5 parser when available. No code changes needed on your end. The native parser is faster and more spec-compliant than the previous fallback. On PHP 8.2 or 8.3 nothing changes.</p>
<h2 id="translation-staticmessage">Translation: StaticMessage</h2>
<p><code>StaticMessage</code> implements <code>TranslatableInterface</code> but intentionally doesn&rsquo;t translate. It passes the string through unchanged regardless of locale. The use case is API responses that must stay in a fixed language regardless of the user&rsquo;s locale, or audit log entries where you need to preserve the original text as-is.</p>
]]></content:encoded></item><item><title>API Platform 4.2: JSON streamer, ObjectMapper, and autoconfigure</title><link>https://guillaumedelre.github.io/2025/09/18/api-platform-4.2-json-streamer-objectmapper-and-autoconfigure/</link><pubDate>Thu, 18 Sep 2025 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2025/09/18/api-platform-4.2-json-streamer-objectmapper-and-autoconfigure/</guid><description>Part 8 of 8 in &amp;quot;API Platform Releases&amp;quot;: API Platform 4.2 streams large JSON collections without buffering, replaces manual DTO mapping with ObjectMapper, and autoconfigures resources from their class attributes.</description><category>api-platform-releases</category><content:encoded><![CDATA[<p>API Platform 4.2 arrived in September 2025. Three changes stand out: a JSON streamer for large collections that avoids buffering the entire response in memory, an ObjectMapper that replaces the manual wiring in <code>stateOptions</code>-based DTO flows, and autoconfiguration of <code>#[ApiResource]</code> without explicit service registration.</p>
<h2 id="json-streamer-for-large-collections">JSON streamer for large collections</h2>
<p>The default Symfony serializer builds the full response in memory before writing it to the output. For a collection of 10,000 items, this means allocating a PHP array, serializing it to a string, and keeping both in memory until the response is flushed. At scale, this is the source of the OOM errors that force people to add pagination everywhere.</p>
<p>4.2 adds a streaming JSON encoder that writes the response incrementally:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">api_platform</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">serializer</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">enable_json_streamer</span>: <span style="color:#66d9ef">true</span>
</span></span></code></pre></div><p>With streaming enabled, the response is written directly to the output buffer as each item is serialized. Memory usage stays roughly constant regardless of collection size. The trade-off: you cannot set response headers after streaming starts, and the HTTP status code must be committed before the first byte is written.</p>
<h2 id="objectmapper-replaces-manual-dto-wiring">ObjectMapper replaces manual DTO wiring</h2>
<p>3.1 introduced <code>stateOptions</code> with <code>DoctrineOrmOptions</code> for separating the API resource from the Doctrine entity. The provider received entity objects and the serializer mapped them to the DTO. This worked, but the mapping was implicit — the serializer used property names to match fields, and anything that did not match was either ignored or caused a normalization error.</p>
<p>4.2 introduces <code>ObjectMapper</code>, a declarative mapping layer between entities and DTOs:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Symfony\Component\ObjectMapper\Attribute\Map</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#[Map(source: BookEntity::class)]
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">BookDto</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#a6e22e">string</span> $title;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#a6e22e">string</span> $authorName;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The <code>#[Map]</code> attribute tells ObjectMapper that <code>BookDto</code> can be populated from <code>BookEntity</code>. Field names are matched by convention; mismatches are declared explicitly at the property level:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Symfony\Component\ObjectMapper\Attribute\Map</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#[Map(source: BookEntity::class)]
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">BookDto</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">#[Map(source: &#39;author.fullName&#39;)]
</span></span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#a6e22e">string</span> $authorName;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The dot notation traverses nested objects. The mapping runs before serialization and replaces the implicit property-matching behavior of the serializer. Unmapped fields raise an error at configuration time, not at runtime.</p>
<p>ObjectMapper works with <code>stateOptions</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Doctrine\Orm\State\Options</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\ApiResource</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\GetCollection</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Symfony\Component\ObjectMapper\Attribute\Map</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#[ApiResource(
</span></span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">operations</span><span style="color:#f92672">:</span> [
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">GetCollection</span>(
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">stateOptions</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Options</span>(<span style="color:#a6e22e">entityClass</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">BookEntity</span><span style="color:#f92672">::</span><span style="color:#a6e22e">class</span>),
</span></span><span style="display:flex;"><span>        ),
</span></span><span style="display:flex;"><span>    ]
</span></span><span style="display:flex;"><span>)]
</span></span><span style="display:flex;"><span><span style="color:#75715e">#[Map(source: BookEntity::class)]
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">BookDto</span> {}
</span></span></code></pre></div><p>The provider fetches <code>BookEntity</code> objects from Doctrine. ObjectMapper converts them to <code>BookDto</code> instances. The serializer writes the DTO. Three distinct steps, each with a clear contract.</p>
<h2 id="typeinfo-integration-throughout-the-stack">TypeInfo integration throughout the stack</h2>
<p>Symfony 7.1 introduced the <a href="https://symfony.com/doc/current/components/type_info.html" target="_blank" rel="noopener noreferrer">TypeInfo component</a>
, a unified type introspection layer that understands union types, intersection types, generic collections, and nullable types across reflection, PHPDoc, and PHP 8.x syntax.</p>
<p>4.2 replaces API Platform&rsquo;s internal type resolution with TypeInfo. This affects filter schema generation, OpenAPI schema inference, and the serializer&rsquo;s type coercion. The visible benefit is that types that previously generated incorrect or missing OpenAPI schemas — <code>Collection&lt;int, Book&gt;</code>, <code>list&lt;string&gt;</code>, intersection types — now produce accurate schemas without manual <code>@ApiProperty</code> annotations.</p>
<h2 id="autoconfigure-apiresource">Autoconfigure <code>#[ApiResource]</code></h2>
<p>Before 4.2, adding <code>#[ApiResource]</code> to a class was sufficient for Hugo to discover it only if the class was in a path scanned by API Platform&rsquo;s resource loader. Outside that path, you needed explicit service configuration.</p>
<p>4.2 hooks into Symfony&rsquo;s autoconfigure system. Any class tagged with <code>#[ApiResource]</code> is automatically registered as a resource regardless of its location, as long as it is in a directory covered by Symfony&rsquo;s component scan. No <code>config/services.yaml</code> entry needed.</p>
<p>For Laravel, the equivalent uses Laravel&rsquo;s service provider autoloading — Eloquent models with <code>#[ApiResource]</code> are picked up automatically without manual registration.</p>
<h2 id="doctrine-existsfilter">Doctrine ExistsFilter</h2>
<p>The <code>ExistsFilter</code> constrains a collection by whether a nullable relation or field is set:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">#[ApiFilter(ExistsFilter::class, properties: [&#39;publishedAt&#39;])]
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Book</span> {}
</span></span></code></pre></div><p><code>GET /books?exists[publishedAt]=true</code> returns books where <code>publishedAt</code> is not null. <code>exists[publishedAt]=false</code> returns books where it is null.</p>
]]></content:encoded></item><item><title>API Platform 4.1: strict query params, multi-spec OpenAPI, and GraphQL limits</title><link>https://guillaumedelre.github.io/2025/02/28/api-platform-4.1-strict-query-params-multi-spec-openapi-and-graphql-limits/</link><pubDate>Fri, 28 Feb 2025 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2025/02/28/api-platform-4.1-strict-query-params-multi-spec-openapi-and-graphql-limits/</guid><description>Part 7 of 8 in &amp;quot;API Platform Releases&amp;quot;: API Platform 4.1 formalizes strict query parameter validation, introduces x-apiplatform-tag for splitting one API into multiple OpenAPI specs, and adds depth and complexity limits for GraphQL.</description><category>api-platform-releases</category><content:encoded><![CDATA[<p>API Platform 4.1 arrived in February 2025 with a batch of features that are less about new capabilities and more about making the existing ones production-ready. Strict query param validation gets a first-class property. OpenAPI gains a mechanism for splitting large APIs into separate specs. GraphQL gets the abuse prevention controls it was missing.</p>
<h2 id="strict-query-parameter-validation">Strict query parameter validation</h2>
<p>3.3 introduced query parameter validation as opt-in. 3.4 deprecated the loose behavior. 4.1 formalizes it with a native <code>strictQueryParameterValidation</code> property on resources and operations: when set to <code>true</code>, unknown query parameters return 400.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\GetCollection</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\QueryParameter</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#[GetCollection(
</span></span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">strictQueryParameterValidation</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">parameters</span><span style="color:#f92672">:</span> [
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">QueryParameter</span>(<span style="color:#a6e22e">key</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;utm_source&#39;</span>, <span style="color:#a6e22e">required</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">false</span>, <span style="color:#a6e22e">schema</span><span style="color:#f92672">:</span> [<span style="color:#e6db74">&#39;type&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;string&#39;</span>]),
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">QueryParameter</span>(<span style="color:#a6e22e">key</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;feature_flag&#39;</span>, <span style="color:#a6e22e">required</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">false</span>, <span style="color:#a6e22e">schema</span><span style="color:#f92672">:</span> [<span style="color:#e6db74">&#39;type&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;string&#39;</span>]),
</span></span><span style="display:flex;"><span>    ]
</span></span><span style="display:flex;"><span>)]
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Book</span> {}
</span></span></code></pre></div><p>Declared parameters pass through; undeclared parameters are rejected. To disable strict validation on a specific operation when it is enabled at the resource level, set <code>strictQueryParameterValidation: false</code> on that operation.</p>
<h2 id="x-apiplatform-tag-for-multi-spec-openapi"><code>x-apiplatform-tag</code> for multi-spec OpenAPI</h2>
<p>Large APIs often need multiple OpenAPI specs: one per team, one per API version, one internal and one public. Before 4.1, the generated spec was one document, and splitting it required post-processing or separate API Platform instances.</p>
<p>4.1 adds an <code>x-apiplatform-tag</code> vendor extension (no trailing <code>s</code>). You tag operations with logical group names via the <code>extensionProperties</code> of an OpenAPI <code>Operation</code> object, then request the spec filtered to one or more groups:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\GetCollection</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\OpenApi\Factory\OpenApiFactory</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\OpenApi\Model\Operation</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#[GetCollection(
</span></span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">openapi</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Operation</span>(
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">extensionProperties</span><span style="color:#f92672">:</span> [<span style="color:#a6e22e">OpenApiFactory</span><span style="color:#f92672">::</span><span style="color:#a6e22e">API_PLATFORM_TAG</span> <span style="color:#f92672">=&gt;</span> [<span style="color:#e6db74">&#39;public&#39;</span>, <span style="color:#e6db74">&#39;v2&#39;</span>]]
</span></span><span style="display:flex;"><span>    )
</span></span><span style="display:flex;"><span>)]
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Book</span> {}
</span></span></code></pre></div><p>Requesting <code>/api/docs.json?filter_tags[]=public</code> returns only the operations tagged <code>public</code>. The full spec is still available without a filter. Groups do not affect the actual API behavior — they are a documentation-layer concern only.</p>
<p>This makes it feasible to maintain one API Platform configuration while serving different spec views to different consumers: a public Swagger UI, a partner portal, and an internal tool that exposes admin endpoints.</p>
<h2 id="http-authentication-in-swagger-ui">HTTP authentication in Swagger UI</h2>
<p>Before 4.1, the Swagger UI bundled with API Platform supported Bearer token authentication via its &ldquo;Authorize&rdquo; dialog. API Key and HTTP Basic authentication were not wired in.</p>
<p>4.1 adds support for multiple security schemes in the generated OpenAPI document. Security schemes are added by decorating the <code>OpenApiFactory</code> and modifying the <code>components.securitySchemes</code> object of the spec. Each declared scheme then appears in Swagger UI&rsquo;s &ldquo;Authorize&rdquo; dialog and is applied to requests made from the UI. This is a documentation and developer experience improvement — the actual authentication logic in your application is not affected.</p>
<h2 id="graphql-query-depth-and-complexity-limits">GraphQL query depth and complexity limits</h2>
<p>GraphQL&rsquo;s recursive query structure makes it trivial to craft a query that is small in bytes but enormous in execution cost. Without limits, a nested query four levels deep across a many-to-many relation can hit the database hundreds of times.</p>
<p>4.1 adds configurable depth and complexity limits:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">api_platform</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">graphql</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">max_query_depth</span>: <span style="color:#ae81ff">10</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">max_query_complexity</span>: <span style="color:#ae81ff">100</span>
</span></span></code></pre></div><p><code>max_query_depth</code> is the maximum nesting level. <code>max_query_complexity</code> assigns a cost to each field and rejects queries whose total cost exceeds the threshold. Queries that exceed either limit are rejected before execution with a 400 response.</p>
<p>There is no universally correct value for these limits — they depend on your schema shape and expected query patterns. The defaults are intentionally permissive to avoid breaking existing APIs on upgrade. Tightening them is a deliberate configuration choice.</p>
<h2 id="operation-level-output-formats">Operation-level output formats</h2>
<p>4.0 and earlier configured accepted and returned content types at the API level. 4.1 lets you narrow this per operation:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\GetCollection</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#[GetCollection(
</span></span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">outputFormats</span><span style="color:#f92672">:</span> [<span style="color:#e6db74">&#39;jsonld&#39;</span> <span style="color:#f92672">=&gt;</span> [<span style="color:#e6db74">&#39;application/ld+json&#39;</span>]],
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">inputFormats</span><span style="color:#f92672">:</span> [<span style="color:#e6db74">&#39;json&#39;</span> <span style="color:#f92672">=&gt;</span> [<span style="color:#e6db74">&#39;application/json&#39;</span>]],
</span></span><span style="display:flex;"><span>)]
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Book</span> {}
</span></span></code></pre></div><p>Operations that do not specify formats inherit the API-level configuration. This is useful for endpoints that need to return a specific format (a CSV export, a binary stream) without changing the defaults for the rest of the API.</p>
]]></content:encoded></item><item><title>PostgreSQL full-text search through Doctrine, without a line of raw SQL</title><link>https://guillaumedelre.github.io/2025/02/10/postgresql-full-text-search-through-doctrine-without-a-line-of-raw-sql/</link><pubDate>Mon, 10 Feb 2025 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2025/02/10/postgresql-full-text-search-through-doctrine-without-a-line-of-raw-sql/</guid><description>How we layered custom DBAL types and DQL wrappers on top of postgresql-for-doctrine to bring PostgreSQL full-text search to a Symfony API Platform project.</description><content:encoded><![CDATA[<p>The search box on the media library returned results in 800 milliseconds on staging. Production had forty times more rows. The query plan showed a sequential scan: no index involved, no way to fix it with a standard B-tree. The product team also wanted multi-word search: type &ldquo;interview president&rdquo;, get results containing both words. A <code>LIKE</code> query with wildcards has no clean way to express that without multiple independent conditions, each requiring its own scan.</p>
<p>PostgreSQL has had built-in full-text search for over fifteen years. The platform was already on PostgreSQL. The catch: the project uses Doctrine ORM, and Doctrine doesn&rsquo;t natively know what a <code>tsvector</code> is.</p>
<p>A community library, <a href="https://github.com/martin-georgiev/postgresql-for-doctrine" target="_blank" rel="noopener noreferrer">postgresql-for-doctrine</a>, covers part of that gap. It registers basic DQL functions like <code>TO_TSQUERY</code>, <code>TO_TSVECTOR</code>, and the <code>@@</code> match operator as separate atomic pieces. The foundation was there. Three things still had to be built on top.</p>
<h2 id="the-type-doctrine-has-never-seen">The type Doctrine has never seen</h2>
<p><a href="https://www.postgresql.org/docs/current/datatype-textsearch.html" target="_blank" rel="noopener noreferrer">PostgreSQL&rsquo;s full-text search</a> is built around two types: <code>tsvector</code> (a pre-processed list of normalized tokens) and <code>tsquery</code> (a search expression). You maintain a <code>tsvector</code> column, index it with GIN, and query with the <code>@@</code> match operator.</p>
<p>Doctrine&rsquo;s DBAL ships no <code>tsvector</code> type. Declaring <code>#[ORM\Column(type: 'tsvector')]</code> without registering it first throws a <code>UnknownColumnTypeException</code>. The fix is a custom DBAL type:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">TsVector</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">Type</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">final</span> <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">const</span> <span style="color:#66d9ef">string</span> <span style="color:#a6e22e">DBAL_TYPE</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;tsvector&#39;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getSQLDeclaration</span>(<span style="color:#66d9ef">array</span> $column, <span style="color:#a6e22e">AbstractPlatform</span> $platform)<span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">self</span><span style="color:#f92672">::</span><span style="color:#a6e22e">DBAL_TYPE</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getName</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">self</span><span style="color:#f92672">::</span><span style="color:#a6e22e">DBAL_TYPE</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">convertToDatabaseValueSQL</span>(<span style="color:#a6e22e">string</span> $sqlExpr, <span style="color:#a6e22e">AbstractPlatform</span> $platform)<span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">sprintf</span>(<span style="color:#e6db74">&#34;to_tsvector(&#39;simple&#39;, %s)&#34;</span>, $sqlExpr);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">convertToDatabaseValue</span>(<span style="color:#a6e22e">mixed</span> $value, <span style="color:#a6e22e">AbstractPlatform</span> $platform)<span style="color:#f92672">:</span> <span style="color:#a6e22e">mixed</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">is_array</span>($value) <span style="color:#f92672">&amp;&amp;</span> <span style="color:#a6e22e">isset</span>($value[<span style="color:#e6db74">&#39;data&#39;</span>])) {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> $value[<span style="color:#e6db74">&#39;data&#39;</span>];
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">is_string</span>($value) <span style="color:#f92672">?</span> $value <span style="color:#f92672">:</span> <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getMappedDatabaseTypes</span>(<span style="color:#a6e22e">AbstractPlatform</span> $platform)<span style="color:#f92672">:</span> <span style="color:#66d9ef">array</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> [<span style="color:#a6e22e">self</span><span style="color:#f92672">::</span><span style="color:#a6e22e">DBAL_TYPE</span>];
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The interesting method is <code>convertToDatabaseValueSQL()</code>. Doctrine calls it to wrap the SQL placeholder before the value reaches the database. The written value automatically becomes <code>to_tsvector('simple', ?)</code> at the DBAL boundary with no extra step needed on the calling side.</p>
<p>Register the type in <code>doctrine.yaml</code>, then map the column on the entity:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">doctrine</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">dbal</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">types</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">tsvector</span>: <span style="color:#ae81ff">App\Doctrine\DBAL\Types\TsVector</span>
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">#[ORM\Column(type: &#39;tsvector&#39;, nullable: true)]
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">protected</span> <span style="color:#f92672">?</span><span style="color:#a6e22e">string</span> $textSearch <span style="color:#f92672">=</span> <span style="color:#66d9ef">null</span>;
</span></span></code></pre></div><p>PHP-side, the value is a plain string. The conversion to a proper <code>tsvector</code> happens invisibly at the DBAL layer.</p>
<p>We used the <code>'simple'</code> dictionary, which tokenizes on whitespace and punctuation without language-specific stemming. The platform handles multiple languages, and French stemming rules would break Spanish. Simple is good enough for phonetics.</p>
<h2 id="keeping-the-column-current">Keeping the column current</h2>
<p>A <code>tsvector</code> column is derived data: it has to stay in sync with the source fields whenever the entity changes. A Doctrine event listener handles that:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">#[AsDoctrineListener(event: Events::prePersist)]
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">#[AsDoctrineListener(event: Events::preUpdate)]
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">MediaTsVectorSubscriber</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">prePersist</span>(<span style="color:#a6e22e">PrePersistEventArgs</span> $event)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span>$event<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getObject</span>() <span style="color:#a6e22e">instanceof</span> <span style="color:#a6e22e">Media</span>) {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span>;
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">updateTextSearch</span>($event<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getObject</span>());
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">preUpdate</span>(<span style="color:#a6e22e">PreUpdateEventArgs</span> $event)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span>$event<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getObject</span>() <span style="color:#a6e22e">instanceof</span> <span style="color:#a6e22e">Media</span>) {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span>;
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">updateTextSearch</span>($event<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getObject</span>());
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">updateTextSearch</span>(<span style="color:#a6e22e">Media</span> $entity)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        $entity<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">setTextSearch</span>(
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">sprintf</span>(<span style="color:#e6db74">&#39;%s %s&#39;</span>, $entity<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getTitle</span>(), $entity<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getCaption</span>())
</span></span><span style="display:flex;"><span>        );
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Before every persist and update, the subscriber concatenates the fields that should be searchable into <code>textSearch</code>. Doctrine flushes the combined string, the DBAL type wraps it in <code>to_tsvector('simple', ...)</code>, and PostgreSQL stores the tokenized form.</p>
<p>One subtlety: the PHP-side value is <code>&quot;title caption&quot;</code>, not the actual tsvector output. The database shows <code>'caption' 'title'</code> (sorted tokens), but the entity holds a plain string. That&rsquo;s expected: the conversion is a DBAL responsibility, not a PHP one. It can be confusing to debug until you remember where the boundary is.</p>
<h2 id="extending-dql-with-fts-operators">Extending DQL with FTS operators</h2>
<p>Doctrine&rsquo;s DQL covers common SQL operations, but anything PostgreSQL-specific is out of scope. That&rsquo;s where <code>postgresql-for-doctrine</code> starts: it registers <code>TO_TSQUERY</code>, <code>TO_TSVECTOR</code>, and <code>TSMATCH</code> as individual DQL functions. Writing a full-text query in DQL without it would mean dropping to native SQL entirely.</p>
<p>The library&rsquo;s functions are atomic, though. Each maps to one SQL call. Expressing a full match check in DQL looks like <code>TSMATCH(o.textSearch, TO_TSQUERY(:term))</code>. Readable enough, but the team wanted something more compact: a single DQL function that encodes both the match operator and the query type, including <code>websearch_to_tsquery</code>, which <code>postgresql-for-doctrine</code> didn&rsquo;t ship.</p>
<p>The solution is <a href="https://www.doctrine-project.org/projects/doctrine-orm/en/latest/cookbook/dql-user-defined-functions.html" target="_blank" rel="noopener noreferrer">custom DQL functions</a> via <code>FunctionNode</code>. You parse the DQL syntax, then emit SQL. All FTS functions share the same two-argument signature, so an abstract base class handles parsing:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">abstract</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">TsFunction</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">FunctionNode</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#a6e22e">PathExpression</span><span style="color:#f92672">|</span><span style="color:#a6e22e">Node</span><span style="color:#f92672">|</span><span style="color:#66d9ef">null</span> $ftsField <span style="color:#f92672">=</span> <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#a6e22e">PathExpression</span><span style="color:#f92672">|</span><span style="color:#a6e22e">Node</span><span style="color:#f92672">|</span><span style="color:#66d9ef">null</span> $queryString <span style="color:#f92672">=</span> <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">parse</span>(<span style="color:#a6e22e">Parser</span> $parser)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        $parser<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">match</span>(<span style="color:#a6e22e">TokenType</span><span style="color:#f92672">::</span><span style="color:#a6e22e">T_IDENTIFIER</span>);
</span></span><span style="display:flex;"><span>        $parser<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">match</span>(<span style="color:#a6e22e">TokenType</span><span style="color:#f92672">::</span><span style="color:#a6e22e">T_OPEN_PARENTHESIS</span>);
</span></span><span style="display:flex;"><span>        $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">ftsField</span> <span style="color:#f92672">=</span> $parser<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">StringPrimary</span>();
</span></span><span style="display:flex;"><span>        $parser<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">match</span>(<span style="color:#a6e22e">TokenType</span><span style="color:#f92672">::</span><span style="color:#a6e22e">T_COMMA</span>);
</span></span><span style="display:flex;"><span>        $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">queryString</span> <span style="color:#f92672">=</span> $parser<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">StringPrimary</span>();
</span></span><span style="display:flex;"><span>        $parser<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">match</span>(<span style="color:#a6e22e">TokenType</span><span style="color:#f92672">::</span><span style="color:#a6e22e">T_CLOSE_PARENTHESIS</span>);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Each concrete class implements <code>getSql()</code> to emit its PostgreSQL expression:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">// e.textSearch @@ websearch_to_tsquery(&#39;simple&#39;, :term)
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">TsWebsearchQueryFunction</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">TsFunction</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getSql</span>(<span style="color:#a6e22e">SqlWalker</span> $sqlWalker)<span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">ftsField</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">dispatch</span>($sqlWalker)
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">.</span><span style="color:#e6db74">&#34; @@ websearch_to_tsquery(&#39;simple&#39;, &#34;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">.</span>$this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">queryString</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">dispatch</span>($sqlWalker)<span style="color:#f92672">.</span><span style="color:#e6db74">&#39;)&#39;</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// ts_rank(e.textSearch, to_tsquery(:term)) for relevance ordering
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">TsRankFunction</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">TsFunction</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getSql</span>(<span style="color:#a6e22e">SqlWalker</span> $sqlWalker)<span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#e6db74">&#39;ts_rank(&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">.</span>$this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">ftsField</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">dispatch</span>($sqlWalker)
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">.</span><span style="color:#e6db74">&#39;, to_tsquery(&#39;</span><span style="color:#f92672">.</span>$this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">queryString</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">dispatch</span>($sqlWalker)<span style="color:#f92672">.</span><span style="color:#e6db74">&#39;))&#39;</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">doctrine</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">orm</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">entity_managers</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">default</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">dql</span>:
</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">string_functions</span>:
</span></span><span style="display:flex;"><span>                        <span style="color:#f92672">tswebsearchquery</span>: <span style="color:#ae81ff">App\Doctrine\ORM\Query\AST\Functions\TsWebsearchQueryFunction</span>
</span></span><span style="display:flex;"><span>                        <span style="color:#f92672">tsrank</span>: <span style="color:#ae81ff">App\Doctrine\ORM\Query\AST\Functions\TsRankFunction</span>
</span></span><span style="display:flex;"><span>                        <span style="color:#f92672">tsquery</span>: <span style="color:#ae81ff">App\Doctrine\ORM\Query\AST\Functions\TsQueryFunction</span>
</span></span><span style="display:flex;"><span>                        <span style="color:#f92672">tsplainquery</span>: <span style="color:#ae81ff">App\Doctrine\ORM\Query\AST\Functions\TsPlainQueryFunction</span>
</span></span></code></pre></div><p><code>websearch_to_tsquery</code> is the right choice for user-facing search: spaces become AND, quoted strings become phrases, <code>-word</code> excludes a term. No need to teach users to type <code>interview &amp; president</code>. It was added in PostgreSQL 11. On older versions, <code>plainto_tsquery</code> is the closest equivalent.</p>
<h2 id="the-api-platform-filter-and-the-gin-index">The API Platform filter and the GIN index</h2>
<p>With the DQL functions registered, the API Platform filter is straightforward. A custom <code>AbstractFilter</code> calls the DQL function directly in the <code>QueryBuilder</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">TextSearchFilter</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">AbstractFilter</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">protected</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">filterProperty</span>(
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">string</span> $property,
</span></span><span style="display:flex;"><span>        $value,
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">QueryBuilder</span> $queryBuilder,
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">QueryNameGeneratorInterface</span> $queryNameGenerator,
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">string</span> $resourceClass,
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">?</span><span style="color:#a6e22e">Operation</span> $operation <span style="color:#f92672">=</span> <span style="color:#66d9ef">null</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">array</span> $context <span style="color:#f92672">=</span> []
</span></span><span style="display:flex;"><span>    )<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (<span style="color:#e6db74">&#39;textSearch&#39;</span> <span style="color:#f92672">!==</span> $property <span style="color:#f92672">||</span> <span style="color:#66d9ef">empty</span>($value)) {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span>;
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        $queryBuilder
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">andWhere</span>(<span style="color:#e6db74">&#39;tswebsearchquery(o.textSearch, :value) = true&#39;</span>)
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">setParameter</span>(<span style="color:#e6db74">&#39;:value&#39;</span>, $value);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getDescription</span>(<span style="color:#a6e22e">string</span> $resourceClass)<span style="color:#f92672">:</span> <span style="color:#66d9ef">array</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> [];
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Apply it on the entity alongside the index declaration:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">#[ORM\Index(
</span></span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">columns</span><span style="color:#f92672">:</span> [<span style="color:#e6db74">&#39;text_search&#39;</span>],
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">name</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;media_text_search_idx_gin&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">options</span><span style="color:#f92672">:</span> [<span style="color:#e6db74">&#39;USING&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;gin (text_search)&#39;</span>]
</span></span><span style="display:flex;"><span>)]
</span></span><span style="display:flex;"><span><span style="color:#75715e">#[ApiFilter(TextSearchFilter::class, properties: [&#39;textSearch&#39; =&gt; &#39;partial&#39;])]
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Media</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// ...
</span></span></span><span style="display:flex;"><span>    <span style="color:#75715e">#[ORM\Column(type: &#39;tsvector&#39;, nullable: true)]
</span></span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">protected</span> <span style="color:#f92672">?</span><span style="color:#a6e22e">string</span> $textSearch <span style="color:#f92672">=</span> <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The <code>USING gin</code> option is non-negotiable. A standard B-tree index on a <code>tsvector</code> column is useless: PostgreSQL can&rsquo;t use it for <code>@@</code> queries. GIN (Generalized Inverted Index) works differently: it indexes each token individually, so lookups by any token are <code>O(log n)</code> rather than <code>O(n)</code>. Without it, you&rsquo;ve built a fast-looking system that still does a full table scan.</p>
<p>A <code>GET /media?textSearch=interview+president</code> now hits the GIN index and returns in single-digit milliseconds regardless of table size.</p>
<h2 id="what-the-split-actually-looked-like">What the split actually looked like</h2>
<p>The library covered the low-level atomic functions. The custom code covered the gaps: a <code>tsvector</code> DBAL type the library didn&rsquo;t provide, convenience DQL wrappers that combined <code>@@</code> and <code>websearch_to_tsquery</code> into a single call, and the application-specific glue connecting it all to Doctrine&rsquo;s event system and API Platform. Nothing needed to drop to a native query.</p>
<p>The split is worth noting in general: <code>postgresql-for-doctrine</code> gives you the atomic PostgreSQL building blocks, but you still need to compose them into something the rest of the codebase can use without thinking about it. The <code>FunctionNode</code> pattern and the <code>convertToDatabaseValueSQL()</code> hook are the two extension points that make that composition clean. Both are worth knowing about, regardless of what library you start from.</p>
]]></content:encoded></item><item><title>API Platform 4.0: Laravel support and PUT rethought</title><link>https://guillaumedelre.github.io/2024/09/27/api-platform-4.0-laravel-support-and-put-rethought/</link><pubDate>Fri, 27 Sep 2024 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2024/09/27/api-platform-4.0-laravel-support-and-put-rethought/</guid><description>Part 6 of 8 in &amp;quot;API Platform Releases&amp;quot;: API Platform 4.0 brings first-class Laravel support with Eloquent and policies, and removes PUT from default operations to fix a long-standing semantic ambiguity.</description><category>api-platform-releases</category><content:encoded><![CDATA[<p>API Platform 4.0 shipped nine days after 3.4, in late September 2024. The version number is honest: there is no new architecture, and the migration from 3.4 is short if you resolved the deprecations. What makes this a major is the scope change — API Platform is no longer a Symfony-only framework — and one opinionated default that reverses six years of PUT behavior.</p>
<h2 id="laravel-as-a-first-class-target">Laravel as a first-class target</h2>
<p>Since its first release, API Platform was built on Symfony. The HTTP layer, metadata, serializer, and Doctrine bridge all assumed Symfony&rsquo;s container, event dispatcher, and request lifecycle. Laravel users could run API Platform through a thin adapter, but filters, security, and Doctrine integration did not work on Eloquent.</p>
<p>4.0 ships a dedicated Laravel bridge. It maps API Platform&rsquo;s state layer onto Laravel&rsquo;s request lifecycle, integrates with Eloquent models directly, and wires into Laravel&rsquo;s authorization system:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\ApiResource</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\Get</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\GetCollection</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Illuminate\Database\Eloquent\Model</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#[ApiResource(
</span></span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">operations</span><span style="color:#f92672">:</span> [<span style="color:#66d9ef">new</span> <span style="color:#a6e22e">GetCollection</span>(), <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Get</span>()]
</span></span><span style="display:flex;"><span>)]
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Book</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">Model</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">protected</span> $fillable <span style="color:#f92672">=</span> [<span style="color:#e6db74">&#39;title&#39;</span>, <span style="color:#e6db74">&#39;author&#39;</span>];
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Authorization uses Laravel policies and gates rather than Symfony&rsquo;s security voters. Operations expose a dedicated <code>policy</code> parameter that maps to a policy method name:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">#[Get(policy: &#39;view&#39;)]
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Book</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">Model</span> {}
</span></span></code></pre></div><p>API Platform maps the <code>policy</code> value to Laravel&rsquo;s <code>Gate::allows()</code> with the model instance. Policies can also be auto-detected: if a model has a registered policy class, API Platform infers the correct method (<code>view</code>, <code>viewAny</code>, <code>create</code>, <code>update</code>, <code>delete</code>) based on the operation type. Filters for Eloquent collections cover the same ground as their Doctrine counterparts: <code>PartialSearchFilter</code>, <code>EqualsFilter</code>, <code>RangeFilter</code>, <code>OrderFilter</code>, <code>DateFilter</code>, and search variants (<code>StartSearchFilter</code>, <code>EndSearchFilter</code>). Pagination, sorting, and validation work through Laravel&rsquo;s native mechanisms.</p>
<p>This is not a compatibility shim. The Laravel bridge is maintained alongside the Symfony bridge and is covered by the same test suite. Projects using either framework get the same resource definition API.</p>
<h2 id="put-removed-from-default-operations">PUT removed from default operations</h2>
<p>Since API Platform 1.0, <code>#[ApiResource]</code> without an explicit <code>operations</code> array generated CRUD operations including PUT. The PUT handler updated existing resources and, after 3.1, could also create them via <code>allowCreate: true</code>.</p>
<p>4.0 removes PUT from the default set. <code>#[ApiResource]</code> now generates GET, POST, PATCH, and DELETE. To use PUT, you must declare it explicitly:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\ApiResource</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\Put</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#[ApiResource(
</span></span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">operations</span><span style="color:#f92672">:</span> [
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// ... other operations
</span></span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Put</span>(),
</span></span><span style="display:flex;"><span>    ]
</span></span><span style="display:flex;"><span>)]
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Book</span> {}
</span></span></code></pre></div><p>The motivation is semantic clarity. PATCH replaces PUT for most partial-update use cases. PUT&rsquo;s semantics — replace the entire resource representation — are rarely what an API actually implements, but the default made it appear in every API unless actively removed. Making PUT opt-in aligns the defaults with how HTTP semantics are actually used in practice.</p>
<h2 id="php-82-minimum">PHP 8.2 minimum</h2>
<p>4.0 drops PHP 8.0 and 8.1. PHP 8.2 is the new minimum. The readonly class syntax, <code>AllowDynamicProperties</code>, and DNF<sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup> types introduced in 8.2 are available throughout the codebase. No specific 8.2 feature is load-bearing for 4.0 — the version bump is primarily about dropping the older maintenance burden.</p>
<h2 id="symfony-64-and-doctrine-orm-217-minimum">Symfony 6.4+ and Doctrine ORM 2.17+ minimum</h2>
<p>On the Symfony side, 4.0 requires Symfony 6.4 or 7.x and Doctrine ORM 2.17 or 3.x. Both were already supported in 3.4. The migration from 3.4 to 4.0 on the Symfony track is: resolve 3.4 deprecations, verify you are on Symfony 6.4+ and ORM 2.17+, then upgrade. No new migration work is required if those are already in place.</p>
<h2 id="what-40-is-not">What 4.0 is not</h2>
<p>4.0 is not a new architecture. The state providers, processors, and resource metadata model from 3.0 are unchanged. The Laravel bridge adds a new execution context but does not change how resources or operations are declared. The split is intentional: if 3.0 was the &ldquo;what&rdquo;, 4.0 is the &ldquo;where&rdquo;.</p>
<div class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1">
<p>Disjunctive Normal Form types: intersection types combined with union, like <code>(A&amp;B)|null</code>.&#160;<a href="#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>
</div>
]]></content:encoded></item><item><title>API Platform 3.4: BackedEnum as resources and DBAL 4 support</title><link>https://guillaumedelre.github.io/2024/09/18/api-platform-3.4-backedenum-as-resources-and-dbal-4-support/</link><pubDate>Wed, 18 Sep 2024 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2024/09/18/api-platform-3.4-backedenum-as-resources-and-dbal-4-support/</guid><description>Part 5 of 8 in &amp;quot;API Platform Releases&amp;quot;: API Platform 3.4 makes BackedEnum classes full API resources, adds a BackedEnumFilter, supports security expressions on parameters, and adds DBAL 4 support.</description><category>api-platform-releases</category><content:encoded><![CDATA[<p>API Platform 3.4 landed in September 2024 as the last minor before the 4.0 jump. The headline feature is BackedEnum as full resources — not just a typed field, but an enum that is itself an API endpoint.</p>
<h2 id="backedenum-as-api-resources">BackedEnum as API resources</h2>
<p>Since PHP 8.1, BackedEnum classes have a fixed set of cases with string or integer backing values. API Platform 3.4 lets you put <code>#[ApiResource]</code> directly on a BackedEnum:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\ApiResource</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\GetCollection</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#[ApiResource(
</span></span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">operations</span><span style="color:#f92672">:</span> [<span style="color:#66d9ef">new</span> <span style="color:#a6e22e">GetCollection</span>()]
</span></span><span style="display:flex;"><span>)]
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">enum</span> <span style="color:#a6e22e">BookStatus</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">case</span> <span style="color:#a6e22e">Draft</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;draft&#39;</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">case</span> <span style="color:#a6e22e">Published</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;published&#39;</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">case</span> <span style="color:#a6e22e">Archived</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;archived&#39;</span>;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>A <code>GET /book_statuses</code> endpoint returns the list of cases. Each case is serialized with its name and value. The endpoint is read-only — enums are immutable by nature.</p>
<p>This is mostly useful for frontend consumers that want a machine-readable list of valid values without hardcoding them. The alternative was a custom controller or a dedicated DTO resource listing the enum values manually.</p>
<h2 id="backedenumfilter">BackedEnumFilter</h2>
<p>The companion to enum resources is <code>BackedEnumFilter</code>, a new filter for Doctrine collections that constrains a query by a BackedEnum property:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Doctrine\Orm\Filter\BackedEnumFilter</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\ApiFilter</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\ApiResource</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#[ApiResource]
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">#[ApiFilter(BackedEnumFilter::class, properties: [&#39;status&#39;])]
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Book</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#a6e22e">BookStatus</span> $status;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>GET /books?status=published</code> filters the collection to books where <code>status</code> equals <code>BookStatus::Published</code>. Invalid enum values return a 400 response. Before this filter, you had to either write a custom filter or use <code>SearchFilter</code> and validate the value manually.</p>
<h2 id="security-expressions-on-parameters">Security expressions on parameters</h2>
<p>3.3 added security to links and properties. 3.4 extends this to query parameters. A parameter can declare a security expression that controls whether it is accepted at all:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\GetCollection</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\QueryParameter</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#[GetCollection(
</span></span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">parameters</span><span style="color:#f92672">:</span> [
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">QueryParameter</span>(
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">key</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;includeDeleted&#39;</span>,
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">security</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;is_granted(&#39;ROLE_ADMIN&#39;)&#34;</span>
</span></span><span style="display:flex;"><span>        ),
</span></span><span style="display:flex;"><span>    ]
</span></span><span style="display:flex;"><span>)]
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Book</span> {}
</span></span></code></pre></div><p>When the security expression is false, the parameter is rejected with a 403, not silently ignored. This is more explicit than checking the user&rsquo;s role inside the provider after receiving the parameter.</p>
<h2 id="dbal-4-support-added">DBAL 4 support added</h2>
<p>3.4 adds support for Doctrine DBAL 4, which ships with type system changes that affect how custom types and platform-specific SQL work. The Doctrine Orm filters and query extensions in API Platform were updated to work with the new DBAL 4 type API.</p>
<p>Both DBAL 3 (<code>^3.4.0</code>) and DBAL 4 are supported simultaneously in 3.4. This is the release to upgrade to if you want to adopt DBAL 4 while staying on a stable API Platform 3.x branch.</p>
<h2 id="query-parameter-validator-deprecated">Query parameter validator deprecated</h2>
<p>3.3 added the strict query parameter validator as an opt-in. 3.4 deprecates the old behavior (unknown parameters silently ignored) in preparation for making strict validation the default in 4.0. Projects that relied on pass-through query parameters have one more release to declare them explicitly.</p>
<h2 id="last-stop-before-40">Last stop before 4.0</h2>
<p>3.4 is the last 3.x release with new features. Anything 3.x that was deprecated by 3.4 is gone in 4.0. The migration path from 3.4 to 4.0 is intentionally short: resolve the deprecations, then upgrade.</p>
]]></content:encoded></item><item><title>API Platform 3.3: headers, link security, and OpenAPI webhooks</title><link>https://guillaumedelre.github.io/2024/04/29/api-platform-3.3-headers-link-security-and-openapi-webhooks/</link><pubDate>Mon, 29 Apr 2024 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2024/04/29/api-platform-3.3-headers-link-security-and-openapi-webhooks/</guid><description>Part 4 of 8 in &amp;quot;API Platform Releases&amp;quot;: API Platform 3.3 adds declarative header configuration, fine-grained link security on sub-resources, and OpenAPI webhook support.</description><category>api-platform-releases</category><content:encoded><![CDATA[<p>API Platform 3.3 shipped in April 2024 with a set of targeted additions. None of them reshape the architecture — 3.2 already closed that chapter. What 3.3 adds is control over things that were previously either hardcoded or required a workaround: response headers, link visibility on sub-resources, and webhooks in the generated spec.</p>
<h2 id="declarative-header-configuration">Declarative header configuration</h2>
<p>Before 3.3, setting custom response headers required either a custom processor that modified the response object or a Symfony event listener on <code>kernel.response</code>. Both approaches worked but lived outside the resource definition.</p>
<p>3.3 adds a <code>parameters</code> parameter to operation metadata:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\Get</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\HeaderParameter</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#[Get(
</span></span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">parameters</span><span style="color:#f92672">:</span> [
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;X-Custom-Header&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">HeaderParameter</span>(<span style="color:#a6e22e">description</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;A custom header&#39;</span>),
</span></span><span style="display:flex;"><span>    ]
</span></span><span style="display:flex;"><span>)]
</span></span></code></pre></div><p>For headers that vary per response (like <code>Cache-Control</code> with a computed max-age), the processor can still set them directly on the response. The <code>headers</code> parameter is primarily for documenting expected headers in the OpenAPI spec and for static header values.</p>
<h2 id="link-security-on-sub-resources">Link security on sub-resources</h2>
<p>When a resource exposes links to related resources, those links appear in the serialized output regardless of whether the current user can access the linked resource. This creates a disclosure problem: a user who can read a book but not its author profile still sees the author&rsquo;s URI in the response.</p>
<p>3.3 adds security expressions to the <code>Link</code> descriptor:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\ApiResource</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\Get</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\Link</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#[ApiResource]
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">#[Get]
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Book</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">#[Link(
</span></span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">toClass</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">Author</span><span style="color:#f92672">::</span><span style="color:#a6e22e">class</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">security</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;is_granted(&#39;ROLE_ADMIN&#39;)&#34;</span>
</span></span><span style="display:flex;"><span>    )]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#a6e22e">Author</span> $author;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The link is omitted from the response when the security expression evaluates to false. The linked resource itself is not affected — only whether the current response includes the reference to it.</p>
<h2 id="apipropertysecurity"><code>ApiProperty::security</code></h2>
<p>The same security expression mechanism is available at the property level via <code>ApiProperty::security</code>. This lets you hide individual fields based on the current user without writing a custom normalizer:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\ApiProperty</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Book</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">#[ApiProperty(security: &#34;is_granted(&#39;ROLE_ADMIN&#39;)&#34;)]
</span></span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#a6e22e">string</span> $internalNote;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The property is excluded from serialization when the expression is false. This is cleaner than a normalizer for the common case of role-gated fields.</p>
<h2 id="openapi-webhooks">OpenAPI webhooks</h2>
<p><a href="https://spec.openapis.org/oas/v3.1.0" target="_blank" rel="noopener noreferrer">OpenAPI 3.1</a>
 supports webhooks — outbound HTTP calls that your API makes to registered listeners — in the spec document itself. Before 3.3, there was no way to document these in API Platform&rsquo;s generated spec.</p>
<p>3.3 adds a <code>Webhook</code> class you pass to the <code>openapi</code> parameter of an operation. Declare a dedicated PHP class with <code>#[ApiResource]</code> and use <code>Webhook</code> on each operation to describe the outbound call shape:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\ApiResource</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\Post</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\OpenApi\Attributes\Webhook</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\OpenApi\Model\Operation</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\OpenApi\Model\PathItem</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#[ApiResource(
</span></span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">operations</span><span style="color:#f92672">:</span> [
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Post</span>(
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">openapi</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Webhook</span>(
</span></span><span style="display:flex;"><span>                <span style="color:#a6e22e">name</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;bookCreated&#39;</span>,
</span></span><span style="display:flex;"><span>                <span style="color:#a6e22e">pathItem</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">PathItem</span>(
</span></span><span style="display:flex;"><span>                    <span style="color:#a6e22e">post</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Operation</span>(<span style="color:#a6e22e">summary</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;A book was created&#39;</span>),
</span></span><span style="display:flex;"><span>                ),
</span></span><span style="display:flex;"><span>            )
</span></span><span style="display:flex;"><span>        ),
</span></span><span style="display:flex;"><span>    ]
</span></span><span style="display:flex;"><span>)]
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">BookWebhook</span> {}
</span></span></code></pre></div><p>The webhook definitions appear in the generated spec under the <code>webhooks</code> key alongside regular paths. Swagger UI renders them in a separate section.</p>
<h2 id="swagger-ui-deep-linking">Swagger UI deep linking</h2>
<p>Swagger UI supports deep linking — bookmarkable URLs that open directly to a specific operation in the interface. Before 3.3, the API Platform integration did not enable this. 3.3 turns on the Swagger UI <code>deepLinking</code> option, configurable via <code>swagger_ui_extra_configuration</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">api_platform</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">openapi</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">swagger_ui_extra_configuration</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">deepLinking</span>: <span style="color:#66d9ef">true</span>
</span></span></code></pre></div><p>With this enabled, the URL fragment updates as you navigate the UI, and pasting or sharing the URL opens the same operation. Useful when writing docs that link directly to a specific endpoint.</p>
<h2 id="strict-query-parameter-validation">Strict query parameter validation</h2>
<p>3.3 tightens the query parameter validator: parameters not declared on the operation now return a 400 response instead of being silently ignored. This behavior is opt-in:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">api_platform</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">validator</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">query_parameter_validation</span>: <span style="color:#66d9ef">true</span>
</span></span></code></pre></div><p>The intent is to catch typos and API misuse early. If you rely on pass-through query parameters for custom logic (logging, feature flags), you need to declare them explicitly on the operation before enabling this.</p>
]]></content:encoded></item><item><title>Symfony 7.0: PHP 8.2 minimum and annotations finally gone</title><link>https://guillaumedelre.github.io/2024/01/12/symfony-7.0-php-8.2-minimum-and-annotations-finally-gone/</link><pubDate>Fri, 12 Jan 2024 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2024/01/12/symfony-7.0-php-8.2-minimum-and-annotations-finally-gone/</guid><description>Part 9 of 11 in &amp;quot;Symfony Releases&amp;quot;: Symfony 7.0 requires PHP 8.2, drops Doctrine annotations entirely, and ships a rebuilt Workflow component.</description><category>symfony-releases</category><content:encoded><![CDATA[<p>Symfony 7.0 landed November 29, 2023, same day as 6.4. The pattern holds: the X.0 release cuts deprecated code and raises the PHP floor. 7.0 requires PHP 8.2 and removes everything that 6.4 flagged as deprecated.</p>
<p>The most visible removal: Doctrine annotations. <code>@Route</code>, <code>@ORM\Column</code>, <code>@Assert</code> - gone. Native PHP attributes have been the recommended approach since Symfony 5.2. 7.0 just makes it official.</p>
<h2 id="attributes-everywhere">Attributes everywhere</h2>
<p>The migration from annotations to attributes is mostly mechanical: syntax changes from <code>@</code> to <code>#[]</code>, and the class references move from Doctrine annotation classes to PHP attribute classes:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">// before
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">/** @Route(&#39;/users&#39;, methods={&#34;GET&#34;}) */</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// after
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">#[Route(&#39;/users&#39;, methods: [&#39;GET&#39;])]
</span></span></span></code></pre></div><p>The real win isn&rsquo;t just the syntax: attributes are validated by the PHP engine, not a docblock parser. IDEs can resolve them without custom plugins. Static analysis tools understand them natively. No more &ldquo;it fails silently at runtime because of a typo in a comment.&rdquo;</p>
<h2 id="workflow-with-php-attributes">Workflow with PHP attributes</h2>
<p>Workflow event listeners and guards can now be registered via attributes:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">#[AsGuard(workflow: &#39;order&#39;, transition: &#39;ship&#39;)]
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">canShip</span>(<span style="color:#a6e22e">Event</span> $event)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span>$event<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getSubject</span>()<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">isPaymentConfirmed</span>()) {
</span></span><span style="display:flex;"><span>        $event<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">setBlocked</span>(<span style="color:#66d9ef">true</span>);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The workflow profiler, a dedicated panel showing the current marking and available transitions, is a genuinely useful debugging tool if you&rsquo;re working with complex state machines.</p>
<h2 id="clock1-datepoint-in-the-clock-component">:clock1: DatePoint in the Clock component</h2>
<p><code>DatePoint</code>, the immutable <code>DateTime</code> with strict error handling introduced in 6.4, is now the recommended way to work with dates. Combine it with PHP 8.2&rsquo;s readonly properties and date value objects in domain code become almost trivially clean:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#a6e22e">readonly</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Order</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">__construct</span>(
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#a6e22e">DatePoint</span> $createdAt,
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#f92672">?</span><span style="color:#a6e22e">DatePoint</span> $shippedAt <span style="color:#f92672">=</span> <span style="color:#66d9ef">null</span>,
</span></span><span style="display:flex;"><span>    ) {}
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="what-70-removes">What 7.0 removes</h2>
<p>The full removal list: Doctrine annotations support, the <code>Templating</code> component bridge, <code>ProxyManager</code> bridge, the <code>Monolog</code> bridge for versions below 3.0, and the Sendinblue transport (replaced by Brevo). PHP 8.0 and 8.1 support also ends. 8.2 is the floor now.</p>
<p>Upgrade from 6.4 with all deprecation notices fixed, and 7.0 is smooth. Skip that step and you&rsquo;re in for a bad time.</p>
<h2 id="scheduler-and-assetmapper-graduate">Scheduler and AssetMapper graduate</h2>
<p>Two components that shipped as experimental in 6.3 are now stable: Scheduler and AssetMapper. Stable means locked APIs, no more <code>@experimental</code> caveats, and they show up properly in the upgrade guide. You can actually rely on them now.</p>
<p>Scheduler gets <code>#[AsCronTask]</code> and <code>#[AsPeriodicTask]</code> for attribute-based task registration, runtime schedule modification with heap recalculation, <code>FailureEvent</code>, and a <code>--date</code> option on <code>schedule:debug</code>. AssetMapper adds CSS file support in importmap, an <code>outdated</code> command, an <code>audit</code> command, and automatic preloading via WebLink.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">#[AsCronTask(&#39;0 2 * * *&#39;)]
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">NightlyReportMessage</span> {}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#[AsPeriodicTask(frequency: &#39;1 hour&#39;)]
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">HourlyCleanupMessage</span> {}
</span></span></code></pre></div><h2 id="service-wiring-gets-two-new-attributes">Service wiring gets two new attributes</h2>
<p><code>#[AutowireLocator]</code> and <code>#[AutowireIterator]</code> landed in 6.4 and graduate to stable in 7.0. They replace the verbose XML/YAML tagged service locator config with something you can just put directly in PHP:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">HandlerRegistry</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">__construct</span>(
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">#[AutowireLocator(&#39;app.handler&#39;, indexAttribute: &#39;key&#39;)]
</span></span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">private</span> <span style="color:#a6e22e">ContainerInterface</span> $handlers,
</span></span><span style="display:flex;"><span>    ) {}
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>#[Target]</code> also gets smarter: when a service has a named autowiring alias like <code>invoice.lock.factory</code>, you can now write <code>#[Target('invoice')]</code> instead of the full alias name. Less noise when the type already tells you what you want.</p>
<h2 id="messenger-gets-more-precise-failure-handling">Messenger gets more precise failure handling</h2>
<p><code>RejectRedeliveredMessageException</code> tells the worker to not retry a message, which is handy when a message arrives twice because of a transport ack timeout and you need exactly-once semantics. <code>messenger:failed:remove --all</code> clears the entire failure transport in one shot, no loop required. Failed retries can also go directly to the failure transport, bypassing the retry queue entirely.</p>
<p>Multiple Redis Sentinel hosts are now supported in the DSN:</p>
<pre tabindex="0"><code>redis-sentinel://host1:26379,host2:26379,host3:26379/mymaster
</code></pre><h2 id="console-gets-signal-names-and-command-profiling">Console gets signal names and command profiling</h2>
<p><code>SignalMap</code> maps signal integers to their POSIX names. When a worker catches <code>SIGTERM</code>, the log now says <code>SIGTERM</code> instead of <code>15</code>. Small thing, real improvement. <code>ConsoleTerminateEvent</code> is dispatched even when the process exits via signal, which wasn&rsquo;t the case before 7.0.</p>
<p>Command profiling lands too: pass <code>--profile</code> to <code>bin/console</code> and the collected data goes straight into the Symfony profiler, browsable from the web UI.</p>
<h2 id="form-small-things-that-add-up">Form: small things that add up</h2>
<p><code>ChoiceType</code> gets a <code>duplicate_preferred_choices</code> option. Set it to <code>false</code> and you stop showing the same option twice when preferred choices overlap with the full list. <code>FormEvent::setData()</code> is deprecated for events where the data is already locked at that point in the lifecycle. The self-closing slash on <code>&lt;input&gt;</code> elements is also gone: <code>&lt;input&gt;</code> is a void element in HTML5 and the slash was technically invalid.</p>
<p>Enum support in forms is a nice one: <code>ChoiceType</code> renders backed enums directly, and translatable enums get their labels through the translator without any custom wiring.</p>
<h2 id="httpfoundation-small-but-useful">HttpFoundation: small but useful</h2>
<p><code>Response::send()</code> gets a <code>$flush</code> parameter. Pass <code>false</code> to buffer the output without flushing to the client, useful when chaining middleware that needs to inspect the response before it leaves the process.</p>
<p><code>UriSigner</code> moves from HttpKernel to HttpFoundation, where it belongs semantically. Same class name, different namespace.</p>
<p>Cookies get CHIPS support (Cookies Having Independent Partitioned State), the browser mechanism for cross-site cookies in a first-party partition. Only matters if you build embeddable widgets, but good to know it&rsquo;s there.</p>
<h2 id="translation-phrase-provider-and-tree-output">Translation: Phrase provider and tree output</h2>
<p>Phrase joins Crowdin and Lokalise as a supported translation provider. Configure it in <code>config/packages/translation.yaml</code> and the <code>translation:push</code> / <code>translation:pull</code> commands handle the sync.</p>
<p><code>translation:pull</code> gets an <code>--as-tree</code> option that writes translation files in nested YAML rather than flat dot-notation keys. Whether that&rsquo;s actually better depends entirely on your team.</p>
<p><code>LocaleSwitcher::runWithLocale()</code> now passes the current locale as an argument to the callback, saving you a <code>getLocale()</code> call inside:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span>$switcher<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">runWithLocale</span>(<span style="color:#e6db74">&#39;fr&#39;</span>, <span style="color:#66d9ef">function</span> (<span style="color:#a6e22e">string</span> $locale) <span style="color:#66d9ef">use</span> ($mailer) {
</span></span><span style="display:flex;"><span>    $mailer<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">send</span>($this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">buildEmail</span>($locale));
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><h2 id="a-few-things-in-serializer-and-domcrawler">A few things in Serializer and DomCrawler</h2>
<p>The Serializer&rsquo;s <code>Context</code> attribute can now target specific classes, so a single DTO can behave differently during (de)serialization depending on which class holds the context. <code>TranslatableNormalizer</code> lands for normalizing objects that implement <code>TranslatableInterface</code>: the translator is called during normalization, not before.</p>
<p><code>Crawler::attr()</code> gains a <code>$default</code> parameter. Instead of null-checking the return value, pass a fallback:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span>$src <span style="color:#f92672">=</span> $crawler<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">attr</span>(<span style="color:#e6db74">&#39;src&#39;</span>, <span style="color:#e6db74">&#39;/placeholder.png&#39;</span>);
</span></span></code></pre></div><p><code>assertAnySelectorText()</code> and <code>assertAnySelectorTextContains()</code> join the DomCrawler assertion set. They pass if at least one matching element satisfies the condition, rather than requiring all of them to match.</p>
<h2 id="httpclient-har-responses-for-testing">HttpClient: HAR responses for testing</h2>
<p><code>MockResponse</code> now accepts HAR (HTTP Archive) files. Record real HTTP interactions in your browser or with a proxy, drop the <code>.har</code> file in your test fixtures, and replay them:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span>$client <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">MockHttpClient</span>(<span style="color:#a6e22e">HarFileResponseFactory</span><span style="color:#f92672">::</span><span style="color:#a6e22e">createFromFile</span>(<span style="color:#66d9ef">__DIR__</span><span style="color:#f92672">.</span><span style="color:#e6db74">&#39;/fixtures/api.har&#39;</span>));
</span></span></code></pre></div><p>Much better than writing response stubs by hand when you&rsquo;re dealing with a complex API.</p>
]]></content:encoded></item><item><title>Symfony 6.4 LTS: AssetMapper, Scheduler, Webhook, and the long-term release</title><link>https://guillaumedelre.github.io/2024/01/10/symfony-6.4-lts-assetmapper-scheduler-webhook-and-the-long-term-release/</link><pubDate>Wed, 10 Jan 2024 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2024/01/10/symfony-6.4-lts-assetmapper-scheduler-webhook-and-the-long-term-release/</guid><description>Part 8 of 11 in &amp;quot;Symfony Releases&amp;quot;: Symfony 6.4 LTS stabilizes AssetMapper — a bundler-free frontend approach — alongside the Scheduler and Webhook components.</description><category>symfony-releases</category><content:encoded><![CDATA[<p>Symfony 6.4 landed November 29, 2023. It&rsquo;s an LTS with a story: four components that shipped as experimental in earlier releases are now stable. The biggest deal is AssetMapper.</p>
<h2 id="assetmapper">AssetMapper</h2>
<p>Modern frontend tooling in Symfony meant Webpack Encore. Encore works: it handles transpilation, bundling, versioning, hot reload. It also requires Node.js, a separate build step, and a non-trivial amount of configuration for what is often a pretty modest frontend.</p>
<p>AssetMapper takes a different position. Modern browsers support ES modules natively. Instead of bundling, ship the files as-is, let the browser resolve imports through an importmap, and manage vendor dependencies through downloaded files rather than npm packages.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>composer require symfony/asset-mapper
</span></span><span style="display:flex;"><span>php bin/console importmap:require lodash
</span></span></code></pre></div><p>No Node.js. No npm. No build step. JavaScript and CSS files are versioned and served directly, with a digest in the URL for cache busting. For apps where the frontend is not the primary engineering concern, this removes an entire toolchain from the equation.</p>
<p>6.4 adds CSS files to the importmap, automatic CSS preloading via WebLink, and commands to audit and update vendor dependencies. The package.json experience, minus npm.</p>
<h2 id="scheduler">Scheduler</h2>
<p>The Scheduler component (periodic and cron-style task scheduling without an external job runner) exits experimental and becomes stable. The API uses attributes:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">#[AsCronTask(&#39;0 * * * *&#39;)]
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">HourlyReport</span> <span style="color:#66d9ef">implements</span> <span style="color:#a6e22e">ScheduledTaskInterface</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">run</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span> { <span style="color:#f92672">...</span> }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Backed by Messenger transports, tasks run in any environment where a worker is running. For many use cases, this replaces the classic <code>cron</code> entry + console command pattern.</p>
<h2 id="webhook-and-remoteevent">Webhook and RemoteEvent</h2>
<p>Also graduating from experimental: the Webhook component handles incoming webhooks from external services. Instead of writing raw controllers that parse payloads and dispatch events by hand, you configure parsers for known services (Stripe, GitHub, Mailgun) and get typed events.</p>
<h2 id="clock3-datepoint">:clock3: DatePoint</h2>
<p>A new <code>DatePoint</code> class in the Clock component: an immutable <code>DateTime</code> wrapper that throws exceptions on invalid modifiers instead of silently returning <code>false</code>. Small thing, but meaningful for code that manipulates dates and actually wants to know when something goes wrong.</p>
<h2 id="the-support-window">The support window</h2>
<p>6.4 LTS gets bug fixes until November 2026 and security fixes until November 2027. The path from 6.4 to 7.4 (the next LTS) runs through the 6.4 deprecation notices, as usual.</p>
<h2 id="routes-without-magic-strings">Routes without magic strings</h2>
<p>FQCN-based route aliases are now generated automatically. If a controller method has a single route, Symfony creates an alias using its fully qualified class name:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">// Previously: only &#39;blog_index&#39; worked
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">// Now: both work identically
</span></span></span><span style="display:flex;"><span>$this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">urlGenerator</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">generate</span>(<span style="color:#e6db74">&#39;blog_index&#39;</span>);
</span></span><span style="display:flex;"><span>$this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">urlGenerator</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">generate</span>(<span style="color:#a6e22e">BlogController</span><span style="color:#f92672">::</span><span style="color:#a6e22e">class</span><span style="color:#f92672">.</span><span style="color:#e6db74">&#39;::index&#39;</span>);
</span></span></code></pre></div><p>For invokable controllers, the alias is just the class name. The practical benefit is IDE navigation and refactoring safety: you&rsquo;re referencing a class constant, not a string that can silently drift.</p>
<h2 id="two-new-di-attributes">Two new DI attributes</h2>
<p><code>#[AutowireLocator]</code> and <code>#[AutowireIterator]</code> join the DI attribute family. Instead of configuring service locators and tagged iterables in YAML, you just declare them on constructor parameters:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">__construct</span>(
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">#[AutowireLocator([FooHandler::class, BarHandler::class])]
</span></span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#a6e22e">ContainerInterface</span> $handlers,
</span></span><span style="display:flex;"><span>) {}
</span></span></code></pre></div><p>Aliases, optional services (prefixed with <code>?</code>), and parameter injection via <code>SubscribedService</code> are all supported. The locator lazy-loads, so only the handlers you actually call get instantiated.</p>
<h2 id="messenger-gets-built-in-handlers">Messenger gets built-in handlers</h2>
<p>Three new message classes cover common tasks that previously required custom handlers.</p>
<p><code>RunProcessMessage</code> dispatches a <code>Process</code> command through the bus. <code>RunCommandMessage</code> does the same for console commands. Both return a context object with the exit code and output. <code>PingWebhookMessage</code> pings a URL, which is useful for monitoring scheduled tasks without spinning up a dedicated health-check service:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span>$this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">bus</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">dispatch</span>(<span style="color:#66d9ef">new</span> <span style="color:#a6e22e">RunCommandMessage</span>(<span style="color:#e6db74">&#39;cache:clear&#39;</span>));
</span></span><span style="display:flex;"><span>$this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">bus</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">dispatch</span>(<span style="color:#66d9ef">new</span> <span style="color:#a6e22e">PingWebhookMessage</span>(<span style="color:#e6db74">&#39;GET&#39;</span>, <span style="color:#e6db74">&#39;https://healthchecks.io/ping/abc123&#39;</span>));
</span></span></code></pre></div><p>The subprocess inheritance problem also got addressed with <code>PhpSubprocess</code>. When you run PHP with a custom memory limit (<code>-d memory_limit=-1</code>), child processes launched with <code>Process</code> don&rsquo;t inherit it. <code>PhpSubprocess</code> does:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span>$sub <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">PhpSubprocess</span>([<span style="color:#e6db74">&#39;bin/console&#39;</span>, <span style="color:#e6db74">&#39;app:heavy-import&#39;</span>]);
</span></span></code></pre></div><h2 id="security-three-fixes-for-real-situations">Security: three fixes for real situations</h2>
<p>The profiler now shows how security badges were resolved during authentication: which ones passed, which failed, and why. Before, you had to add debug output manually when a custom authenticator wasn&rsquo;t behaving.</p>
<p>Login throttling via RateLimiter now hashes PII in logs automatically. IP addresses and usernames get hashed with the kernel secret before they&rsquo;re written. No config needed, no regex on log lines.</p>
<p>Firewall patterns now accept arrays:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">firewalls</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">no_security</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">pattern</span>:
</span></span><span style="display:flex;"><span>            - <span style="color:#e6db74">&#34;^/register$&#34;</span>
</span></span><span style="display:flex;"><span>            - <span style="color:#e6db74">&#34;^/api/webhooks/&#34;</span>
</span></span></code></pre></div><p>No more regex gymnastics for multi-path exclusions.</p>
<h2 id="logout-without-a-dummy-controller">Logout without a dummy controller</h2>
<p>The logout route used to require a controller that did nothing but throw an exception, with a comment explaining that yes, this is intentional. 6.4 eliminates that:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># config/routes/security.yaml</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">_security_logout</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">resource</span>: <span style="color:#ae81ff">security.route_loader.logout</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">type</span>: <span style="color:#ae81ff">service</span>
</span></span></code></pre></div><p>The route loader handles it. The dummy controller is gone. Flex updates the recipe.</p>
<h2 id="the-serializer-in-better-shape">The serializer in better shape</h2>
<p>Three serializer improvements that each solve a real problem.</p>
<p>Class-level <code>#[Groups]</code> attribute: apply a group to the entire class, then override per property. Useful when a resource has a default serialization group and a few fields that need finer control.</p>
<p>Translatable objects now have a dedicated normalizer. Translatable strings (wrapping Doctrine&rsquo;s <code>TranslatableInterface</code>) get translated to the locale passed via <code>NORMALIZATION_LOCALE_KEY</code> during normalization. Before this, you had to write a custom normalizer.</p>
<p>In debug mode, JSON decoding errors now use <code>seld/jsonlint</code> for better messages. Instead of &ldquo;Syntax error&rdquo;, you get the line and what actually went wrong:</p>
<pre tabindex="0"><code>Parse error on line 1: {&#39;foo&#39;: &#39;bar&#39;}
           ^ Invalid string, used single quotes instead of double quotes
</code></pre><h2 id="profilers-for-the-things-that-werent-http-requests">Profilers for the things that weren&rsquo;t HTTP requests</h2>
<p>The command profiler extends the existing profiler to console commands. Add <code>--profile</code> to any command and get a full profiler entry: input/output, execution time, memory, database queries, log messages. Commands that used to need <code>--verbose</code> plus manual timing now have the same debugging experience as HTTP requests.</p>
<p>The workflow profiler does the same for state machines. A new panel shows a graphical representation of your workflows and which transitions fired during the request. Zero configuration.</p>
<h2 id="the-dx-accumulation">The DX accumulation</h2>
<p>Several smaller additions that compound.</p>
<p><code>renderBlock()</code> and <code>renderBlockView()</code> on <code>AbstractController</code> let you render a named Twig block and return it as a <code>Response</code> or string. Handy for Turbo Stream responses where you want to update a fragment without a full controller action.</p>
<p>The <code>defined</code> env var processor returns a boolean rather than the value: <code>true</code> if the variable exists and is non-empty, <code>false</code> otherwise. Useful for feature flags driven by environment variables:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">parameters</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">is_feature_enabled</span>: <span style="color:#e6db74">&#39;%env(defined:FEATURE_FLAG_KEY)%&#39;</span>
</span></span></code></pre></div><p><code>HttpClient</code> now accepts <code>max_retries</code> per request, overriding the global retry strategy. The Finder component&rsquo;s <code>filter()</code> method accepts a second argument to prune entire directories early, which matters when you&rsquo;re searching large trees.</p>
<p>The <code>BrowserKit</code> <code>click()</code> method now accepts server parameters as extra headers, useful in functional tests that need to simulate authenticated API calls while following links.</p>
<h2 id="impersonation-becomes-usable-in-templates">Impersonation becomes usable in templates</h2>
<p>Two new Twig helpers: <code>impersonation_path()</code> and <code>impersonation_url()</code>. They generate the correct URLs including the switch-user query parameter, which is configurable and has no business being hardcoded in templates. Pair them with the existing <code>impersonation_exit_path()</code> for the full admin impersonation flow.</p>
<h2 id="locale-control-everywhere-it-was-missing">Locale control, everywhere it was missing</h2>
<p>Three gaps filled. <code>TemplatedEmail</code> now has a <code>locale()</code> method for rendering emails in the recipient&rsquo;s language. The locale switcher&rsquo;s <code>runWithLocale()</code> now passes the locale as an argument to the callback, so you don&rsquo;t have to capture it from the outer scope. And <code>app.enabledLocales</code> is available in Twig, so you can build language switchers without hardcoding locale lists.</p>
<h2 id="deploying-to-read-only-filesystems">Deploying to read-only filesystems</h2>
<p><code>APP_BUILD_DIR</code> is now an environment variable recognized by the kernel. Set it to redirect compiled artifacts (router cache, Doctrine proxies, preloaded translations) to a directory that exists, even when the default cache directory doesn&rsquo;t. The <code>MicroKernelTrait</code> uses it automatically. The <code>WarmableInterface</code> gained a <code>$buildDir</code> parameter to support this separation: custom cache warmers that write read-only artifacts should update accordingly.</p>
]]></content:encoded></item><item><title>API Platform 3.2: errors as resources and sub-resources come back</title><link>https://guillaumedelre.github.io/2023/10/12/api-platform-3.2-errors-as-resources-and-sub-resources-come-back/</link><pubDate>Thu, 12 Oct 2023 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2023/10/12/api-platform-3.2-errors-as-resources-and-sub-resources-come-back/</guid><description>Part 3 of 8 in &amp;quot;API Platform Releases&amp;quot;: API Platform 3.2 makes errors first-class Problem Detail resources, brings back sub-resources cleanly, and makes event listeners optional.</description><category>api-platform-releases</category><content:encoded><![CDATA[<p>API Platform 3.2 arrived in October 2023 with three changes that pushed the state model further: errors became resources, sub-resources came back in a form that actually fits the architecture, and the last legacy extension point — event listeners — was formally replaced.</p>
<h2 id="errors-as-resources">Errors as resources</h2>
<p>Before 3.2, error handling was outside the resource model. Exceptions were caught by a Symfony event listener and converted to a response, with limited control over the shape of the output.</p>
<p>3.2 makes errors first-class <code>ApiResource</code> classes compliant with <a href="https://www.rfc-editor.org/rfc/rfc9457" target="_blank" rel="noopener noreferrer">RFC 9457</a>
 (Problem Details for HTTP APIs). The built-in error class is <code>ApiPlatform\ApiResource\Error</code>, and you can create your own:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\ApiResource</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\ErrorResource</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\Exception\ProblemExceptionInterface</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#[ApiResource]
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">#[ErrorResource]
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">BookNotFoundError</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">\RuntimeException</span> <span style="color:#66d9ef">implements</span> <span style="color:#a6e22e">ProblemExceptionInterface</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">__construct</span>(<span style="color:#66d9ef">private</span> <span style="color:#a6e22e">readonly</span> <span style="color:#a6e22e">string</span> $bookId)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">parent</span><span style="color:#f92672">::</span><span style="color:#a6e22e">__construct</span>(<span style="color:#e6db74">&#34;Book </span><span style="color:#e6db74">$bookId</span><span style="color:#e6db74"> not found&#34;</span>);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getType</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#e6db74">&#39;/errors/book-not-found&#39;</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>When this exception is thrown anywhere in the state layer, API Platform catches it, serializes it as a Problem Detail response, and generates a proper OpenAPI schema for it. The error type, title, detail, and status are all part of the resource contract — not hardcoded strings in a listener.</p>
<h2 id="sub-resources-without-the-workarounds">Sub-resources without the workarounds</h2>
<p>Sub-resources existed in 2.x but were removed in 3.0 because they were tightly coupled to the old data provider model and couldn&rsquo;t be cleanly mapped to the new operation-first architecture. 3.2 reintroduces them in a way that fits.</p>
<p>A sub-resource is a resource accessible through a parent resource&rsquo;s URI. In 3.2, it is declared directly on the child resource using <code>uriTemplate</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\ApiResource</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\GetCollection</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#[ApiResource(
</span></span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">operations</span><span style="color:#f92672">:</span> [
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">GetCollection</span>(
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">uriTemplate</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;/books/{bookId}/reviews&#39;</span>,
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">uriVariables</span><span style="color:#f92672">:</span> [
</span></span><span style="display:flex;"><span>                <span style="color:#e6db74">&#39;bookId&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Link</span>(<span style="color:#a6e22e">fromClass</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">Book</span><span style="color:#f92672">::</span><span style="color:#a6e22e">class</span>),
</span></span><span style="display:flex;"><span>            ],
</span></span><span style="display:flex;"><span>        ),
</span></span><span style="display:flex;"><span>    ]
</span></span><span style="display:flex;"><span>)]
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Review</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// ...
</span></span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The <code>Link</code> descriptor makes the relationship explicit. The provider receives <code>bookId</code> in <code>$uriVariables</code> and can use it to scope the query. No magic inference, no implicit joins — the URI structure and the data access are both declared.</p>
<h2 id="canonical_uri_template-for-multiple-access-paths"><code>canonical_uri_template</code> for multiple access paths</h2>
<p>When a resource is accessible through multiple URIs (a direct endpoint and a sub-resource endpoint), OpenAPI needs to know which URI is canonical for <code>$ref</code> links. 3.2 uses the top-level <code>uriTemplate</code> on <code>ApiResource</code> as the default canonical URI. For finer control, the <code>canonical_uri_template</code> option can be passed via <code>extraProperties</code> on any operation to override it explicitly.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">#[ApiResource(
</span></span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">uriTemplate</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;/reviews/{id}&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">operations</span><span style="color:#f92672">:</span> [
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Get</span>(),
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">GetCollection</span>(
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">uriTemplate</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;/books/{bookId}/reviews&#39;</span>,
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">uriVariables</span><span style="color:#f92672">:</span> [<span style="color:#e6db74">&#39;bookId&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Link</span>(<span style="color:#a6e22e">fromClass</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">Book</span><span style="color:#f92672">::</span><span style="color:#a6e22e">class</span>)],
</span></span><span style="display:flex;"><span>        ),
</span></span><span style="display:flex;"><span>    ]
</span></span><span style="display:flex;"><span>)]
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Review</span> {}
</span></span></code></pre></div><p>The generated OpenAPI spec uses the canonical URI for schema references, keeping the document consistent when a resource appears under several paths.</p>
<h2 id="union-and-intersection-types">Union and intersection types</h2>
<p>3.2 adds support for PHP union and intersection types in the metadata layer. A property declared as <code>Book|Magazine</code> generates a proper <code>oneOf</code> schema in OpenAPI. This was previously unsupported — you had to fall back to an untyped <code>mixed</code> or annotate the property manually.</p>
<h2 id="event-listeners-made-optional">Event listeners made optional</h2>
<p>The last compatibility shim from 2.x was the ability to use Symfony event listeners on the <code>kernel.request</code> and <code>kernel.view</code> events to intercept API Platform&rsquo;s data flow. 3.2 does not remove them, but introduces an opt-out: setting <code>event_listeners_backward_compatibility_layer: false</code> in the API Platform configuration disables the event-based hooks entirely. The replacement is a provider or processor decorated with another provider or processor. The event-based hook was stateful, order-dependent, and bypassed the operation context entirely. Decorated providers get the operation object and can call the inner provider when ready.</p>
<h2 id="the-state-model-is-now-complete">The state model is now complete</h2>
<p>3.0 introduced the architecture. 3.1 added resource/entity separation. 3.2 closes the remaining gaps: errors have a resource contract, sub-resources have a clean declaration model, and the state layer now covers every extension point that event listeners once handled. The 2.x shims still exist, but opting out of them is now a single config flag.</p>
]]></content:encoded></item><item><title>API Platform 3.1: your resource doesn't have to be your entity</title><link>https://guillaumedelre.github.io/2023/01/23/api-platform-3.1-your-resource-doesnt-have-to-be-your-entity/</link><pubDate>Mon, 23 Jan 2023 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2023/01/23/api-platform-3.1-your-resource-doesnt-have-to-be-your-entity/</guid><description>Part 2 of 8 in &amp;quot;API Platform Releases&amp;quot;: API Platform 3.1 decouples API resources from Doctrine entities, ships a spec-compliant PUT, and collects denormalization errors as a list.</description><category>api-platform-releases</category><content:encoded><![CDATA[<p>Four months after 3.0, API Platform 3.1 arrived with the first batch of features built on the new state model. Not every change is dramatic, but one of them solves a problem that drove a lot of convoluted workarounds in 2.x: your API resource no longer needs to be your Doctrine entity.</p>
<h2 id="the-resourceentity-split">The resource/entity split</h2>
<p>In 2.x, API Platform worked best when your API resource and your persistence model were the same class. Using a DTO as the API surface was possible through the Input/Output DTO system, but that system was removed in 3.0 — it complicated the state model without enough benefit.</p>
<p>3.1 replaces it with something cleaner. The <code>stateOptions</code> parameter on an operation accepts a <code>DoctrineOrmOptions</code> object that points to a different entity:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\ApiResource</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\GetCollection</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Doctrine\Orm\State\Options</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#[ApiResource(
</span></span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">operations</span><span style="color:#f92672">:</span> [
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">GetCollection</span>(
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">stateOptions</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Options</span>(<span style="color:#a6e22e">entityClass</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">BookEntity</span><span style="color:#f92672">::</span><span style="color:#a6e22e">class</span>),
</span></span><span style="display:flex;"><span>        ),
</span></span><span style="display:flex;"><span>    ]
</span></span><span style="display:flex;"><span>)]
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">BookDto</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#a6e22e">string</span> $title;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#a6e22e">string</span> $author;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The provider receives the <code>BookEntity</code> objects from Doctrine and the serialization layer maps them to <code>BookDto</code>. The Doctrine filters, pagination, and ordering all work on <code>BookEntity</code>. The API surface exposes <code>BookDto</code>. The two can evolve independently.</p>
<p>This matters more than it looks. Your persistence model accumulates internal fields, relations, and columns that have no business being in your API. Before 3.1, you either exposed them anyway or built an elaborate normalizer to hide them. Now you declare what the API looks like as a separate class and let the framework handle the mapping.</p>
<h2 id="put-that-follows-the-spec">PUT that follows the spec</h2>
<p>Since version 1.0, API Platform&rsquo;s PUT handler updated existing resources. Creating a resource via PUT — which the HTTP spec explicitly allows — was not supported. 3.1 adds <code>uriTemplate</code>-based creation:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">#[Put(
</span></span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">uriTemplate</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;/books/{id}&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">allowCreate</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>)]
</span></span></code></pre></div><p>With <code>allowCreate: true</code>, a PUT to a URI that does not exist creates the resource instead of returning 404. The identifier comes from the URI, not from the request body. This is what RFC 9110 describes for PUT: &ldquo;If the target resource does not have a current representation and the PUT successfully creates one, then the origin server MUST inform the user agent by sending a 201 (Created) response.&rdquo;</p>
<p>It is a small flag, but it opens API Platform to use cases — idempotent resource creation, client-assigned identifiers — that previously required a custom controller.</p>
<h2 id="denormalization-errors-collected-not-thrown">Denormalization errors collected, not thrown</h2>
<p>Before 3.1, deserialization errors stopped at the first problem. Send a request body with five invalid fields and get an error about the first one. Fix it, send again, find the second. Repeat five times.</p>
<p>3.1 adds a <code>collect_denormalization_errors</code> option on the operation that changes this behavior:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">#[Post(collectDenormalizationErrors: true)]
</span></span></span></code></pre></div><p>With this enabled, API Platform catches all type errors and constraint violations during deserialization and returns them as a structured list in the response, formatted the same way as validation errors. One round-trip, full picture.</p>
<h2 id="apiresourceopenapi-replaces-openapicontext"><code>ApiResource::openapi</code> replaces <code>openapiContext</code></h2>
<p>The old <code>openapiContext</code> parameter accepted a raw array that was merged into the generated OpenAPI schema — convenient but untyped. 3.1 introduces a first-class <code>openapi</code> parameter that accepts an <code>OpenApiOperation</code> object:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\OpenApi\Model\Operation</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\OpenApi\Model\RequestBody</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#[Post(
</span></span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">openapi</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Operation</span>(
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">requestBody</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">RequestBody</span>(
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">description</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;Create a book&#39;</span>,
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">required</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>        ),
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">summary</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;Create a new book entry&#39;</span>,
</span></span><span style="display:flex;"><span>    )
</span></span><span style="display:flex;"><span>)]
</span></span></code></pre></div><p>The old <code>openapiContext</code> array still works but is deprecated. The new approach is typed, IDE-friendly, and validates at construction time rather than at schema generation time. PHP 8.1 backed enums also get proper OpenAPI schema generation in 3.1 — a field typed as a backed enum produces a schema with <code>enum</code> values and the correct type, without any annotation.</p>
<h2 id="the-pattern-is-clear">The pattern is clear</h2>
<p>3.0 established the architecture. 3.1 shows what that architecture enables: clean resource/entity separation without a parallel DTO system, RFC-correct HTTP semantics, better error reporting. None of these would have been as clean to implement on the 2.x data provider model. The features in 3.1 are the first proof that the rewrite was the right call.</p>
]]></content:encoded></item><item><title>API Platform 3.0: a new state model and the end of DataProviders</title><link>https://guillaumedelre.github.io/2022/11/18/api-platform-3.0-a-new-state-model-and-the-end-of-dataproviders/</link><pubDate>Fri, 18 Nov 2022 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2022/11/18/api-platform-3.0-a-new-state-model-and-the-end-of-dataproviders/</guid><description>Part 1 of 8 in &amp;quot;API Platform Releases&amp;quot;: API Platform 3.0 replaced DataProviders and DataPersisters with a state model that makes HTTP operations explicit — and required PHP 8.1 and Symfony 6 to do it.</description><category>api-platform-releases</category><content:encoded><![CDATA[<p>API Platform 3.0 arrived in September 2022 with Symfony 6.1 as a hard minimum and a core architecture that looked nothing like 2.x. The migration guide is long. The reason it&rsquo;s long is interesting.</p>
<p>The old model had a conceptual leak. <code>DataProviderInterface</code> and <code>DataPersisterInterface</code> were called for every HTTP request, but the provider received the operation context as a hint — not as a contract. A collection provider and an item provider were separate interfaces, but both lived in the same mental bucket: &ldquo;things that return data.&rdquo; The HTTP layer knew what was being requested; the provider had to reconstruct that knowledge from context clues passed in the <code>$context</code> array.</p>
<p>3.0 inverts the model. Operations are declared first. Data access is wired to operations.</p>
<h2 id="state-providers-replaced-data-providers">State providers replaced data providers</h2>
<p>The old <code>DataProviderInterface</code> is gone. The replacement is <code>ProviderInterface</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\State\ProviderInterface</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\Operation</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">BookProvider</span> <span style="color:#66d9ef">implements</span> <span style="color:#a6e22e">ProviderInterface</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">provide</span>(<span style="color:#a6e22e">Operation</span> $operation, <span style="color:#66d9ef">array</span> $uriVariables <span style="color:#f92672">=</span> [], <span style="color:#66d9ef">array</span> $context <span style="color:#f92672">=</span> [])<span style="color:#f92672">:</span> <span style="color:#a6e22e">object</span><span style="color:#f92672">|</span><span style="color:#66d9ef">array</span><span style="color:#f92672">|</span><span style="color:#66d9ef">null</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> ($operation <span style="color:#a6e22e">instanceof</span> <span style="color:#a6e22e">CollectionOperationInterface</span>) {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">repository</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">findAll</span>();
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">repository</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">find</span>($uriVariables[<span style="color:#e6db74">&#39;id&#39;</span>]);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The difference is not syntactic. In 2.x, you registered a provider and API Platform called it for any matching resource. In 3.0, you bind a provider to a specific operation. The provider no longer guesses what triggered it — the operation object it receives is the contract.</p>
<h2 id="state-processors-replaced-data-persisters">State processors replaced data persisters</h2>
<p><code>DataPersisterInterface</code> had the same problem on the write side: one class handling create, update, and delete, distinguishing them by inspecting the HTTP method or the object state. <code>ProcessorInterface</code> receives the operation as a typed argument:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\State\ProcessorInterface</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\Operation</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">BookProcessor</span> <span style="color:#66d9ef">implements</span> <span style="color:#a6e22e">ProcessorInterface</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">process</span>(<span style="color:#a6e22e">mixed</span> $data, <span style="color:#a6e22e">Operation</span> $operation, <span style="color:#66d9ef">array</span> $uriVariables <span style="color:#f92672">=</span> [], <span style="color:#66d9ef">array</span> $context <span style="color:#f92672">=</span> [])
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">entityManager</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">persist</span>($data);
</span></span><span style="display:flex;"><span>        $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">entityManager</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">flush</span>();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> $data;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>More usefully: you can bind a different processor per operation. The delete operation gets one that removes. The post operation gets one that validates and stores. No switch statement, no method inspection, no shared class trying to be three things at once.</p>
<h2 id="operations-declared-explicitly-in-php-81-attributes">Operations declared explicitly in PHP 8.1 attributes</h2>
<p>The other half of 3.0 is the metadata layer. Doctrine annotations are replaced by PHP 8.1 native attributes, and each operation is declared explicitly on the resource class:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\ApiResource</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\Get</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\GetCollection</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">ApiPlatform\Metadata\Post</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#[ApiResource(
</span></span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">operations</span><span style="color:#f92672">:</span> [
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">GetCollection</span>(<span style="color:#a6e22e">provider</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">BookProvider</span><span style="color:#f92672">::</span><span style="color:#a6e22e">class</span>),
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Get</span>(<span style="color:#a6e22e">provider</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">BookProvider</span><span style="color:#f92672">::</span><span style="color:#a6e22e">class</span>),
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Post</span>(<span style="color:#a6e22e">processor</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">BookProcessor</span><span style="color:#f92672">::</span><span style="color:#a6e22e">class</span>),
</span></span><span style="display:flex;"><span>    ]
</span></span><span style="display:flex;"><span>)]
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Book</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// ...
</span></span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>This is more verbose than <code>@ApiResource</code> with magic defaults. It is also explicit. You know exactly what HTTP operations exist for this resource, what retrieves data, what writes it, and where the logic lives. The defaults of 2.x were convenient until the day you needed to override one and couldn&rsquo;t figure out which service to decorate without reading the source.</p>
<h2 id="php-81-was-not-a-coincidence">PHP 8.1 was not a coincidence</h2>
<p>The hard requirement for PHP 8.1 is load-bearing. First-class callables make filter registration cleaner. The immutability of operation metadata is enforced through cloning patterns (<code>withX()</code> methods) that rely on named arguments and promoted constructor properties — PHP 8.0 foundations the architecture builds on heavily.</p>
<p>More practically: the full expression of 3.0&rsquo;s architecture — typed operations, operation-scoped providers, explicit metadata — needed 8.1 to not feel like workarounds. Dropping PHP 7.x and 8.0 was not a housekeeping decision.</p>
<h2 id="the-migration-is-real-work">The migration is real work</h2>
<p>The jump from 2.x to 3.0 is not a version bump. Every <code>DataProvider</code> becomes a <code>ProviderInterface</code>. Every <code>DataPersister</code> becomes a <code>ProcessorInterface</code>. Annotations become attributes. Custom normalizers and filters may need restructuring. The upgrade guide documents all of it, but &ldquo;documented&rdquo; does not mean &ldquo;fast.&rdquo;</p>
<p>What you get on the other side is an architecture that scales without the ambient complexity of 2.x: no more guessing which interface to implement, no more <code>$this-&gt;supports()</code> chains, no more invisible defaults quietly overriding explicit config.</p>
<p>3.0 is the API Platform you&rsquo;d design from scratch knowing what you know after years of 2.x. The price is the migration. The version number is honest about that.</p>
]]></content:encoded></item><item><title>Swarrot vs Symfony Messenger: a real-world comparison</title><link>https://guillaumedelre.github.io/2022/01/26/swarrot-vs-symfony-messenger-a-real-world-comparison/</link><pubDate>Wed, 26 Jan 2022 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2022/01/26/swarrot-vs-symfony-messenger-a-real-world-comparison/</guid><description>Swarrot and Symfony Messenger both handle RabbitMQ in PHP. Here is why we kept Swarrot after seriously evaluating a migration.</description><content:encoded><![CDATA[<p>We migrated a media microservices platform to Symfony 6 at the start of 2022. Twelve services, most of them consuming messages from RabbitMQ via <a href="https://github.com/swarrot/swarrot" target="_blank" rel="noopener noreferrer">Swarrot</a>. Symfony 6 made <a href="https://symfony.com/doc/current/messenger.html" target="_blank" rel="noopener noreferrer">Messenger</a> more central than ever, and during the migration planning a developer asked the obvious question: why not switch at the same time?</p>
<p>It ships with the framework. It has retry logic, native AMQP support, first-party documentation. Our setup looked artisanal by comparison.</p>
<p>Fair question. We took it seriously. Here&rsquo;s what we found.</p>
<h2 id="wiring-the-topology-by-hand">Wiring the topology by hand</h2>
<p>Swarrot is a consumer library that wraps the PECL AMQP extension. It reads bytes from a queue, runs them through a chain of processors (their term for middleware), and lets your code decide what to do with the payload. That&rsquo;s really it.</p>
<p>The middleware chain is the interesting part. Processors are nested decorators, each wrapping the next. The outer layers handle infrastructure concerns before the message even reaches your business logic:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">middleware_stack</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">configurator</span>: <span style="color:#e6db74">&#39;swarrot.processor.signal_handler&#39;</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">configurator</span>: <span style="color:#e6db74">&#39;swarrot.processor.max_execution_time&#39;</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">configurator</span>: <span style="color:#e6db74">&#39;swarrot.processor.exception_catcher&#39;</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">configurator</span>: <span style="color:#e6db74">&#39;swarrot.processor.doctrine_object_manager&#39;</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">configurator</span>: <span style="color:#e6db74">&#39;swarrot.processor.ack&#39;</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">configurator</span>: <span style="color:#e6db74">&#39;app.processor.retry&#39;</span>
</span></span></code></pre></div><p><code>signal_handler</code> sits at the top because it needs to catch <code>SIGTERM</code> before any other processor sees it. <code>ack</code> sits near the bottom because you only acknowledge the message after processing succeeds. The order is not arbitrary, and it&rsquo;s entirely visible in configuration.</p>
<p>The topology is equally explicit. You declare everything yourself: exchanges, routing keys, retry queues, dead-letter queues:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">messages_types</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">content.ingest</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">exchange</span>: <span style="color:#ae81ff">e.app.content</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">routing_key</span>: <span style="color:#ae81ff">q.app.content.ingest</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">content.ingest_retry</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">exchange</span>: <span style="color:#ae81ff">e.app.content</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">routing_key</span>: <span style="color:#ae81ff">q.app.content.ingest.retry</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">content.ingest_dead</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">exchange</span>: <span style="color:#ae81ff">e.app.content</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">routing_key</span>: <span style="color:#ae81ff">q.app.content.ingest.dead</span>
</span></span></code></pre></div><p>Three entries per logical message type: main queue, retry queue, dead-letter queue. Everything that exists on the broker is named right here. The config is verbose but honest: no inference, no convention over configuration. If a queue exists in RabbitMQ, you can trace it to a single line of YAML.</p>
<h2 id="when-the-class-name-becomes-the-route">When the class name becomes the route</h2>
<p><a href="https://symfony.com/doc/current/messenger.html" target="_blank" rel="noopener noreferrer">Symfony Messenger</a> operates one level higher. You define a message class, a handler, and a transport. The library handles serialization, routing, retry, and failure queues automatically.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">IngestContent</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">__construct</span>(
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#a6e22e">readonly</span> <span style="color:#a6e22e">string</span> $contentId,
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#a6e22e">readonly</span> <span style="color:#a6e22e">string</span> $source,
</span></span><span style="display:flex;"><span>    ) {}
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">framework</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">messenger</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">transports</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">async</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">dsn</span>: <span style="color:#e6db74">&#39;%env(MESSENGER_TRANSPORT_DSN)%&#39;</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">retry_strategy</span>:
</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">max_retries</span>: <span style="color:#ae81ff">3</span>
</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">delay</span>: <span style="color:#ae81ff">1000</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">routing</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">&#39;App\Message\IngestContent&#39;</span>: <span style="color:#ae81ff">async</span>
</span></span></code></pre></div><p>Messenger serializes the object, puts it on the transport, and deserializes it on the other end into the correct class. No manual topology, no explicit exchange names. The class name is the routing primitive.</p>
<p>That last sentence is exactly where things got complicated for us.</p>
<h2 id="where-typing-becomes-coupling">Where typing becomes coupling</h2>
<p>Messenger assumes that the producer and the consumer share a PHP class definition. That&rsquo;s fine for a single app, or for services that share a dedicated contracts package. In a monorepo of independent Symfony applications, it creates coupling that simply doesn&rsquo;t exist today.</p>
<p>Take a content ingestion message that twelve services consume. With Swarrot, each service reads the raw JSON payload and picks the fields it cares about. Adding a new field means updating the producer. Consumers that don&rsquo;t need the field keep working without any modification.</p>
<p>With Messenger, <code>IngestContent</code> must be defined somewhere that all twelve services can reference. That means either:</p>
<ul>
<li>A shared PHP package, versioned, deployed, and maintained across services. Every schema change becomes a cross-service coordination exercise.</li>
<li>Duplicated classes in each service, which drift silently apart under pressure.</li>
</ul>
<p>Neither is free. The shared package approach inverts the ownership model: the message schema becomes a dependency rather than a contract defined at the boundary. The duplication approach is just the original problem deferred.</p>
<p>The root difference is what a message represents. Messenger is designed for <strong>typed commands</strong>: an object that carries meaning and dispatches to a specific handler. Swarrot treats messages as <strong>opaque data</strong>: bytes that flow through a topology, processed by whatever consumer happens to be listening. If your messages are data, the extra abstraction Messenger adds doesn&rsquo;t help you. It creates friction.</p>
<h2 id="the-blocker">The blocker</h2>
<p>The serialization problem was the decisive one. In a monorepo where services are autonomous, sharing PHP classes between them isn&rsquo;t architecturally neutral: it&rsquo;s a coupling decision that makes future changes harder. We would have been trading a nominally &ldquo;legacy&rdquo; library for a more modern one while introducing exactly the kind of tight coupling we&rsquo;d spent years avoiding.</p>
<p>There were secondary concerns too. The PECL AMQP extension gives direct access to broker features (message priorities, per-queue TTL, headers exchange routing) that Messenger abstracts away. And migrating fifteen consumers without a flag day means running both libraries in parallel, which is a real operational constraint.</p>
<p>But the serialization issue alone would have been enough.</p>
<h2 id="data-or-commands-thats-the-question">Data or commands: that&rsquo;s the question</h2>
<p>The choice isn&rsquo;t about library quality. Messenger is well-maintained, well-documented, and integrates cleanly into the Symfony ecosystem.</p>
<p>The question to ask first is: what are your messages?</p>
<p>If they are typed commands with a known schema and a single authoritative consumer, Messenger is a natural fit. You write a class, a handler, configure a transport, and the infrastructure handles the rest.</p>
<p>If they are data payloads consumed by multiple independent services, each of which owns its own deserialization, the abstraction Messenger adds works against you. Swarrot&rsquo;s explicit topology and raw payload model give you more control where you actually need it.</p>
<p>One real limitation to keep in mind: Swarrot is tied to the PECL AMQP extension, which only implements AMQP 0-9-1. That means RabbitMQ (or a compatible broker) is a hard dependency. If your infrastructure ever moves toward an AMQP 1.0 broker (Azure Service Bus, ActiveMQ Artemis), Swarrot can&rsquo;t follow. Messenger&rsquo;s transport layer abstracts this cleanly: changing brokers means changing a DSN, not rewriting consumers.</p>
<p>If broker portability is a requirement, or likely to become one, that changes the calculus significantly.</p>
<p>Swarrot isn&rsquo;t legacy to migrate away from. For now, it&rsquo;s the right fit: AMQP routing as the primitive, messages as data, RabbitMQ as a long-term infrastructure choice.</p>
<p>That could change. A shared contracts package, a new broker requirement, a greenfield service that doesn&rsquo;t carry the existing topology weight: any of these could tip the balance toward Messenger. The library isn&rsquo;t wrong for this platform. It may just be the right answer for a future version of it.</p>
]]></content:encoded></item><item><title>Symfony 6.0: PHP 8.1 only, and the security system rebuilt</title><link>https://guillaumedelre.github.io/2022/01/12/symfony-6.0-php-8.1-only-and-the-security-system-rebuilt/</link><pubDate>Wed, 12 Jan 2022 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2022/01/12/symfony-6.0-php-8.1-only-and-the-security-system-rebuilt/</guid><description>Part 7 of 11 in &amp;quot;Symfony Releases&amp;quot;: Symfony 6.0 requires PHP 8.1, removes the legacy security system, and rebuilds authentication on a cleaner foundation.</description><category>symfony-releases</category><content:encoded><![CDATA[<p>Symfony 6.0 released November 29, 2021. The defining characteristic: PHP 8.1 is the minimum. Not supported, required. The releases team waited for PHP 8.1 to ship, then cut Symfony 6.0 the next day.</p>
<p>This isn&rsquo;t just a version bump. It&rsquo;s a commitment to build against the current language instead of the historical floor.</p>
<h2 id="the-security-system-finally-rebuilt">The security system, finally rebuilt</h2>
<p>The Symfony security component has two systems. The old one (<code>AnonymousToken</code>, <code>GuardAuthenticatorInterface</code>, a tangle of interfaces that made you implement methods you didn&rsquo;t need) had been deprecated. 6.0 removes it entirely.</p>
<p>The new security system (<code>security.enable_authenticator_manager: true</code> in 5.x) is now the only system. It&rsquo;s cleaner: one interface to implement, clear separation between authentication and authorization, passport-based credential checking. The upgrade from the old guard authenticators isn&rsquo;t painless, but the destination is a lot less confusing.</p>
<h2 id="the-filesystem-path-class">The Filesystem Path class</h2>
<p>Working with filesystem paths in PHP is basically a string manipulation problem. <code>__DIR__</code>, concatenation, <code>realpath()</code>, platform-specific separators: the standard library gives you primitives but no real model.</p>
<p>The new <code>Path</code> class handles this:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Symfony\Component\Filesystem\Path</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">Path</span><span style="color:#f92672">::</span><span style="color:#a6e22e">join</span>(<span style="color:#e6db74">&#39;/var/www&#39;</span>, <span style="color:#e6db74">&#39;html&#39;</span>, <span style="color:#e6db74">&#39;../uploads&#39;</span>); <span style="color:#75715e">// /var/www/uploads
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">Path</span><span style="color:#f92672">::</span><span style="color:#a6e22e">makeRelative</span>(<span style="color:#e6db74">&#39;/var/www/html&#39;</span>, <span style="color:#e6db74">&#39;/var/www&#39;</span>); <span style="color:#75715e">// html
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">Path</span><span style="color:#f92672">::</span><span style="color:#a6e22e">isAbsolute</span>(<span style="color:#e6db74">&#39;./relative/path&#39;</span>); <span style="color:#75715e">// false
</span></span></span></code></pre></div><p>Cross-platform, no side effects, no filesystem access needed. Also in 6.0: nested <code>.gitignore</code> pattern support in Finder.</p>
<h2 id="enums-in-the-form-system">Enums in the form system</h2>
<p>Building on 5.4&rsquo;s groundwork, 6.0 takes enum support further. <code>BackedEnum</code> values round-trip through forms and the serializer without custom transformers. The form component understands enum cases as choice options out of the box.</p>
<h2 id="what-60-removes">What 6.0 removes</h2>
<p>The removal list is extensive: the old security system, the <code>Templating</code> component, PHP annotations support (replaced by native attributes), Doctrine Cache support, <code>ContainerAwareTrait</code>. Six years of accumulated <code>@deprecated</code> markers, finally cleaned out.</p>
<p>Apps that took 5.4 deprecation warnings seriously had a clean upgrade path. Apps that didn&rsquo;t had work to do.</p>
<h2 id="tab-completion-was-always-the-gap">Tab completion was always the gap</h2>
<p>The Console component got shell autocompletion, and it&rsquo;s properly integrated: define a <code>complete()</code> method on your command, and Tab in Bash will suggest valid values for options and arguments.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">DeployCommand</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">Command</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">complete</span>(<span style="color:#a6e22e">CompletionInput</span> $input, <span style="color:#a6e22e">CompletionSuggestions</span> $suggestions)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> ($input<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">mustSuggestOptionValuesFor</span>(<span style="color:#e6db74">&#39;env&#39;</span>)) {
</span></span><span style="display:flex;"><span>            $suggestions<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">suggestValues</span>([<span style="color:#e6db74">&#39;prod&#39;</span>, <span style="color:#e6db74">&#39;staging&#39;</span>, <span style="color:#e6db74">&#39;dev&#39;</span>]);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>All built-in Symfony commands got completion too: <code>debug:router</code>, <code>cache:pool:clear</code>, <code>lint:yaml</code>, and about fifteen others. Run <code>bin/console completion bash &gt;&gt; ~/.bashrc</code> and you&rsquo;re done.</p>
<h2 id="messenger-now-with-attributes-and-batch-processing">Messenger, now with attributes and batch processing</h2>
<p>The <code>#[AsMessageHandler]</code> attribute replaces the old <code>MessageHandlerInterface</code>. Less boilerplate, and you can now configure transport affinity and priority directly on the attribute:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">#[AsMessageHandler(fromTransport: &#39;async&#39;, priority: 10)]
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">SendWelcomeEmailHandler</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">__invoke</span>(<span style="color:#a6e22e">UserRegistered</span> $message)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span> { <span style="color:#f92672">...</span> }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The other significant addition: <code>BatchHandlerInterface</code>. When you&rsquo;re inserting a thousand rows, handling messages one by one is wasteful. Batch handlers collect messages and process them in groups. The default batch size is 10, controlled by <code>BatchHandlerTrait::shouldFlush()</code>. The <code>Acknowledger</code> handles individual success and failure within the batch.</p>
<p><code>reset_on_message: true</code> in the Messenger config resets container services between messages. Previously, a Monolog buffer could fill up across message handling and nobody noticed until production. This prevents that class of statefulness bug without requiring manual cleanup.</p>
<h2 id="the-di-container-gets-more-expressive">The DI container gets more expressive</h2>
<p>Three changes that matter in practice.</p>
<p>Union and intersection types now autowire. PHP 8.1 added intersection types, and Symfony 6.0 wires them:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">__construct</span>(
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#a6e22e">NormalizerInterface</span><span style="color:#f92672">&amp;</span><span style="color:#a6e22e">DenormalizerInterface</span> $serializer
</span></span><span style="display:flex;"><span>) {}
</span></span></code></pre></div><p>This works as long as both interfaces point to the same service through autowiring aliases.</p>
<p><code>TaggedIterator</code> and <code>TaggedLocator</code> attributes gained <code>defaultPriorityMethod</code> and <code>defaultIndexMethod</code> options. You no longer need YAML to express ordering or indexing for tagged services:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">__construct</span>(
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">#[TaggedIterator(tag: &#39;app.handler&#39;, defaultPriorityMethod: &#39;getPriority&#39;)]
</span></span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#a6e22e">iterable</span> $handlers,
</span></span><span style="display:flex;"><span>) {}
</span></span></code></pre></div><p><code>SubscribedService</code> (the attribute that replaces the implicit magic of <code>ServiceSubscriberTrait</code>) makes lazy service access explicit and typeable:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">#[SubscribedService]
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">mailer</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">MailerInterface</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">container</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">get</span>(<span style="color:#66d9ef">__METHOD__</span>);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="validation-gets-three-new-tools">Validation gets three new tools</h2>
<p><code>CssColor</code> validates CSS color values in whatever formats you care about: hex, RGB, HSL, named colors, or any mix. Useful for theme config fields where you want to accept <code>#ff0000</code> but not <code>red</code>, or vice versa.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">#[Assert\CssColor(formats: Assert\CssColor::HEX_LONG)]
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#a6e22e">string</span> $brandColor;
</span></span></code></pre></div><p><code>Cidr</code> validates CIDR notation for IPv4 and IPv6, with options to pin the version and constrain the netmask range. Infrastructure tools and network config forms finally have a first-class constraint.</p>
<p>The third addition isn&rsquo;t a new constraint. It&rsquo;s PHP 8.1 nested attributes making existing compound constraints usable without XML. <code>AtLeastOneOf</code>, <code>Collection</code>, <code>All</code>, <code>Sequentially</code>: all of these previously required annotation workarounds. Now they just work as attributes:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">#[Assert\Collection(
</span></span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">fields</span><span style="color:#f92672">:</span> [
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;email&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Assert\Email</span>(),
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;role&#39;</span>  <span style="color:#f92672">=&gt;</span> [<span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Assert\NotBlank</span>(), <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Assert\Choice</span>([<span style="color:#e6db74">&#39;admin&#39;</span>, <span style="color:#e6db74">&#39;user&#39;</span>])],
</span></span><span style="display:flex;"><span>    ]
</span></span><span style="display:flex;"><span>)]
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">array</span> $payload;
</span></span></code></pre></div><h2 id="serializer-cleaned-up">Serializer, cleaned up</h2>
<p>Two things. First, serialization context is now configurable globally instead of being repeated on every <code>serialize()</code> call:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># config/packages/serializer.yaml</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">serializer</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">default_context</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">enable_max_depth</span>: <span style="color:#66d9ef">true</span>
</span></span></code></pre></div><p>Second, the <code>COLLECT_DENORMALIZATION_ERRORS</code> option changes how the serializer handles type errors on deserialization. Instead of throwing on the first problem, it collects all of them and surfaces them through <code>PartialDenormalizationException</code>. If you&rsquo;re writing an API that deserializes request bodies, this is the difference between returning &ldquo;first field that fails&rdquo; and &ldquo;all fields that fail&rdquo; in a single response.</p>
<h2 id="the-string-utilities-nobody-knew-they-needed">The string utilities nobody knew they needed</h2>
<p><code>trimPrefix()</code> and <code>trimSuffix()</code> on the <code>UnicodeString</code> / <code>ByteString</code> classes. Not glamorous, but stripping a known prefix with <code>ltrim()</code> is a subtle footgun: it strips characters, not strings. These are correct:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">Symfony\Component\String\u</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">u</span>(<span style="color:#e6db74">&#39;file-image-001.png&#39;</span>)<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">trimPrefix</span>(<span style="color:#e6db74">&#39;file-&#39;</span>);   <span style="color:#75715e">// &#39;image-001.png&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">u</span>(<span style="color:#e6db74">&#39;report.html.twig&#39;</span>)<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">trimSuffix</span>(<span style="color:#e6db74">&#39;.twig&#39;</span>);     <span style="color:#75715e">// &#39;report.html&#39;
</span></span></span></code></pre></div><p>Also in this release: <code>NilUlid</code> for zero-value ULIDs, <code>perMonth()</code> and <code>perYear()</code> on RateLimiter for when hourly limits don&rsquo;t make sense, and <code>appendToFile()</code> in the Filesystem component gained an optional <code>LOCK_EX</code> parameter for concurrent writers.</p>
<h2 id="debugging-the-environment">Debugging the environment</h2>
<p><code>debug:dotenv</code> is a new console command that shows which <code>.env</code> files were loaded and where each value came from. When you have <code>.env</code>, <code>.env.local</code>, <code>.env.test</code>, and <code>.env.test.local</code> all fighting each other and something is wrong, this command tells you exactly which file won. It only shows up when the Dotenv component is in use, which is the case for any standard Symfony app.</p>
]]></content:encoded></item><item><title>Symfony 5.4 LTS: enum support, route aliases, and the PHP 8.1 bridge</title><link>https://guillaumedelre.github.io/2022/01/10/symfony-5.4-lts-enum-support-route-aliases-and-the-php-8.1-bridge/</link><pubDate>Mon, 10 Jan 2022 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2022/01/10/symfony-5.4-lts-enum-support-route-aliases-and-the-php-8.1-bridge/</guid><description>Part 6 of 11 in &amp;quot;Symfony Releases&amp;quot;: Symfony 5.4 LTS lands native enum support and the full feature set of 6.0, with backward compatibility intact.</description><category>symfony-releases</category><content:encoded><![CDATA[<p>Symfony 5.4 landed November 29, 2021, same day as Symfony 6.0 and one day after PHP 8.1 was released. Not a coincidence.</p>
<p>5.4 is the LTS, and its job is to carry as much of 6.0&rsquo;s feature set as possible while keeping 5.x compatibility intact. It&rsquo;s also the first Symfony release that actually understands PHP 8.1 features.</p>
<h2 id="enum-support">Enum support</h2>
<p>PHP 8.1 introduced native enums. Symfony 5.4 embraces them immediately:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#a6e22e">enum</span> <span style="color:#a6e22e">Status</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">case</span> <span style="color:#a6e22e">Active</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;active&#39;</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">case</span> <span style="color:#a6e22e">Inactive</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;inactive&#39;</span>;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The <code>EnumType</code> form type renders enums as select fields, no custom transformers needed. The validator understands backed enums. The serializer maps enum values to their backing type and back. Three components updated in one shot, which meant migrating codebases from pseudo-enum constants to real PHP 8.1 enums was actually pretty smooth.</p>
<h2 id="security-voter-cache">Security voter cache</h2>
<p>The <code>CacheableVoterInterface</code> lets voters that always abstain on a given attribute signal that to the security system, which can then skip them on subsequent checks. For apps with many voters, the gain on permission checks adds up fast. Small change, noticeable in practice.</p>
<h2 id="messenger-matures-further">Messenger matures further</h2>
<p>Messenger batch processing (handling multiple messages in a single transaction instead of one by one) is now stable. Rate limiting per transport. Dead letter queues get better tooling. After years as &ldquo;experimental&rdquo;, Messenger in 5.4 is finally the async foundation you can bet on for serious workloads.</p>
<h2 id="console-grew-a-tab-key">Console grew a tab key</h2>
<p>Symfony 5.4 ships shell autocompletion for all commands. Press Tab and the shell suggests command names, argument values, and option values. For built-in commands this works out of the box. For custom commands, add a <code>complete()</code> method:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Symfony\Component\Console\Completion\CompletionInput</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Symfony\Component\Console\Completion\CompletionSuggestions</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">complete</span>(<span style="color:#a6e22e">CompletionInput</span> $input, <span style="color:#a6e22e">CompletionSuggestions</span> $suggestions)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> ($input<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">mustSuggestOptionValuesFor</span>(<span style="color:#e6db74">&#39;format&#39;</span>)) {
</span></span><span style="display:flex;"><span>        $suggestions<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">suggestValues</span>([<span style="color:#e6db74">&#39;json&#39;</span>, <span style="color:#e6db74">&#39;xml&#39;</span>, <span style="color:#e6db74">&#39;csv&#39;</span>]);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>No interface required, just the method and Symfony picks it up. The community also went through all built-in commands (<code>debug:router</code>, <code>cache:pool:clear</code>, <code>secrets:remove</code>, <code>lint:twig</code>, and a dozen more) to add completions before the release.</p>
<h2 id="routes-can-be-aliases-now">Routes can be aliases now</h2>
<p>The routing component now supports aliasing: one route can point to another. The obvious use case is renaming a route without breaking anything that still generates URLs with the old name.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># config/routes.yaml</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">admin_dashboard</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">path</span>: <span style="color:#ae81ff">/admin</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># legacy name kept during transition</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">dashboard</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">alias</span>: <span style="color:#ae81ff">admin_dashboard</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">deprecated</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">package</span>: <span style="color:#e6db74">&#39;acme/my-bundle&#39;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">version</span>: <span style="color:#e6db74">&#39;2.3&#39;</span>
</span></span></code></pre></div><p>Generating a URL with <code>dashboard</code> still works, but fires a deprecation notice. Clean rename paths for bundles that need to maintain public route names while moving on.</p>
<h2 id="exceptions-map-to-http-status-codes-in-config">Exceptions map to HTTP status codes in config</h2>
<p>Before 5.4, mapping an exception class to an HTTP status code meant implementing <code>HttpExceptionInterface</code> or writing a listener. Now it&rsquo;s just a YAML entry:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># config/packages/framework.yaml</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">framework</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">exceptions</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">App\Exception\PaymentRequiredException</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">status_code</span>: <span style="color:#ae81ff">402</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">log_level</span>: <span style="color:#ae81ff">warning</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">App\Exception\MaintenanceException</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">status_code</span>: <span style="color:#ae81ff">503</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">log_level</span>: <span style="color:#ae81ff">info</span>
</span></span></code></pre></div><p>The exception doesn&rsquo;t need to implement anything. The framework reads the map, sets the status code, logs at the configured level. Handy for domain exceptions that have no business knowing about HTTP.</p>
<h2 id="two-new-validator-constraints">Two new validator constraints</h2>
<p>5.4 adds <code>Cidr</code> and <code>CssColor</code> to the Validator component.</p>
<p><code>Cidr</code> validates network notation — IP address plus subnet mask — with control over which IP version to accept and bounds on the mask value:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">#[Assert\Cidr(version: 4, netmaskMin: 16, netmaskMax: 28)]
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#a6e22e">string</span> $allowedSubnet;
</span></span></code></pre></div><p><code>CssColor</code> validates that a string is a valid CSS color. Useful for theme editors, CMS config, or any UI that lets users pick colors:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">#[Assert\CssColor(
</span></span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">formats</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">Assert\CssColor</span><span style="color:#f92672">::</span><span style="color:#a6e22e">HEX_LONG</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">message</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;The accent color must be a 6-digit hex value.&#39;</span>,
</span></span><span style="display:flex;"><span>)]
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#a6e22e">string</span> $accentColor;
</span></span></code></pre></div><h2 id="nested-php-attributes-for-validation-constraints">Nested PHP attributes for validation constraints</h2>
<p>Symfony 5.2 added validator constraints as PHP attributes, but PHP 8.0 had a hard limit on nested attributes. Complex constraints like <code>All</code>, <code>Collection</code>, or <code>AtLeastOneOf</code> were impossible to express in attribute syntax alone. PHP 8.1 lifted that restriction, and 5.4 makes the most of it:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Symfony\Component\Validator\Constraints</span> <span style="color:#66d9ef">as</span> <span style="color:#a6e22e">Assert</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">CartItem</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">#[Assert\All([
</span></span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Assert\NotNull</span>(),
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Assert\Range</span>(<span style="color:#a6e22e">min</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">1</span>),
</span></span><span style="display:flex;"><span>    ])]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">array</span> $quantities;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">#[Assert\AtLeastOneOf(
</span></span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">constraints</span><span style="color:#f92672">:</span> [<span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Assert\Email</span>(), <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Assert\Url</span>()],
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">message</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;Must be a valid email or URL.&#39;</span>,
</span></span><span style="display:flex;"><span>    )]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#a6e22e">string</span> $contact;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>No annotation doc-blocks, no XML mapping. Pure PHP 8.1 attributes all the way down.</p>
<h2 id="dependency-injection-three-things-worth-knowing">Dependency injection: three things worth knowing</h2>
<p>Tagged iterators can now be injected into service locators, which previously only accepted explicit service lists. Union type autowiring works when both sides of the union resolve to the same service, which is common with serializer interfaces:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">__construct</span>(
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#a6e22e">NormalizerInterface</span> <span style="color:#f92672">&amp;</span> <span style="color:#a6e22e">DenormalizerInterface</span> $serializer
</span></span><span style="display:flex;"><span>) {}
</span></span></code></pre></div><p><code>#[SubscribedService]</code> replaces the automatic introspection that <code>ServiceSubscriberTrait</code> did implicitly. It&rsquo;s now an explicit attribute on methods, which makes the dependency visible without any magic:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Symfony\Contracts\Service\Attribute\SubscribedService</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">SomeService</span> <span style="color:#66d9ef">implements</span> <span style="color:#a6e22e">ServiceSubscriberInterface</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">#[SubscribedService]
</span></span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">router</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">RouterInterface</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">container</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">get</span>(<span style="color:#66d9ef">__METHOD__</span>);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="messenger-attributes-worker-state-and-service-reset">Messenger: attributes, worker state, and service reset</h2>
<p>Messenger handlers can drop the <code>MessageHandlerInterface</code> in favor of <code>#[AsMessageHandler]</code>, which also lets you bind a handler to a specific transport and set its priority, all without touching YAML:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">#[AsMessageHandler(fromTransport: &#39;async&#39;, priority: 10)]
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">ProcessOrderHandler</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">__invoke</span>(<span style="color:#a6e22e">ProcessOrder</span> $message)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span> { <span style="color:#75715e">/* ... */</span> }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Worker state is now inspectable via <code>WorkerMetadata</code> inside event listeners, useful when you have workers on multiple transports and need to know which one fired a given event.</p>
<p>Long-running workers accumulate state across messages: entity manager buffers, in-memory caches, open connections. The new <code>reset_on_message</code> option takes care of resetting all resettable services between messages:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">framework</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">messenger</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">reset_on_message</span>: <span style="color:#66d9ef">true</span>
</span></span></code></pre></div><h2 id="serializer-collect-errors-instead-of-throwing">Serializer: collect errors instead of throwing</h2>
<p>Deserializing external JSON into a typed DTO used to throw on the very first type mismatch. The <code>COLLECT_DENORMALIZATION_ERRORS</code> option changes that: all type errors get collected into a <code>PartialDenormalizationException</code>, so you can return a proper 400 with a full list of field-level problems:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">try</span> {
</span></span><span style="display:flex;"><span>    $dto <span style="color:#f92672">=</span> $serializer<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">deserialize</span>($request<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getContent</span>(), <span style="color:#a6e22e">OrderDto</span><span style="color:#f92672">::</span><span style="color:#a6e22e">class</span>, <span style="color:#e6db74">&#39;json&#39;</span>, [
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">DenormalizerInterface</span><span style="color:#f92672">::</span><span style="color:#a6e22e">COLLECT_DENORMALIZATION_ERRORS</span> <span style="color:#f92672">=&gt;</span> <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>    ]);
</span></span><span style="display:flex;"><span>} <span style="color:#66d9ef">catch</span> (<span style="color:#a6e22e">PartialDenormalizationException</span> $e) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">json</span>(
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">array_map</span>(<span style="color:#a6e22e">fn</span>($err) <span style="color:#f92672">=&gt;</span> [<span style="color:#e6db74">&#39;path&#39;</span> <span style="color:#f92672">=&gt;</span> $err<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getPath</span>(), <span style="color:#e6db74">&#39;expected&#39;</span> <span style="color:#f92672">=&gt;</span> $err<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getExpectedTypes</span>()], $e<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getErrors</span>()),
</span></span><span style="display:flex;"><span>        <span style="color:#ae81ff">400</span>
</span></span><span style="display:flex;"><span>    );
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The serializer&rsquo;s default context can also be set globally in YAML, so you stop passing the same options on every call.</p>
<h2 id="language-negotiation-out-of-the-box">Language negotiation out of the box</h2>
<p>Two new framework options handle the <code>Accept-Language</code> header without custom listeners:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">framework</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">enabled_locales</span>: [<span style="color:#e6db74">&#39;en&#39;</span>, <span style="color:#e6db74">&#39;fr&#39;</span>, <span style="color:#e6db74">&#39;de&#39;</span>]
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">set_locale_from_accept_language</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">set_content_language_from_locale</span>: <span style="color:#66d9ef">true</span>
</span></span></code></pre></div><p>With this in place, Symfony reads the browser&rsquo;s preferred language, picks the best match from <code>enabled_locales</code>, sets the request locale, and adds a <code>Content-Language</code> header to the response. The <code>{_locale}</code> route attribute still takes precedence when present.</p>
<h2 id="translation-extraction-not-update">Translation: extraction, not update</h2>
<p>The <code>translation:update</code> command is renamed to <code>translation:extract</code>. The old name sticks around as deprecated. The distinction matters: the command never writes to a database, it extracts translatable strings from source files. The new name finally says what it does.</p>
<p><code>lint:xliff</code> also gains a <code>--format=github</code> option that outputs errors as GitHub Actions annotations, so translation lint failures show up as PR review comments instead of getting buried in log output.</p>
<h2 id="controller-shortcuts-pruned">Controller shortcuts pruned</h2>
<p>Three <code>AbstractController</code> shortcuts are deprecated: <code>getDoctrine()</code>, <code>dispatchMessage()</code>, and the generic <code>get()</code> method for pulling arbitrary services from the container. The direction is explicit constructor injection. For <code>getDoctrine()</code> specifically:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">// before
</span></span></span><span style="display:flex;"><span>$em <span style="color:#f92672">=</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getDoctrine</span>()<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getManager</span>();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// after — inject it directly
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">__construct</span>(<span style="color:#66d9ef">private</span> <span style="color:#a6e22e">EntityManagerInterface</span> $em) {}
</span></span></code></pre></div><p><code>Request::get()</code> is also deprecated. It searched route attributes, query string, and POST body in an undocumented order, which was a great way to get surprising results. Use <code>$request-&gt;query-&gt;get()</code>, <code>$request-&gt;request-&gt;get()</code>, or <code>$request-&gt;attributes-&gt;get()</code> and be explicit about where the value comes from.</p>
<h2 id="the-path-utility-class">The Path utility class</h2>
<p>The Filesystem component gets a <code>Path</code> class ported from <code>webmozart/path-util</code>. It handles the awkward cases that <code>dirname()</code> and <code>realpath()</code> fumble:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Symfony\Component\Filesystem\Path</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">Path</span><span style="color:#f92672">::</span><span style="color:#a6e22e">canonicalize</span>(<span style="color:#e6db74">&#39;../config/../config/services.yaml&#39;</span>); <span style="color:#75715e">// &#39;../config/services.yaml&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">Path</span><span style="color:#f92672">::</span><span style="color:#a6e22e">getDirectory</span>(<span style="color:#e6db74">&#39;C:/&#39;</span>);                               <span style="color:#75715e">// &#39;C:/&#39; (dirname() returns &#39;.&#39;)
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">Path</span><span style="color:#f92672">::</span><span style="color:#a6e22e">getLongestCommonBasePath</span>([
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;/var/www/project/src/Controller/FooController.php&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;/var/www/project/src/Controller/BarController.php&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;/var/www/project/src/Entity/User.php&#39;</span>,
</span></span><span style="display:flex;"><span>]);
</span></span><span style="display:flex;"><span><span style="color:#75715e">// &#39;/var/www/project/src&#39;
</span></span></span></code></pre></div><p>Useful whenever your code deals with paths that cross OS boundaries or involve relative segments.</p>
<h2 id="smaller-things-that-add-up">Smaller things that add up</h2>
<p><code>debug:dotenv</code> shows which <code>.env</code> files were loaded and what value each variable resolves to. The first thing you reach for when environment-specific behavior is acting up.</p>
<p>The String component adds <code>trimPrefix()</code> and <code>trimSuffix()</code> for removing known prefixes or suffixes without writing a substr calculation:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#a6e22e">u</span>(<span style="color:#e6db74">&#39;file-image-0001.png&#39;</span>)<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">trimPrefix</span>(<span style="color:#e6db74">&#39;file-&#39;</span>);    <span style="color:#75715e">// &#39;image-0001.png&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">u</span>(<span style="color:#e6db74">&#39;template.html.twig&#39;</span>)<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">trimSuffix</span>(<span style="color:#e6db74">&#39;.twig&#39;</span>);      <span style="color:#75715e">// &#39;template.html&#39;
</span></span></span></code></pre></div><p>DomCrawler gets <code>innerText()</code>, which returns only the direct text of a node, excluding child elements. <code>text()</code> returns everything including nested text; <code>innerText()</code> returns just the node&rsquo;s own content. Small difference, but it matters when scraping.</p>
<p>The RateLimiter component extends its interval support to <code>perMonth()</code> and <code>perYear()</code>, for apps that need to limit events over longer windows: newsletter sends, API quota resets, annual plan limits.</p>
<p>The Finder component now respects <code>.gitignore</code> files in all subdirectories when you call <code>ignoreVCSIgnored(true)</code>, not just the root. Child directory rules override parent rules, exactly like git itself.</p>
<h2 id="the-lts-window">The LTS window</h2>
<p>5.4 gets bug fixes until November 2024 and security fixes until November 2025. The migration from 5.4 to 6.4 (the next LTS) is intentionally smooth: fix the 5.4 deprecation warnings, and the 6.x jump is mechanical.</p>
<p>The deprecation layer in 5.4 points at everything 6.0 removes: the remaining pieces of the old security system, <code>ContainerAwareTrait</code>, and a handful of legacy form and serializer patterns.</p>
]]></content:encoded></item><item><title>Revision pruning with window functions and logarithms, when DQL wasn't enough</title><link>https://guillaumedelre.github.io/2020/09/27/revision-pruning-with-window-functions-and-logarithms-when-dql-wasnt-enough/</link><pubDate>Sun, 27 Sep 2020 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2020/09/27/revision-pruning-with-window-functions-and-logarithms-when-dql-wasnt-enough/</guid><description>How a logarithmic score and ROW_NUMBER() OVER PARTITION BY solved runaway revision table growth after DQL hit its limits.</description><content:encoded><![CDATA[<p>Every content update on the platform creates a revision. That&rsquo;s by design: editors need a history they can roll back to, and the platform needs an audit trail. What nobody anticipated was the rate. Some articles go through forty saves in a single afternoon. A high-traffic piece accumulates hundreds of revisions over its lifetime. After a few months, the revision table had several million rows.</p>
<p>Deleting them naively wasn&rsquo;t an option. &ldquo;Keep the last 50&rdquo; loses all historical context for articles that haven&rsquo;t been touched in a year. &ldquo;Keep one per day&rdquo; loses all the detail for content that&rsquo;s actively being edited. What we needed was a distribution that matched how revisions are actually used: dense coverage for recent history, sparse coverage for old history.</p>
<p>That&rsquo;s a logarithmic distribution. And building it required raw SQL.</p>
<h2 id="why-simple-strategies-fail">Why simple strategies fail</h2>
<p>The appeal of a fixed window is obvious: keep the N most recent revisions and delete the rest. It&rsquo;s one line of SQL and zero math. The problem is that it treats a revision from yesterday and a revision from three years ago as equally valuable, which they aren&rsquo;t. An editor who opens an article from 2017 doesn&rsquo;t need its last 50 versions; they might need one per quarter. An article that shipped this morning might need every save from the past hour.</p>
<p>A time-based strategy (one revision per calendar day) has the opposite problem: it&rsquo;s too aggressive for active content. If an article gets 30 saves between 09:00 and 10:00, all of them except one disappear. That&rsquo;s not history, that&rsquo;s erasure.</p>
<p>Neither strategy can express &ldquo;keep more detail for recent content, less for old content.&rdquo; That relationship is logarithmic.</p>
<h2 id="the-scoring-idea">The scoring idea</h2>
<p>The algorithm assigns each revision a score based on its age, then keeps only one revision per score bucket. The score formula produces high, widely-spaced values for recent revisions and small, clustered values for old ones.</p>
<p>The core expression, simplified, looks like this:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span>(
</span></span><span style="display:flex;"><span>  ln( <span style="color:#66d9ef">EXTRACT</span>(epoch <span style="color:#66d9ef">FROM</span> (now() <span style="color:#f92672">-</span> created_at)) )
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">/</span>
</span></span><span style="display:flex;"><span>  ( <span style="color:#66d9ef">EXTRACT</span>(epoch <span style="color:#66d9ef">FROM</span> (now() <span style="color:#f92672">-</span> created_at)) <span style="color:#f92672">/</span> <span style="color:#ae81ff">6000</span> )
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span><span style="color:#f92672">*</span> ( <span style="color:#ae81ff">1</span> <span style="color:#f92672">/</span> (<span style="color:#66d9ef">EXTRACT</span>(epoch <span style="color:#66d9ef">FROM</span> (now() <span style="color:#f92672">-</span> created_at)) <span style="color:#f92672">/</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">/</span> <span style="color:#ae81ff">1440</span>) )
</span></span><span style="display:flex;"><span><span style="color:#f92672">*</span> <span style="color:#ae81ff">1000</span>
</span></span></code></pre></div><p>Let <code>s</code> be the age in seconds. The formula is roughly <code>ln(s) / s * C</code>, where both the logarithm in the numerator and <code>s</code> in the denominator make the result decrease rapidly as <code>s</code> grows.</p>
<p>Cast to an integer, the effect is this: a revision saved 10 minutes ago might score 8432, one saved 11 minutes ago scores 8431. They&rsquo;re in different buckets. A revision from six months ago scores 2, one from eight months ago also scores 2. Same bucket. The window function then picks the most recent revision from each bucket and discards the rest.</p>
<p>The result is automatic: recent saves are all kept because each has a distinct score; old saves are thinned because many share the same score.</p>
<h2 id="the-dql-attempt-that-didnt-ship">The DQL attempt that didn&rsquo;t ship</h2>
<p>Window functions aren&rsquo;t part of DQL. Doctrine&rsquo;s query language has no syntax for <code>OVER</code>, <code>PARTITION BY</code>, or <code>ROW_NUMBER()</code>. Before going to raw SQL, the team tried to add them.</p>
<p>The <code>FunctionNode</code> approach works for single SQL functions, as we&rsquo;d already seen with FTS. A <code>RowNumber</code> node emitting <code>ROW_NUMBER()</code> is trivial:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">RowNumber</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">FunctionNode</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getSql</span>(<span style="color:#a6e22e">SqlWalker</span> $sqlWalker)<span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#e6db74">&#39;ROW_NUMBER()&#39;</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The harder part is <code>OVER(PARTITION BY ... ORDER BY ...)</code>. An <code>Over</code> function node was drafted, with a custom <code>PartitionByClause</code> AST node to handle the <code>PARTITION BY</code> clause:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Over</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">FunctionNode</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">protected</span> <span style="color:#f92672">?</span><span style="color:#a6e22e">PartitionByClause</span> $partitionByClause <span style="color:#f92672">=</span> <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">protected</span> <span style="color:#f92672">?</span><span style="color:#a6e22e">OrderByClause</span> $orderByClause <span style="color:#f92672">=</span> <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getSql</span>(<span style="color:#a6e22e">SqlWalker</span> $sqlWalker)<span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#e6db74">&#39;OVER(&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">.</span>($this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">partitionByClause</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">?</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">partitionByClause</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">dispatch</span>($sqlWalker)
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">:</span> ($this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">orderByClause</span>
</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">?</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">orderByClause</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">dispatch</span>($sqlWalker)
</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;&#39;</span>))
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">.</span><span style="color:#e6db74">&#39;)&#39;</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>It was never finished. The classes shipped marked <code>@deprecated</code> and &ldquo;NOT TESTED YET&rdquo;. The issue is composability: DQL&rsquo;s <code>FunctionNode</code> works cleanly for functions that appear in WHERE clauses or SELECT expressions. A window function like <code>ROW_NUMBER() OVER (PARTITION BY ...)</code> is a different structure: it appears in a SELECT position, modifies the surrounding query semantics, and requires the parser to handle <code>PARTITION BY</code> as an extension to DQL&rsquo;s grammar. Making that robust enough to trust in production is a significant investment. Going to DBAL and writing the SQL directly took an afternoon.</p>
<h2 id="the-query-layer-by-layer">The query, layer by layer</h2>
<p>The final implementation is three nested queries:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">DELETE</span> <span style="color:#66d9ef">FROM</span> revision
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> iri <span style="color:#f92672">=</span> <span style="color:#f92672">?</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">AND</span> id <span style="color:#66d9ef">NOT</span> <span style="color:#66d9ef">IN</span> (
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">SELECT</span> id <span style="color:#66d9ef">FROM</span> (
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">SELECT</span>
</span></span><span style="display:flex;"><span>            row_number() OVER (
</span></span><span style="display:flex;"><span>                PARTITION <span style="color:#66d9ef">BY</span> num, iri
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> num <span style="color:#66d9ef">DESC</span>, created_at <span style="color:#66d9ef">DESC</span>
</span></span><span style="display:flex;"><span>            ) <span style="color:#66d9ef">AS</span> lines,
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">*</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">FROM</span> (
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">SELECT</span>
</span></span><span style="display:flex;"><span>                (
</span></span><span style="display:flex;"><span>                    ( ln( <span style="color:#66d9ef">EXTRACT</span>(epoch <span style="color:#66d9ef">FROM</span> (now() <span style="color:#f92672">-</span> created_at)) )
</span></span><span style="display:flex;"><span>                      <span style="color:#f92672">/</span> ( <span style="color:#66d9ef">EXTRACT</span>(epoch <span style="color:#66d9ef">FROM</span> (now() <span style="color:#f92672">-</span> created_at)) <span style="color:#f92672">/</span> <span style="color:#ae81ff">6000</span> ) )
</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">*</span> ( <span style="color:#ae81ff">1</span> <span style="color:#f92672">/</span> (<span style="color:#66d9ef">EXTRACT</span>(epoch <span style="color:#66d9ef">FROM</span> (now() <span style="color:#f92672">-</span> created_at)) <span style="color:#f92672">/</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">/</span> <span style="color:#ae81ff">1440</span>) )
</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">*</span> <span style="color:#ae81ff">1000</span>
</span></span><span style="display:flex;"><span>                )::numeric::integer <span style="color:#66d9ef">AS</span> num,
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">*</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">FROM</span> revision
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">WHERE</span> iri <span style="color:#f92672">=</span> <span style="color:#f92672">?</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> created_at <span style="color:#66d9ef">DESC</span>
</span></span><span style="display:flex;"><span>        ) <span style="color:#66d9ef">AS</span> lst
</span></span><span style="display:flex;"><span>    ) <span style="color:#66d9ef">AS</span> rst
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">WHERE</span> lines <span style="color:#f92672">=</span> <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>);
</span></span></code></pre></div><p><strong>Inner query:</strong> computes <code>num</code>, the integer score, for every revision of the given IRI. Rows are sorted by <code>created_at DESC</code> at this stage.</p>
<p><strong>Middle query:</strong> runs <code>ROW_NUMBER() OVER (PARTITION BY num, iri ORDER BY num DESC, created_at DESC)</code>. Within each score bucket (<code>num</code>), revisions are numbered starting from 1 in descending age order. The most recent revision in each bucket gets <code>lines = 1</code>.</p>
<p><strong>Outer filter:</strong> keeps only the <code>lines = 1</code> rows, one revision per score bucket.</p>
<p><strong>DELETE:</strong> removes every revision for this IRI that isn&rsquo;t in the kept set.</p>
<p>The <code>PARTITION BY num, iri</code> is redundant on the IRI (the whole query is already filtered to one IRI), but makes the intent explicit and keeps the logic correct if the query is ever reused in a broader context.</p>
<p>The method is called from a companion query that identifies which IRIs have accumulated more than a threshold of revisions:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getIrisWithMoreRevisionThan</span>(<span style="color:#a6e22e">int</span> $maxRevisionsCount, <span style="color:#a6e22e">int</span> $limit <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>, <span style="color:#f92672">?</span><span style="color:#a6e22e">int</span> $retencyDay <span style="color:#f92672">=</span> <span style="color:#66d9ef">null</span>)<span style="color:#f92672">:</span> <span style="color:#66d9ef">array</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    $queryBuilder <span style="color:#f92672">=</span> $this
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">createQueryBuilder</span>(<span style="color:#e6db74">&#39;revision&#39;</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">select</span>(<span style="color:#e6db74">&#39;revision.iri&#39;</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">groupBy</span>(<span style="color:#e6db74">&#39;revision.iri&#39;</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">having</span>(<span style="color:#e6db74">&#39;COUNT(1) &gt; :maxRevisions&#39;</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">orderBy</span>(<span style="color:#e6db74">&#39;COUNT(1)&#39;</span>, <span style="color:#a6e22e">Order</span><span style="color:#f92672">::</span><span style="color:#a6e22e">Descending</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">value</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">setParameter</span>(<span style="color:#e6db74">&#39;maxRevisions&#39;</span>, $maxRevisionsCount);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// ...
</span></span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">array_column</span>($queryBuilder<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getQuery</span>()<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getResult</span>(), <span style="color:#e6db74">&#39;iri&#39;</span>);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The two methods run together in a scheduled cleanup: find the IRIs over the threshold, prune each one.</p>
<h2 id="wiring-it-to-a-scheduled-command">Wiring it to a scheduled command</h2>
<p>The pruning query doesn&rsquo;t run in a request. It runs behind a Symfony command, called on a schedule.</p>
<p>The command takes a few options to control how aggressively it runs:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">#[AsCommand(&#39;app:purge:revision&#39;, &#39;Remove useless revisions&#39;)]
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">final</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">PurgeRevisionCommand</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">Command</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">protected</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">configure</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        $this
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">addOption</span>(<span style="color:#e6db74">&#39;max-revisions&#39;</span>, <span style="color:#e6db74">&#39;m&#39;</span>, <span style="color:#a6e22e">InputOption</span><span style="color:#f92672">::</span><span style="color:#a6e22e">VALUE_REQUIRED</span>,
</span></span><span style="display:flex;"><span>                <span style="color:#e6db74">&#39;Revision threshold above which an IRI gets pruned&#39;</span>, <span style="color:#ae81ff">30</span>)
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">addOption</span>(<span style="color:#e6db74">&#39;limit&#39;</span>, <span style="color:#e6db74">&#39;l&#39;</span>, <span style="color:#a6e22e">InputOption</span><span style="color:#f92672">::</span><span style="color:#a6e22e">VALUE_REQUIRED</span>,
</span></span><span style="display:flex;"><span>                <span style="color:#e6db74">&#39;Max number of IRIs to process per run&#39;</span>)
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">addOption</span>(<span style="color:#e6db74">&#39;delay&#39;</span>, <span style="color:#e6db74">&#39;w&#39;</span>, <span style="color:#a6e22e">InputOption</span><span style="color:#f92672">::</span><span style="color:#a6e22e">VALUE_REQUIRED</span>,
</span></span><span style="display:flex;"><span>                <span style="color:#e6db74">&#39;Delay in seconds between each IRI&#39;</span>)
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">addOption</span>(<span style="color:#e6db74">&#39;retencyDay&#39;</span>, <span style="color:#e6db74">&#39;r&#39;</span>, <span style="color:#a6e22e">InputOption</span><span style="color:#f92672">::</span><span style="color:#a6e22e">VALUE_OPTIONAL</span>,
</span></span><span style="display:flex;"><span>                <span style="color:#e6db74">&#39;Only process IRIs whose last revision is older than N days&#39;</span>);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">protected</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">execute</span>(<span style="color:#a6e22e">InputInterface</span> $input, <span style="color:#a6e22e">OutputInterface</span> $output)<span style="color:#f92672">:</span> <span style="color:#a6e22e">int</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        $iris <span style="color:#f92672">=</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">revisionRepository</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getIrisWithMoreRevisionThan</span>(
</span></span><span style="display:flex;"><span>            (<span style="color:#a6e22e">int</span>) $input<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getOption</span>(<span style="color:#e6db74">&#39;max-revisions&#39;</span>),
</span></span><span style="display:flex;"><span>            (<span style="color:#a6e22e">int</span>) $input<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getOption</span>(<span style="color:#e6db74">&#39;limit&#39;</span>),
</span></span><span style="display:flex;"><span>            (<span style="color:#a6e22e">int</span>) $input<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getOption</span>(<span style="color:#e6db74">&#39;retencyDay&#39;</span>),
</span></span><span style="display:flex;"><span>        );
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">foreach</span> ($iris <span style="color:#66d9ef">as</span> $iri) {
</span></span><span style="display:flex;"><span>            $totalDeleted <span style="color:#f92672">+=</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">revisionRepository</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">deleteOldRevisionForIri</span>($iri);
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">usleep</span>((<span style="color:#a6e22e">int</span>) $input<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getOption</span>(<span style="color:#e6db74">&#39;delay&#39;</span>) <span style="color:#f92672">*</span> <span style="color:#ae81ff">1_000_000</span>);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">Command</span><span style="color:#f92672">::</span><span style="color:#a6e22e">SUCCESS</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The <code>--delay</code> option is worth noting: on a busy database, hammering a hundred <code>DELETE</code> statements back-to-back can cause lock contention. A small sleep between iterations keeps the purge from competing with production traffic.</p>
<p>The command runs behind two crontab entries with different thresholds:</p>
<pre tabindex="0"><code># Hourly: keep 30 revisions per IRI, process 100 IRIs per run
0 * * * * php bin/console app:purge:revision --max-revisions 30 --limit 100

# Nightly: for content untouched for a year, keep only 3
0 0 * * * php bin/console app:purge:revision --max-revisions 3 --limit 100 --retencyDay 365
</code></pre><p>The two-level strategy matters. The hourly job keeps 30 revisions per IRI, which is a reasonable ceiling for actively-edited content. The nightly job targets only IRIs not updated in over a year and keeps just 3. An article that hasn&rsquo;t moved in twelve months doesn&rsquo;t need thirty versions in its history.</p>
<h2 id="what-it-looks-like-in-practice">What it looks like in practice</h2>
<p>An article saved 200 times will typically keep 20 to 30 revisions after pruning: most of the recent saves, a handful from last month, one or two from each quarter of the previous year. The exact count depends on the age distribution of the saves, not on an arbitrary cap.</p>
<p>An article last edited two years ago might end up with 5 or 6 revisions. Recent edits are all there; the old history is compressed but not gone.</p>
<p>It&rsquo;s not a perfect history. It&rsquo;s a useful one.</p>
<h2 id="the-line-between-dql-and-raw-sql">The line between DQL and raw SQL</h2>
<p>The window function attempt isn&rsquo;t a failure worth hiding. It&rsquo;s a useful data point: <code>FunctionNode</code> works well for scalar functions in WHERE and SELECT positions, but composing a full <code>ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...)</code> expression in DQL is harder than it looks. The grammar extension, the AST nodes, the SQL walker integration: it&rsquo;s a non-trivial amount of code for something that native SQL handles in three lines.</p>
<p>The practical boundary is roughly this: if a PostgreSQL feature maps to a function call with fixed arity, custom DQL works. If it requires new clause syntax (window frames, CTEs, lateral joins), native DBAL is usually the better trade-off.</p>
]]></content:encoded></item><item><title>Symfony 5.0: String, Notifier, and the secrets vault</title><link>https://guillaumedelre.github.io/2020/01/06/symfony-5.0-string-notifier-and-the-secrets-vault/</link><pubDate>Mon, 06 Jan 2020 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2020/01/06/symfony-5.0-string-notifier-and-the-secrets-vault/</guid><description>Part 5 of 11 in &amp;quot;Symfony Releases&amp;quot;: Symfony 5.0 adds a Unicode-aware String component, a multi-channel Notifier, and a built-in secrets vault.</description><category>symfony-releases</category><content:encoded><![CDATA[<p>Symfony 5.0 released November 21, 2019, same day as 4.4. Where 4.4 is about stability and a long support window, 5.0 is the next chapter: no deprecated code, PHP 7.2.5 minimum, and a handful of new components that finally address gaps that had piled up for years.</p>
<h2 id="the-string-component">The String component</h2>
<p>PHP&rsquo;s string handling is famously scattered: prefix-style functions here (<code>str_</code>), suffix-style there (<code>strpos</code>), inconsistent encoding support, and nothing object-oriented in sight. The String component wraps all of this into a fluent, unicode-aware object API:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Symfony\Component\String\UnicodeString</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>$str <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">UnicodeString</span>(<span style="color:#e6db74">&#39;  Hello World  &#39;</span>);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">echo</span> $str<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">trim</span>()<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">lower</span>()<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">replace</span>(<span style="color:#e6db74">&#39; &#39;</span>, <span style="color:#e6db74">&#39;-&#39;</span>); <span style="color:#75715e">// hello-world
</span></span></span></code></pre></div><p>The practical addition is the <code>Slugger</code>, a locale-aware slug generator that actually handles accented characters correctly:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span>$slug <span style="color:#f92672">=</span> $slugger<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">slug</span>(<span style="color:#e6db74">&#39;L\&#39;été à Montréal&#39;</span>); <span style="color:#75715e">// l-ete-a-montreal
</span></span></span></code></pre></div><p>Before, you&rsquo;d pull in a third-party library or write your own. Now it ships with FrameworkBundle, available by default.</p>
<h2 id="notifier">Notifier</h2>
<p>Email is handled by Mailer. SMS, push notifications, chat messages: no first-party story, until now. The Notifier component adds one: a unified interface over dozens of channels and providers.</p>
<p>The same notification can hit Slack, trigger an SMS via Twilio, or end up as a push notification, all configured through DSNs. Adding a new channel is a config change, not a code change.</p>
<h2 id="secrets-vault">Secrets vault</h2>
<p>Storing secrets in <code>.env</code> files works, but the values are plain text, shared environments are a pain, and there&rsquo;s no native way to encrypt anything at rest.</p>
<p>Symfony 5.0 adds a <code>secrets:</code> command family and a vault mechanism. Secrets are encrypted with a key pair stored outside the repository. The encrypted files get committed; the decrypt key does not. In production, the key comes in as an environment variable or gets injected from a secret manager.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>php bin/console secrets:set DATABASE_PASSWORD
</span></span><span style="display:flex;"><span>php bin/console secrets:decrypt-to-local --force
</span></span></code></pre></div><p>Not a full-blown secrets management solution, but a real step up from a plain <code>.env</code> file sitting unencrypted in your repo.</p>
<h2 id="mailer-gets-a-notification-layer">Mailer gets a notification layer</h2>
<p>The Mailer component arrived in 4.4. What 5.0 adds on top is the <code>NotificationEmail</code> — a pre-styled, responsive email built on Foundation for Emails, with an explicit API for importance levels and call-to-action buttons:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Symfony\Bridge\Twig\Mime\NotificationEmail</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>$email <span style="color:#f92672">=</span> (<span style="color:#66d9ef">new</span> <span style="color:#a6e22e">NotificationEmail</span>())
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">from</span>(<span style="color:#e6db74">&#39;alerts@example.com&#39;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">to</span>(<span style="color:#e6db74">&#39;admin@example.com&#39;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">subject</span>(<span style="color:#e6db74">&#39;Disk usage critical&#39;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">markdown</span>(<span style="color:#e6db74">&#39;The disk on **prod-01** is at 94%. Check it now.&#39;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">action</span>(<span style="color:#e6db74">&#39;Open dashboard&#39;</span>, <span style="color:#e6db74">&#39;https://example.com/servers&#39;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">importance</span>(<span style="color:#a6e22e">NotificationEmail</span><span style="color:#f92672">::</span><span style="color:#a6e22e">IMPORTANCE_URGENT</span>);
</span></span></code></pre></div><p>No template to write, no inline CSS to wrestle with. For transactional alerts, billing notifications, and system emails, it covers 80% of what you need without touching anything.</p>
<h2 id="lazy-firewalls-and-the-caching-problem">Lazy firewalls and the caching problem</h2>
<p>Every stateful firewall in Symfony loads the user from session on every request, whether the action needs it or not. Which means any response is uncacheable by default, even for pages that never touch <code>$this-&gt;getUser()</code>.</p>
<p>5.0 adds <code>lazy</code> mode for firewalls, which defers session access until the code actually calls <code>is_granted()</code> or reaches for the user token:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># config/packages/security.yaml</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">security</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">firewalls</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">main</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">pattern</span>: <span style="color:#ae81ff">^/</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">anonymous</span>: <span style="color:#ae81ff">lazy</span>
</span></span></code></pre></div><p>Pages that don&rsquo;t need the user become cacheable again. New projects get this by default via the Flex recipe; existing ones need a one-line config change.</p>
<h2 id="password-migrations-without-the-big-bang">Password migrations without the big bang</h2>
<p>Migrating a live app from bcrypt to argon2id used to mean forcing a password reset on every user. The <code>PasswordUpgraderInterface</code> makes it gradual: at login, Symfony checks whether the stored hash matches the current algorithm. If not, it rehashes on the spot and calls your upgrader to save it:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">// src/Repository/UserRepository.php
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">UserRepository</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">ServiceEntityRepository</span> <span style="color:#66d9ef">implements</span> <span style="color:#a6e22e">PasswordUpgraderInterface</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">upgradePassword</span>(<span style="color:#a6e22e">UserInterface</span> $user, <span style="color:#a6e22e">string</span> $newHashedPassword)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        $user<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">setPassword</span>($newHashedPassword);
</span></span><span style="display:flex;"><span>        $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getEntityManager</span>()<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">flush</span>();
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Pair that with <code>algorithm: auto</code> in the encoder config, and old hashes migrate silently as users log in. No migration script, no downtime, no user friction.</p>
<h2 id="errorhandler-replaces-debug">ErrorHandler replaces Debug</h2>
<p>The Debug component is gone. Its replacement, ErrorHandler, does the same job (converting PHP errors to exceptions, showing nice error pages) but without requiring Twig. For API apps that never render HTML, that matters: ErrorHandler generates errors in the format of the request (JSON, XML, plain text) following RFC 7807:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;title&#34;</span>: <span style="color:#e6db74">&#34;Not Found&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;status&#34;</span>: <span style="color:#ae81ff">404</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;detail&#34;</span>: <span style="color:#e6db74">&#34;Sorry, the page you are looking for could not be found&#34;</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The routing config moves from <code>TwigBundle</code> to <code>FrameworkBundle</code>, and that&rsquo;s the only migration step for most projects. One line, done.</p>
<h2 id="event-listeners-finally-less-verbose">Event listeners, finally less verbose</h2>
<p>Registering a kernel event listener used to mean explicitly naming the event in the service tag. Symfony 5.0 infers it from the method signature:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">// No tag configuration needed beyond kernel.event_listener
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">final</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">SecurityListener</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">onKernelRequest</span>(<span style="color:#a6e22e">RequestEvent</span> $event)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Symfony reads the type hint and figures out the event
</span></span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># config/services.yaml</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">App\EventListener\SecurityListener</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">tags</span>: [<span style="color:#ae81ff">kernel.event_listener]</span>
</span></span></code></pre></div><p>Use <code>__invoke()</code> and it works the same way. Bulk-register a whole directory of listeners with one resource block, and Symfony figures out which event each one handles.</p>
<h2 id="httpclient-grows-up">HttpClient grows up</h2>
<p>The HttpClient component arrived in 4.4 as stable. 5.0 adds a few useful things on top:</p>
<p>NTLM authentication for corporate environments, conditional buffering via a callback (buffer large responses only when the content-type matches), a <code>max_duration</code> option that caps the total request time regardless of network conditions, and <code>toStream()</code> to turn any response into a standard PHP stream for code that expects <code>fread()</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span>$response <span style="color:#f92672">=</span> $client<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">request</span>(<span style="color:#e6db74">&#39;GET&#39;</span>, <span style="color:#e6db74">&#39;https://api.example.com/large-export&#39;</span>, [
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;max_duration&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#ae81ff">30.0</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;buffer&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#a6e22e">fn</span>(<span style="color:#66d9ef">array</span> $headers)<span style="color:#f92672">:</span> <span style="color:#a6e22e">bool</span> <span style="color:#f92672">=&gt;</span> <span style="color:#a6e22e">str_contains</span>($headers[<span style="color:#e6db74">&#39;content-type&#39;</span>][<span style="color:#ae81ff">0</span>] <span style="color:#f92672">??</span> <span style="color:#e6db74">&#39;&#39;</span>, <span style="color:#e6db74">&#39;json&#39;</span>),
</span></span><span style="display:flex;"><span>]);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Stream it instead of loading it all into memory
</span></span></span><span style="display:flex;"><span>$stream <span style="color:#f92672">=</span> $response<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">toStream</span>();
</span></span></code></pre></div><p>The client also got full interoperability with PSR-18 and HTTPlug v1/v2, so any library that depends on those abstractions just works with it.</p>
<h2 id="what-50-removes">What 5.0 removes</h2>
<p>5.0 drops everything deprecated in 4.4. The most notable:</p>
<ul>
<li><code>WebServerBundle</code> (use <code>symfony server:start</code> from the CLI tool instead)</li>
<li>The old security system&rsquo;s <code>AnonymousToken</code> (replaced by <code>NullToken</code>)</li>
<li>Old form event names</li>
<li>Symfony&rsquo;s internal ClassLoader</li>
<li>The Debug component (replaced by ErrorHandler)</li>
</ul>
<p>If you ran your 4.4 app with deprecation notices active and fixed the warnings, upgrading to 5.0 requires no code changes.</p>
]]></content:encoded></item><item><title>Symfony 4.4 LTS: HttpClient, Mailer, Messenger, and the features that stayed</title><link>https://guillaumedelre.github.io/2020/01/04/symfony-4.4-lts-httpclient-mailer-messenger-and-the-features-that-stayed/</link><pubDate>Sat, 04 Jan 2020 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2020/01/04/symfony-4.4-lts-httpclient-mailer-messenger-and-the-features-that-stayed/</guid><description>Part 4 of 11 in &amp;quot;Symfony Releases&amp;quot;: Symfony 4.4 LTS ships a mature HttpClient and production-ready Messenger — the HTTP and async layers Symfony was missing.</description><category>symfony-releases</category><content:encoded><![CDATA[<p>Symfony 4.4 and 5.0 both landed November 21, 2019. 4.4 is the LTS: same feature set as 5.0, deprecation layer baked in, and a long support window for teams that can&rsquo;t follow every release.</p>
<p>The feature worth singling out arrived in 4.2 and matured through 4.3 and 4.4: <code>HttpClient</code>.</p>
<h2 id="httpclient">HttpClient</h2>
<p>PHP&rsquo;s built-in HTTP options (<code>file_get_contents</code> with stream contexts, cURL, Guzzle) each have their own model, their own quirks, and their own abstraction cost. Symfony 4.2 introduced <code>HttpClient</code>, a first-party HTTP client with one API over multiple transports.</p>
<p>The interface is clean:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span>$response <span style="color:#f92672">=</span> $client<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">request</span>(<span style="color:#e6db74">&#39;GET&#39;</span>, <span style="color:#e6db74">&#39;https://api.example.com/users&#39;</span>);
</span></span><span style="display:flex;"><span>$users <span style="color:#f92672">=</span> $response<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">toArray</span>();
</span></span></code></pre></div><p>The implementation is async by default. Responses are lazy: the network request doesn&rsquo;t happen until you actually read the response. Multiple requests can be initiated and resolved as data arrives, no threads or callbacks needed.</p>
<p>The built-in mock transport (<code>MockHttpClient</code>) makes testing HTTP calls painless without spinning up servers or patching global functions.</p>
<h2 id="mailer">Mailer</h2>
<p>Also stabilized in 4.4: the <code>Mailer</code> component, replacing <code>SwiftMailerBundle</code> as the recommended email solution. Transport is configured via DSN:</p>
<pre tabindex="0"><code>MAILER_DSN=smtp://user:pass@smtp.example.com:587
</code></pre><p>The DSN approach means switching providers (Mailgun, Postmark, SES, local SMTP) is a config change, not a code change. Email testing uses a spooler by default in non-production environments.</p>
<h2 id="messenger-matures">Messenger matures</h2>
<p>The Messenger component landed in 3.4 as experimental. By 4.4 it&rsquo;s stable and battle-tested: async message handling with retry logic, failure transport, and adapters for AMQP, Redis, Doctrine, and in-process transports.</p>
<p>The pattern it enables (handle a request synchronously, dispatch work asynchronously, retry on failure) replaces a class of Gearman/RabbitMQ setups that required separate libraries and significant configuration.</p>
<h2 id="the-lts-window">The LTS window</h2>
<p>4.4 is supported for bugs until November 2022 and security fixes until November 2023. If you&rsquo;re on 4.x and want stability, this is a comfortable place to land. The deprecation warnings it introduces point directly at what 5.0 will require.</p>
<h2 id="the-messenger-component-from-experimental-to-production">The Messenger component, from experimental to production</h2>
<p>Messenger arrived in 4.1 as an experiment. The concept was simple: dispatch a message object to a bus, handle it immediately or route it to a transport for async processing. By 4.3 and 4.4, the experiment had become infrastructure.</p>
<p>The 4.3 release added a dedicated failure transport. When a message fails after all retry attempts, it goes somewhere recoverable rather than just disappearing:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">framework</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">messenger</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">failure_transport</span>: <span style="color:#ae81ff">failed</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">transports</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">async</span>: <span style="color:#e6db74">&#39;%env(MESSENGER_TRANSPORT_DSN)%&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">failed</span>: <span style="color:#e6db74">&#39;doctrine://default?queue_name=failed&#39;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">routing</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">App\Message\SendEmail</span>: <span style="color:#ae81ff">async</span>
</span></span></code></pre></div><p>Messages that land in <code>failed</code> can be inspected and retried manually. Before this, failed messages were a log entry and a headache. After this, they&rsquo;re a queue you can actually work with.</p>
<h2 id="event-dispatching-finally-using-objects-properly">Event dispatching, finally using objects properly</h2>
<p>Since the beginning, Symfony&rsquo;s event system used string event names as the primary identifier. You&rsquo;d define <code>OrderEvents::NEW_ORDER = 'order.new_order'</code>, listen on that string, and pass the event object as a secondary parameter.</p>
<p>4.3 flipped this around. The event object comes first, and the event name becomes optional:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">// Before
</span></span></span><span style="display:flex;"><span>$dispatcher<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">dispatch</span>(<span style="color:#a6e22e">OrderEvents</span><span style="color:#f92672">::</span><span style="color:#a6e22e">NEW_ORDER</span>, $event);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// 4.3+
</span></span></span><span style="display:flex;"><span>$dispatcher<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">dispatch</span>($event);
</span></span></code></pre></div><p>Omit the name and Symfony uses the class name as the identifier. Listeners and subscribers can now reference the class directly:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getSubscribedEvents</span>()<span style="color:#f92672">:</span> <span style="color:#66d9ef">array</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> [
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">OrderPlacedEvent</span><span style="color:#f92672">::</span><span style="color:#a6e22e">class</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;onOrderPlaced&#39;</span>,
</span></span><span style="display:flex;"><span>    ];
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The HttpKernel events were renamed accordingly: <code>GetResponseEvent</code> became <code>RequestEvent</code>, <code>FilterResponseEvent</code> became <code>ResponseEvent</code>. The old names stayed as aliases through 4.x.</p>
<h2 id="vardumper-gets-a-server">VarDumper gets a server</h2>
<p><code>dump()</code> in a controller that returns JSON means your debug output gets injected straight into the response body. For API development, that&rsquo;s annoying enough to make people disable dumping entirely.</p>
<p>4.1 added a VarDumper server that captures dumps separately:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>bin/console server:dump
</span></span></code></pre></div><p>Configure the dump destination in <code>config/packages/dev/debug.yaml</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">debug</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">dump_destination</span>: <span style="color:#e6db74">&#34;tcp://%env(VAR_DUMPER_SERVER)%&#34;</span>
</span></span></code></pre></div><p>Now <code>dump()</code> in your API controller sends data to the server&rsquo;s console instead of polluting the response. The server shows the dump alongside its source file, the HTTP request that triggered it, and the timestamp.</p>
<h2 id="varexporter-for-when-var_export-fails-you">VarExporter, for when <code>var_export()</code> fails you</h2>
<p><code>var_export()</code> has two problems: it ignores serialization semantics and its output isn&rsquo;t PSR-2 compliant. The 4.2 VarExporter component fixes both.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span>$exported <span style="color:#f92672">=</span> <span style="color:#a6e22e">VarExporter</span><span style="color:#f92672">::</span><span style="color:#a6e22e">export</span>([<span style="color:#ae81ff">123</span>, [<span style="color:#e6db74">&#39;abc&#39;</span>, <span style="color:#66d9ef">true</span>]]);
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Returns:
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">// [
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">//     123,
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">//     [
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">//         &#39;abc&#39;,
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">//         true,
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">//     ],
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">// ]
</span></span></span></code></pre></div><p>More importantly, it correctly handles objects implementing <code>Serializable</code>, <code>__sleep</code>, and <code>__wakeup</code>. Where <code>var_export()</code> silently drops serialization methods and exports raw properties, VarExporter produces code that calls the same hooks <code>unserialize()</code> would. The practical use case is cache warming: generating PHP files that can be loaded by OPcache without re-executing expensive computations.</p>
<h2 id="passwords-that-check-against-breach-databases">Passwords that check against breach databases</h2>
<p>The <code>NotCompromisedPassword</code> constraint arrived in 4.3. It checks submitted passwords against haveibeenpwned.com&rsquo;s breach database without sending the actual password anywhere.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Symfony\Component\Validator\Constraints</span> <span style="color:#66d9ef">as</span> <span style="color:#a6e22e">Assert</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">User</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">#[Assert\NotCompromisedPassword]
</span></span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#a6e22e">string</span> $plainPassword;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The implementation uses k-anonymity: SHA-1 hash the password, send only the first five characters to the API, get back all matching hashes, check locally. The password never leaves your server. For registration forms, adding this constraint is one line and a genuinely useful security signal.</p>
<h2 id="workflow-gets-context">Workflow gets context</h2>
<p>The Workflow component existed before 4.x, but 4.3 added context propagation: the ability to pass arbitrary data through a transition and access it in listeners.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span>$workflow<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">apply</span>($article, <span style="color:#e6db74">&#39;publish&#39;</span>, [
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;user&#39;</span> <span style="color:#f92672">=&gt;</span> $user<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getUsername</span>(),
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;reason&#39;</span> <span style="color:#f92672">=&gt;</span> $request<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">request</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">get</span>(<span style="color:#e6db74">&#39;reason&#39;</span>),
</span></span><span style="display:flex;"><span>]);
</span></span></code></pre></div><p>The context arrives in <code>TransitionEvent</code> and gets stored alongside the marking. For audit trails, this is the difference between knowing a transition happened and knowing who triggered it and why. You can also inject context from a subscriber without touching every <code>apply()</code> call, which is handy for cross-cutting concerns like timestamps or current user.</p>
<h2 id="the-autowiring-got-smarter">The autowiring got smarter</h2>
<p>4.2 added binding by type and name together. Before, you could bind by type (<code>LoggerInterface</code>) or by name (<code>$logger</code>), but not both at once. That caused problems when a service needs two different implementations of the same interface:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">services</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">_defaults</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">bind</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">Psr\Log\LoggerInterface $orderLogger</span>: <span style="color:#e6db74">&#39;@monolog.logger.orders&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">Psr\Log\LoggerInterface $paymentLogger</span>: <span style="color:#e6db74">&#39;@monolog.logger.payments&#39;</span>
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">OrderService</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">__construct</span>(
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">private</span> <span style="color:#a6e22e">LoggerInterface</span> $orderLogger,   <span style="color:#75715e">// gets monolog.logger.orders
</span></span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">private</span> <span style="color:#a6e22e">LoggerInterface</span> $paymentLogger, <span style="color:#75715e">// gets monolog.logger.payments
</span></span></span><span style="display:flex;"><span>    ) {}
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The match requires both type and argument name to align, so there&rsquo;s no risk of accidentally injecting the wrong logger.</p>
<h2 id="errorhandler-replaces-the-debug-component">ErrorHandler replaces the Debug component</h2>
<p>The <code>Debug</code> component, unchanged since 2013, had an awkward dependency on TwigBundle even for API-only apps. Any uncaught exception in a JSON API would render an HTML error page unless you wrote custom exception listeners.</p>
<p>4.4 extracts this into a dedicated <code>ErrorHandler</code> component. For non-HTML requests, error responses now follow RFC 7807 out of the box:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;title&#34;</span>: <span style="color:#e6db74">&#34;Not Found&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;status&#34;</span>: <span style="color:#ae81ff">404</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;detail&#34;</span>: <span style="color:#e6db74">&#34;Sorry, the page you are looking for could not be found&#34;</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>No Twig required. The format follows the <code>Accept</code> header: JSON for JSON requests, XML for XML requests. To customize further, you provide a normalizer via the Serializer component rather than a Twig template.</p>
<h2 id="php-74-preloading-wired-in-automatically">PHP 7.4 preloading, wired in automatically</h2>
<p>PHP 7.4 introduced OPcache preloading: load files into shared memory before any requests arrive, so they&rsquo;re available as compiled opcodes from the very first request. The practical gain is 30-50% faster response times with no code changes.</p>
<p>The catch is configuration: you need to specify exactly which files to preload in <code>php.ini</code>. Symfony 4.4 generates that file automatically in the cache directory:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#75715e">; php.ini</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">opcache.preload</span><span style="color:#f92672">=</span><span style="color:#e6db74">/path/to/project/var/cache/prod/App_KernelProdContainer.preload.php</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">opcache.preload_user</span><span style="color:#f92672">=</span><span style="color:#e6db74">www-data</span>
</span></span></code></pre></div><p>Run <code>cache:warmup</code> in production and point OPcache at the generated file. Symfony preloads the container, compiled routes, and Twig templates: the files that are read on every request and never change between deploys.</p>
<h2 id="console-return-codes-and-no_color">Console: return codes and NO_COLOR</h2>
<p>Two small things in 4.4 that honestly should have existed earlier. Commands that don&rsquo;t return an integer from <code>execute()</code> now trigger a deprecation warning. In 5.0, the return type becomes mandatory. Returning <code>0</code> for success, non-zero for failure: standard Unix behavior, and it makes integration with process supervisors and CI pipelines unambiguous.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">protected</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">execute</span>(<span style="color:#a6e22e">InputInterface</span> $input, <span style="color:#a6e22e">OutputInterface</span> $output)<span style="color:#f92672">:</span> <span style="color:#a6e22e">int</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// ...
</span></span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">Command</span><span style="color:#f92672">::</span><span style="color:#a6e22e">SUCCESS</span>; <span style="color:#75715e">// = 0
</span></span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The second: <code>NO_COLOR</code> environment variable support, following the convention from no-color.org. Set it and every Symfony console command drops ANSI escape codes regardless of what the terminal claims to support. Useful for CI environments that capture output as text and then choke on color codes embedded in logs.</p>
]]></content:encoded></item><item><title>Symfony 4.0: Flex and the end of the Standard Edition</title><link>https://guillaumedelre.github.io/2018/01/14/symfony-4.0-flex-and-the-end-of-the-standard-edition/</link><pubDate>Sun, 14 Jan 2018 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2018/01/14/symfony-4.0-flex-and-the-end-of-the-standard-edition/</guid><description>Part 3 of 11 in &amp;quot;Symfony Releases&amp;quot;: Symfony 4.0 killed the Standard Edition and introduced Flex: a microframework that grows only as far as you actually need.</description><category>symfony-releases</category><content:encoded><![CDATA[<p>Symfony 4.0 released November 30, 2017, same day as 3.4. The shared release date is pretty much the only thing they have in common.</p>
<p>4.0 is a different philosophy. The Symfony Standard Edition, the monolithic starting point that bundled everything and left you to remove what you didn&rsquo;t need, is gone. In its place: a microframework that grows.</p>
<h2 id="flex">Flex</h2>
<p>Symfony Flex is a Composer plugin that changes how you install Symfony packages. Before Flex, adding a bundle meant: install via Composer, register in <code>AppKernel.php</code>, add config to <code>config/</code>, update routing if needed. Four steps, all manual.</p>
<p>With Flex, installing a package runs a &ldquo;recipe&rdquo;: a set of automated steps that registers the bundle, generates a config skeleton, and wires routing. Installing Doctrine:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>composer require symfony/orm-pack
</span></span></code></pre></div><p>That command installs the packages, creates <code>config/packages/doctrine.yaml</code>, adds the env variable stubs to <code>.env</code>, and registers everything. One command, zero manual steps.</p>
<p>Recipes are community-contributed and hosted on a central server. Quality varies, but for major packages they&rsquo;re maintained alongside the packages themselves.</p>
<h2 id="the-new-project-structure">The new project structure</h2>
<p>The Standard Edition layout (<code>app/</code>, <code>src/</code>, <code>web/</code>) is replaced by a leaner structure. Config lives in <code>config/</code> split by environment. The public directory is now <code>public/</code>, not <code>web/</code>. The kernel is smaller. Controllers are plain classes, no <code>extends Controller</code> required.</p>
<p>More importantly, the default <code>services.yaml</code> uses the 3.3 autowiring defaults that make explicit service configuration mostly unnecessary. New projects start minimal and grow by adding what they actually need.</p>
<h2 id="services-private-by-default">Services private by default</h2>
<p>4.0&rsquo;s biggest BC break for existing apps: all services are private by default. You can&rsquo;t fetch a service from the container directly anymore, it has to be injected. This is the right call from a DI perspective, but it breaks anything that used <code>$this-&gt;get('service_id')</code> in controllers.</p>
<p>The migration path is <code>AbstractController</code>, which provides the same convenience methods through lazy service locators rather than raw container access.</p>
<h2 id="what-was-removed">What was removed</h2>
<p>4.0 is clean because it removes everything deprecated in 3.4:</p>
<ul>
<li>The old form events, the old security interfaces, the old configuration formats</li>
<li>Support for PHP &lt; 7.1.3</li>
<li>The ClassLoader component</li>
<li>ACL support from SecurityBundle</li>
</ul>
<p>The removals are aggressive. Apps that skipped fixing their 3.4 deprecations will have a rough time. Apps that did the cleanup beforehand have a smooth path.</p>
<p>Symfony 4.0 is the reset the framework needed. The Standard Edition had accumulated years of &ldquo;this is how it&rsquo;s done&rdquo; that Flex sweeps away in one shot.</p>
<h2 id="environment-variables-that-actually-know-their-type">Environment variables that actually know their type</h2>
<p>Before 3.4 and 4.0, environment variables were strings. Always. Trying to inject <code>DATABASE_PORT</code> into an <code>int</code> parameter would silently break or blow up with a type error. The fix was ugly: cast in PHP or avoid typed parameters entirely.</p>
<p>4.0 ships with env var processors that handle the conversion at the container level:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">parameters</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">app.connection.port</span>: <span style="color:#e6db74">&#39;%env(int:DATABASE_PORT)%&#39;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">app.debug_mode</span>: <span style="color:#e6db74">&#39;%env(bool:APP_DEBUG)%&#39;</span>
</span></span></code></pre></div><p>Beyond casting, processors can decode base64, load from files, parse JSON, or resolve container parameters within a value. The <code>json:file:</code> combination turned into a clean pattern for loading secrets from mounted files in containerized deployments:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">parameters</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">env(SECRETS_FILE)</span>: <span style="color:#e6db74">&#39;/run/secrets/app.json&#39;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">app.secrets</span>: <span style="color:#e6db74">&#39;%env(json:file:SECRETS_FILE)%&#39;</span>
</span></span></code></pre></div><p>You can also write custom processors by implementing <code>EnvVarProcessorInterface</code> and tagging the service. Looks like overkill until the day you need it.</p>
<h2 id="tagged-services-without-the-boilerplate">Tagged services without the boilerplate</h2>
<p>Before 4.0, collecting all services with a given tag into one service meant writing a compiler pass. Forty lines of PHP to say &ldquo;give me everything tagged <code>app.handler</code>.&rdquo;</p>
<p>3.4 introduced the <code>!tagged</code> YAML shorthand, and 4.0 carries it forward:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">services</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">App\HandlerCollection</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">arguments</span>: [!<span style="color:#ae81ff">tagged app.handler]</span>
</span></span></code></pre></div><p>The collection is lazy by default when type-hinted as <code>iterable</code>, so services aren&rsquo;t instantiated until you actually iterate. This replaced a whole category of compiler passes that existed for the sole purpose of building lists.</p>
<h2 id="php-as-a-configuration-format">PHP as a configuration format</h2>
<p>YAML has been the default for so long it feels required. It isn&rsquo;t. 4.0 ships with PHP-based configuration using a fluent interface:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">// config/services.php
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">return</span> <span style="color:#66d9ef">function</span> (<span style="color:#a6e22e">ContainerConfigurator</span> $container) {
</span></span><span style="display:flex;"><span>    $services <span style="color:#f92672">=</span> $container<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">services</span>()
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">defaults</span>()
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">autowire</span>()
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">autoconfigure</span>();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    $services<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">load</span>(<span style="color:#e6db74">&#39;App\\&#39;</span>, <span style="color:#e6db74">&#39;../src/&#39;</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">exclude</span>(<span style="color:#e6db74">&#39;../src/{Entity,Repository}&#39;</span>);
</span></span><span style="display:flex;"><span>};
</span></span></code></pre></div><p>Same approach works for routes. The practical benefit: IDE autocompletion, type checking, and actual PHP logic in configuration without the <code>%</code> parameter interpolation syntax. YAML isn&rsquo;t going anywhere, but now you have a choice.</p>
<h2 id="argon2i-because-bcrypt-was-already-aging">Argon2i, because bcrypt was already aging</h2>
<p>Symfony 3.4/4.0 added Argon2i support, winner of the 2015 Password Hashing Competition. Configuration is one line:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">security</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">encoders</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">App\Entity\User</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">algorithm</span>: <span style="color:#ae81ff">argon2i</span>
</span></span></code></pre></div><p>Argon2i is built into PHP 7.2+ and available via the sodium extension on earlier versions. Like bcrypt, it&rsquo;s self-salting, no need to manage salt columns. Unlike bcrypt, it&rsquo;s designed to resist GPU-based attacks with configurable memory usage. If you&rsquo;re starting a new project on 4.0, there&rsquo;s really no reason to reach for bcrypt.</p>
<h2 id="the-form-layer-gets-a-bootstrap-4-theme">The form layer gets a Bootstrap 4 theme</h2>
<p>The existing Bootstrap 3 form theme has been around since Symfony 2.x. Bootstrap 4 ships as a first-class option in 4.0:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">twig</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">form_themes</span>: [<span style="color:#e6db74">&#39;bootstrap_4_layout.html.twig&#39;</span>]
</span></span></code></pre></div><p>More useful in practice: the <code>tel</code> and <code>color</code> HTML5 input types are now available as <code>TelType</code> and <code>ColorType</code> form types. Before, you had to write custom types or override raw widgets for those.</p>
<h2 id="local-service-binding">Local service binding</h2>
<p>Global <code>_defaults</code> bindings apply to all services. Sometimes you need a binding scoped to a specific class or namespace, like different logger instances for different subsystems.</p>
<p>4.0 supports per-service <code>bind</code> for exactly that:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">services</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">App\Service\OrderService</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">bind</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">Psr\Log\LoggerInterface</span>: <span style="color:#e6db74">&#39;@monolog.logger.orders&#39;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">App\Service\PaymentService</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">bind</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">Psr\Log\LoggerInterface</span>: <span style="color:#e6db74">&#39;@monolog.logger.payments&#39;</span>
</span></span></code></pre></div><p>Same interface, two different implementations, no factory, no extra configuration. Small feature, but it kills a whole category of awkward workarounds.</p>
]]></content:encoded></item><item><title>Symfony 3.4 LTS: the bridge you actually want to cross</title><link>https://guillaumedelre.github.io/2018/01/12/symfony-3.4-lts-the-bridge-you-actually-want-to-cross/</link><pubDate>Fri, 12 Jan 2018 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2018/01/12/symfony-3.4-lts-the-bridge-you-actually-want-to-cross/</guid><description>Part 2 of 11 in &amp;quot;Symfony Releases&amp;quot;: Symfony 3.4 LTS is the migration bridge: same features as 3.3 plus every deprecation warning that 4.0 will enforce.</description><category>symfony-releases</category><content:encoded><![CDATA[<p>Symfony 3.4 and 4.0 were released the same day: November 30, 2017. That&rsquo;s not a coincidence, it&rsquo;s the strategy.</p>
<p>3.4 is not a feature release. It ships with exactly the same features as 3.3, plus every deprecation warning that 4.0 will enforce. Its whole purpose is to be the migration tool: upgrade from 3.3 to 3.4, fix what&rsquo;s in your logs, then step to 4.0 cleanly.</p>
<h2 id="why-lts-releases-matter-in-symfonys-model">Why LTS releases matter in Symfony&rsquo;s model</h2>
<p>Symfony releases a new minor version every six months. That pace would be brutal for production apps to follow, so the project designates every fourth minor as an LTS: three years of bug fixes, four of security fixes. Which means teams can target 3.4 and mostly stop thinking about upgrades for a while.</p>
<p>3.4 is the last LTS of the 3.x line. If you&rsquo;re still on 2.x or early 3.x, this is your landing zone.</p>
<h2 id="the-deprecation-layer">The deprecation layer</h2>
<p>Every feature that 4.0 removes is deprecated in 3.4. Run your app on 3.4 with deprecation notices enabled and your logs become a to-do list. The common ones:</p>
<ul>
<li>Services without explicit visibility (public/private) generate warnings — 4.0 makes all services private by default</li>
<li><code>ControllerTrait</code> is deprecated in favor of <code>AbstractController</code></li>
<li>The old security authenticator interfaces are marked for removal</li>
<li>YAML-only service configuration without autowiring annotations triggers warnings</li>
</ul>
<p>The intended workflow: upgrade to 3.4, run the test suite with deprecation notices as errors (<code>SYMFONY_DEPRECATIONS_HELPER=max[self]=0</code> in PHPUnit), fix everything that fails. After that, the upgrade to 4.0 is basically mechanical.</p>
<h2 id="the-support-window">The support window</h2>
<p>3.4 LTS receives bug fixes until November 2020 and security fixes until November 2021. That&rsquo;s a comfortable runway for apps that can&rsquo;t follow every release. The cost: staying on the 3.x architecture, with no Flex, no micro-framework structure, no zero-config autowiring by default.</p>
<p>The bridge is there. Whether and when you cross it is a business decision, not a technical one.</p>
<h2 id="services-go-private">Services go private</h2>
<p>3.4 flipped the default visibility of services from public to private. Before this, <code>$container-&gt;get('app.my_service')</code> was perfectly normal code. After this, it&rsquo;s an anti-pattern that generates a deprecation warning in 3.4 and breaks entirely in 4.0.</p>
<p>The reasoning is simple: fetching services directly from the container hides dependencies and defeats static analysis. If you inject through the constructor, the container can optimize the graph, tree-shake unused services, and catch mistakes at compile time. If you pull them at runtime, it can&rsquo;t.</p>
<p>For apps already using autowiring, the migration is usually small. The sticky point is controllers that extend <code>Controller</code> and call <code>$this-&gt;get('something')</code>. The fix is switching to <code>AbstractController</code>, which provides the same shortcuts but through lazy service locators instead of raw container access.</p>
<p>For services that genuinely need to be public (accessed from legacy code or functional tests), mark them explicitly:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">services</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">App\Service\LegacyAdapter</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">public</span>: <span style="color:#66d9ef">true</span>
</span></span></code></pre></div><h2 id="binding-scalar-arguments-once">Binding scalar arguments once</h2>
<p>A classic friction point with autowiring: scalar constructor arguments. If ten services all need <code>$projectDir</code>, you had to configure each one individually. The <code>bind</code> key under <code>_defaults</code> fixes that:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">services</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">_defaults</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">autowire</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">autoconfigure</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">bind</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">$projectDir</span>: <span style="color:#e6db74">&#39;%kernel.project_dir%&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">$mailerDsn</span>: <span style="color:#e6db74">&#39;%env(MAILER_DSN)%&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">Psr\Log\LoggerInterface $auditLogger</span>: <span style="color:#e6db74">&#39;@monolog.logger.audit&#39;</span>
</span></span></code></pre></div><p>Any service with a constructor parameter named <code>$projectDir</code> gets the bound value automatically. You can also bind by type-hint, which handles the common case where multiple logger channels exist and you need a specific one. Bindings in <code>_defaults</code> apply to all services in the file; you can override per-service if needed.</p>
<h2 id="injecting-tagged-services-without-a-compiler-pass">Injecting tagged services without a compiler pass</h2>
<p>Before 3.4, collecting all services with a given tag meant writing a compiler pass. Now there&rsquo;s a YAML shorthand:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">services</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">App\Chain\TransformerChain</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">arguments</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">$transformers</span>: !<span style="color:#ae81ff">tagged app.transformer</span>
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">TransformerChain</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">__construct</span>(<span style="color:#66d9ef">private</span> <span style="color:#a6e22e">iterable</span> $transformers) {}
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The <code>!tagged</code> notation creates an <code>IteratorArgument</code>: services are lazily instantiated as you iterate, so unused transformers never get built. For ordering, add a <code>priority</code> attribute to the tag definition on each service.</p>
<h2 id="a-logger-that-ships-with-the-framework">A logger that ships with the framework</h2>
<p>No Monolog? No problem. Symfony 3.4 includes a PSR-3 logger that writes to <code>php://stderr</code> by default. Autowire it with <code>Psr\Log\LoggerInterface</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Psr\Log\LoggerInterface</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">MyService</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">__construct</span>(<span style="color:#66d9ef">private</span> <span style="color:#a6e22e">LoggerInterface</span> $logger) {}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">doSomething</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">logger</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">warning</span>(<span style="color:#e6db74">&#39;Something questionable happened&#39;</span>, [<span style="color:#e6db74">&#39;context&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;here&#39;</span>]);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The default minimum level is <code>warning</code>. The target is container and Kubernetes workloads where stderr is the natural log sink. It&rsquo;s deliberately minimal: no handlers, no processors, no channels. When you need those, install Monolog.</p>
<h2 id="guard-authenticators-got-a-supports-method">Guard authenticators got a supports() method</h2>
<p>The Guard component&rsquo;s <code>getCredentials()</code> method was pulling double duty: deciding whether the authenticator should handle the request, and extracting the credentials. Returning <code>null</code> was the signal to skip. That made the contract messy.</p>
<p>3.4 added <code>supports()</code> to separate those concerns:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">ApiTokenAuthenticator</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">AbstractGuardAuthenticator</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">supports</span>(<span style="color:#a6e22e">Request</span> $request)<span style="color:#f92672">:</span> <span style="color:#a6e22e">bool</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> $request<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">headers</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">has</span>(<span style="color:#e6db74">&#39;X-API-TOKEN&#39;</span>);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getCredentials</span>(<span style="color:#a6e22e">Request</span> $request)<span style="color:#f92672">:</span> <span style="color:#66d9ef">array</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Only called when supports() returns true.
</span></span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Must always return credentials now.
</span></span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> [<span style="color:#e6db74">&#39;token&#39;</span> <span style="color:#f92672">=&gt;</span> $request<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">headers</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">get</span>(<span style="color:#e6db74">&#39;X-API-TOKEN&#39;</span>)];
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The old <code>GuardAuthenticatorInterface</code> is deprecated. The practical benefit: base classes can implement shared <code>getUser()</code> and <code>checkCredentials()</code> logic, while subclasses only override <code>supports()</code> and <code>getCredentials()</code>. One responsibility each.</p>
<h2 id="two-new-debug-commands">Two new debug commands</h2>
<p><code>debug:autowiring</code> replaces the old <code>debug:container --types</code> for discovering which type-hints work with autowiring:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>$ bin/console debug:autowiring log
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Autowirable Services
</span></span><span style="display:flex;"><span><span style="color:#f92672">====================</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  Psr<span style="color:#ae81ff">\L</span>og<span style="color:#ae81ff">\L</span>oggerInterface
</span></span><span style="display:flex;"><span>      alias to monolog.logger
</span></span><span style="display:flex;"><span>  Psr<span style="color:#ae81ff">\L</span>og<span style="color:#ae81ff">\L</span>oggerInterface $auditLogger
</span></span><span style="display:flex;"><span>      alias to monolog.logger.audit
</span></span></code></pre></div><p>Pass a keyword to filter. No more guessing whether it&rsquo;s <code>LoggerInterface</code> or <code>Logger</code>.</p>
<p><code>debug:form</code> gives you the same introspection capability for form types:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>$ bin/console debug:form App<span style="color:#ae81ff">\F</span>orm<span style="color:#ae81ff">\O</span>rderType label_attr
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Option: label_attr
</span></span><span style="display:flex;"><span>  Required: false
</span></span><span style="display:flex;"><span>  Default: <span style="color:#f92672">[]</span>
</span></span><span style="display:flex;"><span>  Allowed types: array
</span></span></code></pre></div><p>Without arguments it lists all registered form types, extensions, and guessers. With a type name and option name it shows every constraint on that option. Before this, you either read the source or trial-and-errored your way through.</p>
<h2 id="sessions-got-stricter-by-default">Sessions got stricter by default</h2>
<p>3.4 implements PHP 7.0&rsquo;s <code>SessionUpdateTimestampHandlerInterface</code>, which brings two things: lazy session writes (only written when data actually changed) and strict session ID validation (IDs that don&rsquo;t exist in the store are rejected rather than silently created, which blocks a class of session fixation attacks).</p>
<p>The old <code>WriteCheckSessionHandler</code>, <code>NativeSessionHandler</code>, and <code>NativeProxy</code> classes are deprecated. The <code>MemcacheSessionHandler</code> (note: not Memcached) is gone too, since the underlying PECL extension stopped receiving PHP 7 updates.</p>
<h2 id="twig-form-themes-can-now-be-scoped">Twig form themes can now be scoped</h2>
<p>Global form themes apply to every form in the app. If one form needs a completely different look, you had no clean way to opt out. The <code>only</code> keyword handles that:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-twig" data-lang="twig"><span style="display:flex;"><span><span style="color:#75715e">{%</span> <span style="color:#66d9ef">raw</span> <span style="color:#75715e">%}</span>{% form_theme orderForm with [&#39;form/order_layout.html.twig&#39;] only %}<span style="color:#75715e">{%</span> <span style="color:#66d9ef">endraw</span> <span style="color:#75715e">%}</span>
</span></span></code></pre></div><p>The <code>only</code> keyword disables all global themes for that form, including the base <code>form_div_layout.html.twig</code>. Your custom theme then needs to either provide all the blocks it uses, or explicitly pull them in with <code>{% raw %}{% use 'form_div_layout.html.twig' %}{% endraw %}</code>.</p>
<h2 id="overriding-bundle-templates-without-infinite-loops">Overriding bundle templates without infinite loops</h2>
<p>Overriding a bundle template that you also need to extend used to cause a circular reference error. Override <code>@TwigBundle/Exception/error404.html.twig</code> and also try to inherit from it? The old namespace resolution would follow your override and loop forever.</p>
<p>3.4 introduced the <code>@!</code> prefix to explicitly reference the original bundle template, bypassing any overrides:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-twig" data-lang="twig"><span style="display:flex;"><span><span style="color:#75715e">{%</span> <span style="color:#66d9ef">raw</span> <span style="color:#75715e">%}</span>{# templates/bundles/TwigBundle/Exception/error404.html.twig #}
</span></span><span style="display:flex;"><span>{% extends &#39;@!Twig/Exception/error404.html.twig&#39; %}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>{% block title %}Page not found{% endblock %}<span style="color:#75715e">{%</span> <span style="color:#66d9ef">endraw</span> <span style="color:#75715e">%}</span>
</span></span></code></pre></div><p><code>@TwigBundle</code> resolves to your override if one exists. <code>@!TwigBundle</code> always resolves to the original. Override-and-extend, without the gymnastics.</p>
]]></content:encoded></item><item><title>Symfony 3.3: when services stopped being a configuration nightmare</title><link>https://guillaumedelre.github.io/2017/07/13/symfony-3.3-when-services-stopped-being-a-configuration-nightmare/</link><pubDate>Thu, 13 Jul 2017 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2017/07/13/symfony-3.3-when-services-stopped-being-a-configuration-nightmare/</guid><description>Part 1 of 11 in &amp;quot;Symfony Releases&amp;quot;: Symfony 3.3 made autowiring the default and turned service configuration from mountains of YAML into almost nothing.</description><category>symfony-releases</category><content:encoded><![CDATA[<p>Symfony 3.3 shipped May 29th. It&rsquo;s the release that changed how I think about service configuration. In hindsight, it was basically a preview of what 4.0 would make the new default.</p>
<h2 id="the-autowiring-problem">The autowiring problem</h2>
<p>Before 3.3, Symfony&rsquo;s DI was powerful but verbose. Every service had to be declared explicitly in <code>services.yml</code> with its arguments listed. Autowiring existed since 3.1, but it was opt-in per service and had enough edge cases to bite you. Teams either wrote mountains of YAML or leaned on third-party bundles to cut the noise.</p>
<p>3.3 rewrote the defaults. With <code>autoconfigure: true</code> and <code>autowire: true</code> set once in the defaults section, every class in <code>src/</code> becomes a service automatically, and its constructor dependencies are resolved by type. What used to take twenty lines of YAML now takes zero:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">services</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">_defaults</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">autowire</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">autoconfigure</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">App\</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">resource</span>: <span style="color:#e6db74">&#39;../src/&#39;</span>
</span></span></code></pre></div><p>That single block is the entire service configuration for most apps. The framework discovers services, injects dependencies, and applies tags (command, event subscriber, voter&hellip;) based on the interfaces each class implements.</p>
<h2 id="instanceof-conditionals">instanceof conditionals</h2>
<p>The <code>instanceof</code> keyword in service configuration handles the tagging that previously required explicit declaration:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">services</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">_instanceof</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">Symfony\Component\EventDispatcher\EventSubscriberInterface</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">tags</span>: [<span style="color:#e6db74">&#39;kernel.event_subscriber&#39;</span>]
</span></span></code></pre></div><p>Any service implementing <code>EventSubscriberInterface</code> gets the tag automatically. Same for <code>Command</code>, <code>Voter</code>, <code>MessageHandlerInterface</code>. The boilerplate evaporates.</p>
<h2 id="dotenv-component">Dotenv component</h2>
<p>Before 3.3, Symfony had no built-in way to load <code>.env</code> files. The standard answer was a third-party package. The new <code>Dotenv</code> component reads <code>.env</code> and populates <code>$_ENV</code> and <code>$_SERVER</code>, making environment-based configuration a first-class citizen at last.</p>
<h2 id="service-discovery-from-the-filesystem">Service discovery from the filesystem</h2>
<p>The <code>resource</code> option ties it all together. Instead of registering every class individually, you point the container at a directory and it scans for PSR-4 classes:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">services</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">App\</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">resource</span>: <span style="color:#e6db74">&#39;../src/&#39;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">exclude</span>: <span style="color:#e6db74">&#39;../src/{Entity,Migrations}&#39;</span>
</span></span></code></pre></div><p>Every class found becomes a service with its FQCN as the service ID. The <code>exclude</code> option handles things like Doctrine entities that you don&rsquo;t want the container touching. And no, it&rsquo;s not magic: it&rsquo;s a filesystem scan at compile time, so the cost is paid once during cache warmup, not per request.</p>
<h2 id="when-you-need-a-subset-of-the-container">When you need a subset of the container</h2>
<p>Service locators solve a specific tension: some services legitimately need lazy access to a variable set of other services, but injecting the full container is an anti-pattern — it hides dependencies and defeats static analysis. The solution is a locator that explicitly declares what it contains.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">services</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">App\Handler\HandlerLocator</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">class</span>: <span style="color:#ae81ff">Symfony\Component\DependencyInjection\ServiceLocator</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">tags</span>: [<span style="color:#e6db74">&#39;container.service_locator&#39;</span>]
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">arguments</span>:
</span></span><span style="display:flex;"><span>            -
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">App\Command\CreateOrder</span>: <span style="color:#e6db74">&#39;@App\Handler\CreateOrderHandler&#39;</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">App\Command\CancelOrder</span>: <span style="color:#e6db74">&#39;@App\Handler\CancelOrderHandler&#39;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">App\Bus\CommandBus</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">arguments</span>: [<span style="color:#e6db74">&#39;@App\Handler\HandlerLocator&#39;</span>]
</span></span></code></pre></div><p>The locator implements PSR-11&rsquo;s <code>ContainerInterface</code>, so the receiving class type-hints against <code>Psr\Container\ContainerInterface</code>. Services inside it are lazily instantiated: if a given handler never gets called during a request, it never gets built.</p>
<p>And speaking of PSR-11: Symfony 3.3 made its container implement that standard. Which means any library expecting a PSR-11 container now works directly with Symfony&rsquo;s container, no adapter needed.</p>
<h2 id="routing-got-faster">Routing got faster</h2>
<p>The routing component rewrote how it generates dump files. In an app with 900 routes, URL matching dropped from 7.5ms to 2.5ms per match: a 66% reduction. The optimizations live in the compiled output, not the runtime path, so existing route definitions benefit automatically after a cache clear.</p>
<h2 id="finding-the-project-root-without-counting-directory-separators">Finding the project root without counting directory separators</h2>
<p>Before 3.3, getting the project root meant using the delightfully awkward <code>%kernel.root_dir%/../</code> pattern, because <code>getRootDir()</code> pointed at the <code>app/</code> directory. The new <code>getProjectDir()</code> method walks up from the kernel file until it finds <code>composer.json</code> and returns that directory.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">// Before
</span></span></span><span style="display:flex;"><span>$path <span style="color:#f92672">=</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getParameter</span>(<span style="color:#e6db74">&#39;kernel.root_dir&#39;</span>) <span style="color:#f92672">.</span> <span style="color:#e6db74">&#39;/../var/data.db&#39;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// After
</span></span></span><span style="display:flex;"><span>$path <span style="color:#f92672">=</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getParameter</span>(<span style="color:#e6db74">&#39;kernel.project_dir&#39;</span>) <span style="color:#f92672">.</span> <span style="color:#e6db74">&#39;/var/data.db&#39;</span>;
</span></span></code></pre></div><p>The corresponding parameter is <code>%kernel.project_dir%</code>. If you deploy without <code>composer.json</code>, you can override the method in your kernel class and return whatever path makes sense.</p>
<h2 id="flash-messages-without-touching-the-session-object">Flash messages without touching the session object</h2>
<p>The old way of iterating flash messages in Twig required reaching through <code>app.session.flashbag</code>, which also forced the session to start whether or not there were any messages. The new <code>app.flashes</code> helper avoids both:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-twig" data-lang="twig"><span style="display:flex;"><span><span style="color:#75715e">{%</span> <span style="color:#66d9ef">raw</span> <span style="color:#75715e">%}</span>{% for label, messages in app.flashes %}
</span></span><span style="display:flex;"><span>    {% for message in messages %}
</span></span><span style="display:flex;"><span>        &lt;div class=&#34;flash-{{ label }}&#34;&gt;{{ message }}&lt;/div&gt;
</span></span><span style="display:flex;"><span>    {% endfor %}
</span></span><span style="display:flex;"><span>{% endfor %}<span style="color:#75715e">{%</span> <span style="color:#66d9ef">endraw</span> <span style="color:#75715e">%}</span>
</span></span></code></pre></div><p>If there are no flash messages, the session never starts. You can also filter by type: <code>app.flashes('error')</code> returns only error messages.</p>
<h2 id="the-encode-password-command-grew-a-brain">The encode-password command grew a brain</h2>
<p>The <code>security:encode-password</code> console command got smarter. Instead of requiring you to pass the user class as an argument, it now lists the configured user classes and lets you pick:</p>
<pre tabindex="0"><code>$ bin/console security:encode-password

  For which user class would you like to encode a password?
  [0] App\Entity\User
  [1] App\Entity\AdminUser
</code></pre><p>It also normalizes encoder configuration to handle edge cases with email-format usernames that the previous version would silently corrupt by replacing <code>@</code> with underscores. Nice catch.</p>
<h2 id="http2-push-and-resource-hints">HTTP/2 push and resource hints</h2>
<p>The WebLink component handles the <code>Link</code> HTTP header, which tells browsers (and HTTP/2 proxies) to preload, prefetch, or preconnect to resources before the page even asks for them. It comes as a set of Twig functions:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-twig" data-lang="twig"><span style="display:flex;"><span><span style="color:#75715e">{%</span> <span style="color:#66d9ef">raw</span> <span style="color:#75715e">%}</span>{{ preload(&#39;/fonts/custom.woff2&#39;, { as: &#39;font&#39;, crossorigin: true }) }}
</span></span><span style="display:flex;"><span>{{ prefetch(&#39;/api/next-page-data.json&#39;) }}
</span></span><span style="display:flex;"><span>{{ dns_prefetch(&#39;https://fonts.googleapis.com&#39;) }}<span style="color:#75715e">{%</span> <span style="color:#66d9ef">endraw</span> <span style="color:#75715e">%}</span>
</span></span></code></pre></div><p>Each call adds a corresponding <code>Link</code> header to the response. For apps behind an HTTP/2-capable proxy, this can trigger server push before the browser has even parsed the HTML. You enable it in <code>config.yml</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">framework</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">web_link</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">enabled</span>: <span style="color:#66d9ef">true</span>
</span></span></code></pre></div><h2 id="deprecations-you-can-actually-trust">Deprecations you can actually trust</h2>
<p>Container compilation used to generate deprecation warnings that vanished on the next page load because the cached container was already built. 3.3 persists those messages to disk and surfaces them in the web debug toolbar alongside request-phase deprecations. If a class is being deprecated during service compilation, you&rsquo;ll see it without having to nuke the cache first.</p>
<h2 id="what-this-meant-for-40">What this meant for 4.0</h2>
<p>3.3&rsquo;s autowiring defaults are exactly what Symfony 4.0 shipped as the new standard project structure. The <code>services.yaml</code> in every new Symfony 4 project is essentially the snippet above. If you had already picked up what 3.3 introduced, 4.0&rsquo;s &ldquo;new way&rdquo; felt familiar rather than foreign.</p>
<p>The direction was clear: less configuration, more convention. Let PHP figure out what to wire together.</p>
]]></content:encoded></item><item><title>Enforcing UTC in Doctrine without touching your entities</title><link>https://guillaumedelre.github.io/2017/02/19/enforcing-utc-in-doctrine-without-touching-your-entities/</link><pubDate>Sun, 19 Feb 2017 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2017/02/19/enforcing-utc-in-doctrine-without-touching-your-entities/</guid><description>How to override Doctrine&amp;#39;s built-in types to enforce UTC everywhere, without touching a single entity.</description><content:encoded><![CDATA[<p>A timestamp coming back from the database one hour off. Not every time. Only when the dev server runs in <code>Europe/Paris</code> and CI runs in UTC. The kind of bug that disappears when you look for it and comes back in production on a Friday evening.</p>
<p>The problem isn&rsquo;t in the business logic. It&rsquo;s in what Doctrine quietly does with dates.</p>
<h2 id="what-doctrine-does-by-default">What Doctrine does by default</h2>
<p>When you declare a <code>datetime</code> field in a Doctrine entity, the conversion between PHP and the database goes through <code>DateTimeType</code>. That class calls <code>format()</code> on your <code>DateTime</code> object to write to the database, and <code>DateTime::createFromFormat()</code> to read it back. No mention of timezone anywhere.</p>
<p>If your PHP object is in <code>Europe/Paris</code>, Doctrine formats <code>2017-01-15 11:30:00</code> and writes it as-is. If the server reading that field is in UTC, it gets <code>2017-01-15 11:30:00</code> and interprets it as UTC. One hour has evaporated in the round trip, without a single error message.</p>
<p><a href="https://www.doctrine-project.org/projects/doctrine-orm/en/latest/cookbook/working-with-datetime.html" target="_blank" rel="noopener noreferrer">The Doctrine docs cover this</a>, suggesting custom types as the fix. What they mention in passing is that you can give those custom types the same name as the built-in ones. That detail changes everything.</p>
<h2 id="replace-dont-add">Replace, don&rsquo;t add</h2>
<p>Most custom Doctrine type examples introduce a new name: <code>utc_datetime</code>, <code>app_date</code>, and so on. You then annotate every field with <code>type: 'utc_datetime'</code> in the entities. It works, but it&rsquo;s tedious and doesn&rsquo;t protect against a forgotten <code>type: 'datetime'</code>.</p>
<p>The other option: register the custom type under the name <code>datetime</code>. Doctrine replaces its own type with yours, everywhere, no exceptions. Every <code>datetime</code> field across all entities goes through your logic, without changing a single annotation.</p>
<p>That&rsquo;s what we just shipped across our PHP microservices platform. Here&rsquo;s what it looks like.</p>
<h2 id="the-shared-trait">The shared trait</h2>
<p>Both types (<code>date</code> and <code>datetime</code>) share the same conversion logic through a trait:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">trait</span> <span style="color:#a6e22e">UTCDate</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#a6e22e">\DateTimeZone</span> $utc;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">convertToPHPValue</span>($value, <span style="color:#a6e22e">AbstractPlatform</span> $platform)<span style="color:#f92672">:</span> <span style="color:#f92672">?</span><span style="color:#a6e22e">\DateTime</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (<span style="color:#66d9ef">null</span> <span style="color:#f92672">===</span> $value <span style="color:#f92672">||</span> $value <span style="color:#a6e22e">instanceof</span> <span style="color:#a6e22e">\DateTime</span>) {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> $value;
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        $format <span style="color:#f92672">=</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getFormat</span>($platform);
</span></span><span style="display:flex;"><span>        $converted <span style="color:#f92672">=</span> <span style="color:#a6e22e">\DateTime</span><span style="color:#f92672">::</span><span style="color:#a6e22e">createFromFormat</span>($format, $value, $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getUtc</span>());
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span>$converted) {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">\RuntimeException</span>(
</span></span><span style="display:flex;"><span>                <span style="color:#a6e22e">sprintf</span>(<span style="color:#e6db74">&#39;Could not convert database value &#34;%s&#34; to DateTime using format &#34;%s&#34;.&#39;</span>, $value, $format)
</span></span><span style="display:flex;"><span>            );
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">postConvert</span>($converted);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> $converted;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">abstract</span> <span style="color:#66d9ef">protected</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getFormat</span>(<span style="color:#a6e22e">AbstractPlatform</span> $platform)<span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getUtc</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">\DateTimeZone</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (<span style="color:#66d9ef">empty</span>($this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">utc</span>)) {
</span></span><span style="display:flex;"><span>            $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">utc</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">\DateTimeZone</span>(<span style="color:#e6db74">&#39;UTC&#39;</span>);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">utc</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The key: <code>\DateTime::createFromFormat()</code> receives an explicit UTC timezone. The raw value from the database is interpreted as UTC, regardless of what the PHP server&rsquo;s timezone is set to.</p>
<h2 id="utcdatetimetype">UTCDateTimeType</h2>
<p>For <code>datetime</code> fields, the write path also needs to enforce UTC:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">UTCDateTimeType</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">DateTimeType</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">use</span> <span style="color:#a6e22e">UTCDate</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">#[\Override]
</span></span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">convertToPHPValue</span>($value, <span style="color:#a6e22e">AbstractPlatform</span> $platform)<span style="color:#f92672">:</span> <span style="color:#f92672">?</span><span style="color:#a6e22e">\DateTime</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (<span style="color:#66d9ef">null</span> <span style="color:#f92672">===</span> $value <span style="color:#f92672">||</span> $value <span style="color:#a6e22e">instanceof</span> <span style="color:#a6e22e">\DateTimeInterface</span>) {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">parent</span><span style="color:#f92672">::</span><span style="color:#a6e22e">convertToPHPValue</span>($value, $platform);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">parent</span><span style="color:#f92672">::</span><span style="color:#a6e22e">convertToPHPValue</span>(<span style="color:#e6db74">&#34;</span><span style="color:#e6db74">$value</span><span style="color:#e6db74">+0000&#34;</span>, $platform);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">#[\Override]
</span></span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">convertToDatabaseValue</span>($value, <span style="color:#a6e22e">AbstractPlatform</span> $platform)<span style="color:#f92672">:</span> <span style="color:#f92672">?</span><span style="color:#a6e22e">string</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> ($value <span style="color:#a6e22e">instanceof</span> <span style="color:#a6e22e">\DateTime</span>) {
</span></span><span style="display:flex;"><span>            $value<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">setTimezone</span>($this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getUtc</span>());
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">parent</span><span style="color:#f92672">::</span><span style="color:#a6e22e">convertToDatabaseValue</span>($value, $platform);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">#[\Override]
</span></span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">protected</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getFormat</span>(<span style="color:#a6e22e">AbstractPlatform</span> $platform)<span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> $platform<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getDateTimeFormatString</span>();
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">protected</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">postConvert</span>(<span style="color:#a6e22e">\DateTime</span> $converted)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span> {}
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>On read (<code>convertToPHPValue</code>), if the value is a raw string, we append <code>+0000</code> before delegating to the parent. The parent then uses that timezone suffix to create the PHP object correctly.</p>
<p>On write (<code>convertToDatabaseValue</code>), we force the <code>DateTime</code> to UTC before formatting. What goes into the database is always UTC.</p>
<h2 id="utcdatetype">UTCDateType</h2>
<p>For <code>date</code> columns (no time component), same approach with one extra step:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">UTCDateType</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">DateType</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">use</span> <span style="color:#a6e22e">UTCDate</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">#[\Override]
</span></span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">protected</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getFormat</span>(<span style="color:#a6e22e">AbstractPlatform</span> $platform)<span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> $platform<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getDateFormatString</span>();
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">protected</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">postConvert</span>(<span style="color:#a6e22e">\DateTime</span> $converted)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        $converted<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">setTime</span>(<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">0</span>);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The <code>postConvert()</code> method resets the time to <code>00:00:00</code> after parsing. Without it, a <code>date</code> field might come back with <code>23:59:59</code> or <code>00:00:00+02:00</code> depending on the server&rsquo;s timezone, which breaks comparisons and ordering.</p>
<h2 id="registering-in-symfony">Registering in Symfony</h2>
<p>The decisive part: declaring the types under their built-in names in <code>config/packages/doctrine.yaml</code>.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">doctrine</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">dbal</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">types</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">date</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">class</span>: <span style="color:#ae81ff">App\Doctrine\DBAL\Types\UTCDateType</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">datetime</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">class</span>: <span style="color:#ae81ff">App\Doctrine\DBAL\Types\UTCDateTimeType</span>
</span></span></code></pre></div><p>That&rsquo;s it. Doctrine swaps out its own implementations for yours. Existing entities don&rsquo;t change, migrations don&rsquo;t move, annotations stay <code>type: Types::DATETIME_MUTABLE</code>. The behavior changes globally, without friction.</p>
<h2 id="12-microservices-89-columns-one-config-block">12 microservices, 89 columns, one config block</h2>
<p>These two types are now running across 12 independent microservices, each with its own Doctrine config, covering 89 production columns. CI servers run in UTC, dev machines in <code>Europe/Paris</code>, data travels between them without shifting. It&rsquo;s not spectacular. It&rsquo;s just reliable.</p>
<p>The real lesson isn&rsquo;t technical: an unresolved timezone issue is a data integrity issue. Offsets accumulate silently, comparisons go wrong, exports become inaccurate. Two lines of config and three classes can prevent that permanently.</p>
]]></content:encoded></item></channel></rss>