<?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>Posts on Guillaume Delré</title><link>https://guillaumedelre.github.io/posts/</link><description>Recent content in Posts 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/posts/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>Building a self-hosted homelab with Docker Compose and Traefik</title><link>https://guillaumedelre.github.io/2026/02/17/building-a-self-hosted-homelab-with-docker-compose-and-traefik/</link><pubDate>Tue, 17 Feb 2026 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2026/02/17/building-a-self-hosted-homelab-with-docker-compose-and-traefik/</guid><description>Complete guide to setting up a Docker homelab with Traefik and sslip.io: independent stacks, auto-configured dashboard, common pitfalls documented.</description><content:encoded><![CDATA[<p>For years I wanted a homelab at home. A place of my own to host development tools, monitor my machines, run home automation, and experiment without risking breaking anything important. The idea is simple. Getting it running, a bit less so.</p>
<p>Back then, Kubernetes didn&rsquo;t exist yet. Options for running multiple services on a single machine came down to bash scripting, hand-written Nginx configs, and a lot of coffee. Tutorials on &ldquo;homelab for humans&rdquo; were nowhere to be found.</p>
<p>This tutorial is what I wish I had found back then. It&rsquo;s been running for several years now. Not without evolving: services added, others dropped, choices revisited. But the foundation is there, stable — and that&rsquo;s what success looks like in self-hosting.</p>
<p>The setup: ten self-hosted web services on a local machine, accessible from a browser via readable URLs, without touching DNS configuration, without renting a VPS, without managing TLS certificates. The ingredient that makes it possible: <a href="https://sslip.io" target="_blank" rel="noopener noreferrer">sslip.io</a>
, a public DNS service that encodes the IP directly in the domain name. <code>service.192.168.1.10.sslip.io</code> resolves to <code>192.168.1.10</code>, with zero configuration, from any machine on the local network.</p>
<p>This tutorial is aimed at someone who knows Docker but is starting from scratch on self-hosted service orchestration.</p>
<hr>
<h2 id="table-of-contents">Table of contents</h2>
<ol>
<li><a href="#1-philosophy-and-architecture-choices">Philosophy and architecture choices</a>
</li>
<li><a href="#2-the-building-blocks">The building blocks</a>
</li>
<li><a href="#3-step-by-step-setup">Step-by-step setup</a>
</li>
<li><a href="#4-adding-a-new-service">Adding a new service</a>
</li>
<li><a href="#5-patterns-and-conventions">Patterns and conventions</a>
</li>
<li><a href="#6-common-pitfalls">Common pitfalls</a>
</li>
<li><a href="#conclusion">Conclusion</a>
</li>
<li><a href="#references">References</a>
</li>
</ol>
<hr>
<h2 id="1-philosophy-and-architecture-choices">1. Philosophy and architecture choices</h2>
<h3 id="goal">Goal</h3>
<p>Run multiple web services on a local machine, accessible from a browser via readable URLs, without touching DNS configuration, without renting a VPS, without managing TLS certificates.</p>
<h3 id="why-docker-compose-and-not-something-else">Why Docker Compose and not something else?</h3>
<p>Docker Compose is the right level of complexity for a personal homelab. Kubernetes is too heavy for a single machine. Docker Swarm is in decline. Compose is simple, readable, versionable, and sufficient for dozens of services.</p>
<h3 id="why-traefik-and-not-nginx-proxy-manager">Why Traefik and not Nginx Proxy Manager?</h3>
<p><strong>Nginx Proxy Manager (NPM)</strong> is a graphical interface for configuring Nginx as a reverse proxy. Routes are stored in a database and configured through a UI.</p>
<p><strong><a href="https://github.com/traefik/traefik" target="_blank" rel="noopener noreferrer">Traefik</a>
</strong> automatically reads Docker container labels and generates its configuration on the fly. When a container starts with the right labels, Traefik discovers it and creates the route immediately, without restarting, without opening any UI.</p>
<p>This &ldquo;configuration as code&rdquo; approach has two major advantages:</p>
<ul>
<li>A service&rsquo;s configuration lives in its <code>compose.yaml</code>, in the same place as everything else.</li>
<li>Adding a service requires no changes to Traefik.</li>
</ul>
<h3 id="why-dockge-and-not-portainer">Why Dockge and not Portainer?</h3>
<p><strong>Portainer</strong> is a full Docker management tool: images, volumes, networks, individual containers&hellip; powerful but complex.</p>
<p><strong><a href="https://github.com/louislam/dockge" target="_blank" rel="noopener noreferrer">Dockge</a>
</strong> is focused on a single thing: managing Docker Compose stacks. Its UI is minimal and intuitive. For a homelab where everything is managed through Compose, it&rsquo;s sufficient and much more pleasant to use.</p>
<h3 id="why-sslipio">Why sslip.io?</h3>
<p>Web services need a hostname (e.g. <code>dozzle.myserver.local</code>) for Traefik to route correctly. The usual options:</p>
<ul>
<li>Edit <code>/etc/hosts</code> on every machine: tedious, not shareable.</li>
<li>Set up a local DNS server (Pi-hole, AdGuard): requires additional infrastructure.</li>
<li>Buy a domain and configure DNS: costs money and time.</li>
</ul>
<p><strong>sslip.io</strong> is a public DNS service that automatically resolves <code>&lt;anything&gt;.&lt;IP&gt;.sslip.io</code> to <code>&lt;IP&gt;</code>. Example: <code>dozzle.192.168.1.10.sslip.io</code> resolves to <code>192.168.1.10</code>. Nothing to configure — the DNS works everywhere without touching anything.</p>
<hr>
<h2 id="2-the-building-blocks">2. The building blocks</h2>
<h3 id="the-shared-docker-network">The shared Docker network</h3>
<p>All services and Traefik must share the same Docker network so Traefik can communicate with them. This network is called <code>traefik</code> and is created once:</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 network create traefik
</span></span></code></pre></div><p>It is an <strong>external</strong> network (created outside any Compose file). Each <code>compose.yaml</code> declares it as external:</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">networks</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">traefik</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">external</span>: <span style="color:#66d9ef">true</span>
</span></span></code></pre></div><p>Why external rather than internal to a Compose file? Because multiple independent stacks all need to connect to it. A network internal to a Compose file is only accessible to services within that file.</p>
<h3 id="traefik-the-reverse-proxy">Traefik: the reverse proxy</h3>
<p>Traefik listens on port 80 and routes HTTP requests to the right container based on the <code>Host</code> header.</p>
<p>Its main configuration lives in <code>stacks/traefik/docker/traefik/traefik.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">api</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">dashboard</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">insecure</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">entryPoints</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">web</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">address</span>: :<span style="color:#ae81ff">80</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">ping</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">address</span>: :<span style="color:#ae81ff">8082</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">providers</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">docker</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">endpoint</span>: <span style="color:#ae81ff">unix:///var/run/docker.sock</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">exposedByDefault</span>: <span style="color:#66d9ef">false</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">log</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">level</span>: <span style="color:#ae81ff">INFO</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">global</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">sendAnonymousUsage</span>: <span style="color:#66d9ef">false</span>
</span></span></code></pre></div><p><code>exposedByDefault: false</code> is important: Traefik ignores all containers by default. A container must explicitly opt in with the label <code>traefik.enable: true</code>. This prevents accidentally exposing services.</p>
<p>The <code>ping</code> entrypoint on port 8082 is dedicated to health checks. Separating it from the <code>web</code> entrypoint prevents health check requests from appearing in access logs.</p>
<p>To access the Docker daemon, Traefik mounts the socket:</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">volumes</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#ae81ff">/var/run/docker.sock:/var/run/docker.sock</span>
</span></span></code></pre></div><h3 id="dockge-the-stack-manager">Dockge: the stack manager</h3>
<p>Dockge runs inside a container itself (the <code>compose.yaml</code> at the root of the repo). It needs two things:</p>
<ol>
<li>Access to the Docker socket to manage the other containers.</li>
<li>Access to the stack directories to read and edit <code>compose.yaml</code> files.</li>
</ol>
<p>The critical point is the stack mount. Dockge launches stacks by passing absolute paths to the Docker daemon. These paths must be identical inside the Dockge container and on the host. The solution:</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">volumes</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#ae81ff">${PWD}/stacks:${PWD}/stacks</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">environment</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">DOCKGE_STACKS_DIR</span>: <span style="color:#ae81ff">${PWD}/stacks</span>
</span></span></code></pre></div><p><code>${PWD}</code> is a shell variable resolved at <code>docker compose up</code> time. It equals the current directory. If Dockge is launched from <code>/home/user/homelab</code>, the stacks folder will be mounted at <code>/home/user/homelab/stacks</code> on both sides. This is the only way to prevent Docker from creating ghost directories in the wrong place.</p>
<p><strong>Practical consequence</strong>: always run <code>docker compose up -d</code> from the root of the repo.</p>
<p>Dockge&rsquo;s persistent data (configuration, history) lives in a named volume created in advance:</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 volume create homelab_dockge_data
</span></span></code></pre></div><p>A named volume survives <code>docker compose down -v</code>. An anonymous volume would be destroyed with the stack.</p>
<hr>
<h2 id="3-step-by-step-setup">3. Step-by-step setup</h2>
<h3 id="step-1-clone-and-configure">Step 1: clone and configure</h3>
<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>git clone &lt;repo&gt; homelab
</span></span><span style="display:flex;"><span>cd homelab
</span></span></code></pre></div><p>Find the machine&rsquo;s local IP:</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>hostname -I | awk <span style="color:#e6db74">&#39;{print $1}&#39;</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># e.g.: 192.168.1.10</span>
</span></span></code></pre></div><p>Create and edit the root <code>.env</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-bash" data-lang="bash"><span style="display:flex;"><span>cp .env.example .env
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Edit .env:</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># IP=192.168.1.10</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># DOMAIN=sslip.io</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># COMPOSE_PROJECT_NAME=dockge  ← important, see conventions section</span>
</span></span></code></pre></div><h3 id="step-2-docker-prerequisites">Step 2: Docker prerequisites</h3>
<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 network create traefik
</span></span><span style="display:flex;"><span>docker volume create homelab_dockge_data
</span></span></code></pre></div><h3 id="step-3-start-dockge">Step 3: start Dockge</h3>
<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>echo <span style="color:#e6db74">&#34;STACKS_DIR=</span><span style="color:#66d9ef">$(</span>pwd<span style="color:#66d9ef">)</span><span style="color:#e6db74">/stacks&#34;</span> &gt;&gt; .env
</span></span><span style="display:flex;"><span>docker compose up -d
</span></span></code></pre></div><p>Dockge is accessible at <code>http://&lt;IP&gt;:5001</code>. It is exposed directly on port 5001, not through Traefik (Traefik is not running yet at this point). Create an admin account on first launch.</p>
<h3 id="step-4-configure-the-stacks">Step 4: configure the stacks</h3>
<p>For each directory in <code>stacks/</code>, copy the <code>.env.example</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-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#66d9ef">for</span> stack in stacks/*/; <span style="color:#66d9ef">do</span>
</span></span><span style="display:flex;"><span>    cp <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>stack<span style="color:#e6db74">}</span><span style="color:#e6db74">.env.example&#34;</span> <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>stack<span style="color:#e6db74">}</span><span style="color:#e6db74">.env&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">done</span>
</span></span></code></pre></div><p>Then edit each <code>.env</code> to set <code>IP</code> and <code>DOMAIN</code> to the same values as in step 1. The <code>COMPOSE_PROJECT_NAME</code> value is pre-filled with the folder name — do not change it (see conventions section).</p>
<p>For <code>filebrowser</code>, also set <code>FILEBROWSER_ROOT</code> to the local path to expose.</p>
<h3 id="step-5-start-the-stacks-from-dockge">Step 5: start the stacks from Dockge</h3>
<p>From the Dockge interface (<code>http://&lt;IP&gt;:5001</code>), in this order:</p>
<p><strong>1. Traefik first</strong></p>
<p>Traefik must be running before the other services. Without Traefik, routes don&rsquo;t exist and services are unreachable via their URL.</p>
<p>After starting, verify Traefik is healthy:</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 ps --filter name<span style="color:#f92672">=</span>traefik
</span></span></code></pre></div><p><strong>2. The other stacks in any order</strong></p>
<p>Each stack automatically registers itself with Traefik via its Docker labels. Traefik discovers new containers in real time.</p>
<p><strong>3. Homepage last</strong></p>
<p>Homepage reads Docker labels from all running containers at startup to build the dashboard. Starting it last ensures it discovers all active services from the first launch.</p>
<hr>
<h2 id="4-adding-a-new-service">4. Adding a new service</h2>
<p>Here is the <code>compose.yaml</code> template for any new 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">services</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">myservice</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">image</span>: <span style="color:#ae81ff">vendor/myservice:latest</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">restart</span>: <span style="color:#ae81ff">unless-stopped</span>
</span></span><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-SHELL&#34;</span>, <span style="color:#e6db74">&#34;wget -qO- http://127.0.0.1:&lt;PORT&gt;/ || exit 1&#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><span style="display:flex;"><span>        <span style="color:#f92672">labels</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#75715e"># Homepage - auto-discovery in dashboard</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">homepage.group</span>: <span style="color:#ae81ff">tools</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">homepage.name</span>: <span style="color:#ae81ff">My Service</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">homepage.icon</span>: <span style="color:#ae81ff">https://cdn.jsdelivr.net/gh/selfhst/icons/webp/myservice.webp</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">homepage.href</span>: <span style="color:#ae81ff">http://${COMPOSE_PROJECT_NAME}.${IP}.${DOMAIN}</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#75715e"># Traefik - HTTP routing</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">traefik.enable</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">traefik.http.routers.myservice.entrypoints</span>: <span style="color:#ae81ff">web</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">traefik.http.routers.myservice.rule</span>: <span style="color:#ae81ff">Host(`${COMPOSE_PROJECT_NAME}.${IP}.${DOMAIN}`)</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">traefik.http.services.myservice.loadbalancer.server.port</span>: <span style="color:#ae81ff">&lt;PORT&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">networks</span>:
</span></span><span style="display:flex;"><span>            - <span style="color:#ae81ff">traefik</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">networks</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">traefik</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">external</span>: <span style="color:#66d9ef">true</span>
</span></span></code></pre></div><p>And the associated <code>.env.example</code>:</p>
<pre tabindex="0"><code>COMPOSE_PROJECT_NAME=myservice
IP=127.0.0.1
DOMAIN=sslip.io
</code></pre><p><strong>The folder name determines the subdomain.</strong> If the folder is called <code>myservice</code>, the service will be accessible at <code>myservice.&lt;IP&gt;.&lt;DOMAIN&gt;</code>. That&rsquo;s it.</p>
<p>To find services worth adding, <a href="https://selfh.st" target="_blank" rel="noopener noreferrer">selfh.st</a>
 is an excellent resource: it&rsquo;s a catalog of self-hosted software organized by category (media, security, productivity, monitoring&hellip;), with a description, screenshot, and GitHub link for each. The site also publishes a weekly newsletter on new releases.</p>
<h3 id="checklist-for-a-new-service">Checklist for a new service</h3>
<ul>
<li><input disabled="" type="checkbox"> Create <code>stacks/&lt;subdomain-name&gt;/compose.yaml</code></li>
<li><input disabled="" type="checkbox"> Create <code>stacks/&lt;subdomain-name&gt;/.env.example</code> with <code>COMPOSE_PROJECT_NAME=&lt;name&gt;</code></li>
<li><input disabled="" type="checkbox"> Copy <code>.env.example</code> to <code>.env</code> and fill in IP/DOMAIN</li>
<li><input disabled="" type="checkbox"> Check the port in the Traefik labels</li>
<li><input disabled="" type="checkbox"> Choose the Homepage group: <code>infra</code>, <code>monitoring</code>, <code>tools</code></li>
<li><input disabled="" type="checkbox"> Find the icon on <a href="https://github.com/selfhst/icons" target="_blank" rel="noopener noreferrer">selfhst/icons</a>
</li>
<li><input disabled="" type="checkbox"> Add persistent data in a volume if needed</li>
<li><input disabled="" type="checkbox"> Start from Dockge and verify the container is <code>healthy</code></li>
</ul>
<hr>
<h2 id="5-patterns-and-conventions">5. Patterns and conventions</h2>
<h3 id="the-compose_project_name-variable">The <code>${COMPOSE_PROJECT_NAME}</code> variable</h3>
<p>Docker Compose automatically sets <code>COMPOSE_PROJECT_NAME</code> to the stack folder name. We use it to build URLs dynamically:</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">traefik.http.routers.dozzle.rule</span>: <span style="color:#ae81ff">Host(`${COMPOSE_PROJECT_NAME}.${IP}.${DOMAIN}`)</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">homepage.href</span>: <span style="color:#ae81ff">http://${COMPOSE_PROJECT_NAME}.${IP}.${DOMAIN}</span>
</span></span></code></pre></div><p>Advantage: no <code>*_HOST</code> variable to maintain in each <code>.env</code>. Renaming the folder automatically changes the subdomain.</p>
<p><strong>Warning</strong>: in the <code>.env</code>, <code>COMPOSE_PROJECT_NAME</code> must be defined explicitly with the stack folder name. Without it, Docker Compose uses the current directory name at launch time, which can produce unexpected values depending on where the command is run from.</p>
<h3 id="homepage-groups">Homepage groups</h3>
<p>Services are organized into three groups in the dashboard:</p>
<table>
  <thead>
      <tr>
          <th>Group</th>
          <th>Services</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>infra</code></td>
          <td><a href="https://github.com/traefik/traefik" target="_blank" rel="noopener noreferrer">Traefik</a>
, <a href="https://github.com/louislam/dockge" target="_blank" rel="noopener noreferrer">Dockge</a>
, <a href="https://github.com/containrrr/watchtower" target="_blank" rel="noopener noreferrer">Watchtower</a>
, <a href="https://github.com/gethomepage/homepage" target="_blank" rel="noopener noreferrer">Homepage</a>
</td>
      </tr>
      <tr>
          <td><code>monitoring</code></td>
          <td><a href="https://github.com/amir20/dozzle" target="_blank" rel="noopener noreferrer">Dozzle</a>
, <a href="https://github.com/nicolargo/glances" target="_blank" rel="noopener noreferrer">Glances</a>
, <a href="https://github.com/louislam/uptime-kuma" target="_blank" rel="noopener noreferrer">Uptime Kuma</a>
</td>
      </tr>
      <tr>
          <td><code>tools</code></td>
          <td><a href="https://github.com/gtsteffaniak/filebrowser" target="_blank" rel="noopener noreferrer">FileBrowser</a>
, <a href="https://github.com/CorentinTh/it-tools" target="_blank" rel="noopener noreferrer">IT-Tools</a>
, <a href="https://github.com/Stirling-Tools/Stirling-PDF" target="_blank" rel="noopener noreferrer">Stirling PDF</a>
</td>
      </tr>
  </tbody>
</table>
<p>This grouping is specific to this homelab, not an enforced convention. Homepage accepts any value for <code>homepage.group</code>: you can create as many groups as needed and name them however you like (<code>media</code>, <code>home-automation</code>, <code>dev</code>&hellip;). The dashboard reorganizes automatically.</p>
<h3 id="health-checks">Health checks</h3>
<p>All services have a health check. This is crucial because <strong>Traefik silently ignores <code>unhealthy</code> containers</strong>: a service with a failing health check will not appear in routing, even with <code>traefik.enable: true</code>.</p>
<p>Three edge cases encountered in practice:</p>
<p><strong>1. <code>localhost</code> does not always resolve to <code>127.0.0.1</code></strong></p>
<p>In some minimal images, <code>localhost</code> is not resolved. Use <code>127.0.0.1</code> 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">test</span>: [<span style="color:#e6db74">&#34;CMD-SHELL&#34;</span>, <span style="color:#e6db74">&#34;wget -qO- http://127.0.0.1:8080/ || exit 1&#34;</span>]
</span></span></code></pre></div><p><strong>2. Images without a shell (<code>scratch</code>-based)</strong></p>
<p>Images based on <code>scratch</code> (e.g. Dozzle) do not contain <code>/bin/sh</code>. <code>CMD-SHELL</code> fails. Use the embedded binary:</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">test</span>: [<span style="color:#e6db74">&#34;CMD&#34;</span>, <span style="color:#e6db74">&#34;/dozzle&#34;</span>, <span style="color:#e6db74">&#34;healthcheck&#34;</span>]
</span></span></code></pre></div><p><strong>3. Images without <code>wget</code> or <code>curl</code></strong></p>
<p>Some Node.js or JVM images have neither wget nor curl. Possible solutions:</p>
<ul>
<li>If Node.js is available: <code>node -e &quot;require('http').get('http://localhost:PORT', r =&gt; process.exit(r.statusCode &lt; 400 ? 0 : 1)).on('error', () =&gt; process.exit(1))&quot;</code></li>
<li>If curl is available: <code>curl -fs http://127.0.0.1:PORT/</code></li>
<li>If the app binary exposes a healthcheck subcommand: use it directly.</li>
</ul>
<h3 id="data-persistence">Data persistence</h3>
<p>For services that have data (configuration, user accounts, database):</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">volumes</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#ae81ff">./docker/data:/path/in/container</span>
</span></span></code></pre></div><p>The <code>./docker/</code> folder lives inside the stack directory and can be versioned, except for runtime data which goes in <code>.gitignore</code>.</p>
<p><strong>Rule</strong>: add <code>stacks/&lt;service&gt;/docker/</code> to <code>.gitignore</code> if the folder contains data that should not be committed (SQLite databases, uploads&hellip;).</p>
<h3 id="traefik-label-conventions">Traefik label conventions</h3>
<p>By convention, the name used in Traefik labels (<code>traefik.http.routers.&lt;name&gt;</code>) matches the Docker service name in <code>compose.yaml</code>. In practice, align it with the folder name:</p>
<pre tabindex="0"><code>stacks/it-tools/    →    service: ittools    →    traefik.http.routers.ittools.*
</code></pre><p>This is not a technical constraint from Traefik, just a readability convention.</p>
<hr>
<h2 id="6-common-pitfalls">6. Common pitfalls</h2>
<h3 id="dockge-stop-then-start-not-restart">Dockge: Stop then Start, not Restart</h3>
<p>When a <code>compose.yaml</code> is modified from an IDE and the changes need to be applied, use <strong>Stop + Start</strong> from Dockge, not &ldquo;Restart&rdquo;. Restart restarts the existing container without re-reading the <code>compose.yaml</code>. Stop + Start recreates the container with the new configuration.</p>
<h3 id="modified-labels-restart-homepage">Modified labels: restart Homepage</h3>
<p>Homepage reads Docker labels <strong>at startup</strong>. If <code>homepage.group</code> or <code>homepage.name</code> is changed for a service, Homepage won&rsquo;t see it until it is restarted.</p>
<h3 id="container-starts-but-is-not-routable">Container starts but is not routable</h3>
<p>Check in order:</p>
<ol>
<li><code>docker ps</code>: is the container <code>healthy</code>? Traefik ignores <code>unhealthy</code> containers.</li>
<li>Is the container on the <code>traefik</code> network?</li>
</ol>
<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 inspect &lt;container&gt; --format <span style="color:#e6db74">&#39;{{json .NetworkSettings.Networks}}&#39;</span>
</span></span></code></pre></div><ol start="3">
<li>Is the label <code>traefik.enable: true</code> present?</li>
<li>Does the <code>Host(...)</code> rule match the URL being tested?</li>
</ol>
<h3 id="mounting-non-existent-files-under-docker-desktop--wsl">Mounting non-existent files under Docker Desktop / WSL</h3>
<p>When Docker Desktop (WSL) mounts a <strong>file</strong> that does not yet exist on the host, it creates a <strong>directory</strong> instead. This ghost directory then blocks the mount of the actual file. Symptom: the container fails to start with a mount error.</p>
<p>Solution: ensure the file exists on the host before starting the container, or use a directory mount instead of a file mount.</p>
<h3 id="watchtower-docker-api-too-old">Watchtower: Docker API too old</h3>
<p>On some configurations, Watchtower tries to communicate with the daemon starting the negotiation at API v1.25 (its historical minimum). Recent versions of Docker reject this version. Symptom: the container restarts in a loop with <code>client version 1.25 is too old. Minimum supported API version is 1.40</code>.</p>
<p>Fix in the Watchtower <code>compose.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">environment</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">DOCKER_API_VERSION</span>: <span style="color:#e6db74">&#34;1.40&#34;</span>
</span></span></code></pre></div><p><code>1.40</code> is the value to use, regardless of your Docker version. It is not your exact version — it is the minimum the daemon accepts, as stated in the error message. To check the actual API version of your daemon:</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 version --format <span style="color:#e6db74">&#39;{{.Server.APIVersion}}&#39;</span>
</span></span></code></pre></div><h3 id="pwd-in-dockges-compose-file"><code>${PWD}</code> in Dockge&rsquo;s compose file</h3>
<p><code>${PWD}</code> is not a <code>.env</code> variable — it is a shell variable resolved at <code>docker compose up</code> time. It equals the current terminal directory. Running <code>docker compose up -d</code> from any other directory will produce a wrong value and break stack volume mounts.</p>
<hr>
<p><em>This homelab is designed to run on a Linux machine or WSL. All commands have been tested on Ubuntu/WSL2 with Docker Desktop.</em></p>
<hr>
<h2 id="conclusion">Conclusion</h2>
<p>I&rsquo;m well aware this tutorial doesn&rsquo;t cover everything. We could have added authentication in front of each service, run the whole thing over HTTPS, set up a socket proxy to limit the Docker daemon&rsquo;s exposure, or pinned precise image versions. But each of those points would have considerably lengthened the article and the complexity of the setup. The goal was to start with something functional and maintainable, not to build a fortress on day one.</p>
<p>The perfect homelab doesn&rsquo;t exist. The one that runs, does.</p>
<div style="border: 1px solid #e8e8e8; padding: 16px; margin-top: 2em; border-radius: 3px;">
  <img src="https://cdn.simpleicons.org/github" width="20" style="vertical-align: middle; margin-right: 8px;" />
  <strong><a href="https://github.com/guillaumedelre/homelab" target="_blank" rel="noopener noreferrer">guillaumedelre/homelab</a></strong>
  <p style="margin: 8px 0 0; color: #828282; font-size: 14px;">Docker Compose homelab with Traefik — independent stacks, auto-configured dashboard, and zero DNS configuration using sslip.io.</p>
</div>
<h2 id="references">References</h2>
<table>
  <thead>
      <tr>
          <th>Project</th>
          <th>Link</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>sslip.io</td>
          <td><a href="https://sslip.io" target="_blank" rel="noopener noreferrer">sslip.io</a>
</td>
      </tr>
      <tr>
          <td>selfh.st</td>
          <td><a href="https://selfh.st" target="_blank" rel="noopener noreferrer">selfh.st</a>
</td>
      </tr>
      <tr>
          <td>Traefik</td>
          <td><a href="https://github.com/traefik/traefik" target="_blank" rel="noopener noreferrer">github.com/traefik/traefik</a>
</td>
      </tr>
      <tr>
          <td>Dockge</td>
          <td><a href="https://github.com/louislam/dockge" target="_blank" rel="noopener noreferrer">github.com/louislam/dockge</a>
</td>
      </tr>
      <tr>
          <td>Homepage</td>
          <td><a href="https://github.com/gethomepage/homepage" target="_blank" rel="noopener noreferrer">github.com/gethomepage/homepage</a>
</td>
      </tr>
      <tr>
          <td>Dozzle</td>
          <td><a href="https://github.com/amir20/dozzle" target="_blank" rel="noopener noreferrer">github.com/amir20/dozzle</a>
</td>
      </tr>
      <tr>
          <td>Glances</td>
          <td><a href="https://github.com/nicolargo/glances" target="_blank" rel="noopener noreferrer">github.com/nicolargo/glances</a>
</td>
      </tr>
      <tr>
          <td>FileBrowser</td>
          <td><a href="https://github.com/gtsteffaniak/filebrowser" target="_blank" rel="noopener noreferrer">github.com/gtsteffaniak/filebrowser</a>
</td>
      </tr>
      <tr>
          <td>IT-Tools</td>
          <td><a href="https://github.com/CorentinTh/it-tools" target="_blank" rel="noopener noreferrer">github.com/CorentinTh/it-tools</a>
</td>
      </tr>
      <tr>
          <td>Stirling PDF</td>
          <td><a href="https://github.com/Stirling-Tools/Stirling-PDF" target="_blank" rel="noopener noreferrer">github.com/Stirling-Tools/Stirling-PDF</a>
</td>
      </tr>
      <tr>
          <td>Uptime Kuma</td>
          <td><a href="https://github.com/louislam/uptime-kuma" target="_blank" rel="noopener noreferrer">github.com/louislam/uptime-kuma</a>
</td>
      </tr>
      <tr>
          <td>Watchtower</td>
          <td><a href="https://github.com/containrrr/watchtower" target="_blank" rel="noopener noreferrer">github.com/containrrr/watchtower</a>
</td>
      </tr>
      <tr>
          <td>selfhst/icons</td>
          <td><a href="https://github.com/selfhst/icons" target="_blank" rel="noopener noreferrer">github.com/selfhst/icons</a>
</td>
      </tr>
  </tbody>
</table>
]]></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>PHP 8.5: the pipe operator, a URI library, and a lot of cleanup</title><link>https://guillaumedelre.github.io/2026/01/04/php-8.5-the-pipe-operator-a-uri-library-and-a-lot-of-cleanup/</link><pubDate>Sun, 04 Jan 2026 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2026/01/04/php-8.5-the-pipe-operator-a-uri-library-and-a-lot-of-cleanup/</guid><description>Part 11 of 11 in &amp;quot;PHP Releases&amp;quot;: PHP 8.5 adds a pipe operator for readable functional pipelines and a native URI class that ends fragile string parsing.</description><category>php-releases</category><content:encoded><![CDATA[<p>PHP 8.5 shipped November 20th. Two features define this release: the pipe operator and the URI extension. They solve different problems, but both share the same motivation: making common operations less awkward to express.</p>
<h2 id="the-pipe-operator">The pipe operator</h2>
<p>Functional pipelines in PHP have always been a mess. Chaining transformations meant either nesting function calls inside out, or breaking them into intermediate 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-php" data-lang="php"><span style="display:flex;"><span><span style="color:#75715e">// before — read right to left
</span></span></span><span style="display:flex;"><span>$result <span style="color:#f92672">=</span> <span style="color:#a6e22e">array_sum</span>(<span style="color:#a6e22e">array_map</span>(<span style="color:#e6db74">&#39;strlen&#39;</span>, <span style="color:#a6e22e">array_filter</span>($strings, <span style="color:#e6db74">&#39;strlen&#39;</span>)));
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// or verbose but readable
</span></span></span><span style="display:flex;"><span>$filtered   <span style="color:#f92672">=</span> <span style="color:#a6e22e">array_filter</span>($strings, <span style="color:#e6db74">&#39;strlen&#39;</span>);
</span></span><span style="display:flex;"><span>$lengths    <span style="color:#f92672">=</span> <span style="color:#a6e22e">array_map</span>(<span style="color:#e6db74">&#39;strlen&#39;</span>, $filtered);
</span></span><span style="display:flex;"><span>$result     <span style="color:#f92672">=</span> <span style="color:#a6e22e">array_sum</span>($lengths);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// after — read left to right
</span></span></span><span style="display:flex;"><span>$result <span style="color:#f92672">=</span> $strings
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">|&gt;</span> <span style="color:#a6e22e">array_filter</span>(<span style="color:#f92672">?</span>, <span style="color:#e6db74">&#39;strlen&#39;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">|&gt;</span> <span style="color:#a6e22e">array_map</span>(<span style="color:#e6db74">&#39;strlen&#39;</span>, <span style="color:#f92672">?</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">|&gt;</span> <span style="color:#a6e22e">array_sum</span>(<span style="color:#f92672">?</span>);
</span></span></code></pre></div><p>The <code>|&gt;</code> operator passes the left-hand value into the right-hand expression. The <code>?</code> placeholder marks where it goes. Pipelines now read in the order operations happen: left to right, top to bottom.</p>
<p>This pairs well with first-class callables from PHP 8.1. The two features compose nicely:</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>$result <span style="color:#f92672">=</span> $input <span style="color:#f92672">|&gt;</span> <span style="color:#a6e22e">trim</span>(<span style="color:#f92672">...</span>) <span style="color:#f92672">|&gt;</span> <span style="color:#a6e22e">strtolower</span>(<span style="color:#f92672">...</span>) <span style="color:#f92672">|&gt;</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">normalize</span>(<span style="color:#f92672">...</span>);
</span></span></code></pre></div><h2 id="the-uri-extension">The URI extension</h2>
<p>Handling URIs in PHP has always meant either reaching for a third-party library or cobbling together <code>parse_url()</code> (returns an array, not an object), <code>http_build_query()</code>, and manual string concatenation.</p>
<p>The new <code>Uri</code> extension gives you a proper object-oriented 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>$uri <span style="color:#f92672">=</span> <span style="color:#a6e22e">Uri\Uri</span><span style="color:#f92672">::</span><span style="color:#a6e22e">parse</span>(<span style="color:#e6db74">&#39;https://example.com/path?query=value#fragment&#39;</span>);
</span></span><span style="display:flex;"><span>$modified <span style="color:#f92672">=</span> $uri<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">withPath</span>(<span style="color:#e6db74">&#39;/new-path&#39;</span>)<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">withQuery</span>(<span style="color:#e6db74">&#39;key=val&#39;</span>);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">echo</span> $modified; <span style="color:#75715e">// https://example.com/new-path?key=val#fragment
</span></span></span></code></pre></div><p>Immutable value objects, RFC-compliant parsing, modify individual components without parsing and reconstructing the whole string. Long overdue.</p>
<h2 id="nodiscard">#[\NoDiscard]</h2>
<p>A new attribute that generates a warning when the return value is ignored:</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">#[\NoDiscard(&#34;Use the returned collection, the original is unchanged&#34;)]
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">filter</span>(<span style="color:#a6e22e">callable</span> $fn)<span style="color:#f92672">:</span> <span style="color:#66d9ef">static</span> { <span style="color:#f92672">...</span> }
</span></span></code></pre></div><p>Useful for immutable methods where ignoring the return value is almost certainly a bug. Common in other languages for years, now in PHP where it belongs.</p>
<h2 id="clone-with">clone with</h2>
<p>Cloning an object with modified properties without using property hooks or a custom <code>with()</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>$updated <span style="color:#f92672">=</span> <span style="color:#66d9ef">clone</span>($point) <span style="color:#a6e22e">with</span> { <span style="color:#a6e22e">x</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">10</span>, <span style="color:#a6e22e">y</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">20</span> };
</span></span></code></pre></div><p>Clean syntax for a pattern readonly objects needed: you clone to &ldquo;modify&rdquo; since direct mutation isn&rsquo;t allowed.</p>
<p>PHP 8.5 has a functional streak. The pipe operator and URI extension together make data transformation code meaningfully easier to read. The language keeps moving in a consistent direction.</p>
<h2 id="closures-in-constant-expressions">Closures in constant expressions</h2>
<p>A constraint that&rsquo;s been baked in since PHP 5: constant expressions (attribute arguments, property defaults, parameter defaults, <code>const</code> declarations) couldn&rsquo;t contain closures or first-class callables. 8.5 removes 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">#[Validate(fn($v) =&gt; $v &gt; 0)]
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#a6e22e">int</span> $count <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#66d9ef">NORMALIZER</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">strtolower</span>(<span style="color:#f92672">...</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">Config</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">Closure</span> $transform <span style="color:#f92672">=</span> <span style="color:#a6e22e">trim</span>(<span style="color:#f92672">...</span>),
</span></span><span style="display:flex;"><span>    ) {}
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>This is the missing piece that makes attributes genuinely expressive for validation and transformation rules. Before 8.5, you had to pass class names or string references to attributes and let the framework look them up. Now the callable lives directly in the attribute.</p>
<h2 id="attributes-on-constants">Attributes on constants</h2>
<p>The <code>#[\Deprecated]</code> attribute from 8.4 couldn&rsquo;t be applied to <code>const</code> declarations. 8.5 adds attribute support for constants generally:</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">const</span> <span style="color:#66d9ef">OLD_LIMIT</span> <span style="color:#f92672">=</span> <span style="color:#ae81ff">100</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#[\Deprecated(&#39;Use RATE_LIMIT instead&#39;, since: &#39;3.0&#39;)]
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#66d9ef">API_TIMEOUT</span> <span style="color:#f92672">=</span> <span style="color:#ae81ff">30</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#66d9ef">RATE_LIMIT</span> <span style="color:#f92672">=</span> <span style="color:#ae81ff">60</span>;
</span></span></code></pre></div><p><code>ReflectionConstant</code>, a new reflection class in 8.5, exposes <code>getAttributes()</code> so tools can read them. Combined with closures in constant expressions, attributes on constants become a real metadata layer for compile-time values.</p>
<h2 id="override-extends-to-properties">#[\Override] extends to properties</h2>
<p>PHP 8.3 brought <code>#[\Override]</code> for methods. 8.5 extends it to properties:</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">Base</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;default&#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">Derived</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">Base</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:#a6e22e">string</span> $name <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;derived&#39;</span>;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>If the property doesn&rsquo;t exist in the parent, PHP throws an error. Particularly useful with property hooks from 8.4: you can now signal that a hooked property is intentionally overriding a parent&rsquo;s.</p>
<h2 id="static-asymmetric-visibility">Static asymmetric visibility</h2>
<p>8.4 introduced asymmetric visibility (<code>public private(set)</code>) for instance properties. 8.5 brings that to <code>static</code> properties too:</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">Registry</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">private</span>(<span style="color:#a6e22e">set</span>) <span style="color:#66d9ef">array</span> $items <span style="color:#f92672">=</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">static</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">register</span>(<span style="color:#a6e22e">string</span> $key, <span style="color:#a6e22e">mixed</span> $value)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">self</span><span style="color:#f92672">::</span>$items[$key] <span style="color:#f92672">=</span> $value;
</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">echo</span> <span style="color:#a6e22e">Registry</span><span style="color:#f92672">::</span>$items[<span style="color:#e6db74">&#39;foo&#39;</span>]; <span style="color:#75715e">// readable
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">Registry</span><span style="color:#f92672">::</span>$items[<span style="color:#e6db74">&#39;bar&#39;</span>] <span style="color:#f92672">=</span> <span style="color:#ae81ff">1</span>; <span style="color:#75715e">// Error: cannot write outside class
</span></span></span></code></pre></div><p>Straightforward pattern: expose a static collection for reading, block external mutation.</p>
<h2 id="constructor-promotion-for-final-properties">Constructor promotion for final properties</h2>
<p>Property promotion in constructors has existed since PHP 8.0. The <code>final</code> modifier on promoted properties was the missing piece, 8.5 adds 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">class</span> <span style="color:#a6e22e">ValueObject</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:#66d9ef">final</span> <span style="color:#a6e22e">readonly</span> <span style="color:#a6e22e">string</span> $id,
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">final</span> <span style="color:#a6e22e">readonly</span> <span style="color:#a6e22e">string</span> $name,
</span></span><span style="display:flex;"><span>    ) {}
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>A subclass can&rsquo;t override <code>$id</code> or <code>$name</code> with a property of the same name. The <code>final readonly</code> combination on promoted properties makes value objects as locked down as possible without sealing the whole class.</p>
<h2 id="casts-in-constant-expressions">Casts in constant expressions</h2>
<p>Another gap in constant expressions: no type casts. 8.5 allows 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">const</span> <span style="color:#66d9ef">PRECISION</span> <span style="color:#f92672">=</span> (<span style="color:#a6e22e">int</span>) <span style="color:#ae81ff">3.7</span>;      <span style="color:#75715e">// 3
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#66d9ef">THRESHOLD</span> <span style="color:#f92672">=</span> (<span style="color:#a6e22e">float</span>) <span style="color:#e6db74">&#39;1.5&#39;</span>;  <span style="color:#75715e">// 1.5
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#66d9ef">FLAG</span> <span style="color:#f92672">=</span> (<span style="color:#a6e22e">bool</span>) <span style="color:#ae81ff">1</span>;            <span style="color:#75715e">// true
</span></span></span></code></pre></div><p>Sounds minor until you have configuration constants derived from environment variables that need type coercion right at the declaration.</p>
<h2 id="fatal-errors-include-backtraces">Fatal errors include backtraces</h2>
<p>Before 8.5, a fatal error (out-of-memory, stack overflow, type error in certain contexts) produced a message with no context about where in the code it happened. Finding the cause meant inserting debug logging and reproducing.</p>
<p>8.5 adds stack backtraces to fatal error messages, in the same format as exception backtraces. A new INI directive, <code>fatal_error_backtraces</code>, controls the behavior. It&rsquo;s on by default.</p>
<h2 id="array_first-and-array_last">array_first() and array_last()</h2>
<p>PHP has had <code>reset()</code> and <code>end()</code> for accessing the first and last elements of an array since PHP 3. Both mutate the array&rsquo;s internal pointer (not safe to call on a reference), and they return <code>false</code> for empty arrays in a way that&rsquo;s indistinguishable from a stored <code>false</code> 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>$values <span style="color:#f92672">=</span> [<span style="color:#ae81ff">10</span>, <span style="color:#ae81ff">20</span>, <span style="color:#ae81ff">30</span>];
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>$first <span style="color:#f92672">=</span> <span style="color:#a6e22e">array_first</span>($values);  <span style="color:#75715e">// 10
</span></span></span><span style="display:flex;"><span>$last  <span style="color:#f92672">=</span> <span style="color:#a6e22e">array_last</span>($values);   <span style="color:#75715e">// 30
</span></span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>$first <span style="color:#f92672">=</span> <span style="color:#a6e22e">array_first</span>([]);       <span style="color:#75715e">// null
</span></span></span></code></pre></div><p>The new functions return <code>null</code> for empty arrays, don&rsquo;t touch the internal pointer, and work on any array expression without needing a variable. <code>reset($this-&gt;getItems())</code> was a deprecation warning waiting to happen.</p>
<h2 id="get_error_handler-and-get_exception_handler">get_error_handler() and get_exception_handler()</h2>
<p>PHP has <code>set_error_handler()</code> and <code>set_exception_handler()</code>. Getting the current handler meant either storing it yourself before setting it, or calling <code>set_error_handler(null)</code> and capturing what came back, which also cleared the handler in the process.</p>
<p>8.5 adds:</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>$current <span style="color:#f92672">=</span> <span style="color:#a6e22e">get_error_handler</span>();
</span></span><span style="display:flex;"><span>$current <span style="color:#f92672">=</span> <span style="color:#a6e22e">get_exception_handler</span>();
</span></span></code></pre></div><p>Handy in middleware chains where you want to wrap the existing handler without losing it, or in tests where you want to verify a handler was actually installed.</p>
<h2 id="intllistformatter">IntlListFormatter</h2>
<p>Formatting a list with locale-appropriate conjunctions has always needed manual string assembly. 8.5 adds <code>IntlListFormatter</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>$formatter <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">IntlListFormatter</span>(<span style="color:#e6db74">&#39;en_US&#39;</span>, <span style="color:#a6e22e">IntlListFormatter</span><span style="color:#f92672">::</span><span style="color:#a6e22e">TYPE_AND</span>);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">echo</span> $formatter<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">format</span>([<span style="color:#e6db74">&#39;apples&#39;</span>, <span style="color:#e6db74">&#39;oranges&#39;</span>, <span style="color:#e6db74">&#39;pears&#39;</span>]);
</span></span><span style="display:flex;"><span><span style="color:#75715e">// &#34;apples, oranges, and pears&#34;
</span></span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>$formatter <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">IntlListFormatter</span>(<span style="color:#e6db74">&#39;fr_FR&#39;</span>, <span style="color:#a6e22e">IntlListFormatter</span><span style="color:#f92672">::</span><span style="color:#a6e22e">TYPE_OR</span>);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">echo</span> $formatter<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">format</span>([<span style="color:#e6db74">&#39;rouge&#39;</span>, <span style="color:#e6db74">&#39;bleu&#39;</span>, <span style="color:#e6db74">&#39;vert&#39;</span>]);
</span></span><span style="display:flex;"><span><span style="color:#75715e">// &#34;rouge, bleu ou vert&#34;
</span></span></span></code></pre></div><p>The class wraps ICU&rsquo;s <code>ListFormatter</code>. Three types: <code>TYPE_AND</code>, <code>TYPE_OR</code>, <code>TYPE_UNITS</code>. Width constants control whether you get &ldquo;and&rdquo; or &ldquo;&amp;&rdquo;. Oxford comma handling, locale-specific conjunction placement, all handled by ICU.</p>
<h2 id="filter_throw_on_failure-for-filter_var">FILTER_THROW_ON_FAILURE for filter_var()</h2>
<p><code>filter_var()</code> returns <code>false</code> on validation failure, which produces the classic <code>false vs null vs 0</code> ambiguity when you&rsquo;re filtering untrusted input. A new flag changes 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:#66d9ef">try</span> {
</span></span><span style="display:flex;"><span>    $email <span style="color:#f92672">=</span> <span style="color:#a6e22e">filter_var</span>($input, <span style="color:#a6e22e">FILTER_VALIDATE_EMAIL</span>, <span style="color:#a6e22e">FILTER_THROW_ON_FAILURE</span>);
</span></span><span style="display:flex;"><span>} <span style="color:#66d9ef">catch</span> (<span style="color:#a6e22e">Filter\FilterFailedException</span> $e) {
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// explicitly invalid, not ambiguously false
</span></span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The <code>Filter\FilterFailedException</code> and <code>Filter\FilterException</code> classes are new in 8.5. The flag can&rsquo;t be combined with <code>FILTER_NULL_ON_FAILURE</code>: the behaviors are mutually exclusive.</p>
<h2 id="deprecations-that-clean-up-years-of-technical-debt">Deprecations that clean up years of technical debt</h2>
<p>The backtick operator (<code>`command`</code> as an alias for <code>shell_exec()</code>) is deprecated. It&rsquo;s an obscure syntax that surprises anyone reading the code and is inconsistent with every other PHP function call.</p>
<p>Non-canonical cast names (<code>(boolean)</code>, <code>(integer)</code>, <code>(double)</code>, <code>(binary)</code>) are deprecated in favor of their short forms: <code>(bool)</code>, <code>(int)</code>, <code>(float)</code>, <code>(string)</code>. The long forms have been undocumented for years; 8.5 starts the formal removal.</p>
<p>Semicolon-terminated <code>case</code> statements are deprecated:</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">// deprecated
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">switch</span> ($x) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">case</span> <span style="color:#ae81ff">1</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">break</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">// correct
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">switch</span> ($x) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">case</span> <span style="color:#ae81ff">1</span><span style="color:#f92672">:</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The semicolon form has been syntactically valid since PHP 4 but nobody uses it on purpose. It&rsquo;s a typo PHP happened to accept.</p>
<p><code>__sleep()</code> and <code>__wakeup()</code> are deprecated in favor of <code>__serialize()</code> and <code>__unserialize()</code>, which return and receive arrays and compose correctly with inheritance. The old methods had messy semantics around property visibility.</p>
<h2 id="max_memory_limit-caps-runaway-allocations">max_memory_limit caps runaway allocations</h2>
<p>A new startup-only INI directive: <code>max_memory_limit</code>. It sets a ceiling that <code>memory_limit</code> can&rsquo;t exceed at runtime. If a script calls <code>ini_set('memory_limit', '10G')</code> and <code>max_memory_limit</code> is <code>512M</code>, PHP warns and caps the value.</p>
<p>Useful in shared hosting environments, or anywhere you want to make sure a bug or a malicious payload can&rsquo;t convince PHP to raise its own limit and eat the whole machine&rsquo;s RAM.</p>
<h2 id="opcache-is-always-present">Opcache is always present</h2>
<p>In 8.5, Opcache is always compiled into the PHP binary and always loaded. The old situation (Opcache as a loadable extension that might or might not be present depending on build configuration) is gone.</p>
<p>You can still disable it: <code>opcache.enable=0</code> works fine. What changes is the guarantee that the Opcache API (<code>opcache_get_status()</code>, <code>opcache_invalidate()</code>, etc.) is always available, regardless of how PHP was compiled. Any code that checks <code>extension_loaded('opcache')</code> before calling Opcache functions can drop the check.</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>Observability on FrankenPHP containers before the cloud migration was done</title><link>https://guillaumedelre.github.io/2025/06/07/observability-on-frankenphp-containers-before-the-cloud-migration-was-done/</link><pubDate>Sat, 07 Jun 2025 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2025/06/07/observability-on-frankenphp-containers-before-the-cloud-migration-was-done/</guid><description>Moving 14 PHP microservices to the cloud meant needing observability before the migration was done, not after. FrankenPHP&amp;#39;s Caddy layer made that possible with two lines of config.</description><content:encoded><![CDATA[<p>When you run workloads on-premise, you can get away with almost no observability. You have SSH. You have <code>top</code>. You have someone who knows that the authentication service always spikes on Monday mornings. Institutional knowledge substitutes for instrumentation, and nobody budgets the time to replace it.</p>
<p>Then you migrate to the cloud. The institutional knowledge doesn&rsquo;t follow. The SSH access is gone or inconvenient. And for the first time, you&rsquo;re staring at fourteen FrankenPHP containers with no idea what they&rsquo;re actually doing.</p>
<p>That&rsquo;s the moment you need metrics. Not eventually. Before the migration is done.</p>
<h2 id="the-problem-with-doing-it-properly">The problem with doing it properly</h2>
<p>The correct way to instrument a PHP service for Prometheus: add a client library, write counters and histograms around what you care about, expose a <code>/metrics</code> route, update the scrape config. For one service, that&rsquo;s a reasonable afternoon. For fourteen services mid-migration, it&rsquo;s a multi-sprint project that competes with everything else that needs to move.</p>
<p>The calculation is awkward. You need metrics to trust that the migration is going well. But adding metrics to everything before the migration means the migration takes longer. And the longer it takes, the more you need metrics to know where you stand.</p>
<p>Something had to give.</p>
<h2 id="what-frankenphp-carries-without-announcing-it">What FrankenPHP carries without announcing it</h2>
<p>FrankenPHP is not a PHP runtime that happens to use <a href="https://caddyserver.com" target="_blank" rel="noopener noreferrer">Caddy</a> as its web server. The relationship is inverted: Caddy is the server, and PHP is a Caddy module. Every HTTP request flows through Caddy before it reaches application code.</p>
<p>Caddy ships with a Prometheus-compatible metrics endpoint built in. No plugin, no extra binary. Enable the admin API and it&rsquo;s there.</p>
<p><code>CADDY_GLOBAL_OPTIONS</code> is a FrankenPHP environment variable that injects directives directly into Caddy&rsquo;s global configuration block. Two lines are enough:</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">environment</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">CADDY_GLOBAL_OPTIONS</span>: |<span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        admin 0.0.0.0:2019
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        metrics</span>
</span></span></code></pre></div><p><code>admin 0.0.0.0:2019</code> binds the admin API to all network interfaces - the default is localhost-only, which is unreachable from a Prometheus container on the same network. <code>metrics</code> enables the endpoint.</p>
<p>After that, every container responds to <code>GET :2019/metrics</code> with a full Prometheus payload. Request counts labeled by status code, latency histograms, active connections. No route added to the application. No <code>composer require</code>. No Dockerfile change.</p>
<p>One environment variable, added to each service definition in a single commit. Fourteen scrape targets, all producing data.</p>
<h2 id="a-usable-picture-in-grafana">A usable picture in Grafana</h2>
<p>The Prometheus scrape config lists every service by its container 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:#f92672">scrape_configs</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">job_name</span>: <span style="color:#ae81ff">caddy</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">metrics_path</span>: <span style="color:#ae81ff">/metrics</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">static_configs</span>:
</span></span><span style="display:flex;"><span>          - <span style="color:#f92672">targets</span>:
</span></span><span style="display:flex;"><span>              - <span style="color:#ae81ff">authentication:2019</span>
</span></span><span style="display:flex;"><span>              - <span style="color:#ae81ff">content:2019</span>
</span></span><span style="display:flex;"><span>              - <span style="color:#ae81ff">media:2019</span>
</span></span><span style="display:flex;"><span>              <span style="color:#75715e"># all 14 services</span>
</span></span></code></pre></div><p>Grafana sits on top of Prometheus. The Caddy community dashboard gives you request rates, error rates, and latency percentiles per service, per endpoint, per status code. Within a day of the migration landing in the new environment, there was something meaningful to look at.</p>
<p>The data tier follows the same logic: exporters for PostgreSQL, Redis, and RabbitMQ scrape at the infrastructure level without touching application code. Community dashboards exist for all of them.</p>
<h2 id="what-this-baseline-actually-covers">What this baseline actually covers</h2>
<p>The HTTP metrics from Caddy are web server metrics, not application metrics. They answer: is this service receiving traffic, is it returning errors, how fast is it responding. The kind of questions you ask when something is broken and you need to triage in the dark.</p>
<p>They don&rsquo;t answer: how many items were processed today, which background job is stuck, what is the business impact of this latency spike. For those you need application instrumentation, and that work still exists when you have specific things to measure.</p>
<p>But in a migration context, that distinction matters less than it sounds. The things that break during a cloud migration are mostly infrastructure problems: a service that can&rsquo;t reach its database, a memory limit that was set too low, a queue consumer that stopped picking up messages. Those are exactly the things the baseline covers.</p>
<p>Getting instrumentation right for business-level events can wait until the platform is stable. Getting enough visibility to know whether the migration succeeded cannot.</p>
]]></content:encoded></item><item><title>Local HTTPS with Traefik: traefik.me is dead, long live sslip.io</title><link>https://guillaumedelre.github.io/2025/04/17/local-https-with-traefik-traefik.me-is-dead-long-live-sslip.io/</link><pubDate>Thu, 17 Apr 2025 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2025/04/17/local-https-with-traefik-traefik.me-is-dead-long-live-sslip.io/</guid><description>traefik.me&amp;#39;s wildcard cert was revoked in 2025. Here&amp;#39;s how to replace it with sslip.io, mkcert, and a local Traefik setup.</description><content:encoded><![CDATA[<p>The setup seemed perfect. Point <code>*.traefik.me</code> at 127.0.0.1, download a wildcard certificate from the same domain, drop it into Traefik, and every local service gets a clean HTTPS URL with no IP in the address bar. No Let&rsquo;s Encrypt rate limits, no <code>mkcert</code> to explain to teammates, no self-signed warnings to click through. Just <code>https://myapp.traefik.me</code> and a green padlock.</p>
<p>Then in March 2025, Let&rsquo;s Encrypt revoked the certificate. The wildcard cert for traefik.me is gone and it&rsquo;s not coming back.</p>
<h2 id="what-traefikme-was-actually-selling">What traefik.me was actually selling</h2>
<p>traefik.me is a wildcard DNS resolver. Type <code>anything.traefik.me</code> and it resolves to 127.0.0.1. Type <code>anything.10.0.0.1.traefik.me</code> and it resolves to 10.0.0.1. No account, no configuration, no infrastructure to maintain. The DNS part still works fine, by the way.</p>
<p>The certificate was the bonus: a wildcard cert for <code>*.traefik.me</code> that pyrou, the maintainer, generated with Let&rsquo;s Encrypt and distributed at <code>https://traefik.me/cert.pem</code> and <code>https://traefik.me/privkey.pem</code>. It was convenient precisely because it was shared: download, drop into Traefik, done.</p>
<p>Sharing a private key is why it died.</p>
<p>The CA/Browser Forum Baseline Requirements, section 9.6.3, require subscribers to &ldquo;maintain sole control&rdquo; over their private key. Distributing it to anyone who visits a URL is the exact opposite of sole control. Let&rsquo;s Encrypt sent a notice, blocked future issuance for the domain, and revoked the existing certificate. Pyrou confirmed the situation and recommended mkcert as an alternative. The project will live on as a DNS resolver only.</p>
<p>The cert had already been revoked twice before 2025. Third time was the last.</p>
<h2 id="sslipio-does-the-same-thing-differently">sslip.io does the same thing, differently</h2>
<p>sslip.io is also a wildcard DNS resolver, with one difference: the IP is encoded in the hostname rather than resolved from a fallback. <code>10-0-0-1.sslip.io</code> resolves to <code>10.0.0.1</code>. <code>myapp.192-168-1-10.sslip.io</code> resolves to <code>192.168.1.10</code>. IPv6 works too.</p>
<p>The infrastructure behind sslip.io is also more visible: three nameservers in Singapore, the US, and Poland, handling over 10,000 requests per second, with public monitoring. About 1,000 GitHub stars and active maintenance under the Apache 2.0 licence.</p>
<p>Strip away the certificate story and the comparison is pretty straightforward:</p>
<table>
  <thead>
      <tr>
          <th></th>
          <th>traefik.me</th>
          <th>sslip.io</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>DNS wildcard</td>
          <td>yes</td>
          <td>yes</td>
      </tr>
      <tr>
          <td>Fallback to 127.0.0.1</td>
          <td>yes</td>
          <td>no</td>
      </tr>
      <tr>
          <td>IPv6</td>
          <td>no</td>
          <td>yes</td>
      </tr>
      <tr>
          <td>Wildcard certificate</td>
          <td><del>yes</del> revoked</td>
          <td>no</td>
      </tr>
      <tr>
          <td>Infrastructure</td>
          <td>opaque</td>
          <td>documented</td>
      </tr>
      <tr>
          <td>Project activity</td>
          <td>stalled</td>
          <td>active</td>
      </tr>
  </tbody>
</table>
<p>traefik.me&rsquo;s only remaining advantage is the 127.0.0.1 fallback: URLs without an IP segment. That matters if you really want <code>myapp.traefik.me</code> instead of <code>myapp.127-0-0-1.sslip.io</code>. Whether that difference is worth the infrastructure uncertainty is a short conversation.</p>
<h2 id="mkcert-fills-the-gap">mkcert fills the gap</h2>
<p>mkcert creates a local certificate authority, installs it in the system trust store and whatever browsers it finds, then issues certificates signed by that CA. Browsers see a trusted chain. No warning, no click-through, no &ldquo;proceed anyway&rdquo;.</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>mkcert -install
</span></span></code></pre></div><p>That&rsquo;s the one-time setup. After that, generating a certificate is one command:</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>mkcert <span style="color:#e6db74">&#34;*.127-0-0-1.sslip.io&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># produces _wildcard.127-0-0-1.sslip.io.pem</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#          _wildcard.127-0-0-1.sslip.io-key.pem</span>
</span></span></code></pre></div><p>The limitation is that mkcert&rsquo;s CA is local. Other machines on the network won&rsquo;t trust it by default. For a solo dev setup that&rsquo;s fine. For a shared team environment, you&rsquo;d need to distribute the CA root, which is essentially the same operational problem traefik.me was trying to avoid, just smaller in scope.</p>
<h2 id="the-traefik-configuration">The Traefik configuration</h2>
<p>The setup is the same regardless of which DNS service you pick. Traefik needs the certificate mounted as a volume and a static file provider pointing at a TLS configuration 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:#75715e"># traefik/config/tls.yml</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">tls</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">certificates</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">certFile</span>: <span style="color:#ae81ff">/certs/cert.pem</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">keyFile</span>: <span style="color:#ae81ff">/certs/key.pem</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">stores</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">defaultCertificate</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">certFile</span>: <span style="color:#ae81ff">/certs/cert.pem</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">keyFile</span>: <span style="color:#ae81ff">/certs/key.pem</span>
</span></span></code></pre></div><p>The key practice: run Traefik in its own Compose project, separate from the services it routes to. Each service project connects to Traefik through a shared external network. Start and stop services independently without touching the reverse proxy.</p>
<p>Start by creating the external network once:</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 network create traefik-public
</span></span></code></pre></div><p><strong><code>traefik/compose.yml</code></strong> - Traefik alone, owning the network:</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">traefik</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">image</span>: <span style="color:#ae81ff">traefik:v3</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">ports</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#e6db74">&#34;80:80&#34;</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#e6db74">&#34;443:443&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">volumes</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">/var/run/docker.sock:/var/run/docker.sock</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">./config:/etc/traefik/config</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">./certs:/certs</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">command</span>:
</span></span><span style="display:flex;"><span>      - --<span style="color:#ae81ff">entrypoints.web.address=:80</span>
</span></span><span style="display:flex;"><span>      - --<span style="color:#ae81ff">entrypoints.websecure.address=:443</span>
</span></span><span style="display:flex;"><span>      - --<span style="color:#ae81ff">providers.docker=true</span>
</span></span><span style="display:flex;"><span>      - --<span style="color:#ae81ff">providers.docker.network=traefik-public</span>
</span></span><span style="display:flex;"><span>      - --<span style="color:#ae81ff">providers.file.directory=/etc/traefik/config</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">networks</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">traefik-public</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">networks</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">traefik-public</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">external</span>: <span style="color:#66d9ef">true</span>
</span></span></code></pre></div><p>Copy the mkcert output into <code>./certs/</code>, rename to <code>cert.pem</code> and <code>key.pem</code>, then:</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 compose -f traefik/compose.yml up -d
</span></span></code></pre></div><p>Traefik is up, listening on 80 and 443, watching Docker for new containers. Nothing is routed yet.</p>
<p><strong><code>whoami/compose.yml</code></strong> - a service that joins the same network:</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">whoami</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">image</span>: <span style="color:#ae81ff">traefik/whoami</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">labels</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#e6db74">&#34;traefik.enable=true&#34;</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#e6db74">&#34;traefik.http.routers.whoami.rule=Host(`whoami.127-0-0-1.sslip.io`)&#34;</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#e6db74">&#34;traefik.http.routers.whoami.tls=true&#34;</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#e6db74">&#34;traefik.http.routers.whoami.entrypoints=websecure&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">networks</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">traefik-public</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">networks</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">traefik-public</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">external</span>: <span style="color:#66d9ef">true</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>docker compose -f whoami/compose.yml up -d
</span></span></code></pre></div><p>Traefik detects the new container via the Docker provider, reads its labels, and adds the route. <code>https://whoami.127-0-0-1.sslip.io</code> responds immediately. Bring <code>whoami</code> down and the route disappears. Traefik keeps running without noticing.</p>
<p>The <code>external: true</code> declaration is the load-bearing line. Without it, Compose creates a project-scoped network: Traefik and <code>whoami</code> end up on different networks and can&rsquo;t reach each other, even though both are running. The external network is the shared bus every service project must explicitly opt into.</p>
<p>If you prefer traefik.me URLs, replace the mkcert command and the host label:</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>mkcert <span style="color:#e6db74">&#34;*.traefik.me&#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-yaml" data-lang="yaml"><span style="display:flex;"><span>- <span style="color:#e6db74">&#34;traefik.http.routers.whoami.rule=Host(`whoami.traefik.me`)&#34;</span>
</span></span></code></pre></div><p>The DNS fallback to 127.0.0.1 handles the rest.</p>
<h2 id="what-the-traefikme-story-actually-teaches">What the traefik.me story actually teaches</h2>
<p>The certificate distribution model was always fragile. A &ldquo;public-private key pair&rdquo; is a contradiction in terms. Every revocation was a warning that the next one could be permanent. Eventually it was.</p>
<p>The lesson isn&rsquo;t specific to traefik.me. Any service that provides convenience by quietly removing a security boundary will eventually hit that boundary. mkcert is the right tool for this problem because it operates entirely within your own trust domain: you generate the CA, you install it, you issue the certificates. Nothing depends on a third party&rsquo;s continued willingness to bend certificate issuance rules.</p>
<p>sslip.io solves the DNS part cleanly. mkcert solves the TLS part cleanly. They compose well. The traefik.me setup was simpler, for a while. Until it wasn&rsquo;t.</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>PHP 8.4: property hooks and the end of the getter/setter ceremony</title><link>https://guillaumedelre.github.io/2025/01/05/php-8.4-property-hooks-and-the-end-of-the-getter/setter-ceremony/</link><pubDate>Sun, 05 Jan 2025 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2025/01/05/php-8.4-property-hooks-and-the-end-of-the-getter/setter-ceremony/</guid><description>Part 10 of 11 in &amp;quot;PHP Releases&amp;quot;: PHP 8.4 brings property hooks: get/set logic directly on properties, replacing twenty years of getter/setter boilerplate.</description><category>php-releases</category><content:encoded><![CDATA[<p>PHP 8.4 released November 21st. Property hooks are the feature. Everything else, and there&rsquo;s quite a bit of it, is secondary.</p>
<h2 id="property-hooks">Property hooks</h2>
<p>For twenty years, if you wanted behavior on property access in PHP you had to write getters and setters:</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">User</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#a6e22e">string</span> $_name;
</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 style="color:#66d9ef">return</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">_name</span>; }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">setName</span>(<span style="color:#a6e22e">string</span> $name)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span> {
</span></span><span style="display:flex;"><span>        $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">_name</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">strtoupper</span>($name);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>PHP 8.4 adds hooks directly on the 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">class</span> <span style="color:#a6e22e">User</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#a6e22e">string</span> $name {
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">set</span>(<span style="color:#a6e22e">string</span> $name) {
</span></span><span style="display:flex;"><span>            $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">name</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">strtoupper</span>($name);
</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>You can define <code>get</code> and <code>set</code> hooks independently. A property with only a <code>get</code> hook is computed on access:</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">Circle</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#a6e22e">float</span> $area {
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">get</span> <span style="color:#f92672">=&gt;</span> <span style="color:#a6e22e">M_PI</span> <span style="color:#f92672">*</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">radius</span> <span style="color:#f92672">**</span> <span style="color:#ae81ff">2</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">__construct</span>(<span style="color:#66d9ef">public</span> <span style="color:#a6e22e">float</span> $radius) {}
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>No backing storage, no explicit getter method, full IDE support. Interfaces can declare properties with hooks too, which means contracts can now specify behavior on property access, something that was flat-out impossible before.</p>
<h2 id="asymmetric-visibility">Asymmetric visibility</h2>
<p>A lighter option for when you just want public read, private write:</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">Version</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">private</span>(<span style="color:#a6e22e">set</span>) <span style="color:#a6e22e">string</span> $value <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;1.0.0&#39;</span>;
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>$v <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Version</span>();
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">echo</span> $v<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">value</span>;      <span style="color:#75715e">// works
</span></span></span><span style="display:flex;"><span>$v<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">value</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;2.0&#39;</span>;  <span style="color:#75715e">// Error
</span></span></span></code></pre></div><p>Kills the <code>private $x</code> + <code>public getX()</code> pattern for read-only public properties without needing full readonly semantics.</p>
<h2 id="array_find-and-friends">array_find() and friends</h2>
<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>$first <span style="color:#f92672">=</span> <span style="color:#a6e22e">array_find</span>($users, <span style="color:#a6e22e">fn</span>($u) <span style="color:#f92672">=&gt;</span> $u<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">isActive</span>());
</span></span><span style="display:flex;"><span>$any   <span style="color:#f92672">=</span> <span style="color:#a6e22e">array_any</span>($users, <span style="color:#a6e22e">fn</span>($u) <span style="color:#f92672">=&gt;</span> $u<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">isPremium</span>());
</span></span><span style="display:flex;"><span>$all   <span style="color:#f92672">=</span> <span style="color:#a6e22e">array_all</span>($users, <span style="color:#a6e22e">fn</span>($u) <span style="color:#f92672">=&gt;</span> $u<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">isVerified</span>());
</span></span></code></pre></div><p>These have been in every other language&rsquo;s standard library for decades. In PHP, you had to use <code>array_filter()</code> + index access or write a manual loop. They exist now: <code>array_find()</code>, <code>array_find_key()</code>, <code>array_any()</code>, <code>array_all()</code>.</p>
<h2 id="instantiation-without-extra-parentheses">Instantiation without extra parentheses</h2>
<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:#66d9ef">new</span> <span style="color:#a6e22e">MyClass</span>())<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">method</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:#66d9ef">new</span> <span style="color:#a6e22e">MyClass</span>()<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">method</span>();
</span></span></code></pre></div><p>A syntax restriction that was always annoying and never justified is gone.</p>
<h2 id="lazy-objects">Lazy objects</h2>
<p>Objects whose initialization is deferred until first property access:</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>$user <span style="color:#f92672">=</span> $reflector<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">newLazyProxy</span>(<span style="color:#a6e22e">fn</span>() <span style="color:#f92672">=&gt;</span> $repository<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">find</span>($id));
</span></span><span style="display:flex;"><span><span style="color:#75715e">// No database call yet
</span></span></span><span style="display:flex;"><span>$user<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">name</span>; <span style="color:#75715e">// Now the proxy initializes
</span></span></span></code></pre></div><p>The direct audience is framework ORM and DI container authors, not application developers. But the effect shows up in every app that uses Doctrine or Symfony: lazy loading implemented at the language level rather than through code generation.</p>
<p>PHP 8.4 is a language that barely resembles the PHP 5 most of us started with. Property hooks in particular: they&rsquo;re not a workaround, they&rsquo;re a design feature.</p>
<h2 id="deprecated-for-your-own-code">#[\Deprecated] for your own code</h2>
<p>PHP has emitted deprecation notices for built-in functions for years. 8.4 lets you wire the same mechanism into your own 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">ApiClient</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">#[\Deprecated(
</span></span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">message</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;Use fetchJson() instead&#39;</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">since</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;2.0&#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">get</span>(<span style="color:#a6e22e">string</span> $url)<span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span> { <span style="color:#f92672">...</span> }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Calling a deprecated method now emits <code>E_USER_DEPRECATED</code>, just like calling <code>mysql_connect()</code>. IDEs pick it up, static analyzers flag it, the error log captures it. Before this, the only option was a <code>@deprecated</code> PHPDoc comment: fine for IDEs, completely invisible to the engine.</p>
<h2 id="bcmathnumber-makes-arbitrary-precision-usable">BcMath\Number makes arbitrary precision usable</h2>
<p>The <code>bcmath</code> functions have been in PHP since forever, but their procedural API makes chaining anything painful. 8.4 adds <code>BcMath\Number</code>, an object wrapper with operator overloading:</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>$a <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">BcMath\Number</span>(<span style="color:#e6db74">&#39;10.5&#39;</span>);
</span></span><span style="display:flex;"><span>$b <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">BcMath\Number</span>(<span style="color:#e6db74">&#39;3.2&#39;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>$result <span style="color:#f92672">=</span> $a <span style="color:#f92672">+</span> $b;             <span style="color:#75715e">// BcMath\Number(&#39;13.7&#39;)
</span></span></span><span style="display:flex;"><span>$result <span style="color:#f92672">=</span> $a <span style="color:#f92672">*</span> $b <span style="color:#f92672">-</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">BcMath\Number</span>(<span style="color:#e6db74">&#39;1&#39;</span>);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">echo</span> $result;                  <span style="color:#75715e">// 32.6
</span></span></span></code></pre></div><p>The <code>+</code>, <code>-</code>, <code>*</code>, <code>/</code>, <code>**</code>, <code>%</code> operators all work. The object is immutable. Scale propagates automatically through operations. Financial calculations, which used to mean chains of <code>bcadd(bcmul(...), ...)</code>, now just read like arithmetic.</p>
<p>New procedural functions complete the picture: <code>bcceil()</code>, <code>bcfloor()</code>, <code>bcround()</code>, <code>bcdivmod()</code>.</p>
<h2 id="roundingmode-enum-replaces-php_round_-constants">RoundingMode enum replaces PHP_ROUND_* constants</h2>
<p><code>round()</code> has always taken a <code>$mode</code> int from a set of <code>PHP_ROUND_*</code> constants. 8.4 replaces that with a <code>RoundingMode</code> enum with cleaner names and four additional modes that weren&rsquo;t available before:</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">round</span>(<span style="color:#ae81ff">2.5</span>, <span style="color:#a6e22e">mode</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">RoundingMode</span><span style="color:#f92672">::</span><span style="color:#a6e22e">HalfAwayFromZero</span>);  <span style="color:#75715e">// 3
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">round</span>(<span style="color:#ae81ff">2.5</span>, <span style="color:#a6e22e">mode</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">RoundingMode</span><span style="color:#f92672">::</span><span style="color:#a6e22e">HalfTowardsZero</span>);   <span style="color:#75715e">// 2
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">round</span>(<span style="color:#ae81ff">2.5</span>, <span style="color:#a6e22e">mode</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">RoundingMode</span><span style="color:#f92672">::</span><span style="color:#a6e22e">HalfEven</span>);          <span style="color:#75715e">// 2 (banker&#39;s rounding)
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">round</span>(<span style="color:#ae81ff">2.5</span>, <span style="color:#a6e22e">mode</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">RoundingMode</span><span style="color:#f92672">::</span><span style="color:#a6e22e">HalfOdd</span>);           <span style="color:#75715e">// 3
</span></span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// The four new modes (only available via the enum)
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">round</span>(<span style="color:#ae81ff">2.3</span>, <span style="color:#a6e22e">mode</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">RoundingMode</span><span style="color:#f92672">::</span><span style="color:#a6e22e">TowardsZero</span>);       <span style="color:#75715e">// 2
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">round</span>(<span style="color:#ae81ff">2.7</span>, <span style="color:#a6e22e">mode</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">RoundingMode</span><span style="color:#f92672">::</span><span style="color:#a6e22e">AwayFromZero</span>);      <span style="color:#75715e">// 3
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">round</span>(<span style="color:#ae81ff">2.3</span>, <span style="color:#a6e22e">mode</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">RoundingMode</span><span style="color:#f92672">::</span><span style="color:#a6e22e">PositiveInfinity</span>);  <span style="color:#75715e">// 3
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">round</span>(<span style="color:#ae81ff">2.3</span>, <span style="color:#a6e22e">mode</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">RoundingMode</span><span style="color:#f92672">::</span><span style="color:#a6e22e">NegativeInfinity</span>);  <span style="color:#75715e">// 2
</span></span></span></code></pre></div><p>The old <code>PHP_ROUND_*</code> constants still work. The enum is the path forward.</p>
<h2 id="multibyte-string-functions-that-should-have-existed">Multibyte string functions that should have existed</h2>
<p><code>mb_trim()</code>, <code>mb_ltrim()</code>, <code>mb_rtrim()</code>: trim functions that respect multibyte character boundaries, not just ASCII whitespace. Also new: <code>mb_ucfirst()</code> and <code>mb_lcfirst()</code> for proper title-casing of multibyte strings.</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>$s <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;\u{200B}hello\u{200B}&#34;</span>; <span style="color:#75715e">// Zero-width spaces
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">echo</span> <span style="color:#a6e22e">mb_trim</span>($s);              <span style="color:#75715e">// &#34;hello&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">echo</span> <span style="color:#a6e22e">mb_ucfirst</span>(<span style="color:#e6db74">&#39;über&#39;</span>);       <span style="color:#75715e">// &#34;Über&#34;
</span></span></span></code></pre></div><p>These fill gaps that have been sitting there since <code>mbstring</code> was introduced.</p>
<h2 id="request_parse_body-for-non-post-requests">request_parse_body() for non-POST requests</h2>
<p>PHP automatically parses <code>application/x-www-form-urlencoded</code> and <code>multipart/form-data</code> into <code>$_POST</code> and <code>$_FILES</code>, but only for POST requests. PATCH and PUT requests with the same content types needed manual parsing with <code>file_get_contents('php://input')</code> and custom 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:#75715e">// Inside a PATCH handler
</span></span></span><span style="display:flex;"><span>[$_POST, $_FILES] <span style="color:#f92672">=</span> <span style="color:#a6e22e">request_parse_body</span>();
</span></span></code></pre></div><p>The function returns a tuple. Same parsing logic PHP uses for POST, now available for any HTTP method.</p>
<h2 id="a-new-dom-api-that-follows-the-spec">A new DOM API that follows the spec</h2>
<p>The existing <code>DOMDocument</code> API was built on an older DOM level 3 spec with PHP-specific quirks layered on top. 8.4 adds a parallel <code>Dom\</code> namespace that implements the WHATWG Living Standard:</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>$doc <span style="color:#f92672">=</span> <span style="color:#a6e22e">Dom\HTMLDocument</span><span style="color:#f92672">::</span><span style="color:#a6e22e">createFromString</span>(<span style="color:#e6db74">&#39;&lt;p class=&#34;lead&#34;&gt;Hello&lt;/p&gt;&#39;</span>);
</span></span><span style="display:flex;"><span>$p <span style="color:#f92672">=</span> $doc<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">querySelector</span>(<span style="color:#e6db74">&#39;p&#39;</span>);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">echo</span> $p<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">classList</span>;  <span style="color:#75715e">// &#34;lead&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">echo</span> $p<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">id</span>;         <span style="color:#75715e">// &#34;&#34;
</span></span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>$doc2 <span style="color:#f92672">=</span> <span style="color:#a6e22e">Dom\HTMLDocument</span><span style="color:#f92672">::</span><span style="color:#a6e22e">createFromFile</span>(<span style="color:#e6db74">&#39;page.html&#39;</span>);
</span></span></code></pre></div><p><code>Dom\HTMLDocument</code> parses HTML5 correctly, tag soup included. <code>Dom\XMLDocument</code> handles strict XML. The new classes are strict about types, return proper node types, and expose modern properties like <code>classList</code>, <code>id</code>, <code>className</code>. The old <code>DOMDocument</code> stays, unchanged, for backward compatibility.</p>
<h2 id="pdo-gets-driver-specific-subclasses">PDO gets driver-specific subclasses</h2>
<p><code>PDO::connect()</code> and direct instantiation now return driver-specific subclasses when available:</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>$pdo <span style="color:#f92672">=</span> <span style="color:#a6e22e">PDO</span><span style="color:#f92672">::</span><span style="color:#a6e22e">connect</span>(<span style="color:#e6db74">&#39;mysql:host=localhost;dbname=test&#39;</span>, <span style="color:#e6db74">&#39;user&#39;</span>, <span style="color:#e6db74">&#39;pass&#39;</span>);
</span></span><span style="display:flex;"><span><span style="color:#75715e">// $pdo is now a Pdo\Mysql instance
</span></span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>$pdo <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Pdo\Pgsql</span>(<span style="color:#e6db74">&#39;pgsql:host=localhost;dbname=test&#39;</span>, <span style="color:#e6db74">&#39;user&#39;</span>, <span style="color:#e6db74">&#39;pass&#39;</span>);
</span></span></code></pre></div><p>Each driver subclass (<code>Pdo\Mysql</code>, <code>Pdo\Pgsql</code>, <code>Pdo\Sqlite</code>, <code>Pdo\Firebird</code>, <code>Pdo\Odbc</code>, <code>Pdo\DbLib</code>) can expose driver-specific methods without polluting the base <code>PDO</code> interface. Doctrine, Laravel, and similar ORMs can now type-hint against the specific driver class when they need driver-specific behavior.</p>
<h2 id="openssl-gets-modern-key-support">OpenSSL gets modern key support</h2>
<p><code>openssl_pkey_new()</code> and related functions now support Curve25519 and Curve448, the modern elliptic curves that have replaced older NIST curves in most security recommendations:</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>$key <span style="color:#f92672">=</span> <span style="color:#a6e22e">openssl_pkey_new</span>([<span style="color:#e6db74">&#39;curve_name&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;ed25519&#39;</span>, <span style="color:#e6db74">&#39;private_key_type&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#a6e22e">OPENSSL_KEYTYPE_EC</span>]);
</span></span><span style="display:flex;"><span>$details <span style="color:#f92672">=</span> <span style="color:#a6e22e">openssl_pkey_get_details</span>($key);
</span></span></code></pre></div><p><code>x25519</code> and <code>x448</code> for key exchange, <code>ed25519</code> and <code>ed448</code> for signatures. All four now work with <code>openssl_sign()</code> and <code>openssl_verify()</code>.</p>
<h2 id="pcre-variable-length-lookbehind">PCRE: variable-length lookbehind</h2>
<p>The bundled PCRE2 library update (10.44) brings variable-length lookbehind assertions, something Perl and Python regex engines had and PHP couldn&rsquo;t do:</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">// Match &#34;bar&#34; only when preceded by &#34;foo&#34; or &#34;foooo&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">preg_match</span>(<span style="color:#e6db74">&#39;/(?&lt;=foo+)bar/&#39;</span>, <span style="color:#e6db74">&#39;foooobar&#39;</span>, $matches);
</span></span></code></pre></div><p>Lookbehind assertions used to require a fixed-width pattern. Now they can match patterns of variable length. The <code>r</code> modifier (<code>PCRE2_EXTRA_CASELESS_RESTRICT</code>) is also new: it prevents mixing ASCII and non-ASCII characters in case-insensitive matches, closing a class of Unicode confusion attacks.</p>
<h2 id="datetime-gets-microseconds-and-timestamp-factory">DateTime gets microseconds and timestamp factory</h2>
<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>$dt <span style="color:#f92672">=</span> <span style="color:#a6e22e">DateTimeImmutable</span><span style="color:#f92672">::</span><span style="color:#a6e22e">createFromTimestamp</span>(<span style="color:#ae81ff">1700000000.5</span>);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">echo</span> $dt<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getMicrosecond</span>(); <span style="color:#75715e">// 500000
</span></span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>$with_micros <span style="color:#f92672">=</span> $dt<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">setMicrosecond</span>(<span style="color:#ae81ff">123456</span>);
</span></span></code></pre></div><p><code>createFromTimestamp()</code> accepts a float for sub-second precision. <code>getMicrosecond()</code> and <code>setMicrosecond()</code> round out the API for the microsecond component that&rsquo;s been inside <code>DateTime</code> internally but inaccessible directly.</p>
<h2 id="fpow-for-ieee-754-compliance">fpow() for IEEE 754 compliance</h2>
<p><code>pow(0, -2)</code> in PHP has historically returned an implementation-defined value. 8.4 deprecates <code>pow()</code> with a zero base and negative exponent and introduces <code>fpow()</code>, which strictly follows IEEE 754: <code>fpow(0, -2)</code> returns <code>INF</code>, as the standard defines:</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">echo</span> <span style="color:#a6e22e">fpow</span>(<span style="color:#ae81ff">2.0</span>, <span style="color:#ae81ff">3.0</span>);   <span style="color:#75715e">// 8.0
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">echo</span> <span style="color:#a6e22e">fpow</span>(<span style="color:#ae81ff">0.0</span>, <span style="color:#f92672">-</span><span style="color:#ae81ff">1.0</span>);  <span style="color:#75715e">// INF
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">echo</span> <span style="color:#a6e22e">fpow</span>(<span style="color:#f92672">-</span><span style="color:#ae81ff">1.0</span>, <span style="color:#a6e22e">INF</span>);  <span style="color:#75715e">// 1.0
</span></span></span></code></pre></div><p>Worth knowing in any code doing mathematical computations where IEEE compliance matters.</p>
<h2 id="the-cost-of-bcrypt-goes-up">The cost of bcrypt goes up</h2>
<p>The default cost for <code>password_hash()</code> with <code>PASSWORD_BCRYPT</code> went from <code>10</code> to <code>12</code>. This hits any code calling <code>password_hash($pass, PASSWORD_BCRYPT)</code> without an explicit cost. The goal is to keep the default roughly &ldquo;a few hundred milliseconds on modern hardware&rdquo; as hardware gets faster.</p>
<p>If you store bcrypt hashes and upgrade to 8.4, existing hashes stay valid: <code>password_verify()</code> reads the cost from the hash itself. New hashes use cost 12. <code>password_needs_rehash()</code> returns true for old hashes if you pass <code>['cost' =&gt; 12]</code>, so you can upgrade them on next login.</p>
<h2 id="deprecations-that-matter">Deprecations that matter</h2>
<p>Implicitly nullable parameters are deprecated. If a parameter has a default of <code>null</code>, the type has to say so 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">// Deprecated in 8.4
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">function</span> <span style="color:#a6e22e">foo</span>(<span style="color:#a6e22e">string</span> $s <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:#75715e">// Correct
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">function</span> <span style="color:#a6e22e">foo</span>(<span style="color:#f92672">?</span><span style="color:#a6e22e">string</span> $s <span style="color:#f92672">=</span> <span style="color:#66d9ef">null</span>) {}
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">function</span> <span style="color:#a6e22e">foo</span>(<span style="color:#a6e22e">string</span><span style="color:#f92672">|</span><span style="color:#66d9ef">null</span> $s <span style="color:#f92672">=</span> <span style="color:#66d9ef">null</span>) {}
</span></span></code></pre></div><p><code>trigger_error()</code> with <code>E_USER_ERROR</code> is deprecated: replace it with an exception or <code>exit()</code>. The <code>E_USER_ERROR</code> level was always an awkward hybrid between a recoverable error and a fatal one, and nobody was sure which.</p>
<p><code>lcg_value()</code> is deprecated too. Use <code>Random\Randomizer::getFloat()</code> instead. The LCG generator had poor randomness properties and no seeding control.</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>PHP 8.3: typed constants and the small wins that stick</title><link>https://guillaumedelre.github.io/2024/01/07/php-8.3-typed-constants-and-the-small-wins-that-stick/</link><pubDate>Sun, 07 Jan 2024 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2024/01/07/php-8.3-typed-constants-and-the-small-wins-that-stick/</guid><description>Part 9 of 11 in &amp;quot;PHP Releases&amp;quot;: PHP 8.3 adds typed class constants, a json_validate function, and a cleaner way to fetch class constants dynamically.</description><category>php-releases</category><content:encoded><![CDATA[<p>PHP 8.3 landed November 23rd. Quiet release by PHP standards: no enum-sized shift, no JIT. What it does have is a focused set of improvements that close long-standing gaps in the type system and add functions that should have existed years ago.</p>
<h2 id="typed-class-constants">Typed class constants</h2>
<p>Class constants have been untyped since their introduction. PHP 8.3 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-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">interface</span> <span style="color:#a6e22e">HasVersion</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#66d9ef">string</span> <span style="color:#a6e22e">VERSION</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">App</span> <span style="color:#66d9ef">implements</span> <span style="color:#a6e22e">HasVersion</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#66d9ef">string</span> <span style="color:#a6e22e">VERSION</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;1.0.0&#39;</span>;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Without typed constants, an interface constant could be overridden with a completely different type in an implementing class and nothing would complain. Typed constants close that gap, and on interface-driven codebases the impact is immediate.</p>
<h2 id="dynamic-class-constant-access">Dynamic class constant access</h2>
<p>A gap that required a workaround since constants were introduced:</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>$name <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;STATUS&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">echo</span> <span style="color:#a6e22e">MyClass</span><span style="color:#f92672">::</span>{$name}; <span style="color:#75715e">// now works
</span></span></span></code></pre></div><p>Before, accessing a constant with a dynamic name meant calling <code>constant('MyClass::STATUS')</code>. The new syntax is consistent with how PHP already handles variable variables and dynamic method calls.</p>
<h2 id="readonly-can-now-be-amended-in-clone">readonly can now be amended in clone</h2>
<p>A specific but genuinely annoying limitation of 8.1 readonly: you couldn&rsquo;t clone an object and change a readonly property. 8.3 adds the ability to reinitialize readonly properties during cloning, which makes immutable value objects usable in a lot more patterns.</p>
<h2 id="json_validate">json_validate()</h2>
<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">if</span> (<span style="color:#a6e22e">json_validate</span>($string)) {
</span></span><span style="display:flex;"><span>    $data <span style="color:#f92672">=</span> <span style="color:#a6e22e">json_decode</span>($string);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Before 8.3, the only way to validate a JSON string was to decode it and check for errors. <code>json_validate()</code> checks without allocating the decoded structure, which matters when you only need to know if the string is valid JSON, not what&rsquo;s in it.</p>
<h2 id="randomizer-improvements">Randomizer improvements</h2>
<p><code>getBytesFromString()</code> generates a random string composed only of characters from a given 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>$rng <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Random\Randomizer</span>();
</span></span><span style="display:flex;"><span>$token <span style="color:#f92672">=</span> $rng<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getBytesFromString</span>(<span style="color:#e6db74">&#39;abcdefghijklmnopqrstuvwxyz0123456789&#39;</span>, <span style="color:#ae81ff">32</span>);
</span></span></code></pre></div><p>The previous approach: <code>str_split</code>, <code>array_map</code>, random selection, <code>implode</code>. It worked, but it was longer than it had any right to be.</p>
<p>8.3 is for the teams that adopt PHP versions quickly and want the incremental improvements. The typed constants alone are worth it on any codebase with interface constants.</p>
<h2 id="override-makes-inheritance-explicit">#[\Override] makes inheritance explicit</h2>
<p>Before 8.3, nothing stopped you from writing a method you thought was overriding a parent&rsquo;s, when you had a typo in the name or the parent had quietly removed it. Silent bugs, zero feedback from the engine.</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">Cache</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">get</span>(<span style="color:#a6e22e">string</span> $key)<span style="color:#f92672">:</span> <span style="color:#a6e22e">mixed</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Engine verifies this method exists in a parent or interface
</span></span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>If the method doesn&rsquo;t exist in any parent class or implemented interface, PHP throws an error. Same concept as Java&rsquo;s <code>@Override</code> or C#&rsquo;s <code>override</code>, finally in PHP.</p>
<h2 id="final-on-trait-methods">final on trait methods</h2>
<p>Traits have always had rough edges in PHP&rsquo;s OOP model. One specific problem: a class using a trait could override any of its methods, undermining whatever guarantees the trait was trying to provide. 8.3 lets the trait itself mark a method as final:</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">Singleton</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">final</span> <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getInstance</span>()<span style="color:#f92672">:</span> <span style="color:#66d9ef">static</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></span></code></pre></div><p>Now a class using the trait cannot override <code>getInstance()</code>. The guarantee holds.</p>
<h2 id="anonymous-classes-can-be-readonly">Anonymous classes can be readonly</h2>
<p>PHP 8.1 brought readonly classes. Anonymous classes were left out for some reason. 8.3 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-php" data-lang="php"><span style="display:flex;"><span>$point <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">readonly</span> <span style="color:#a6e22e">class</span>(<span style="color:#ae81ff">3</span>, <span style="color:#ae81ff">4</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">float</span> $x,
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#a6e22e">float</span> $y,
</span></span><span style="display:flex;"><span>    ) {}
</span></span><span style="display:flex;"><span>};
</span></span></code></pre></div><p>Handy when you need a throwaway immutable value object without the ceremony of naming it.</p>
<h2 id="static-variable-initializers-accept-expressions">Static variable initializers accept expressions</h2>
<p>A small but long-standing restriction: static variable initializers only accepted constant expressions, no function calls. 8.3 drops that constraint:</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">function</span> <span style="color:#a6e22e">connection</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">PDO</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">static</span> $pdo <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">PDO</span>(<span style="color:#a6e22e">getenv</span>(<span style="color:#e6db74">&#39;DATABASE_URL&#39;</span>));
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> $pdo;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The initializer runs once on first call, the static variable persists. Achievable with a null-check before, this is just cleaner.</p>
<h2 id="mb_str_pad-finally-exists">mb_str_pad() finally exists</h2>
<p><code>str_pad()</code> has always been byte-aware, not character-aware. For multibyte strings (Arabic, Japanese, accented characters) it produced wrong output. 8.3 finally adds the multibyte variant:</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>$padded <span style="color:#f92672">=</span> <span style="color:#a6e22e">mb_str_pad</span>(<span style="color:#e6db74">&#39;日本&#39;</span>, <span style="color:#ae81ff">10</span>, <span style="color:#e6db74">&#39;*&#39;</span>, <span style="color:#a6e22e">STR_PAD_BOTH</span>);
</span></span></code></pre></div><p>The function respects character boundaries, not byte counts.</p>
<h2 id="str_increment-and-str_decrement">str_increment() and str_decrement()</h2>
<p>PHP&rsquo;s <code>++</code> operator on strings has a history of quirks: it increments letter sequences (<code>'a'</code> → <code>'b'</code>, <code>'z'</code> → <code>'aa'</code>), but <code>--</code> never worked symmetrically. The behavior was surprising enough that 8.3 deprecates <code>++</code>/<code>--</code> on non-alphanumeric strings and introduces explicit 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-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">echo</span> <span style="color:#a6e22e">str_increment</span>(<span style="color:#e6db74">&#39;a&#39;</span>);  <span style="color:#75715e">// b
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">echo</span> <span style="color:#a6e22e">str_increment</span>(<span style="color:#e6db74">&#39;Az&#39;</span>); <span style="color:#75715e">// Ba
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">echo</span> <span style="color:#a6e22e">str_decrement</span>(<span style="color:#e6db74">&#39;b&#39;</span>);  <span style="color:#75715e">// a
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">echo</span> <span style="color:#a6e22e">str_decrement</span>(<span style="color:#e6db74">&#39;Ba&#39;</span>); <span style="color:#75715e">// Az
</span></span></span></code></pre></div><p>The functions make the intent obvious and the behavior predictable.</p>
<h2 id="randomrandomizer-gets-float-support">Random\Randomizer gets float support</h2>
<p>8.3 fills in the float side of the Randomizer 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>$rng <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Random\Randomizer</span>();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// A float in [0.0, 1.0)
</span></span></span><span style="display:flex;"><span>$f <span style="color:#f92672">=</span> $rng<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">nextFloat</span>();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// A float in a specific range with controlled boundary inclusion
</span></span></span><span style="display:flex;"><span>$f <span style="color:#f92672">=</span> $rng<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getFloat</span>(<span style="color:#ae81ff">1.5</span>, <span style="color:#ae81ff">3.5</span>, <span style="color:#a6e22e">Random\IntervalBoundary</span><span style="color:#f92672">::</span><span style="color:#a6e22e">ClosedOpen</span>);
</span></span></code></pre></div><p><code>IntervalBoundary</code> is a new enum with four values: <code>ClosedOpen</code>, <code>ClosedClosed</code>, <code>OpenClosed</code>, <code>OpenOpen</code>. This matters for statistical correctness: the naive approach of <code>rand() / getrandmax()</code> doesn&rsquo;t produce a uniform distribution over floats.</p>
<h2 id="the-date-exception-hierarchy">The Date exception hierarchy</h2>
<p>Date/time errors in PHP used to throw generic exceptions with no way to tell &ldquo;malformed string&rdquo; from &ldquo;invalid timezone&rdquo; without parsing the message yourself. 8.3 adds a proper hierarchy:</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>    <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">DateTimeImmutable</span>(<span style="color:#e6db74">&#39;not a date&#39;</span>);
</span></span><span style="display:flex;"><span>} <span style="color:#66d9ef">catch</span> (<span style="color:#a6e22e">DateMalformedStringException</span> $e) {
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// specifically a parsing failure
</span></span></span><span style="display:flex;"><span>} <span style="color:#66d9ef">catch</span> (<span style="color:#a6e22e">DateException</span> $e) {
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// other date-related errors
</span></span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The full tree: <code>DateError</code> (engine-level), <code>DateException</code> (base), with specific subclasses for invalid timezone, malformed interval string, malformed period string, and malformed date string.</p>
<h2 id="gc_status-tells-you-more">gc_status() tells you more</h2>
<p><code>gc_status()</code> now returns eight additional fields: <code>running</code>, <code>protected</code>, <code>full</code>, <code>buffer_size</code>, and timing breakdowns (<code>application_time</code>, <code>collector_time</code>, <code>destructor_time</code>, <code>free_time</code>). If you&rsquo;re profiling memory pressure or GC pauses, this data was previously unavailable without pulling in an extension.</p>
<h2 id="strrchr-grows-a-direction-argument">strrchr() grows a direction argument</h2>
<p><code>strrchr()</code> (find the last occurrence of a character, return from there to end) now accepts a <code>$before_needle</code> boolean, matching the API of <code>strstr()</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>$path <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;/var/www/html/index.php&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">echo</span> <span style="color:#a6e22e">strrchr</span>($path, <span style="color:#e6db74">&#39;/&#39;</span>, <span style="color:#a6e22e">before_needle</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">true</span>);  <span style="color:#75715e">// /var/www/html
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">echo</span> <span style="color:#a6e22e">strrchr</span>($path, <span style="color:#e6db74">&#39;/&#39;</span>);                        <span style="color:#75715e">// /index.php
</span></span></span></code></pre></div><p>A function that&rsquo;s been in PHP since 1994, finally consistent with its sibling.</p>
<h2 id="deprecations-worth-noting">Deprecations worth noting</h2>
<p><code>get_class()</code> and <code>get_parent_class()</code> without arguments now emit deprecation notices. The argumentless forms relied on implicit <code>$this</code> context, which was easy to misread. Pass the object explicitly.</p>
<p><code>assert_options()</code> and the <code>ASSERT_*</code> constants are deprecated in favor of the <code>zend.assertions</code> INI directive, which is the right tool for controlling assertion behavior across environments.</p>
<p>The <code>++</code>/<code>--</code> operators on empty strings and non-numeric non-alphanumeric strings now emit deprecation warnings. The behavior was undefined territory. 8.3 starts the migration toward defined behavior in 9.0.</p>
<h2 id="stack-overflow-protection">Stack overflow protection</h2>
<p>Two new INI directives: <code>zend.max_allowed_stack_size</code> sets a hard limit on PHP&rsquo;s stack depth, and <code>zend.reserved_stack_size</code> sets aside a buffer for cleanup after a limit is hit. Before 8.3, deeply recursive code could just crash at the OS level. Now PHP catches it and throws an <code>Error</code> with a useful message.</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>PHP 8.2: readonly classes and the deprecation that matters</title><link>https://guillaumedelre.github.io/2023/01/22/php-8.2-readonly-classes-and-the-deprecation-that-matters/</link><pubDate>Sun, 22 Jan 2023 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2023/01/22/php-8.2-readonly-classes-and-the-deprecation-that-matters/</guid><description>Part 8 of 11 in &amp;quot;PHP Releases&amp;quot;: PHP 8.2 introduces readonly classes, deprecates dynamic properties, and adds disjunctive normal form types.</description><category>php-releases</category><content:encoded><![CDATA[<p>PHP 8.2 dropped December 8th. Readonly classes are the headline. The deprecation of dynamic properties is the one that actually requires your attention.</p>
<h2 id="dynamic-properties-deprecated">Dynamic properties deprecated</h2>
<p>PHP has always allowed adding properties to objects that weren&rsquo;t declared in the 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">class</span> <span style="color:#a6e22e">User</span> {}
</span></span><span style="display:flex;"><span>$user <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">User</span>();
</span></span><span style="display:flex;"><span>$user<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">name</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;Alice&#39;</span>; <span style="color:#75715e">// no declaration, no error... until now
</span></span></span></code></pre></div><p>In 8.2, this triggers a deprecation notice. In PHP 9.0 it becomes a fatal error. The grace period exists, but the migration clock is running.</p>
<p>The reasoning is solid: dynamic properties are a classic source of typos that silently pass (write <code>$user-&gt;nmae</code> and PHP just creates a new property instead of complaining). Explicit declarations make the class contract clear and make tooling actually useful.</p>
<p>Migration is mostly mechanical: declare the properties, or slap <code>#[AllowDynamicProperties]</code> on legacy classes you can&rsquo;t touch yet.</p>
<h2 id="readonly-classes">Readonly classes</h2>
<p>8.1 added <code>readonly</code> for individual properties. 8.2 adds it to the class declaration itself:</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">Point</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">float</span> $x,
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#a6e22e">float</span> $y,
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#a6e22e">float</span> $z,
</span></span><span style="display:flex;"><span>    ) {}
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>All promoted and explicitly declared properties become readonly automatically. Value objects (coordinates, money amounts, identifiers) are the obvious target. The syntax is clean and the intent reads clearly.</p>
<p>One constraint: readonly classes can&rsquo;t have non-typed properties, which were already a bad idea with readonly anyway.</p>
<h2 id="dnf-types">DNF types</h2>
<p>Disjunctive Normal Form types let you combine union and intersection 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-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">function</span> <span style="color:#a6e22e">process</span>(<span style="color:#a6e22e">Countable</span><span style="color:#f92672">&amp;</span><span style="color:#a6e22e">Iterator</span><span style="color:#f92672">|</span><span style="color:#66d9ef">null</span> $collection)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span> { <span style="color:#f92672">...</span> }
</span></span></code></pre></div><p><code>(Countable&amp;Iterator)|null</code>: an object that implements both interfaces, or null. This covers type expressions that 8.0 union types and 8.1 intersection types each got partway to but couldn&rsquo;t represent together.</p>
<h2 id="the-random-extension">The Random extension</h2>
<p>A dedicated <code>Random</code> extension replaces the scattered <code>rand()</code>, <code>mt_rand()</code>, <code>random_int()</code> functions with an object-oriented 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>$rng <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Random\Randomizer</span>();
</span></span><span style="display:flex;"><span>$rng<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getInt</span>(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">100</span>);
</span></span><span style="display:flex;"><span>$rng<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">shuffleArray</span>($items);
</span></span></code></pre></div><p>Engines are swappable: <code>Mersenne Twister</code>, <code>PCG64</code>, <code>Xoshiro256StarStar</code>, or <code>CryptoSafeEngine</code> for security-sensitive contexts. Same code, seeded deterministic engine in tests, cryptographic engine in production.</p>
<p>8.2 is a consolidation release. The dynamic properties deprecation is the one decision you need to make now.</p>
<h2 id="null-false-and-true-as-standalone-types"><code>null</code>, <code>false</code>, and <code>true</code> as standalone types</h2>
<p>PHP has had <code>nullable</code> types since 7.1 and union types since 8.0, but <code>null</code> as a standalone type declaration wasn&rsquo;t valid. 8.2 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-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">function</span> <span style="color:#a6e22e">alwaysNull</span>()<span style="color:#f92672">:</span> <span style="color:#66d9ef">null</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</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">function</span> <span style="color:#a6e22e">disabled</span>()<span style="color:#f92672">:</span> <span style="color:#66d9ef">false</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">false</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">function</span> <span style="color:#a6e22e">enabled</span>()<span style="color:#f92672">:</span> <span style="color:#66d9ef">true</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">true</span>;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>false</code> and <code>true</code> as standalone types are useful when you need to be precise about what a function can actually return. It&rsquo;s narrow but correct: a function that returns <code>false</code> on failure and a string on success should declare <code>string|false</code>, and now both sides of that union are real types.</p>
<h2 id="constants-in-traits">Constants in traits</h2>
<p>Traits could hold properties and methods. Constants were the odd gap. 8.2 closes 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">trait</span> <span style="color:#a6e22e">Timestamps</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">const</span> <span style="color:#66d9ef">DATE_FORMAT</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;Y-m-d H:i:s&#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">formatCreatedAt</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">string</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">createdAt</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">format</span>(<span style="color:#a6e22e">self</span><span style="color:#f92672">::</span><span style="color:#a6e22e">DATE_FORMAT</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">Article</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Timestamps</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">echo</span> <span style="color:#a6e22e">Article</span><span style="color:#f92672">::</span><span style="color:#a6e22e">DATE_FORMAT</span>; <span style="color:#75715e">// &#39;Y-m-d H:i:s&#39;
</span></span></span></code></pre></div><p>The constant belongs to the class that uses the trait, not the trait itself, so you can&rsquo;t access <code>Timestamps::DATE_FORMAT</code> directly. Expected scoping behavior, consistent with how trait methods already work.</p>
<h2 id="sensitiveparameter"><code>#[SensitiveParameter]</code></h2>
<p>Stack traces have always been a liability: function arguments get logged verbatim, which means passwords and tokens end up in your error logs and monitoring dashboards. 8.2 adds an attribute to stop 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:#66d9ef">function</span> <span style="color:#a6e22e">authenticate</span>(
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">string</span> $user,
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">#[\SensitiveParameter] string $password,
</span></span></span><span style="display:flex;"><span>)<span style="color:#f92672">:</span> <span style="color:#a6e22e">bool</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// if this throws, the stack trace shows:
</span></span></span><span style="display:flex;"><span>    <span style="color:#75715e">// authenticate(&#39;alice&#39;, Object(SensitiveParameterValue))
</span></span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">hash</span>(<span style="color:#e6db74">&#39;sha256&#39;</span>, $password) <span style="color:#f92672">===</span> <span style="color:#a6e22e">getStoredHash</span>($user);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The parameter value in the trace gets replaced with a <code>SensitiveParameterValue</code> object. One attribute, zero excuses not to add it to every function that touches credentials.</p>
<h2 id="deprecated-string-interpolation-syntaxes">Deprecated string interpolation syntaxes</h2>
<p>Two ways to interpolate expressions inside strings are deprecated in 8.2:</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>$name <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;world&#39;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// These are deprecated:
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">echo</span> <span style="color:#e6db74">&#34;Hello </span><span style="color:#e6db74">${</span>name<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>;       <span style="color:#75715e">// use &#34;$name&#34; or &#34;{$name}&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">echo</span> <span style="color:#e6db74">&#34;Hello </span><span style="color:#e6db74">${</span>getName()<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>;  <span style="color:#75715e">// use &#34;{$this-&gt;getName()}&#34;
</span></span></span></code></pre></div><p>The <code>${...}</code> forms created ambiguity between variable variables and expressions. The cleaner <code>{$...}</code> syntax has always been there and does the same thing. This is mostly a search-and-replace job in codebases that picked up the deprecated forms out of habit.</p>
<h2 id="utf8_encode-and-utf8_decode-deprecated"><code>utf8_encode()</code> and <code>utf8_decode()</code> deprecated</h2>
<p>These two functions are deprecated in 8.2 and gone in 9.0. Their behavior was always narrower than the names suggested: <code>utf8_encode()</code> converts ISO-8859-1 to UTF-8, not &ldquo;any encoding to UTF-8.&rdquo;</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">// Deprecated in 8.2:
</span></span></span><span style="display:flex;"><span>$utf8 <span style="color:#f92672">=</span> <span style="color:#a6e22e">utf8_encode</span>($latin1String);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Use instead:
</span></span></span><span style="display:flex;"><span>$utf8 <span style="color:#f92672">=</span> <span style="color:#a6e22e">mb_convert_encoding</span>($latin1String, <span style="color:#e6db74">&#39;UTF-8&#39;</span>, <span style="color:#e6db74">&#39;ISO-8859-1&#39;</span>);
</span></span></code></pre></div><p><code>mb_convert_encoding()</code> or <code>iconv()</code> handle the general case. If you&rsquo;re actually dealing with Latin-1 input, the replacement is a direct swap.</p>
<h2 id="locale-independent-string-functions">Locale-independent string functions</h2>
<p>Several string functions silently varied behavior based on the system locale, producing different results in production versus a dev container. In 8.2, they&rsquo;re locale-independent and ASCII-only:</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">// strtolower, strtoupper, stristr, stripos, strripos,
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">// lcfirst, ucfirst, ucwords, str_ireplace now do ASCII case conversion only.
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">// For locale-aware behavior, use mb_* equivalents:
</span></span></span><span style="display:flex;"><span>$lowered <span style="color:#f92672">=</span> <span style="color:#a6e22e">mb_strtolower</span>($text, <span style="color:#e6db74">&#39;UTF-8&#39;</span>);
</span></span></code></pre></div><p>This is a correctness fix. If your code was relying on locale-sensitive behavior from these functions, it was already broken on systems with different locale configurations. 8.2 makes the behavior deterministic everywhere, which is what you actually wanted.</p>
<h2 id="str_split-on-empty-string"><code>str_split()</code> on empty string</h2>
<p>A quiet behavior change worth noting:</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">// PHP 8.1: str_split(&#39;&#39;) === [&#39;&#39;]
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">// PHP 8.2: str_split(&#39;&#39;) === []
</span></span></span></code></pre></div><p>The new behavior makes more sense: splitting nothing produces nothing. If you&rsquo;re checking <code>count(str_split($input))</code>, an empty input no longer produces a count of 1.</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>From Vagrant to Docker Compose: a retrospective</title><link>https://guillaumedelre.github.io/2022/04/18/from-vagrant-to-docker-compose-a-retrospective/</link><pubDate>Mon, 18 Apr 2022 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2022/04/18/from-vagrant-to-docker-compose-a-retrospective/</guid><description>Why we replaced Vagrant with Docker Compose: the real friction points, the migration path, and what we&amp;#39;d do differently.</description><content:encoded><![CDATA[<p>I ran Vagrant for years. A Vagrantfile per project, a shared base box, a provision script that worked on Tuesday but not on Thursday. The promise was simple: reproducible environments for everyone on the team. The reality was more complicated.</p>
<h2 id="the-vagrant-years">The Vagrant years</h2>
<p>The setup made sense at the time. One VM per project, provisioned with shell scripts or Ansible, shared via a versioned Vagrantfile. Onboarding was theoretically <code>vagrant up</code> and you&rsquo;re done.</p>
<p>In practice, it was <code>vagrant up</code>, wait four minutes, watch the provision fail on a package that changed its download URL, fix it, reprovision, wait again. Vagrantfiles accumulated configuration over time: workarounds for specific machines, OS version pinning, memory tweaks for the team member whose laptop had 8GB. The files became historical documents nobody wanted to touch.</p>
<p>The VM itself was the other problem. Booting took time. Running took memory and CPU that could have gone to the application. File syncing between host and guest added latency that made PHP apps feel slower than they had any right to be. The overhead was significant for what was ultimately just &ldquo;run a web server.&rdquo;</p>
<p>We lived with it because everyone did. Vagrant was the standard for local PHP development, and the alternative (each developer managing their own LAMP stack) was clearly worse.</p>
<h2 id="the-project-that-changed-the-model">The project that changed the model</h2>
<p>The shift wasn&rsquo;t a decision we made. It was a project that arrived already containerized.</p>
<p>A new client project had a <code>docker-compose.yml</code> at the root, a <code>Dockerfile</code>, and a README that said <code>docker compose up</code>. We ran it. The containers started in seconds. PHP-FPM, nginx, PostgreSQL, Redis: all running, all networked, no provisioning step. Stop the containers, start them again, same state.</p>
<p>The contrast with our Vagrant setup was immediate. Not faster by a percentage: faster by a different order. And the Compose file was actually readable: each service, its image, its volumes, its environment variables, its dependencies. Compared to a provision script that SSHed into a VM and ran apt-get, this was legible.</p>
<p>We migrated everything. Not gradually, all at once, over a sprint. Every project got a <code>docker-compose.yml</code>. Every Vagrantfile was deleted. The transition was the most painful three weeks of infrastructure work I remember, and also the most clearly worth it.</p>
<h2 id="what-docker-compose-actually-changed">What docker-compose actually changed</h2>
<p>Beyond the speed, Compose changed the mental model. Vagrant abstracted a machine. Compose abstracted a set of processes. The distinction matters: with Compose, you can stop the database without stopping the application server, scale a worker service independently, swap the PostgreSQL image for a newer version without touching anything else.</p>
<p>The <code>services</code> declaration also replaced the VM provisioning problem entirely. If a new developer joins, they don&rsquo;t run a provision script that may or may not work on their OS version. They run <code>docker compose up</code> and get the exact same images everyone else runs.</p>
<p>CI/CD got simpler too. The same <code>docker-compose.yml</code> that ran locally could run in the pipeline. The environment parity that Vagrant promised but rarely delivered was actually real with Compose.</p>
<h2 id="the-quiet-deprecation">The quiet deprecation</h2>
<p>For years, the command was <code>docker-compose</code>: a separate binary, installed independently from Docker itself, written in Python, versioned independently. We used it, it worked, nobody thought much about it.</p>
<p>At some point a colleague mentioned that Docker had integrated Compose directly into the <code>docker</code> CLI. The new command was <code>docker compose</code>, no hyphen, Go rewrite, bundled with Docker Desktop. The old <code>docker-compose</code> binary was deprecated.</p>
<p>We had been using v1 for two years after v2 shipped. Our CI scripts, our Makefiles, our documentation all said <code>docker-compose</code>. Nothing had broken because Docker maintained the old binary for a long time. But the ecosystem had moved on quietly, and we&rsquo;d missed it.</p>
<p>The migration was trivial: a hyphen removed from every script, a few aliases updated. The lesson was less trivial. Infrastructure tooling evolves without ceremony. The announcement happened, the blog posts were written, the deprecation notices were there. We just weren&rsquo;t paying attention.</p>
<h2 id="the-actual-retrospective">The actual retrospective</h2>
<p>Looking back across Vagrant → <code>docker-compose</code> → <code>docker compose</code>, the pattern is less about the tools and more about the defaults.</p>
<p>Vagrant defaulted to &ldquo;it works on my VM.&rdquo; The overhead of sharing that VM was permanent.</p>
<p>Compose defaulted to &ldquo;it works in these containers.&rdquo; The images are the artifacts; the host machine is irrelevant.</p>
<p>The hyphen between <code>docker</code> and <code>compose</code> was always cosmetic. What mattered was the shift from provisioned machines to declarative services. That shift happened the day we ran a project someone else containerized and realized we never wanted to go back.</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>PHP 8.1: enums, fibers, and the type system growing up</title><link>https://guillaumedelre.github.io/2022/01/09/php-8.1-enums-fibers-and-the-type-system-growing-up/</link><pubDate>Sun, 09 Jan 2022 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2022/01/09/php-8.1-enums-fibers-and-the-type-system-growing-up/</guid><description>Part 7 of 11 in &amp;quot;PHP Releases&amp;quot;: PHP 8.1 adds native enums, fibers for cooperative multitasking, readonly properties, and intersection types.</description><category>php-releases</category><content:encoded><![CDATA[<p>PHP 8.1 released November 25th. It follows 8.0&rsquo;s sweeping overhaul with something different: fewer features, but each one thought through rather than bolted on.</p>
<h2 id="enums">Enums</h2>
<p>This is the one that changes codebases the moment you upgrade. Before 8.1, PHP enumerations were either class constants, strings, or integers with nothing enforcing 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:#75715e">// before: nothing stops Status::INVALID from being passed
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#66d9ef">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">const</span> <span style="color:#66d9ef">INACTIVE</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;inactive&#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><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><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">function</span> <span style="color:#a6e22e">activate</span>(<span style="color:#a6e22e">Status</span> $status)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span> { <span style="color:#f92672">...</span> }
</span></span></code></pre></div><p>PHP enums are objects, not scalars. They support methods, interfaces, and constants. Backed enums (with a string or int value) serialize cleanly and map to database columns naturally. Pure enums (no backing type) enforce domain concepts without worrying about serialization.</p>
<p>The immediate effect: every status field, every finite set of states in every codebase I maintain became an enum candidate. The type system finally has a native way to express the thing every PHP project has been faking for years.</p>
<h2 id="fibers">Fibers</h2>
<p>Fibers are a cooperative concurrency primitive: you can pause and resume execution of a function, yielding control without threads.</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>$fiber <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Fiber</span>(<span style="color:#66d9ef">function</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span> {
</span></span><span style="display:flex;"><span>    $value <span style="color:#f92672">=</span> <span style="color:#a6e22e">Fiber</span><span style="color:#f92672">::</span><span style="color:#a6e22e">suspend</span>(<span style="color:#e6db74">&#39;first&#39;</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">echo</span> <span style="color:#e6db74">&#34;Resumed with: </span><span style="color:#e6db74">{</span>$value<span style="color:#e6db74">}</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">&#34;</span>;
</span></span><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>$result <span style="color:#f92672">=</span> $fiber<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">start</span>();    <span style="color:#75715e">// &#39;first&#39;
</span></span></span><span style="display:flex;"><span>$fiber<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">resume</span>(<span style="color:#e6db74">&#39;hello&#39;</span>);      <span style="color:#75715e">// &#34;Resumed with: hello&#34;
</span></span></span></code></pre></div><p>Fibers are the foundation async libraries like ReactPHP and Amp have needed from the runtime for a while. For most application developers the direct API matters less than the libraries built on top of it, but understanding fibers explains what those libraries are doing underneath.</p>
<h2 id="pencil2-readonly-properties">:pencil2: Readonly properties</h2>
<p>8.0 brought constructor promotion. 8.1 adds <code>readonly</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">User</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">int</span> $id,
</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> $name,
</span></span><span style="display:flex;"><span>    ) {}
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>A <code>readonly</code> property can be written exactly once, during initialization. After that, any write throws an <code>Error</code>. Combined with constructor promotion, value objects and DTOs become concise and actually mean what they say.</p>
<h2 id="first-class-callable-syntax">First-class callable syntax</h2>
<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>$fn <span style="color:#f92672">=</span> <span style="color:#a6e22e">strlen</span>(<span style="color:#f92672">...</span>);
</span></span><span style="display:flex;"><span>$fn <span style="color:#f92672">=</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">process</span>(<span style="color:#f92672">...</span>);
</span></span><span style="display:flex;"><span>$fn <span style="color:#f92672">=</span> <span style="color:#a6e22e">MyClass</span><span style="color:#f92672">::</span><span style="color:#a6e22e">create</span>(<span style="color:#f92672">...</span>);
</span></span></code></pre></div><p><code>...</code> after a callable creates a <code>Closure</code> without <code>Closure::fromCallable()</code> boilerplate. Useful when passing methods as callbacks.</p>
<p>8.1 is precise. Enums alone justify the upgrade.</p>
<h2 id="intersection-types">Intersection types</h2>
<p>Union types landed in 8.0. Intersection types follow in 8.1. Where a union says &ldquo;one of these&rdquo;, an intersection says &ldquo;all of these&rdquo;:</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">function</span> <span style="color:#a6e22e">process</span>(<span style="color:#a6e22e">Countable</span><span style="color:#f92672">&amp;</span><span style="color:#a6e22e">Iterator</span> $collection)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">foreach</span> ($collection <span style="color:#66d9ef">as</span> $item) { <span style="color:#75715e">/* ... */</span> }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">echo</span> <span style="color:#a6e22e">count</span>($collection);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>One constraint: intersection types can&rsquo;t be mixed with union types in the same declaration (that arrives in 8.2 as DNF types). But this already unlocks precise type-checking for objects that must satisfy multiple interfaces at once, a pattern frameworks use constantly that had to stay untyped until now.</p>
<h2 id="the-never-return-type">The <code>never</code> return type</h2>
<p>A function that never returns (it always throws or exits) now has a type to say so:</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">function</span> <span style="color:#a6e22e">redirect</span>(<span style="color:#a6e22e">string</span> $url)<span style="color:#f92672">:</span> <span style="color:#a6e22e">never</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">header</span>(<span style="color:#e6db74">&#34;Location: </span><span style="color:#e6db74">{</span>$url<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">exit</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">function</span> <span style="color:#a6e22e">fail</span>(<span style="color:#a6e22e">string</span> $message)<span style="color:#f92672">:</span> <span style="color:#a6e22e">never</span> {
</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>($message);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The practical benefit: static analyzers can prove that code after a <code>never</code> function is unreachable, and callers know there&rsquo;s no return value to handle. Before this, it lived in docblocks with no enforcement.</p>
<h2 id="final-class-constants">Final class constants</h2>
<p>Before 8.1, any subclass could quietly override a parent&rsquo;s class constant. Now you can put a stop to 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:#66d9ef">class</span> <span style="color:#a6e22e">Base</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">VERSION</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;1.0&#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">Child</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">Base</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Fatal error: Cannot override final constant Base::VERSION
</span></span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">const</span> <span style="color:#66d9ef">VERSION</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;2.0&#39;</span>;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Relatedly, interface constants are now overridable by implementing classes by default. A separate behavior fix that had been inconsistent since interfaces were introduced.</p>
<h2 id="new-in-initializers"><code>new</code> in initializers</h2>
<p>Default parameter values used to be restricted to scalars and arrays. 8.1 drops that restriction:</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">Logger</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">Handler</span> $handler <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">NullHandler</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">function</span> <span style="color:#a6e22e">createUser</span>(
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">Validator</span> $validator <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">DefaultValidator</span>(),
</span></span><span style="display:flex;"><span>)<span style="color:#f92672">:</span> <span style="color:#a6e22e">User</span> { <span style="color:#75715e">/* ... */</span> }
</span></span></code></pre></div><p>Same goes for attribute arguments and static variable initializers. Which means dependency injection with sensible defaults no longer needs a null check and lazy instantiation inside the method body.</p>
<h2 id="array-unpacking-with-string-keys">Array unpacking with string keys</h2>
<p>Array unpacking via the spread operator only worked with integer-keyed arrays before 8.1. String keys work now too:</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>$defaults <span style="color:#f92672">=</span> [<span style="color:#e6db74">&#39;color&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;red&#39;</span>, <span style="color:#e6db74">&#39;size&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;M&#39;</span>];
</span></span><span style="display:flex;"><span>$custom <span style="color:#f92672">=</span> [<span style="color:#e6db74">&#39;size&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;L&#39;</span>, <span style="color:#e6db74">&#39;weight&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;200g&#39;</span>];
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>$merged <span style="color:#f92672">=</span> [<span style="color:#f92672">...</span>$defaults, <span style="color:#f92672">...</span>$custom];
</span></span><span style="display:flex;"><span><span style="color:#75715e">// [&#39;color&#39; =&gt; &#39;red&#39;, &#39;size&#39; =&gt; &#39;L&#39;, &#39;weight&#39; =&gt; &#39;200g&#39;]
</span></span></span></code></pre></div><p>Later keys override earlier ones. Same behavior as <code>array_merge()</code>, but expressed inline. Performance difference is marginal; readability difference is not.</p>
<h2 id="fsync-and-fdatasync"><code>fsync</code> and <code>fdatasync</code></h2>
<p>Two functions that had no good reason to be missing from a filesystem-oriented language:</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>$fp <span style="color:#f92672">=</span> <span style="color:#a6e22e">fopen</span>(<span style="color:#e6db74">&#39;/tmp/important.dat&#39;</span>, <span style="color:#e6db74">&#39;w&#39;</span>);
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">fwrite</span>($fp, $data);
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">fsync</span>($fp);   <span style="color:#75715e">// flush OS buffers to physical storage
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">fclose</span>($fp);
</span></span></code></pre></div><p><code>fdatasync()</code> does the same but skips metadata sync when you only care about the data being durable. Both return <code>false</code> on failure. If you&rsquo;re writing anything that needs crash safety, you needed these.</p>
<h2 id="passing-null-to-non-nullable-built-in-parameters">Passing <code>null</code> to non-nullable built-in parameters</h2>
<p>A quieter but consequential change: built-in functions that accept strings, integers, etc. have always silently swallowed <code>null</code> and coerced it. In 8.1, that starts emitting a deprecation notice.</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">str_contains</span>(<span style="color:#e6db74">&#34;foobar&#34;</span>, <span style="color:#66d9ef">null</span>);
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Deprecated: Passing null to parameter #2 ($needle) of type string is deprecated
</span></span></span></code></pre></div><p>This aligns built-in functions with user-defined functions, which already refused nullable arguments for non-nullable parameters. PHP 9.0 turns this into a hard error. If you&rsquo;re passing <code>null</code> into string functions, now is a better time to fix it than during a production incident.</p>
<h2 id="mysqli-exceptions-by-default">MySQLi exceptions by default</h2>
<p>Before 8.1, MySQLi failed silently unless you explicitly called <code>mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT)</code>. That&rsquo;s now the default:</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">// This throws \mysqli_sql_exception on connection failure in 8.1
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">// Previously returned false and set an error you had to check manually
</span></span></span><span style="display:flex;"><span>$connection <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">mysqli</span>(<span style="color:#e6db74">&#39;localhost&#39;</span>, <span style="color:#e6db74">&#39;user&#39;</span>, <span style="color:#e6db74">&#39;wrong_password&#39;</span>, <span style="color:#e6db74">&#39;db&#39;</span>);
</span></span></code></pre></div><p>Every codebase that catches MySQLi errors by checking return values needs to be reviewed. The silent failures that caused hard-to-diagnose bugs now throw exceptions, which is the right behavior, just potentially surprising if you hit it mid-upgrade.</p>
]]></content:encoded></item><item><title>PHP 8.0: match, named arguments, attributes, and JIT</title><link>https://guillaumedelre.github.io/2021/01/10/php-8.0-match-named-arguments-attributes-and-jit/</link><pubDate>Sun, 10 Jan 2021 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2021/01/10/php-8.0-match-named-arguments-attributes-and-jit/</guid><description>Part 6 of 11 in &amp;quot;PHP Releases&amp;quot;: PHP 8.0 reshapes the language: JIT compiler, named arguments, match expressions, union types, and nullsafe operator.</description><category>php-releases</category><content:encoded><![CDATA[<p>PHP 8.0 shipped November 26th. I&rsquo;ve been running it for six weeks on a side project and a greenfield service at work. It&rsquo;s the most significant PHP release since 7.0, and in some ways more impactful, because the changes pile on top of each other in useful ways.</p>
<h2 id="jit">JIT</h2>
<p>The Just-In-Time compiler was the headline announcement. The reality in production is more nuanced: for typical web apps (database queries, HTTP calls, template rendering) the gains are modest, because those workloads are I/O bound, not compute bound. Where JIT actually shines is CPU-intensive code: image manipulation, data transformation, mathematical computation.</p>
<p>For most web apps, the performance improvement comes from the overall engine work in 8.0, not JIT specifically. Still worth enabling though: it costs nothing on I/O-bound work.</p>
<h2 id="match-expressions">Match expressions</h2>
<p><code>switch</code> has three problems: it uses loose comparison, it falls through by default, and it can&rsquo;t be used as an expression. <code>match</code> fixes all three:</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>$result <span style="color:#f92672">=</span> <span style="color:#a6e22e">match</span>($status) {
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;active&#39;</span>, <span style="color:#e6db74">&#39;pending&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;processing&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;done&#39;</span>             <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;finished&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">default</span>            <span style="color:#f92672">=&gt;</span> <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">\UnexpectedValueException</span>($status),
</span></span><span style="display:flex;"><span>};
</span></span></code></pre></div><p>Strict comparison. No fall-through. Expression that returns a value. Non-exhaustive match throws. After one week with <code>match</code> I stopped writing <code>switch</code>.</p>
<h2 id="named-arguments">Named arguments</h2>
<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">array_slice</span>(<span style="color:#66d9ef">array</span><span style="color:#f92672">:</span> $users, <span style="color:#a6e22e">offset</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">0</span>, <span style="color:#a6e22e">length</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">10</span>, <span style="color:#a6e22e">preserve_keys</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">true</span>);
</span></span></code></pre></div><p>Named arguments let you pass arguments in any order and skip optional ones. The obvious win is readability on functions with multiple boolean flags. The less obvious win: named arguments survive PHP version upgrades even when parameter order changes, because you&rsquo;re naming what you mean.</p>
<h2 id="attributes">Attributes</h2>
<p>Out with docblock annotations (the <code>@Route</code>, <code>@ORM\Column</code> style that frameworks have relied on for years), in with first-class PHP syntax:</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">#[Route(&#39;/users&#39;, methods: [&#39;GET&#39;])]
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">#[IsGranted(&#39;ROLE_ADMIN&#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">list</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">Response</span> { <span style="color:#f92672">...</span> }
</span></span></code></pre></div><p>Attributes are validated by the engine, not parsed from strings. IDE support just works, no plugin magic needed. For Symfony and Doctrine users, this is the real daily win of PHP 8.0.</p>
<h2 id="constructor-promotion">Constructor promotion</h2>
<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">User</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">int</span> $id,
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#a6e22e">string</span> $name,
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">private</span> <span style="color:#f92672">?</span><span style="color:#a6e22e">string</span> $email <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><p>Properties declared and assigned in one line in the constructor signature. The most immediate refactoring win in 8.0: every data class I&rsquo;ve touched since upgrading is half the lines it used to be.</p>
<h2 id="nullsafe-operator">Nullsafe operator</h2>
<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>$city <span style="color:#f92672">=</span> $user<span style="color:#f92672">?-&gt;</span><span style="color:#a6e22e">getAddress</span>()<span style="color:#f92672">?-&gt;</span><span style="color:#a6e22e">getCity</span>()<span style="color:#f92672">?-&gt;</span><span style="color:#a6e22e">getName</span>();
</span></span></code></pre></div><p><code>null</code> at any point in the chain short-circuits the rest and returns <code>null</code>. The alternative was nested null checks or a chain of early returns. This composes naturally.</p>
<h2 id="union-types">Union types</h2>
<p>Named arguments make function signatures more explicit at the call site. Union types make them more honest at the declaration site:</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">function</span> <span style="color:#a6e22e">processInput</span>(<span style="color:#a6e22e">int</span><span style="color:#f92672">|</span><span style="color:#a6e22e">float</span><span style="color:#f92672">|</span><span style="color:#a6e22e">string</span> $value)<span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span><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:#66d9ef">if</span> (<span style="color:#a6e22e">is_string</span>($value)) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">strlen</span>($value);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> (<span style="color:#a6e22e">int</span>) <span style="color:#a6e22e">round</span>($value);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The union <code>int|float|string</code> is a literal OR. The engine enforces it on entry and exit. Before 8.0, &ldquo;this parameter accepts int or float&rdquo; lived in a docblock that nothing enforced. There&rsquo;s also <code>null</code> as a type component: <code>?string</code> is just syntactic sugar for <code>string|null</code>, both are valid.</p>
<p>One special case: <code>false</code>. PHP has a bunch of built-in functions that return a typed value on success and <code>false</code> on failure. The 8.0 type system accommodates that: <code>array|false</code>, <code>string|false</code>. It&rsquo;s an honest acknowledgment that the codebase can&rsquo;t be rewritten overnight.</p>
<h2 id="static-return-type">static return type</h2>
<p><code>static</code> as a return type was possible informally through docblocks, but 8.0 makes it official. The distinction between <code>self</code> and <code>static</code> matters in inheritance:</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">Builder</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">protected</span> <span style="color:#66d9ef">array</span> $config <span style="color:#f92672">=</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">set</span>(<span style="color:#a6e22e">string</span> $key, <span style="color:#a6e22e">mixed</span> $value)<span style="color:#f92672">:</span> <span style="color:#66d9ef">static</span> {
</span></span><span style="display:flex;"><span>        $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">config</span>[$key] <span style="color:#f92672">=</span> $value;
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> $this;
</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">SpecialBuilder</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">Builder</span> {}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>$result <span style="color:#f92672">=</span> (<span style="color:#66d9ef">new</span> <span style="color:#a6e22e">SpecialBuilder</span>())<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">set</span>(<span style="color:#e6db74">&#39;foo&#39;</span>, <span style="color:#e6db74">&#39;bar&#39;</span>);
</span></span><span style="display:flex;"><span><span style="color:#75715e">// $result is SpecialBuilder, not Builder
</span></span></span></code></pre></div><p>With <code>self</code> as the return type, that chain would return <code>Builder</code>, breaking fluent interfaces in subclasses. <code>static</code> makes fluent APIs work correctly across inheritance hierarchies without manual overrides.</p>
<h2 id="mixed-type">mixed type</h2>
<p><code>mixed</code> was a docblock convention for years. 8.0 makes it a real type that shows up in signatures:</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">function</span> <span style="color:#a6e22e">debug</span>(<span style="color:#a6e22e">mixed</span> $value)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">var_dump</span>($value);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>It accepts everything: <code>null</code>, objects, resources, scalars, arrays. Semantically it&rsquo;s the same as having no type declaration, but it&rsquo;s explicit rather than absent. The difference between &ldquo;this parameter is untyped&rdquo; and &ldquo;this parameter intentionally accepts anything.&rdquo; Worth using when you&rsquo;re writing a general-purpose utility that would be dishonest with a narrower type.</p>
<h2 id="throw-as-expression">throw as expression</h2>
<p>Before 8.0, <code>throw</code> was a statement. Sounds like a pedantic distinction until you hit the places where you actually want an 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">// In a ternary:
</span></span></span><span style="display:flex;"><span>$value <span style="color:#f92672">=</span> $input <span style="color:#f92672">??</span> <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">\InvalidArgumentException</span>(<span style="color:#e6db74">&#39;input required&#39;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// In an arrow function:
</span></span></span><span style="display:flex;"><span>$getId <span style="color:#f92672">=</span> <span style="color:#a6e22e">fn</span>(<span style="color:#a6e22e">User</span> $u) <span style="color:#f92672">=&gt;</span> $u<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">id</span> <span style="color:#f92672">??</span> <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">\RuntimeException</span>(<span style="color:#e6db74">&#39;no id&#39;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// In a match arm (which is already an expression):
</span></span></span><span style="display:flex;"><span>$status <span style="color:#f92672">=</span> <span style="color:#a6e22e">match</span>($code) {
</span></span><span style="display:flex;"><span>    <span style="color:#ae81ff">200</span>     <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;ok&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#ae81ff">404</span>     <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;not found&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">default</span> <span style="color:#f92672">=&gt;</span> <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">\UnexpectedValueException</span>(<span style="color:#e6db74">&#34;unknown code: </span><span style="color:#e6db74">$code</span><span style="color:#e6db74">&#34;</span>),
</span></span><span style="display:flex;"><span>};
</span></span></code></pre></div><p>The last one is particularly useful: match without a default will throw <code>UnhandledMatchError</code> automatically, but sometimes you want to control the exception type and message.</p>
<h2 id="catch-without-a-variable">catch without a variable</h2>
<p>Small quality-of-life fix. When you catch an exception but don&rsquo;t actually use the object, 8.0 lets you omit the variable:</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>    $result <span style="color:#f92672">=</span> $cache<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">get</span>($key);
</span></span><span style="display:flex;"><span>} <span style="color:#66d9ef">catch</span> (<span style="color:#a6e22e">CacheMissException</span>) {
</span></span><span style="display:flex;"><span>    $result <span style="color:#f92672">=</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">compute</span>($key);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Before 8.0, you had to write <code>catch (CacheMissException $e)</code> and then either use <code>$e</code> or live with the IDE warning about an unused variable. Neither was satisfying.</p>
<h2 id="string-functions-that-should-have-existed-years-ago">String functions that should have existed years ago</h2>
<p>Three functions that every PHP developer has written manually at least once:</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">str_contains</span>(<span style="color:#e6db74">&#39;hello world&#39;</span>, <span style="color:#e6db74">&#39;world&#39;</span>);  <span style="color:#75715e">// true
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">str_starts_with</span>(<span style="color:#e6db74">&#39;hello world&#39;</span>, <span style="color:#e6db74">&#39;hell&#39;</span>); <span style="color:#75715e">// true
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">str_ends_with</span>(<span style="color:#e6db74">&#39;hello world&#39;</span>, <span style="color:#e6db74">&#39;world&#39;</span>); <span style="color:#75715e">// true
</span></span></span></code></pre></div><p>Before 8.0, the go-to approaches were <code>strpos() !== false</code>, <code>strncmp()</code>, or <code>substr() ===</code>, all of which require stopping to remember the semantics. These new functions are just direct and readable. No regex, no offset arithmetic.</p>
<h2 id="stable-sort">Stable sort</h2>
<p>PHP&rsquo;s sorting functions weren&rsquo;t stable before 8.0. &ldquo;Not stable&rdquo; means elements that compare as equal could end up in any order relative to each other. In practice this caused subtle bugs in UI code that needed consistent ordering, pagination that shifted between loads, and tests that only passed by luck.</p>
<p>8.0 guarantees stability across all sorting functions: <code>sort()</code>, <code>usort()</code>, <code>array_multisort()</code>, and the rest. Equal elements keep their original relative position. This is the behavior most people assumed was already there.</p>
<h2 id="weakmap">WeakMap</h2>
<p>7.4 brought <code>WeakReference</code> for single objects. 8.0 brings <code>WeakMap</code>: a map where both the keys (objects) and their associated data can be garbage collected when no other reference to the key object exists:</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">RequestCache</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#a6e22e">WeakMap</span> $cache;
</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>        $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">cache</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">WeakMap</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">get</span>(<span style="color:#a6e22e">Request</span> $request)<span style="color:#f92672">:</span> <span style="color:#a6e22e">Response</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">cache</span>[$request] <span style="color:#f92672">??=</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">compute</span>($request);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The moment <code>$request</code> is no longer referenced anywhere else, the entry disappears from the map. No manual cleanup needed. It&rsquo;s the right pattern for memoization and computed property caches where you don&rsquo;t want to be the sole reason an object stays alive.</p>
<h2 id="new-exception-types">New exception types</h2>
<p><code>ValueError</code> is thrown when a function gets the right type but an invalid value, as opposed to <code>TypeError</code> which fires on wrong 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-php" data-lang="php"><span style="display:flex;"><span><span style="color:#a6e22e">array_chunk</span>([], <span style="color:#f92672">-</span><span style="color:#ae81ff">5</span>); <span style="color:#75715e">// ValueError: array_chunk(): Argument #2 ($length) must be greater than 0
</span></span></span></code></pre></div><p>Before 8.0, many of these were warnings that returned <code>false</code> or <code>null</code>. Now they throw. The engine is stricter, which means you catch problems earlier instead of getting weird results somewhere downstream.</p>
<h2 id="get_debug_type-and-fdiv">get_debug_type() and fdiv()</h2>
<p>Two utility functions worth knowing.</p>
<p><code>get_debug_type()</code> returns a normalized string representation of any value, handy for error 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-php" data-lang="php"><span style="display:flex;"><span><span style="color:#a6e22e">get_debug_type</span>(<span style="color:#ae81ff">1</span>);          <span style="color:#75715e">// &#34;int&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">get_debug_type</span>(<span style="color:#ae81ff">1.0</span>);        <span style="color:#75715e">// &#34;float&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">get_debug_type</span>(<span style="color:#66d9ef">null</span>);       <span style="color:#75715e">// &#34;null&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">get_debug_type</span>(<span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Foo</span>());  <span style="color:#75715e">// &#34;Foo&#34; (not &#34;object&#34;)
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">get_debug_type</span>([]);         <span style="color:#75715e">// &#34;array&#34;
</span></span></span></code></pre></div><p>The difference from <code>gettype()</code>: it returns class names for objects and uses normalized names (<code>&quot;int&quot;</code> not <code>&quot;integer&quot;</code>). Exactly what you want when building an exception message that says what you got versus what you expected.</p>
<p><code>fdiv()</code> performs floating-point division following IEEE 754, meaning division by zero returns <code>INF</code>, <code>-INF</code>, or <code>NAN</code> instead of a warning:</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">fdiv</span>(<span style="color:#ae81ff">10</span>, <span style="color:#ae81ff">0</span>);   <span style="color:#75715e">// INF
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">fdiv</span>(<span style="color:#f92672">-</span><span style="color:#ae81ff">10</span>, <span style="color:#ae81ff">0</span>);  <span style="color:#75715e">// -INF
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">fdiv</span>(<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">0</span>);    <span style="color:#75715e">// NAN
</span></span></span></code></pre></div><h2 id="the-changes-that-break-things">The changes that break things</h2>
<p>8.0 also ships a few changes that aren&rsquo;t features, they&rsquo;re corrections.</p>
<p>The big one: <code>0 == &quot;foo&quot;</code> is now <code>false</code>. In PHP 7, comparing an integer to a non-numeric string would cast the string to 0, so <code>0 == &quot;anything-non-numeric&quot;</code> evaluated to <code>true</code>. That was a persistent source of bugs and security headaches. PHP 8 flips it: the integer gets cast to a string instead:</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">var_dump</span>(<span style="color:#ae81ff">0</span> <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;foo&#34;</span>);  <span style="color:#75715e">// bool(false) in 8.0, bool(true) in 7.x
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">var_dump</span>(<span style="color:#ae81ff">0</span> <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;&#34;</span>);     <span style="color:#75715e">// bool(false) in 8.0, bool(true) in 7.x
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">var_dump</span>(<span style="color:#ae81ff">0</span> <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;0&#34;</span>);    <span style="color:#75715e">// bool(true) in both (&#34;0&#34; is numeric)
</span></span></span></code></pre></div><p>If you relied on this intentionally, you already knew it was sketchy. If you didn&rsquo;t know you relied on it, 8.0 will find those code paths for you.</p>
<p>Several functions that used to return resources now return proper objects: <code>curl_init()</code> returns a <code>CurlHandle</code>, <code>imagecreate()</code> returns a <code>GdImage</code>, <code>xml_parser_create()</code> returns an <code>XMLParser</code>. Code that checks <code>is_resource($curl)</code> will break, because <code>is_resource()</code> returns <code>false</code> for these objects. The fix is to check against <code>false</code> (the return value on failure) rather than checking the type of the success case.</p>
<p>PHP 8.0 is the kind of release where the features reinforce each other. Attributes play well with constructor promotion. Match pairs naturally with union types. The string functions cut noise that was hiding intent. The corrections are occasionally breaking, but they push the language toward consistency it should have had years ago.</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>PHP 7.4: typed properties and the arrow function you actually want</title><link>https://guillaumedelre.github.io/2020/01/12/php-7.4-typed-properties-and-the-arrow-function-you-actually-want/</link><pubDate>Sun, 12 Jan 2020 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2020/01/12/php-7.4-typed-properties-and-the-arrow-function-you-actually-want/</guid><description>Part 5 of 11 in &amp;quot;PHP Releases&amp;quot;: PHP 7.4 brings typed properties and concise arrow functions — the last 7.x release and the clearest preview of PHP 8.</description><category>php-releases</category><content:encoded><![CDATA[<p>PHP 7.4 landed November 28th. It&rsquo;s the last 7.x release before PHP 8.0, and it feels like it. The features are substantial enough to stand on their own, but they also read as groundwork for what&rsquo;s coming.</p>
<h2 id="typed-properties">Typed properties</h2>
<p>This is the one. Since PHP 7.0, you could type function parameters and return values. But class properties? Still untyped:</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">User</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#a6e22e">int</span> $id;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#a6e22e">string</span> $name;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#f92672">?</span><span style="color:#a6e22e">DateTimeInterface</span> $deletedAt;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>7.4 changes that. Typed properties enforce types at assignment, not just at call sites. Classes become self-documenting in a way that docblocks never quite managed, and the engine catches type errors before they propagate through half your stack.</p>
<p>One subtlety: typed properties are <code>uninitialized</code> by default (not <code>null</code>). Accessing an uninitialized property throws an <code>Error</code>. This trips people up: <code>?string</code> doesn&rsquo;t imply a default of <code>null</code>. You still need an explicit <code>= null</code> for that.</p>
<h2 id="arrow-functions">Arrow functions</h2>
<p>Closures in PHP have always required explicitly importing outer scope variables with <code>use</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>$multiplier <span style="color:#f92672">=</span> <span style="color:#ae81ff">3</span>;
</span></span><span style="display:flex;"><span>$fn <span style="color:#f92672">=</span> <span style="color:#a6e22e">fn</span>($x) <span style="color:#f92672">=&gt;</span> $x <span style="color:#f92672">*</span> $multiplier; <span style="color:#75715e">// no use() needed
</span></span></span></code></pre></div><p>Arrow functions capture the enclosing scope automatically. Single expression, implicit return, no boilerplate. They don&rsquo;t replace full closures for complex logic, but for short callbacks they eliminate a class of noise that had been accumulating for years.</p>
<h2 id="opcache-preloading">Opcache preloading</h2>
<p>For long-lived PHP-FPM setups, preloading allows a script to load and compile PHP files into opcache memory at server startup. Those files are available to all requests without compilation overhead.</p>
<p>The gain varies by application. On large frameworks where the same files are loaded on every request, it&rsquo;s real. On smaller apps, negligible. Worth benchmarking before adding the configuration complexity.</p>
<h2 id="the-small-ones-that-add-up">The small ones that add up</h2>
<p>The features mentioned in passing deserve more than a line. The null coalescing assignment operator <code>??=</code> solves a pattern that was annoying enough to write every single time but never annoying enough to bother abstracting:</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>$config[<span style="color:#e6db74">&#39;timeout&#39;</span>] <span style="color:#f92672">??=</span> <span style="color:#ae81ff">30</span>;
</span></span><span style="display:flex;"><span><span style="color:#75715e">// equivalent to: $config[&#39;timeout&#39;] = $config[&#39;timeout&#39;] ?? 30;
</span></span></span></code></pre></div><p>Spread operator in array literals does what you&rsquo;d expect from the function call version — unpack an iterable into an array literal:</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>$defaults <span style="color:#f92672">=</span> [<span style="color:#e6db74">&#39;color&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;blue&#39;</span>, <span style="color:#e6db74">&#39;size&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;M&#39;</span>];
</span></span><span style="display:flex;"><span>$options <span style="color:#f92672">=</span> [<span style="color:#e6db74">&#39;size&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;L&#39;</span>, <span style="color:#f92672">...</span>$defaults, <span style="color:#e6db74">&#39;weight&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#ae81ff">1.2</span>];
</span></span><span style="display:flex;"><span><span style="color:#75715e">// [&#39;size&#39; =&gt; &#39;M&#39;, &#39;color&#39; =&gt; &#39;blue&#39;, &#39;weight&#39; =&gt; 1.2]
</span></span></span></code></pre></div><p>Note: string keys weren&rsquo;t supported in 7.4 for array unpacking. That came later.</p>
<p>Covariant return types and contravariant parameter types close a gap that made some inheritance patterns needlessly awkward. A child class can now narrow its return type to a subtype of the parent&rsquo;s, without hitting a fatal error:</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">Producer</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">get</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">Iterator</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">ChildProducer</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">Producer</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">get</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">ArrayIterator</span> {} <span style="color:#75715e">// ArrayIterator implements Iterator
</span></span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="reading-numbers-at-3am">Reading numbers at 3am</h2>
<p>The numeric literal separator is one of those features you don&rsquo;t know you wanted until the first time you write a large constant and immediately lose track of the magnitude:</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>$earthMass    <span style="color:#f92672">=</span> <span style="color:#ae81ff">5_972_168_000_000_000_000_000_000</span>; <span style="color:#75715e">// kg
</span></span></span><span style="display:flex;"><span>$lightSpeed   <span style="color:#f92672">=</span> <span style="color:#ae81ff">299_792_458</span>;                        <span style="color:#75715e">// m/s
</span></span></span><span style="display:flex;"><span>$planck       <span style="color:#f92672">=</span> <span style="color:#ae81ff">6.626</span><span style="color:#a6e22e">_070_15e</span><span style="color:#f92672">-</span><span style="color:#ae81ff">34</span>;                  <span style="color:#75715e">// J·s
</span></span></span><span style="display:flex;"><span>$hexMask      <span style="color:#f92672">=</span> <span style="color:#ae81ff">0xFF_EC_D5_08</span>;
</span></span><span style="display:flex;"><span>$binaryFlags  <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span><span style="color:#a6e22e">b0001_1111_0010_0000</span>;
</span></span></code></pre></div><p>The underscore is purely syntactic. The engine strips it before parsing the value. You can put it anywhere between digits, though convention follows the natural grouping of the number system you&rsquo;re working in.</p>
<h2 id="holding-without-owning">Holding without owning</h2>
<p><code>WeakReference</code> lets you hold a reference to an object without preventing the garbage collector from destroying it. The use case is caches and registries: you want to know an object is alive, but you don&rsquo;t want to be the reason it stays alive:</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>$object <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">HeavyObject</span>();
</span></span><span style="display:flex;"><span>$ref <span style="color:#f92672">=</span> <span style="color:#a6e22e">WeakReference</span><span style="color:#f92672">::</span><span style="color:#a6e22e">create</span>($object);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">var_dump</span>($ref<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">get</span>()); <span style="color:#75715e">// object(HeavyObject)
</span></span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">unset</span>($object);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">var_dump</span>($ref<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">get</span>()); <span style="color:#75715e">// NULL — GC collected it
</span></span></span></code></pre></div><p>Before 7.4 you had <code>WeakRef</code> via an extension, and some frameworks were doing <code>SplObjectStorage</code> tricks that didn&rsquo;t quite behave the same way. The native class is just straightforward.</p>
<h2 id="serialization-without-surprise">Serialization without surprise</h2>
<p>Custom object serialization before 7.4 went through the <code>Serializable</code> interface: implement <code>serialize()</code> and <code>unserialize()</code>, return a string, reconstruct from it. The problem is that <code>serialize()</code> triggered <code>__sleep()</code>, <code>unserialize()</code> triggered <code>__wakeup()</code>, and the interaction between these hooks was fragile, especially in inheritance hierarchies.</p>
<p>7.4 introduces <code>__serialize()</code> and <code>__unserialize()</code>, which work with arrays instead of strings and don&rsquo;t interact with the old hooks:</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">Session</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#a6e22e">string</span> $token;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#a6e22e">\DateTime</span> $createdAt;
</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">__serialize</span>()<span style="color:#f92672">:</span> <span style="color:#66d9ef">array</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> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">token</span>, <span style="color:#e6db74">&#39;created&#39;</span> <span style="color:#f92672">=&gt;</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">createdAt</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getTimestamp</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">__unserialize</span>(<span style="color:#66d9ef">array</span> $data)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span> {
</span></span><span style="display:flex;"><span>        $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">token</span> <span style="color:#f92672">=</span> $data[<span style="color:#e6db74">&#39;token&#39;</span>];
</span></span><span style="display:flex;"><span>        $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">createdAt</span> <span style="color:#f92672">=</span> (<span style="color:#66d9ef">new</span> <span style="color:#a6e22e">\DateTime</span>())<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">setTimestamp</span>($data[<span style="color:#e6db74">&#39;created&#39;</span>]);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>When both the new and old methods exist on the same class, <code>__serialize()</code> wins. The old <code>Serializable</code> interface gets deprecated in 8.1.</p>
<h2 id="what-the-standard-library-quietly-got">What the standard library quietly got</h2>
<p><code>mb_str_split()</code> does what <code>str_split()</code> does but correctly for multibyte strings. The gap was genuinely embarrassing for a language used in as many locales as 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:#a6e22e">mb_str_split</span>(<span style="color:#e6db74">&#39;héllo&#39;</span>, <span style="color:#ae81ff">1</span>); <span style="color:#75715e">// [&#39;h&#39;, &#39;é&#39;, &#39;l&#39;, &#39;l&#39;, &#39;o&#39;]
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">str_split</span>(<span style="color:#e6db74">&#39;héllo&#39;</span>, <span style="color:#ae81ff">1</span>);    <span style="color:#75715e">// [&#39;h&#39;, &#39;Ã&#39;, &#39;©&#39;, &#39;l&#39;, &#39;l&#39;, &#39;o&#39;] — broken
</span></span></span></code></pre></div><p><code>strip_tags()</code> now accepts an array of allowed tags, which is cleaner than the string format it used to require:</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">strip_tags</span>($html, [<span style="color:#e6db74">&#39;p&#39;</span>, <span style="color:#e6db74">&#39;br&#39;</span>, <span style="color:#e6db74">&#39;strong&#39;</span>]); <span style="color:#75715e">// was: &#39;&lt;p&gt;&lt;br&gt;&lt;strong&gt;&#39;
</span></span></span></code></pre></div><p><code>proc_open()</code> now accepts an array command, bypassing shell interpretation entirely. Same idea as Python&rsquo;s <code>subprocess</code> with <code>shell=False</code>. Worth knowing whenever you&rsquo;re passing user-supplied arguments to an external process.</p>
<h2 id="the-ffi-chapter">The FFI chapter</h2>
<p>The Foreign Function Interface extension landed in 7.4 after spending time in a feature branch. It lets PHP call native C functions by loading a shared library and declaring the signatures:</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>$ffi <span style="color:#f92672">=</span> <span style="color:#a6e22e">FFI</span><span style="color:#f92672">::</span><span style="color:#a6e22e">cdef</span>(<span style="color:#e6db74">&#34;int strlen(const char *s);&#34;</span>, <span style="color:#e6db74">&#34;libc.so.6&#34;</span>);
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">var_dump</span>($ffi<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">strlen</span>(<span style="color:#e6db74">&#34;hello&#34;</span>)); <span style="color:#75715e">// int(5)
</span></span></span></code></pre></div><p>The practical applications are narrow but real: calling platform APIs with no PHP binding, wrapping performance-critical C code without writing a full extension, or just poking at native libraries directly. Not a replacement for proper extensions in production, but it removes the &ldquo;write a C extension&rdquo; barrier for exploration.</p>
<h2 id="what-got-deprecated">What got deprecated</h2>
<p>A few things that should have been cleaned up a while ago finally got the deprecation treatment in 7.4.</p>
<p>Nested ternaries without parentheses have been ambiguous forever. PHP evaluated them left-to-right while pretty much every other language with a ternary evaluates right-to-left:</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">// Was ambiguous, now deprecated:
</span></span></span><span style="display:flex;"><span>$a <span style="color:#f92672">?</span> $b <span style="color:#f92672">:</span> $c <span style="color:#f92672">?</span> $d <span style="color:#f92672">:</span> $e;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Make it explicit:
</span></span></span><span style="display:flex;"><span>($a <span style="color:#f92672">?</span> $b <span style="color:#f92672">:</span> $c) <span style="color:#f92672">?</span> $d <span style="color:#f92672">:</span> $e;
</span></span></code></pre></div><p>Curly brace offset access for strings and arrays — <code>$str{0}</code> instead of <code>$str[0]</code> — is deprecated and gone in 8.0. It was always an alias, never a separate feature.</p>
<p><code>implode()</code> with reversed argument order (array first, glue second) is deprecated. The function has accepted both orders since the beginning, which was a mistake. The correct order is <code>implode(string $separator, array $array)</code>.</p>
<h2 id="what-comes-next">What comes next</h2>
<p>7.4 is the last 7.x release. The deprecations are mostly ground-clearing for removals in 8.0. The RFC backlog for 8.0 is substantial: JIT, attributes, named arguments, match expressions. 7.4 is a solid place to land while waiting for all that to arrive.</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>From a €10 sensor to a Home Assistant dashboard with a Raspberry Pi and MQTT</title><link>https://guillaumedelre.github.io/2019/11/17/from-a-10-sensor-to-a-home-assistant-dashboard-with-a-raspberry-pi-and-mqtt/</link><pubDate>Sun, 17 Nov 2019 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2019/11/17/from-a-10-sensor-to-a-home-assistant-dashboard-with-a-raspberry-pi-and-mqtt/</guid><description>A €10 BME280 sensor, a Raspberry Pi, and an MQTT broker: building a room climate monitor that feeds Home Assistant.</description><content:encoded><![CDATA[<p>The question was simple: what&rsquo;s the temperature and humidity in my home office right now? Not the weather outside, not a city average — the actual conditions in the room where I spend most of my day. Opening a weather app for that felt wrong.</p>
<p>A Raspberry Pi was already running on the shelf. A BME280 sensor costs around €10. This should have been a weekend project.</p>
<p>It mostly was, except for the part where I assumed reading a temperature sensor meant reading a register.</p>
<h2 id="four-wires-and-a-chip">Four wires and a chip</h2>
<p>The Bosch BME280 measures temperature, humidity, and atmospheric pressure over I²C. Four wires to the Raspberry Pi GPIO pins, enable I²C in <code>raspi-config</code>, and the sensor shows up at address <code>0x77</code> on the bus:</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>i2cdetect -y <span style="color:#ae81ff">1</span>
</span></span></code></pre></div><p>That&rsquo;s the easy part. The catch is what happens next.</p>
<h2 id="you-dont-just-read-the-temperature">You don&rsquo;t just read the temperature</h2>
<p>The BME280 doesn&rsquo;t hand you <code>21.5°C</code>. It gives you raw ADC values: 20-bit integers that mean absolutely nothing by themselves. To get an actual temperature, you have to:</p>
<ol>
<li>Read the calibration coefficients Bosch burned into the chip&rsquo;s EEPROM at the factory (registers <code>0x88</code>, <code>0xA1</code>, <code>0xE1</code>)</li>
<li>Apply Bosch&rsquo;s compensation formulas: double-precision floating point arithmetic that uses those coefficients to turn raw values into real measurements</li>
<li>Wait for the measurement to finish by polling the status register</li>
</ol>
<p>The temperature compensation alone takes the raw value, applies a quadratic correction with three calibration constants, and spits out a value in hundredths of degrees Celsius. Pressure depends on the corrected temperature. Humidity depends on both.</p>
<p>It&rsquo;s all straight from the <a href="https://www.bosch-sensortec.com/media/boschsensortec/downloads/datasheets/bst-bme280-ds002.pdf" target="_blank" rel="noopener noreferrer">Bosch datasheet</a>, nothing clever. But it does mean the driver isn&rsquo;t a five-liner. It&rsquo;s implementing a spec, not importing a library.</p>
<h2 id="making-it-network-accessible">Making it network-accessible</h2>
<p>Once the driver worked, the next question was how to get those values into Home Assistant. The simplest path: a Flask API with two endpoints.</p>
<p><code>GET /bme280</code> returns the current reading as JSON. <code>GET /bme280/publish</code> reads the sensor and pushes the three values to an MQTT broker. A cron job on the Pi calls the publish endpoint every few minutes, and Home Assistant picks up the values in real time.</p>
<p>The MQTT discovery mechanism made the Home Assistant side almost frictionless. One <code>mosquitto_pub</code> command per sensor type — publishing a JSON config payload to the right topic — and the entities appear automatically in the UI. No <code>configuration.yaml</code> editing, no restart required.</p>
<pre tabindex="0"><code>BME280  ──I²C──►  bme280.py  ──►  Flask API  ──MQTT──►  Home Assistant
</code></pre><p>The full setup guide is <a href="https://github.com/guillaumedelre/bme280" target="_blank" rel="noopener noreferrer">in the repo</a>.</p>
<h2 id="what-i-didnt-expect">What I didn&rsquo;t expect</h2>
<p><strong>The Bosch calibration is non-negotiable.</strong> I started by reading the raw temperature register directly and scaling it naively. The result was numbers that looked almost plausible and were completely wrong. The compensation algorithm isn&rsquo;t optional decoration, it&rsquo;s what makes the output mean anything.</p>
<p><strong>Polling beats events here.</strong> The sensor doesn&rsquo;t push data, you ask it for a reading. A cron job every minute is all you need for room monitoring. Real-time streaming would be overkill and would probably wear out the sensor faster.</p>
<p><strong>MQTT discovery is underrated.</strong> Manually declaring sensors in <code>configuration.yaml</code> works, but auto-discovery just feels right. Publish a config payload once, and Home Assistant takes it from there. Adding a new sensor type later takes about thirty seconds.</p>
<p>The room is now 21.4°C and 47% humidity. I know this without opening anything.</p>
<h2 id="a-note-on-the-official-bosch-sensorapi">A note on the official Bosch SensorAPI</h2>
<p>While writing the driver I peeked at the <a href="https://github.com/boschsensortec/BME280_SensorAPI" target="_blank" rel="noopener noreferrer">official Bosch SensorAPI</a> for reference. Two things caught my attention.</p>
<p>The Linux userspace example doesn&rsquo;t actually work on a Raspberry Pi out of the box. Several contributors tripped over the same bug independently: <code>ioctl</code> is called before <code>dev_addr</code> is assigned, so the I²C device address never gets set properly. The fix is obvious once you see it, and multiple PRs documented it, but they sat open for years. Some still are.</p>
<p>Then there&rsquo;s <a href="https://github.com/boschsensortec/BME280_SensorAPI/pull/94" target="_blank" rel="noopener noreferrer">PR #94</a> (still open as of early 2025), reporting undefined behavior in <code>bme280_get_sensor_mode()</code>: the left operand of a bitwise <code>&amp;</code> is an uninitialized variable, caught by static analysis.</p>
<p>The chip itself is great. But manufacturer reference code is a starting point, not gospel. Implementing the compensation algorithm straight from the datasheet meant I understood every line of it. When a reading looks weird, there&rsquo;s no mystery C library to blame.</p>
<div style="border: 1px solid #e8e8e8; padding: 16px; margin-top: 2em; border-radius: 3px;">
  <img src="https://cdn.simpleicons.org/github" width="20" style="vertical-align: middle; margin-right: 8px;" />
  <strong><a href="https://github.com/guillaumedelre/bme280" target="_blank" rel="noopener noreferrer">guillaumedelre/bme280</a></strong>
  <p style="margin: 8px 0 0; color: #828282; font-size: 14px;">Python driver for the BME280 sensor — temperature, humidity, and pressure over I²C, with MQTT publishing and Home Assistant integration.</p>
</div>
]]></content:encoded></item><item><title>PHP 7.3: small wins that add up</title><link>https://guillaumedelre.github.io/2019/01/20/php-7.3-small-wins-that-add-up/</link><pubDate>Sun, 20 Jan 2019 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2019/01/20/php-7.3-small-wins-that-add-up/</guid><description>Part 4 of 11 in &amp;quot;PHP Releases&amp;quot;: PHP 7.3 brings flexible heredoc syntax, trailing commas in function calls, and quality-of-life fixes that add up quickly.</description><category>php-releases</category><content:encoded><![CDATA[<p>PHP 7.3 shipped December 6th. No single killer feature. It&rsquo;s a collection of quality-of-life improvements that individually feel minor but together make daily work noticeably less annoying.</p>
<h2 id="flexible-heredoc-and-nowdoc">Flexible heredoc and nowdoc</h2>
<p>Until 7.3, the closing marker of a heredoc had to be at column zero. That forced awkward de-indentation in otherwise well-formatted 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:#75715e">// before
</span></span></span><span style="display:flex;"><span>$html <span style="color:#f92672">=</span> <span style="color:#e6db74">&lt;&lt;&lt;</span><span style="color:#e6db74">HTML</span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &lt;div&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        &lt;p&gt;Hello&lt;/p&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &lt;/div&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">HTML; // had to be at column 0, ugly
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">// after
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">$html = &lt;&lt;&lt;HTML
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &lt;div&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        &lt;p&gt;Hello&lt;/p&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &lt;/div&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    </span><span style="color:#e6db74">HTML</span>;
</span></span></code></pre></div><p>The closing marker can now be indented to match the surrounding code, and that indentation is stripped from the content. This looks cosmetic. It&rsquo;s not. Heredocs in nested contexts (class methods, conditionals) were visually jarring before. Now they fit.</p>
<h2 id="1234-array_key_first-and-array_key_last">:1234: array_key_first() and array_key_last()</h2>
<p>This existed forever as a workaround:</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>$first <span style="color:#f92672">=</span> <span style="color:#a6e22e">array_keys</span>($array)[<span style="color:#ae81ff">0</span>];
</span></span></code></pre></div><p>7.3 adds the obvious helpers:</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>$first <span style="color:#f92672">=</span> <span style="color:#a6e22e">array_key_first</span>($array);
</span></span><span style="display:flex;"><span>$last  <span style="color:#f92672">=</span> <span style="color:#a6e22e">array_key_last</span>($array);
</span></span></code></pre></div><p>And <code>is_countable()</code> to safely check before calling <code>count()</code> on something that might not implement <code>Countable</code>. Functions that should have existed years ago.</p>
<h2 id="pcre2">PCRE2</h2>
<p>The regular expression engine was migrated from PCRE to PCRE2. Mostly invisible for existing patterns, but PCRE2 is actively maintained and handles edge cases better. The main practical impact: some patterns that previously produced undefined behavior now throw errors. That&rsquo;s the correct behavior, even if it surprises you on the first upgrade.</p>
<h2 id="trailing-commas-in-function-calls">Trailing commas in function calls</h2>
<p>7.2 allowed trailing commas in grouped namespace imports. 7.3 extends this to function and method calls:</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>$result <span style="color:#f92672">=</span> <span style="color:#a6e22e">array_merge</span>(
</span></span><span style="display:flex;"><span>    $defaults,
</span></span><span style="display:flex;"><span>    $overrides,
</span></span><span style="display:flex;"><span>    $extras, <span style="color:#75715e">// no more removing this comma before the closing paren
</span></span></span><span style="display:flex;"><span>);
</span></span></code></pre></div><p>This matters most with multiline calls. Adding or removing an argument no longer means touching the adjacent line. Diffs stay honest, rebases get a little less painful.</p>
<h2 id="reference-assignments-in-array-destructuring">Reference assignments in array destructuring</h2>
<p>Array destructuring gained the ability to capture references instead of copies:</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>$data <span style="color:#f92672">=</span> [<span style="color:#e6db74">&#39;Alice&#39;</span>, <span style="color:#ae81ff">42</span>];
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>[<span style="color:#f92672">&amp;</span>$name, $age] <span style="color:#f92672">=</span> $data;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>$name <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;Bob&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">var_dump</span>($data[<span style="color:#ae81ff">0</span>]); <span style="color:#75715e">// string(3) &#34;Bob&#34;
</span></span></span></code></pre></div><p>Nested references work too:</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>[$a, [<span style="color:#f92672">&amp;</span>$b]] <span style="color:#f92672">=</span> [<span style="color:#ae81ff">1</span>, [<span style="color:#ae81ff">2</span>]];
</span></span></code></pre></div><p>More niche than trailing commas, but the right tool when you need to alias deep into a structure without a pile of intermediate assignments.</p>
<h2 id="instanceof-with-literals-is-now-legal"><code>instanceof</code> with literals is now legal</h2>
<p>Previously, using <code>instanceof</code> with a literal on the left side was a parse error. 7.3 makes it valid:</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">var_dump</span>(<span style="color:#66d9ef">null</span> <span style="color:#a6e22e">instanceof</span> <span style="color:#66d9ef">stdClass</span>); <span style="color:#75715e">// bool(false)
</span></span></span></code></pre></div><p>It always evaluates to <code>false</code>, which is exactly correct. The benefit is that code that conditionally constructs a value and then checks its type no longer needs to extract the value into a variable first. Useful in generated code and test helpers.</p>
<h2 id="json_decode-and-json_encode-can-throw-now"><code>json_decode()</code> and <code>json_encode()</code> can throw now</h2>
<p>Before 7.3, JSON errors were silent unless you remembered to check <code>json_last_error()</code>. Easy to forget, easy to get wrong:</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>$data <span style="color:#f92672">=</span> <span style="color:#a6e22e">json_decode</span>($response);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">json_last_error</span>() <span style="color:#f92672">!==</span> <span style="color:#a6e22e">JSON_ERROR_NONE</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// most people forgot this part
</span></span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>7.3 adds <code>JSON_THROW_ON_ERROR</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>$data <span style="color:#f92672">=</span> <span style="color:#a6e22e">json_decode</span>($response, <span style="color:#66d9ef">true</span>, <span style="color:#ae81ff">512</span>, <span style="color:#a6e22e">JSON_THROW_ON_ERROR</span>);
</span></span><span style="display:flex;"><span><span style="color:#75715e">// throws JsonException on malformed input
</span></span></span></code></pre></div><p><code>JsonException</code> extends <code>RuntimeException</code>. Catch it specifically or let it propagate. Should have worked this way from day one.</p>
<h2 id="setcookie-with-an-options-array"><code>setcookie()</code> with an options array</h2>
<p>The old <code>setcookie()</code> signature is a relic: seven positional arguments, most of which you leave as defaults just to reach the one you actually want. 7.3 adds an alternative form that takes an associative array:</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">setcookie</span>(<span style="color:#e6db74">&#39;session&#39;</span>, $token, [
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;expires&#39;</span>  <span style="color:#f92672">=&gt;</span> <span style="color:#a6e22e">time</span>() <span style="color:#f92672">+</span> <span style="color:#ae81ff">3600</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;path&#39;</span>     <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;/&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;secure&#39;</span>   <span style="color:#f92672">=&gt;</span> <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;httponly&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;samesite&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;Lax&#39;</span>,
</span></span><span style="display:flex;"><span>]);
</span></span></code></pre></div><p>The <code>samesite</code> option is the real reason this was added — the old positional signature had no slot for it. <code>session_set_cookie_params()</code> received the same treatment, and a new <code>session.cookie_samesite</code> ini directive covers the default.</p>
<h2 id="hrtime-for-benchmarking-that-actually-measures-time"><code>hrtime()</code> for benchmarking that actually measures time</h2>
<p><code>microtime()</code> reads wall clock time. Fine for most cases. But it&rsquo;s affected by NTP adjustments, and its resolution is implementation-dependent. <code>hrtime()</code> reads the monotonic high-resolution clock:</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>$start <span style="color:#f92672">=</span> <span style="color:#a6e22e">hrtime</span>(<span style="color:#66d9ef">true</span>);  <span style="color:#75715e">// nanoseconds as integer
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">doWork</span>();
</span></span><span style="display:flex;"><span>$elapsed <span style="color:#f92672">=</span> <span style="color:#a6e22e">hrtime</span>(<span style="color:#66d9ef">true</span>) <span style="color:#f92672">-</span> $start;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">echo</span> $elapsed <span style="color:#f92672">/</span> <span style="color:#ae81ff">1e6</span> <span style="color:#f92672">.</span> <span style="color:#e6db74">&#34; ms</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">&#34;</span>;
</span></span></code></pre></div><p>Without the <code>true</code> argument it returns <code>[seconds, nanoseconds]</code> as a two-element array. Use this for microbenchmarks, or anywhere clock drift would silently corrupt your measurement.</p>
<h2 id="gc_status--looking-inside-the-garbage-collector"><code>gc_status()</code> — looking inside the garbage collector</h2>
<p>PHP&rsquo;s cyclic garbage collector runs when a buffer of potential cycles fills up. Until 7.3 you had no easy way to see what it was actually doing. <code>gc_status()</code> exposes the internal state:</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>$status <span style="color:#f92672">=</span> <span style="color:#a6e22e">gc_status</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;runs&#39;       =&gt; 3,
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">//   &#39;collected&#39;  =&gt; 127,
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">//   &#39;threshold&#39;  =&gt; 10001,
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">//   &#39;roots&#39;      =&gt; 42,
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">// ]
</span></span></span></code></pre></div><p>Not something most app code needs. Useful when you&rsquo;re trying to figure out why memory keeps climbing under specific workloads.</p>
<h2 id="compileerror-joins-the-exception-hierarchy"><code>CompileError</code> joins the exception hierarchy</h2>
<p>Parse errors have been catchable as <code>ParseError</code> since PHP 7.0. 7.3 introduces <code>CompileError</code> as the parent class for compile-time failures, with <code>ParseError</code> becoming a subclass of it:</p>
<pre tabindex="0"><code>Error
└── CompileError
    └── ParseError
</code></pre><p>In practice, code that catches <code>ParseError</code> still works. The new class just gives future compile-time errors (that aren&rsquo;t parse errors) a proper home in the hierarchy.</p>
<h2 id="bcscale-as-a-getter"><code>bcscale()</code> as a getter</h2>
<p>The BC Math scale was always settable via <code>bcscale($n)</code>. Getting the current scale required tracking it yourself. 7.3 makes <code>bcscale()</code> work without 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:#a6e22e">bcscale</span>(<span style="color:#ae81ff">4</span>);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">echo</span> <span style="color:#a6e22e">bcscale</span>(); <span style="color:#75715e">// 4
</span></span></span></code></pre></div><p>Minor. Worth knowing if you&rsquo;re writing library code that needs to respect or restore whoever called it&rsquo;s scale setting.</p>
<h2 id="the-continue-inside-switch-warning">The <code>continue</code> inside <code>switch</code> warning</h2>
<p>This one is a correctness fix that looks like a deprecation. In PHP, <code>continue</code> inside a <code>switch</code> has always behaved like <code>break</code> — it exits the switch, not the enclosing loop. Developers coming from other languages often write this expecting to skip to the next loop iteration:</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">foreach</span> ($items <span style="color:#66d9ef">as</span> $item) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">switch</span> ($item<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">type</span>) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">case</span> <span style="color:#e6db74">&#39;skip&#39;</span><span style="color:#f92672">:</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">continue</span>; <span style="color:#75715e">// WRONG: exits the switch, not the foreach
</span></span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>7.3 adds a warning for this pattern. The fix is <code>continue 2</code> to explicitly target the enclosing loop. The behavior hasn&rsquo;t changed. The silence has.</p>
<h2 id="deprecations">Deprecations</h2>
<p>Case-insensitive constants declared via <code>define()</code> are deprecated:</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">define</span>(<span style="color:#e6db74">&#39;MY_CONST&#39;</span>, <span style="color:#ae81ff">42</span>, <span style="color:#66d9ef">true</span>); <span style="color:#75715e">// third argument deprecated
</span></span></span></code></pre></div><p>Passing a non-string needle to <code>strpos()</code>, <code>strstr()</code>, and related functions is deprecated. In PHP 8 these will interpret the needle as a string, not an ASCII codepoint. If you&rsquo;re passing integers to these functions intentionally, <code>chr($n)</code> is the explicit form.</p>
<p><code>fgetss()</code> is deprecated — it was <code>fgets()</code> with HTML/PHP tags stripped. Use <code>fgets()</code> and strip tags explicitly if you need them stripped. The <code>string.strip_tags</code> stream filter goes with it.</p>
<p>7.3 is the kind of release you appreciate in hindsight. Nothing individually dramatic, but after six months with it the heredoc fix alone has paid back the upgrade cost in readability. Sometimes boring is exactly right.</p>
]]></content:encoded></item><item><title>PHP 7.2: goodbye mcrypt, hello sodium</title><link>https://guillaumedelre.github.io/2018/01/14/php-7.2-goodbye-mcrypt-hello-sodium/</link><pubDate>Sun, 14 Jan 2018 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2018/01/14/php-7.2-goodbye-mcrypt-hello-sodium/</guid><description>Part 3 of 11 in &amp;quot;PHP Releases&amp;quot;: PHP 7.2 removes the unmaintained mcrypt extension and bundles libsodium — finally modernizing PHP&amp;#39;s cryptography story.</description><category>php-releases</category><content:encoded><![CDATA[<p>PHP 7.2 released November 30th. The headline isn&rsquo;t a language feature, it&rsquo;s a removal. <code>mcrypt</code> is gone.</p>
<p>This is good news, even if it doesn&rsquo;t feel that way when you&rsquo;re the one migrating.</p>
<h2 id="the-mcrypt-problem">The mcrypt problem</h2>
<p><code>mcrypt</code> has been unmaintained since 2007. More than a decade of stagnation in a cryptography library. It was deprecated in 7.1, and 7.2 removes it entirely. The replacement is <code>sodium</code>, now bundled as a core extension.</p>
<p>Sodium is the PHP binding for <a href="https://libsodium.org" target="_blank" rel="noopener noreferrer">libsodium</a>, a modern cryptographic library built around safe defaults. Where mcrypt required you to pick the right cipher, mode, and padding (and get it wrong silently), sodium&rsquo;s API makes dangerous choices structurally hard. <code>sodium_crypto_secretbox()</code> for symmetric encryption, <code>sodium_crypto_box()</code> for asymmetric, <code>sodium_crypto_sign()</code> for signatures. The names tell you what you&rsquo;re doing.</p>
<p>If you have mcrypt code in production, the migration is unavoidable. Worth doing carefully too: cryptography code that &ldquo;still works&rdquo; can be silently broken in ways you won&rsquo;t notice until it&rsquo;s too late.</p>
<h2 id="the-object-type-hint">The object type hint</h2>
<p>7.2 adds <code>object</code> as a parameter and return 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">function</span> <span style="color:#a6e22e">serialize</span>(<span style="color:#a6e22e">object</span> $data)<span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// accepts any object
</span></span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>It&rsquo;s broad — any object satisfies it — but it&rsquo;s better than no type at all when you genuinely don&rsquo;t care about the specific class. Complements the existing <code>array</code>, <code>callable</code>, and class-specific hints.</p>
<h2 id="argon2-in-password_hash">Argon2 in password_hash</h2>
<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>$hash <span style="color:#f92672">=</span> <span style="color:#a6e22e">password_hash</span>($password, <span style="color:#a6e22e">PASSWORD_ARGON2I</span>);
</span></span></code></pre></div><p><code>PASSWORD_BCRYPT</code> was the default and still reasonable, but Argon2 won the Password Hashing Competition for a reason: it&rsquo;s memory-hard, which makes GPU-based cracking significantly more expensive. Worth switching for new apps.</p>
<p>7.2 is more of a security release than a language one. Remove mcrypt, add sodium, and you&rsquo;ve moved the platform to a place where you can actually trust it with sensitive data. The language features are incremental. The infrastructure shift is not.</p>
<h2 id="parameter-types-you-can-now-drop-on-purpose">Parameter types you can now drop on purpose</h2>
<p>7.2 formalizes something that was previously just a smell: when you implement an interface or override a method, you can now omit the parameter type entirely. This is valid contravariance under the Liskov substitution principle.</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">interface</span> <span style="color:#a6e22e">Serializable</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">serialize</span>(<span style="color:#66d9ef">array</span> $data)<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></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">JsonSerializer</span> <span style="color:#66d9ef">implements</span> <span style="color:#a6e22e">Serializable</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">serialize</span>($data)<span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span> { <span style="color:#75715e">// no type — accepts more, still valid
</span></span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">json_encode</span>($data);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>It reads oddly at first. But it&rsquo;s logically sound: a method that accepts anything is strictly more permissive than one that only accepts arrays. The type system agrees, even if your code reviewer raises an eyebrow.</p>
<h2 id="abstract-methods-that-evolve">Abstract methods that evolve</h2>
<p>When an abstract class extends another abstract class, it can now override the abstract method with a different signature. The constraint is directional: parameters can be widened (contravariant), return types can be narrowed (covariant).</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">BaseProcessor</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">abstract</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">process</span>(<span style="color:#a6e22e">string</span> $input);
</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">class</span> <span style="color:#a6e22e">TypedProcessor</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">BaseProcessor</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">abstract</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">process</span>($input)<span style="color:#f92672">:</span> <span style="color:#a6e22e">int</span>; <span style="color:#75715e">// parameter widened, return type added
</span></span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>This was rejected before 7.2. It unlocks intermediate abstractions without forcing every leaf class to repeat the same signature.</p>
<h2 id="trailing-commas-in-grouped-use-statements">Trailing commas in grouped use statements</h2>
<p>Small, but I notice its absence when it&rsquo;s missing. Grouped namespace imports can now have a trailing comma on the last item:</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">App\Services\</span>{
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">UserService</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">OrderService</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">NotificationService</span>, <span style="color:#75715e">// comma here — finally
</span></span></span><span style="display:flex;"><span>};
</span></span></code></pre></div><p>This means you can reorder or add lines without touching the previous last entry. Git diffs get cleaner, merge conflicts get rarer.</p>
<h2 id="count-grew-a-conscience"><code>count()</code> grew a conscience</h2>
<p>Before 7.2, <code>count(null)</code> returned 0. Silently. No warning. That&rsquo;s exactly the kind of thing that buries a bug for months. Now it emits <code>E_WARNING</code> when you pass something that isn&rsquo;t an array or a <code>Countable</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:#a6e22e">count</span>(<span style="color:#66d9ef">null</span>);  <span style="color:#75715e">// Warning: count(): Parameter must be an array or an object that implements Countable
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">count</span>(<span style="color:#ae81ff">42</span>);    <span style="color:#75715e">// same
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">count</span>(<span style="color:#e6db74">&#34;hi&#34;</span>);  <span style="color:#75715e">// same
</span></span></span></code></pre></div><p>The behavior didn&rsquo;t change for valid inputs. Only the silence was broken. That&rsquo;s the correct direction.</p>
<h2 id="spl_object_id--the-thing-you-were-emulating-with-splobjectstorage"><code>spl_object_id()</code> — the thing you were emulating with SplObjectStorage</h2>
<p>If you&rsquo;ve ever built a map keyed on object identity, you&rsquo;ve written something 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-php" data-lang="php"><span style="display:flex;"><span>$storage <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">SplObjectStorage</span>();
</span></span><span style="display:flex;"><span>$storage[$obj] <span style="color:#f92672">=</span> <span style="color:#66d9ef">true</span>;
</span></span></code></pre></div><p>7.2 adds <code>spl_object_id()</code>, which returns a unique integer for the lifetime of an object. It&rsquo;s the same internal handle <code>SplObjectStorage</code> uses, made directly accessible:</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>$id <span style="color:#f92672">=</span> <span style="color:#a6e22e">spl_object_id</span>($obj); <span style="color:#75715e">// e.g. 42
</span></span></span><span style="display:flex;"><span>$map[$id] <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;something&#39;</span>;
</span></span></code></pre></div><p>The integer is reused after the object is destroyed, so don&rsquo;t hold onto it past the object&rsquo;s lifetime. Within a well-scoped context though, it&rsquo;s a cheap identity key.</p>
<h2 id="pdo-national-character-strings">PDO: national character strings</h2>
<p>When working with databases that distinguish between regular and national character string types (Oracle, SQL Server), 7.2 adds the flags you needed:</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>$stmt <span style="color:#f92672">=</span> $pdo<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">prepare</span>(<span style="color:#e6db74">&#34;SELECT * FROM users WHERE name = ?&#34;</span>);
</span></span><span style="display:flex;"><span>$stmt<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">bindValue</span>(<span style="color:#ae81ff">1</span>, <span style="color:#e6db74">&#39;Ångström&#39;</span>, <span style="color:#a6e22e">PDO</span><span style="color:#f92672">::</span><span style="color:#a6e22e">PARAM_STR</span> <span style="color:#f92672">|</span> <span style="color:#a6e22e">PDO</span><span style="color:#f92672">::</span><span style="color:#a6e22e">PARAM_STR_NATL</span>);
</span></span></code></pre></div><p>Or set a connection-level default:</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>$pdo<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">setAttribute</span>(<span style="color:#a6e22e">PDO</span><span style="color:#f92672">::</span><span style="color:#a6e22e">ATTR_DEFAULT_STR_PARAM</span>, <span style="color:#a6e22e">PDO</span><span style="color:#f92672">::</span><span style="color:#a6e22e">PARAM_STR_NATL</span>);
</span></span></code></pre></div><p><code>PDO::PARAM_STR_NATL</code> signals that the string is a national character type (NCHAR/NVARCHAR). Obscure, yes. Essential if you&rsquo;ve ever watched your Unicode data come out mangled because the driver had no idea about the difference.</p>
<h2 id="gd-got-bmp-support-and-clipping-rectangles">GD got BMP support and clipping rectangles</h2>
<p>Two things worth knowing. First, BMP files are now first-class citizens in the GD extension:</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>$image <span style="color:#f92672">=</span> <span style="color:#a6e22e">imagecreatefrombmp</span>(<span style="color:#e6db74">&#39;photo.bmp&#39;</span>);
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">imagebmp</span>($image, <span style="color:#e6db74">&#39;output.bmp&#39;</span>);
</span></span></code></pre></div><p>Second, you can now define a clipping rectangle so that drawing operations only affect a portion of the image:</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">imagesetclip</span>($image, <span style="color:#ae81ff">10</span>, <span style="color:#ae81ff">10</span>, <span style="color:#ae81ff">200</span>, <span style="color:#ae81ff">150</span>); <span style="color:#75715e">// x1, y1, x2, y2
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">// everything drawn outside this rectangle is silently ignored
</span></span></span></code></pre></div><p>Neither feature reshapes how most apps work, but both replace &ldquo;install an extra library&rdquo; with &ldquo;it&rsquo;s just in core now.&rdquo;</p>
<h2 id="mb_chr-and-mb_ord--unicodes-chr-and-ord"><code>mb_chr()</code> and <code>mb_ord()</code> — Unicode&rsquo;s <code>chr()</code> and <code>ord()</code></h2>
<p>PHP has had <code>chr()</code> and <code>ord()</code> forever. They work on bytes. For Unicode code points, you were on your own. 7.2 adds the multibyte equivalents:</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>$char <span style="color:#f92672">=</span> <span style="color:#a6e22e">mb_chr</span>(<span style="color:#ae81ff">0x1F600</span>); <span style="color:#75715e">// returns the 😀 emoji
</span></span></span><span style="display:flex;"><span>$code <span style="color:#f92672">=</span> <span style="color:#a6e22e">mb_ord</span>(<span style="color:#e6db74">&#39;é&#39;</span>);     <span style="color:#75715e">// returns 233
</span></span></span></code></pre></div><p>And <code>mb_scrub()</code>, which strips invalid byte sequences from a string rather than failing silently or throwing:</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>$clean <span style="color:#f92672">=</span> <span style="color:#a6e22e">mb_scrub</span>($untrustedInput, <span style="color:#e6db74">&#39;UTF-8&#39;</span>);
</span></span></code></pre></div><p>Handy at any external boundary: API responses, file uploads, database reads from legacy systems.</p>
<h2 id="deprecations-worth-knowing-before-74-arrives">Deprecations worth knowing before 7.4 arrives</h2>
<p>Several things were soft-deprecated in 7.2 that will become errors in later versions. The ones most likely to bite:</p>
<p><code>__autoload()</code> is deprecated. If you&rsquo;re still registering a global autoload function instead of using <code>spl_autoload_register()</code>, fix it before it becomes a fatal.</p>
<p><code>create_function()</code> is deprecated. It&rsquo;s a wrapper around <code>eval()</code> and was always dangerous. Use a closure:</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>$fn <span style="color:#f92672">=</span> <span style="color:#a6e22e">create_function</span>(<span style="color:#e6db74">&#39;$x&#39;</span>, <span style="color:#e6db74">&#39;return $x * 2;&#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>$fn <span style="color:#f92672">=</span> <span style="color:#a6e22e">fn</span>($x) <span style="color:#f92672">=&gt;</span> $x <span style="color:#f92672">*</span> <span style="color:#ae81ff">2</span>;
</span></span></code></pre></div><p><code>each()</code> is deprecated. The loop pattern it enabled is better written as <code>foreach</code>. There&rsquo;s no loss here.</p>
<p><code>parse_str()</code> without a second argument dumps parsed values into the local symbol table — a security issue that should never have been allowed. Always pass the output variable:</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">parse_str</span>($queryString, $params); <span style="color:#75715e">// correct
</span></span></span></code></pre></div><p>The <code>(unset)</code> cast is deprecated because it always returns <code>null</code>, which you can just write as <code>null</code>. Completely pointless syntax that should never have existed.</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>Controlling a USB missile launcher over HTTP with FastAPI and Docker</title><link>https://guillaumedelre.github.io/2017/02/21/controlling-a-usb-missile-launcher-over-http-with-fastapi-and-docker/</link><pubDate>Tue, 21 Feb 2017 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2017/02/21/controlling-a-usb-missile-launcher-over-http-with-fastapi-and-docker/</guid><description>How we wired a USB foam missile launcher to the CI pipeline — and what Docker, udev, and WSL2 had to say about it.</description><content:encoded><![CDATA[<p>The rule was simple: whoever breaks the CI build owes the team a coffee. It worked fine for a while. Then someone suggested we needed something with more immediate feedback. Something physical. Something that fires.</p>
<p>A <a href="http://www.dreamcheeky.com/thunder-missile-launcher" target="_blank" rel="noopener noreferrer">Dream Cheeky Thunder</a> appeared on a desk shortly after. Four foam missiles, a USB cable, and a very clear team consensus: hook it to the cluster, wire it to the build pipeline, and let the CI decide who deserves a volley.</p>
<p>The launcher needed to respond to HTTP calls from anywhere on the network. No driver, no GUI, no manual aiming. Just an endpoint that makes it shoot in the direction of the guilty party&rsquo;s desk.</p>
<p>This is the story of <a href="https://github.com/guillaumedelre/dream-cheeky-thunder" target="_blank" rel="noopener noreferrer">dream-cheeky-thunder</a>.</p>
<p><img alt="Dream Cheeky Thunder" loading="lazy" src="https://raw.githubusercontent.com/guillaumedelre/dream-cheeky-thunder/develop/docs/Dream-Cheeky-Thunder.jpg"></p>
<h2 id="no-sdk-no-docs-no-problem">No SDK, no docs, no problem</h2>
<p>Dream Cheeky never published a protocol spec. The launcher speaks raw USB HID, and the only starting point was a vendored Python script from 2012 floating around in forum threads. Vendor ID <code>0x2123</code>, product ID <code>0x1010</code>, and a handful of control bytes that someone had reverse engineered years before.</p>
<p>That was enough. The protocol is simple: send a byte sequence to move the motors, send another to fire. The tricky part is that the launcher has no position feedback. No encoders, no limit switches beyond the physical hard stops at the extremes. You drive it blind.</p>
<h2 id="from-usb-to-http">From USB to HTTP</h2>
<p>The CI pipeline needed to trigger the launcher over the network. A local script wasn&rsquo;t going to cut it — the launcher had to be reachable from any machine on the cluster, including the build server. So: a REST API.</p>
<p>FastAPI was the obvious choice. The targeting flow from the CI side ends up being three HTTP calls:</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>curl -X POST http://localhost:8000/park      <span style="color:#75715e"># reset to known position</span>
</span></span><span style="display:flex;"><span>curl -X POST http://localhost:8000/yaw/20    <span style="color:#75715e"># rotate toward guilty desk</span>
</span></span><span style="display:flex;"><span>curl -X POST <span style="color:#e6db74">&#34;http://localhost:8000/fire?shots=2&#34;</span>
</span></span></code></pre></div><p>The <code>/park</code> call matters more than it looks. Since the launcher has no position feedback, the server estimates the current angle by tracking how long the motors have been running. That estimate drifts. Bumping the hardware, interrupting a command, or just the imprecision of time-based tracking — they all accumulate. Parking drives both motors against the physical hard stops at full sweep, which guarantees alignment regardless of what the server thinks it knows. Skip it, and your aim is a guess.</p>
<p>The full API reference is <a href="https://github.com/guillaumedelre/dream-cheeky-thunder/blob/develop/docs/api.md" target="_blank" rel="noopener noreferrer">in the repo</a>. There&rsquo;s also a web UI if you prefer clicking over <code>curl</code>.</p>
<h2 id="docker-knows-nothing-about-usb">Docker knows nothing about USB</h2>
<p>Running this in a Docker container on the cluster was where the fun really started: containers don&rsquo;t see USB devices by default.</p>
<p>The <code>devices</code> mount in <code>compose.yaml</code> exposes the USB bus to the container:</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">devices</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">/dev/bus/usb:/dev/bus/usb</span>
</span></span></code></pre></div><p>Not enough. First run came back with <code>USBError: [Errno 13] Access denied</code>. The device node is there inside the container, but it inherits permissions from the host, and on the host only root can open it by default.</p>
<p>The fix is a udev rule. Drop one file into <code>/etc/udev/rules.d/</code>, and the kernel sets the right group and permissions when the device plugs in. After that, the container user can open it without needing elevated privileges. The rule ships with the project, setup instructions are <a href="https://github.com/guillaumedelre/dream-cheeky-thunder/blob/develop/docs/setup-linux.md" target="_blank" rel="noopener noreferrer">in the docs</a>.</p>
<h2 id="wsl2-made-it-interesting">WSL2 made it interesting</h2>
<p>Half the team runs Windows with Docker Desktop on WSL2. That&rsquo;s where things got creative.</p>
<p>WSL2 has no access to USB devices by default: the Windows kernel holds them, and the <code>devices</code> mount alone does nothing because WSL2 simply doesn&rsquo;t see the hardware. The fix is <a href="https://github.com/dorssel/usbipd-win" target="_blank" rel="noopener noreferrer">usbipd-win</a>, which forwards the USB device from Windows into the WSL2 kernel over IP. Once that&rsquo;s done, the Linux path works exactly the same: udev rule, <code>devices</code> mount, done.</p>
<p>The attachment doesn&rsquo;t survive reboots, though. usbipd v4+ added a policy mechanism that automates reconnection, which killed the &ldquo;it worked yesterday&rdquo; mystery that had been annoying us for days.</p>
<h2 id="what-actually-surprised-us">What actually surprised us</h2>
<p><strong>Time-based positioning works well enough.</strong> No encoders meant we went in expecting the angle tracking to be basically useless. Turns out, parking before every sequence kept it accurate enough to reliably aim at a specific desk. Not millimeter precision, but foam missile precision is fine.</p>
<p><strong>The <code>devices</code> mount is necessary but not sufficient.</strong> The permission error was confusing precisely because the device was clearly visible inside the container. The udev rule is the bit most tutorials quietly skip.</p>
<p><strong>The coffee rule was never the same after this.</strong> Once the launcher was wired to the pipeline, broken builds suddenly became a lot more motivating to fix.</p>
<div style="border: 1px solid #e8e8e8; padding: 16px; margin-top: 2em; border-radius: 3px;">
  <img src="https://cdn.simpleicons.org/github" width="20" style="vertical-align: middle; margin-right: 8px;" />
  <strong><a href="https://github.com/guillaumedelre/dream-cheeky-thunder" target="_blank" rel="noopener noreferrer">guillaumedelre/dream-cheeky-thunder</a></strong>
  <p style="margin: 8px 0 0; color: #828282; font-size: 14px;">FastAPI + Docker + PyUSB — HTTP control for the Dream Cheeky Thunder USB missile launcher. Pull requests welcome, especially if you have a better angle calibration approach.</p>
</div>
]]></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><item><title>PHP 7.1: a tighter type system and the small wins around it</title><link>https://guillaumedelre.github.io/2017/01/15/php-7.1-a-tighter-type-system-and-the-small-wins-around-it/</link><pubDate>Sun, 15 Jan 2017 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2017/01/15/php-7.1-a-tighter-type-system-and-the-small-wins-around-it/</guid><description>Part 2 of 11 in &amp;quot;PHP Releases&amp;quot;: PHP 7.1 filled the gaps left by 7.0: nullable types, void return, and destructuring — small additions that made the type system usable.</description><category>php-releases</category><content:encoded><![CDATA[<p>PHP 7.1 shipped December 1st. No 2x performance headline, no engine rewrite. It fills in the gaps that 7.0 left in the type system, and those gaps were genuinely annoying.</p>
<h2 id="nullable-types">Nullable types</h2>
<p>7.0 let you declare <code>string $name</code> as a parameter type. What it didn&rsquo;t let you do was say &ldquo;this can also be null&rdquo;. You had to drop the type hint entirely or hack around it. 7.1 adds <code>?</code> prefix:</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">function</span> <span style="color:#a6e22e">findUser</span>(<span style="color:#f92672">?</span><span style="color:#a6e22e">int</span> $id)<span style="color:#f92672">:</span> <span style="color:#f92672">?</span><span style="color:#a6e22e">User</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> ($id <span style="color:#f92672">===</span> <span style="color:#66d9ef">null</span>) <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">null</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>($id);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>This sounds minor. It&rsquo;s not. Nullable types make the difference between a signature that tells you what a function does and one that lies by omission. Every codebase I&rsquo;ve worked on has functions that can return null. Now you can actually say so instead of hiding it in a docblock.</p>
<h2 id="void-return-type">Void return type</h2>
<p>The complement to nullable: a function that intentionally returns nothing:</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">process</span>(<span style="color:#a6e22e">Order</span> $order)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span> {
</span></span><span style="display:flex;"><span>    $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">dispatcher</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">dispatch</span>(<span style="color:#66d9ef">new</span> <span style="color:#a6e22e">OrderProcessed</span>($order));
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>void</code> makes the intent explicit and prevents accidentally returning a value from a function that shouldn&rsquo;t. Combined with nullable types, PHP&rsquo;s type system in 7.1 is quite a bit more expressive than 7.0.</p>
<h2 id="class-constant-visibility">Class constant visibility</h2>
<p>A small but welcome fix. Constants in classes were always public before 7.1. Now:</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">Config</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">const</span> <span style="color:#66d9ef">DB_PASSWORD</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;secret&#39;</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">protected</span> <span style="color:#66d9ef">const</span> <span style="color:#66d9ef">VERSION</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;2.0&#39;</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">const</span> <span style="color:#66d9ef">MAX_RETRIES</span> <span style="color:#f92672">=</span> <span style="color:#ae81ff">3</span>;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Keeping implementation details private matters. This should have existed from the start.</p>
<h2 id="catching-multiple-exceptions">Catching multiple exceptions</h2>
<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>    <span style="color:#75715e">// ...
</span></span></span><span style="display:flex;"><span>} <span style="color:#66d9ef">catch</span> (<span style="color:#a6e22e">InvalidArgumentException</span> <span style="color:#f92672">|</span> <span style="color:#a6e22e">RuntimeException</span> $e) {
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// handle both
</span></span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Saves a duplicate catch block when two exceptions need identical handling. Simple, useful.</p>
<h2 id="destructuring-arrays-without-list">Destructuring arrays without list()</h2>
<p><code>list()</code> has been in PHP since 4.0 and always felt slightly out of place syntactically. 7.1 adds a shorthand using <code>[]</code> that reads much more naturally:</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>[$first, $second] <span style="color:#f92672">=</span> $coordinates;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">foreach</span> ($rows <span style="color:#66d9ef">as</span> [$id, $name, $email]) {
</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>It also gains key support, which makes destructuring associative arrays finally usable:</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:#e6db74">&#39;id&#39;</span> <span style="color:#f92672">=&gt;</span> $id, <span style="color:#e6db74">&#39;name&#39;</span> <span style="color:#f92672">=&gt;</span> $name] <span style="color:#f92672">=</span> $user;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">foreach</span> ($records <span style="color:#66d9ef">as</span> [<span style="color:#e6db74">&#39;id&#39;</span> <span style="color:#f92672">=&gt;</span> $id, <span style="color:#e6db74">&#39;status&#39;</span> <span style="color:#f92672">=&gt;</span> $status]) {
</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>Before this, extracting named keys from an array meant either <code>extract()</code> (which dumps everything into scope and invites collisions) or a bunch of individual assignments. This is just cleaner.</p>
<h2 id="the-iterable-type">The iterable type</h2>
<p>If you write a function that accepts either an array or a generator, there was no clean type hint for that in 7.0. You either typed it as <code>array</code> and silently excluded generators, or dropped the hint entirely:</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">function</span> <span style="color:#a6e22e">processItems</span>(<span style="color:#a6e22e">iterable</span> $items)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">foreach</span> ($items <span style="color:#66d9ef">as</span> $item) {
</span></span><span style="display:flex;"><span>        $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">handle</span>($item);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>iterable</code> accepts anything you can <code>foreach</code> over: arrays and <code>Traversable</code> implementations. It also works as a return type. Not dramatic, but it closes a real gap.</p>
<h2 id="negative-string-offsets">Negative string offsets</h2>
<p>String indexing with <code>[]</code> or <code>{}</code> now accepts negative values, counting from the end:</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>$str <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;hello&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">echo</span> $str[<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>]; <span style="color:#75715e">// &#34;o&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">echo</span> $str[<span style="color:#f92672">-</span><span style="color:#ae81ff">2</span>]; <span style="color:#75715e">// &#34;l&#34;
</span></span></span></code></pre></div><p>Several string functions got the same treatment: <code>strpos()</code>, <code>substr()</code>, <code>substr_count()</code>, and others now accept a negative offset. Consistent with how Python has worked forever. Better late than never.</p>
<h2 id="closurefromcallable">Closure::fromCallable()</h2>
<p>Before this, converting a callable (like <code>[$object, 'method']</code> or a function name string) to a proper <code>Closure</code> required <code>Closure::bind()</code> or <code>bindTo()</code> with awkward scope handling. 7.1 adds a static factory 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">class</span> <span style="color:#a6e22e">Processor</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">transform</span>(<span style="color:#a6e22e">string</span> $value)<span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">strtoupper</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">getTransformer</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">Closure</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">Closure</span><span style="color:#f92672">::</span><span style="color:#a6e22e">fromCallable</span>([$this, <span style="color:#e6db74">&#39;transform&#39;</span>]);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The resulting closure captures the correct <code>$this</code> and scope. It&rsquo;s particularly useful when passing methods as callbacks to functions that expect <code>callable</code>, or when building pipelines.</p>
<h2 id="argumentcounterror">ArgumentCountError</h2>
<p>In PHP 7.0, calling a user-defined function with too few arguments generated a warning and execution continued with <code>null</code>-filled parameters. In 7.1, it throws an <code>ArgumentCountError</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">function</span> <span style="color:#a6e22e">connect</span>(<span style="color:#a6e22e">string</span> $host, <span style="color:#a6e22e">int</span> $port)<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><span style="display:flex;"><span><span style="color:#66d9ef">try</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">connect</span>(<span style="color:#e6db74">&#39;localhost&#39;</span>); <span style="color:#75715e">// Throws ArgumentCountError
</span></span></span><span style="display:flex;"><span>} <span style="color:#66d9ef">catch</span> (<span style="color:#a6e22e">\ArgumentCountError</span> $e) {
</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><code>ArgumentCountError</code> extends <code>TypeError</code>, which extends <code>Error</code>. Call sites that previously silently degraded now fail loudly. That&rsquo;s a migration risk if you have code that relied on the permissive behavior, but honestly, it&rsquo;s the right call.</p>
<p>7.1 is the kind of release that makes you trust a platform more. The core team was clearly paying attention to the friction, not just shipping headlines.</p>
]]></content:encoded></item><item><title>PHP 7.0: performance, types, and the features that stuck</title><link>https://guillaumedelre.github.io/2016/01/17/php-7.0-performance-types-and-the-features-that-stuck/</link><pubDate>Sun, 17 Jan 2016 00:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2016/01/17/php-7.0-performance-types-and-the-features-that-stuck/</guid><description>Part 1 of 11 in &amp;quot;PHP Releases&amp;quot;: PHP 7.0 doubled performance with a Zend Engine rewrite and finally brought scalar type hints to the language.</description><category>php-releases</category><content:encoded><![CDATA[<p>PHP 7.0 dropped on December 3rd. A month and a half in, I&rsquo;ve migrated two projects and the results are hard to ignore.</p>
<p>The headline number is 2x faster than PHP 5.6. That&rsquo;s not a benchmark cherry-pick — it&rsquo;s the median across real applications. The Zend Engine was rewritten to use a new internal value representation that cuts memory usage significantly and reduces allocations. On one project, average response time dropped by 40% with zero code changes. You just upgrade and it goes faster.</p>
<p>But performance isn&rsquo;t the most interesting part.</p>
<h2 id="types-finally">Types, finally</h2>
<p>PHP has had type hints for objects since 5.0, for arrays since 5.1. In 7.0, you can finally declare scalar types for function parameters and return values:</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">function</span> <span style="color:#a6e22e">add</span>(<span style="color:#a6e22e">int</span> $a, <span style="color:#a6e22e">int</span> $b)<span style="color:#f92672">:</span> <span style="color:#a6e22e">int</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> $a <span style="color:#f92672">+</span> $b;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>In strict mode (<code>declare(strict_types=1)</code>), passing a float to that function throws a <code>TypeError</code>. In the default coercive mode, PHP converts the value. That distinction matters: strict mode is per-file, so you can adopt it gradually without nuking your whole codebase at once.</p>
<p>Return type declarations are the other half. Putting intent in the signature rather than a docblock means the engine enforces it, not a code reviewer who might be half-asleep.</p>
<h2 id="the-null-coalescing-operator">The null coalescing operator</h2>
<p><code>??</code> is small but used constantly:</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>$username <span style="color:#f92672">=</span> $_GET[<span style="color:#e6db74">&#39;user&#39;</span>] <span style="color:#f92672">??</span> <span style="color:#e6db74">&#39;guest&#39;</span>;
</span></span></code></pre></div><p>That replaces <code>isset($_GET['user']) ? $_GET['user'] : 'guest'</code>. It chains too: <code>$a ?? $b ?? $c</code>. After years of <code>isset()</code> noise, this alone was worth upgrading.</p>
<h2 id="the-breaking-part">The breaking part</h2>
<p>The error handling overhaul is the real upgrade risk. Many fatal errors are now <code>Error</code> exceptions, catchable but different from <code>Exception</code>. Code that relied on fatal errors to halt execution silently now needs explicit handling. Legacy error suppression with <code>@</code> also works differently in places.</p>
<p>Read the migration guide before touching a production app. The payoff is real, but the gap between 5.6 and 7.0 is the widest PHP has ever had.</p>
<h2 id="the-spaceship-operator">The spaceship operator</h2>
<p><code>&lt;=&gt;</code> is a combined comparison operator that returns -1, 0, or 1. It&rsquo;s mostly there for sorting:</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">usort</span>($users, <span style="color:#66d9ef">function</span> ($a, $b) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> $a<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">age</span> <span style="color:#f92672">&lt;=&gt;</span> $b<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">age</span>;
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><p>Before this, a custom sort comparator was a small exercise in remembered arithmetic. <code>$a - $b</code> works for integers but silently breaks for floats. <code>&lt;=&gt;</code> does the right thing for every comparable type.</p>
<h2 id="anonymous-classes">Anonymous classes</h2>
<p>You can now instantiate a class defined inline, on the spot, without giving it a 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>$logger <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">class</span>($config) <span style="color:#66d9ef">implements</span> <span style="color:#a6e22e">LoggerInterface</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:#66d9ef">array</span> $config) {}
</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">log</span>(<span style="color:#a6e22e">string</span> $message)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">file_put_contents</span>($this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">config</span>[<span style="color:#e6db74">&#39;path&#39;</span>], $message <span style="color:#f92672">.</span> <span style="color:#a6e22e">PHP_EOL</span>, <span style="color:#a6e22e">FILE_APPEND</span>);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>};
</span></span></code></pre></div><p>The canonical use case is test doubles and one-off interface implementations that don&rsquo;t deserve a file. It removes a real friction point: the gap between &ldquo;I need an object&rdquo; and &ldquo;I have to create a class file for a 10-line thing&rdquo;.</p>
<h2 id="cryptographically-secure-randomness">Cryptographically secure randomness</h2>
<p>PHP 5&rsquo;s <code>rand()</code> and <code>mt_rand()</code> were never meant for security. 7.0 adds two functions that are:</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>$token <span style="color:#f92672">=</span> <span style="color:#a6e22e">bin2hex</span>(<span style="color:#a6e22e">random_bytes</span>(<span style="color:#ae81ff">32</span>)); <span style="color:#75715e">// 64-character hex token
</span></span></span><span style="display:flex;"><span>$pin   <span style="color:#f92672">=</span> <span style="color:#a6e22e">random_int</span>(<span style="color:#ae81ff">100000</span>, <span style="color:#ae81ff">999999</span>);
</span></span></code></pre></div><p><code>random_bytes()</code> pulls from the OS CSPRNG. <code>random_int()</code> wraps that for integers. These replace every home-grown token generation scheme that was quietly doing it wrong, which is most of them.</p>
<h2 id="group-use-declarations">Group use declarations</h2>
<p>Before 7.0, importing five things from the same namespace meant five <code>use</code> statements. Now:</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">App\Model\</span>{<span style="color:#a6e22e">User</span>, <span style="color:#a6e22e">Order</span>, <span style="color:#a6e22e">Product</span>};
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">App\Helpers\</span>{<span style="color:#a6e22e">formatDate</span>, <span style="color:#a6e22e">slugify</span>};
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#66d9ef">const</span> <span style="color:#66d9ef">App\Config\</span>{<span style="color:#a6e22e">MAX_RETRIES</span>, <span style="color:#a6e22e">TIMEOUT</span>};
</span></span></code></pre></div><p>Small ergonomic improvement, but it reduces the visual noise at the top of files with deep namespace hierarchies.</p>
<h2 id="generators-grew-up">Generators grew up</h2>
<p>Generators in 5.5 were interesting but incomplete. 7.0 adds two things. First, a generator can now have a return value, accessible after iteration ends:</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">function</span> <span style="color:#a6e22e">process</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">Generator</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">yield</span> <span style="color:#e6db74">&#39;step 1&#39;</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">yield</span> <span style="color:#e6db74">&#39;step 2&#39;</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#e6db74">&#39;done&#39;</span>;
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>$gen <span style="color:#f92672">=</span> <span style="color:#a6e22e">process</span>();
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">foreach</span> ($gen <span style="color:#66d9ef">as</span> $step) { <span style="color:#75715e">/* ... */</span> }
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">echo</span> $gen<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getReturn</span>(); <span style="color:#75715e">// &#34;done&#34;
</span></span></span></code></pre></div><p>Second, <code>yield from</code> delegates to another generator or iterable, transparently passing values and return values through:</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">function</span> <span style="color:#a6e22e">inner</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">Generator</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">yield</span> <span style="color:#ae81ff">1</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">yield</span> <span style="color:#ae81ff">2</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#e6db74">&#39;inner done&#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">function</span> <span style="color:#a6e22e">outer</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">Generator</span> {
</span></span><span style="display:flex;"><span>    $result <span style="color:#f92672">=</span> <span style="color:#66d9ef">yield</span> <span style="color:#a6e22e">from</span> <span style="color:#a6e22e">inner</span>();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">echo</span> $result; <span style="color:#75715e">// &#34;inner done&#34;
</span></span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">yield</span> <span style="color:#ae81ff">3</span>;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>This makes composing generators practical without manually plumbing values between them.</p>
<h2 id="closurecall">Closure::call()</h2>
<p>A more direct way to bind a closure to an object and call it 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:#66d9ef">class</span> <span style="color:#a6e22e">Counter</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#a6e22e">int</span> $count <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>;
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>$increment <span style="color:#f92672">=</span> <span style="color:#66d9ef">function</span> (<span style="color:#a6e22e">int</span> $by)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span> {
</span></span><span style="display:flex;"><span>    $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">count</span> <span style="color:#f92672">+=</span> $by;
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>$increment<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">call</span>(<span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Counter</span>(), <span style="color:#ae81ff">5</span>);
</span></span></code></pre></div><p><code>bindTo()</code> existed before but required two steps. <code>call()</code> collapses them and is faster at runtime because it skips the intermediate closure creation.</p>
<h2 id="unicode-escape-syntax-in-strings">Unicode escape syntax in strings</h2>
<p>You can now embed Unicode characters directly in double-quoted strings or heredocs using a codepoint:</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">echo</span> <span style="color:#e6db74">&#34;\u{1F418}&#34;</span>; <span style="color:#75715e">// 🐘
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">echo</span> <span style="color:#e6db74">&#34;\u{00E9}&#34;</span>;  <span style="color:#75715e">// é
</span></span></span></code></pre></div><p>Beats copy-pasting characters from a Unicode table into source files, which is what people were actually doing.</p>
<h2 id="safer-unserialize">Safer unserialize()</h2>
<p><code>unserialize()</code> has a long history of being a vector for object injection attacks. 7.0 adds an <code>allowed_classes</code> option:</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>$data <span style="color:#f92672">=</span> <span style="color:#a6e22e">unserialize</span>($input, [<span style="color:#e6db74">&#39;allowed_classes&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#66d9ef">false</span>]);
</span></span><span style="display:flex;"><span>$data <span style="color:#f92672">=</span> <span style="color:#a6e22e">unserialize</span>($input, [<span style="color:#e6db74">&#39;allowed_classes&#39;</span> <span style="color:#f92672">=&gt;</span> [<span style="color:#a6e22e">User</span><span style="color:#f92672">::</span><span style="color:#a6e22e">class</span>, <span style="color:#a6e22e">Order</span><span style="color:#f92672">::</span><span style="color:#a6e22e">class</span>]]);
</span></span></code></pre></div><p>Passing <code>false</code> prevents any object from being instantiated during deserialization. This is the default you want when deserializing untrusted input.</p>
<h2 id="1234-integer-division">:1234: Integer division</h2>
<p><code>intdiv()</code> is explicit integer division with no float intermediate:</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>$pages <span style="color:#f92672">=</span> <span style="color:#a6e22e">intdiv</span>(<span style="color:#a6e22e">count</span>($items), $perPage); <span style="color:#75715e">// int, no casting needed
</span></span></span></code></pre></div><p>Yes, you could cast the result of a division. <code>intdiv()</code> makes the intent clear and avoids the float precision edge cases that casting introduces for large numbers.</p>
<h2 id="constants-as-arrays">Constants as arrays</h2>
<p>Before 7.0, <code>define()</code> only accepted scalar values. Arrays worked with <code>const</code> at class or namespace scope but not with <code>define()</code>. Now they do:</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">define</span>(<span style="color:#e6db74">&#39;HTTP_METHODS&#39;</span>, [<span style="color:#e6db74">&#39;GET&#39;</span>, <span style="color:#e6db74">&#39;POST&#39;</span>, <span style="color:#e6db74">&#39;PUT&#39;</span>, <span style="color:#e6db74">&#39;DELETE&#39;</span>, <span style="color:#e6db74">&#39;PATCH&#39;</span>]);
</span></span></code></pre></div><p>Useful for configuration that needs to be a constant but lives outside a class.</p>
<h2 id="assertions-with-teeth">Assertions with teeth</h2>
<p><code>assert()</code> got a proper redesign. In PHP 5, assertions were a runtime eval of strings. Now they can throw exceptions and be completely removed in production with zero overhead:</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">// In php.ini or at bootstrap:
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">// assert.active = 1 (dev), 0 (prod)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">// assert.exception = 1
</span></span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">assert</span>($user<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">isVerified</span>(), <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">\LogicException</span>(<span style="color:#e6db74">&#39;Unverified user reached checkout&#39;</span>));
</span></span></code></pre></div><p>When <code>assert.active = 0</code>, the expression is never evaluated. When it&rsquo;s on, a failing assertion throws the provided exception directly. This is finally a tool worth reaching for, without the embarrassment of admitting you used it.</p>
<h2 id="the-session_start-overhaul">The session_start() overhaul</h2>
<p><code>session_start()</code> now accepts an array of options that override <code>php.ini</code> directives for that 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-php" data-lang="php"><span style="display:flex;"><span><span style="color:#a6e22e">session_start</span>([
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;cookie_lifetime&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#ae81ff">86400</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;cookie_secure&#39;</span>   <span style="color:#f92672">=&gt;</span> <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;cookie_httponly&#39;</span>  <span style="color:#f92672">=&gt;</span> <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;cookie_samesite&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;Lax&#39;</span>,
</span></span><span style="display:flex;"><span>]);
</span></span></code></pre></div><p>Before this, you either set options globally in <code>php.ini</code> or called <code>ini_set()</code> before <code>session_start()</code>. Neither was great when you needed different session configurations in different parts of an app.</p>
]]></content:encoded></item></channel></rss>