<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://guillaumedelre.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://guillaumedelre.github.io/" rel="alternate" type="text/html" /><updated>2026-05-15T06:08:26+00:00</updated><id>https://guillaumedelre.github.io/feed.xml</id><title type="html">Guillaume Delré</title><subtitle>Tech articles about PHP, Symfony, API Platform and more.</subtitle><author><name>Guillaume Delré</name><email>delre.guillaume@gmail.com</email></author><entry><title type="html">Symfony 8.0: PHP 8.4 minimum, native lazy objects, and FormFlow</title><link href="https://guillaumedelre.github.io/2026/01/12/symfony-8-0/" rel="alternate" type="text/html" title="Symfony 8.0: PHP 8.4 minimum, native lazy objects, and FormFlow" /><published>2026-01-12T00:00:00+00:00</published><updated>2026-01-12T00:00:00+00:00</updated><id>https://guillaumedelre.github.io/2026/01/12/symfony-8-0</id><content type="html" xml:base="https://guillaumedelre.github.io/2026/01/12/symfony-8-0/"><![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="zzz-native-lazy-objects">:zzz: Native lazy objects</h2>

<p>Symfony’s proxy system, used for lazy service initialization and Doctrine’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 class="language-plaintext highlighter-rouge">LazyGhostTrait</code> and <code class="language-plaintext highlighter-rouge">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="page_with_curl-formflow">:page_with_curl: 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="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">#[AsFormFlow]</span>
<span class="kd">class</span> <span class="nc">CheckoutFlow</span> <span class="kd">extends</span> <span class="nc">AbstractFormFlow</span>
<span class="p">{</span>
    <span class="k">protected</span> <span class="k">function</span> <span class="n">defineSteps</span><span class="p">():</span> <span class="kt">Steps</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="nc">Steps</span><span class="o">::</span><span class="nf">create</span><span class="p">()</span>
            <span class="o">-&gt;</span><span class="nf">add</span><span class="p">(</span><span class="s1">'shipping'</span><span class="p">,</span> <span class="nc">ShippingType</span><span class="o">::</span><span class="n">class</span><span class="p">)</span>
            <span class="o">-&gt;</span><span class="nf">add</span><span class="p">(</span><span class="s1">'payment'</span><span class="p">,</span> <span class="nc">PaymentType</span><span class="o">::</span><span class="n">class</span><span class="p">)</span>
            <span class="o">-&gt;</span><span class="nf">add</span><span class="p">(</span><span class="s1">'review'</span><span class="p">,</span> <span class="nc">ReviewType</span><span class="o">::</span><span class="n">class</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="scissors-xml-and-fluent-php-config-removed">:scissors: 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="no_entry-what-else-is-gone">:no_entry: What else is gone</h2>

<ul>
  <li>PHP 8.2 and 8.3 support (8.4 minimum)</li>
  <li>The <code class="language-plaintext highlighter-rouge">ContainerAwareInterface</code> and <code class="language-plaintext highlighter-rouge">ContainerAwareTrait</code></li>
  <li>Symfony’s internal use of <code class="language-plaintext highlighter-rouge">LazyGhostTrait</code> and <code class="language-plaintext highlighter-rouge">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’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 class="language-plaintext highlighter-rouge">#[Input]</code> attribute turns a DTO into the command’s argument/option bag. No more calling <code class="language-plaintext highlighter-rouge">$input-&gt;getArgument()</code> inside the handler:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">#[AsCommand(name: 'app:send-report')]</span>
<span class="kd">class</span> <span class="nc">SendReportCommand</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">function</span> <span class="n">__invoke</span><span class="p">(</span>
        <span class="c1">#[Input] SendReportInput $input,</span>
    <span class="p">):</span> <span class="kt">int</span> <span class="p">{</span>
        <span class="c1">// $input-&gt;email, $input-&gt;dryRun, etc.</span>
        <span class="k">return</span> <span class="nc">Command</span><span class="o">::</span><span class="no">SUCCESS</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">BackedEnum</code> is supported in invokable commands, so an option declared as a <code class="language-plaintext highlighter-rouge">Status</code> enum gets validated and cast automatically. Interactive commands get <code class="language-plaintext highlighter-rouge">#[Interact]</code> and <code class="language-plaintext highlighter-rouge">#[Ask]</code> attributes to declare question prompts inline. <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">#[Route]</code> on controller classes are auto-registered without needing an explicit <code class="language-plaintext highlighter-rouge">resource:</code> entry in <code class="language-plaintext highlighter-rouge">config/routes.yaml</code>. The tag <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">#[Route]</code> also gains a <code class="language-plaintext highlighter-rouge">_query</code> parameter for setting query parameters at generation time, and multiple environments in the <code class="language-plaintext highlighter-rouge">env</code> option.</p>

<h2 id="security-csrf-and-oidc-get-better-tooling">Security: CSRF and OIDC get better tooling</h2>

<p><code class="language-plaintext highlighter-rouge">#[IsCsrfTokenValid]</code> gets a <code class="language-plaintext highlighter-rouge">$tokenSource</code> argument so you can specify where the token comes from (header, cookie, form field) rather than relying on a fixed convention. <code class="language-plaintext highlighter-rouge">SameOriginCsrfTokenManager</code> adds <code class="language-plaintext highlighter-rouge">Sec-Fetch-Site</code> header validation, a browser-native CSRF protection mechanism that doesn’t need token injection at all.</p>

<p>The <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">access_decision()</code> and <code class="language-plaintext highlighter-rouge">access_decision_for_user()</code> expose the authorization voter result in templates without going through the security facade. <code class="language-plaintext highlighter-rouge">#[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 class="language-plaintext highlighter-rouge">ObjectMapper</code> maps between objects without hand-written transformers, using attribute-based configuration. <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">JsonStreamer</code> also drops its dependency on <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">services.yaml</code> or <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">Video</code> constraint joins the <code class="language-plaintext highlighter-rouge">Image</code> constraint for validating uploaded video files (MIME type, duration, codec). The <code class="language-plaintext highlighter-rouge">Url</code> constraint accepts <code class="language-plaintext highlighter-rouge">protocols: ['*']</code> to allow any RFC 3986-compliant scheme, useful when storing arbitrary URLs that include <code class="language-plaintext highlighter-rouge">git+ssh://</code>, <code class="language-plaintext highlighter-rouge">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’s retry middleware. For high-volume queues on AWS, this removes a round-trip through PHP for transient failures. A <code class="language-plaintext highlighter-rouge">MessageSentToTransportsEvent</code> fires after a message is dispatched, carrying information about which transports actually received it.</p>

<p><code class="language-plaintext highlighter-rouge">messenger:consume</code> gets <code class="language-plaintext highlighter-rouge">--exclude-receivers</code> to pair with <code class="language-plaintext highlighter-rouge">--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="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">return</span> <span class="p">(</span><span class="k">new</span> <span class="nc">Definition</span><span class="p">(</span><span class="n">states</span><span class="o">:</span> <span class="p">[</span><span class="s1">'draft'</span><span class="p">,</span> <span class="s1">'review'</span><span class="p">,</span> <span class="s1">'published'</span><span class="p">]))</span>
    <span class="o">-&gt;</span><span class="nf">addTransition</span><span class="p">(</span><span class="k">new</span> <span class="nc">Transition</span><span class="p">(</span><span class="s1">'publish'</span><span class="p">,</span> <span class="s1">'review'</span><span class="p">,</span> <span class="s1">'published'</span><span class="p">,</span> <span class="n">weight</span><span class="o">:</span> <span class="mi">10</span><span class="p">))</span>
    <span class="o">-&gt;</span><span class="nf">addTransition</span><span class="p">(</span><span class="k">new</span> <span class="nc">Transition</span><span class="p">(</span><span class="s1">'reject'</span><span class="p">,</span> <span class="s1">'review'</span><span class="p">,</span> <span class="s1">'draft'</span><span class="p">,</span> <span class="n">weight</span><span class="o">:</span> <span class="mi">1</span><span class="p">));</span>
</code></pre></div></div>

<h2 id="lock-lockkeynormalizer">Lock: LockKeyNormalizer</h2>

<p><code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">QUERY</code> method (a safe, idempotent method with a body, unlike <code class="language-plaintext highlighter-rouge">GET</code>) is now supported throughout the stack: <code class="language-plaintext highlighter-rouge">Request</code>, HTTP cache, WebProfiler, and HttpClient. If you build search APIs that need a structured request body and also want caching, <code class="language-plaintext highlighter-rouge">QUERY</code> is the right semantic choice.</p>

<p><code class="language-plaintext highlighter-rouge">Request::createFromGlobals()</code> now parses the body of <code class="language-plaintext highlighter-rouge">PUT</code>, <code class="language-plaintext highlighter-rouge">DELETE</code>, <code class="language-plaintext highlighter-rouge">PATCH</code>, and <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">config/packages/*.yaml</code> against these schemas and provide autocompletion without any plugin. The schema is generated during cache warmup and placed at <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">$_SERVER['APP_RUNTIME']</code> is set, that runtime class takes precedence. You can also pick the error renderer based on <code class="language-plaintext highlighter-rouge">APP_RUNTIME_MODE</code>, which is useful when running the same codebase in HTTP and CLI contexts with different error presentation needs.</p>]]></content><author><name>Guillaume Delré</name><email>delre.guillaume@gmail.com</email></author><category term="development" /><category term="symfony" /><category term="php84" /><category term="lazy-objects" /><category term="forms" /><summary type="html"><![CDATA[Symfony 8.0 requires PHP 8.4, replaces its proxy code generator with native lazy objects, and introduces FormFlow.]]></summary></entry><entry><title type="html">Symfony 7.4 LTS: message signing, PHP config arrays, and the last 7.x</title><link href="https://guillaumedelre.github.io/2026/01/10/symfony-7-4-lts/" rel="alternate" type="text/html" title="Symfony 7.4 LTS: message signing, PHP config arrays, and the last 7.x" /><published>2026-01-10T00:00:00+00:00</published><updated>2026-01-10T00:00:00+00:00</updated><id>https://guillaumedelre.github.io/2026/01/10/symfony-7-4-lts</id><content type="html" xml:base="https://guillaumedelre.github.io/2026/01/10/symfony-7-4-lts/"><![CDATA[<p>Symfony 7.4 landed November 2025, alongside 8.0. It’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’t or won’t follow 8.0’s PHP 8.4 requirement, 7.4 is where you land.</p>

<h2 id="envelope_with_arrow-message-signing-in-messenger">:envelope_with_arrow: Message signing in Messenger</h2>

<p>Transport security in Messenger has always been the application’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’t tampered with or injected from outside. Configuration lives at the transport level:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">framework</span><span class="pi">:</span>
    <span class="na">messenger</span><span class="pi">:</span>
        <span class="na">transports</span><span class="pi">:</span>
            <span class="na">async</span><span class="pi">:</span>
                <span class="na">dsn</span><span class="pi">:</span> <span class="s1">'</span><span class="s">%env(MESSENGER_TRANSPORT_DSN)%'</span>
                <span class="na">options</span><span class="pi">:</span>
                    <span class="na">signing_key</span><span class="pi">:</span> <span class="s1">'</span><span class="s">%env(MESSENGER_SIGNING_KEY)%'</span>
</code></pre></div></div>

<h2 id="gear-php-array-configuration">:gear: PHP array configuration</h2>

<p>Symfony’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 class="language-plaintext highlighter-rouge">config/reference.php</code> is auto-generated as a type-annotated reference, and the result reads like data rather than code:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">return</span> <span class="k">static</span> <span class="k">function</span> <span class="p">(</span><span class="kt">FrameworkConfig</span> <span class="nv">$framework</span><span class="p">):</span> <span class="kt">void</span> <span class="p">{</span>
    <span class="nv">$framework</span><span class="o">-&gt;</span><span class="nf">router</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">strictRequirements</span><span class="p">(</span><span class="kc">null</span><span class="p">);</span>
    <span class="nv">$framework</span><span class="o">-&gt;</span><span class="nf">session</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">enabled</span><span class="p">(</span><span class="kc">true</span><span class="p">);</span>
<span class="p">};</span>
</code></pre></div></div>

<p>The fluent format is deprecated. Arrays are the future, and honestly it’s a better format.</p>

<h2 id="shield-oidc-improvements">:shield: OIDC improvements</h2>

<p><code class="language-plaintext highlighter-rouge">#[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 class="language-plaintext highlighter-rouge">security:oidc-token:generate</code> command makes dev and testing a lot less painful.</p>

<h2 id="calendar-the-support-window">:calendar: 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’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 class="language-plaintext highlighter-rouge">#[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="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">function</span> <span class="n">index</span><span class="p">(</span><span class="c1">#[CurrentUser] AdminUser|Customer $user): Response</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">#[Route]</code> accepts an array for the <code class="language-plaintext highlighter-rouge">env</code> option, so a debug route active only in <code class="language-plaintext highlighter-rouge">dev</code> and <code class="language-plaintext highlighter-rouge">test</code> no longer needs two separate definitions. <code class="language-plaintext highlighter-rouge">#[AsDecorator]</code> is now repeatable, meaning one class can decorate multiple services at once. <code class="language-plaintext highlighter-rouge">#[AsEventListener]</code> method signatures accept union event types. <code class="language-plaintext highlighter-rouge">#[IsGranted]</code> gets a <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">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’s gone in 8.0; in 7.4 it still works but triggers a deprecation. The replacements are explicit: <code class="language-plaintext highlighter-rouge">$request-&gt;attributes-&gt;get()</code>, <code class="language-plaintext highlighter-rouge">$request-&gt;query-&gt;get()</code>, <code class="language-plaintext highlighter-rouge">$request-&gt;request-&gt;get()</code>.</p>

<p>Body parsing for <code class="language-plaintext highlighter-rouge">PUT</code>, <code class="language-plaintext highlighter-rouge">PATCH</code>, <code class="language-plaintext highlighter-rouge">DELETE</code>, and <code class="language-plaintext highlighter-rouge">QUERY</code> requests arrives at the same time. Previously Symfony only parsed <code class="language-plaintext highlighter-rouge">application/x-www-form-urlencoded</code> and <code class="language-plaintext highlighter-rouge">multipart/form-data</code> for <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">GET</code>, <code class="language-plaintext highlighter-rouge">HEAD</code>, <code class="language-plaintext highlighter-rouge">CONNECT</code>, and <code class="language-plaintext highlighter-rouge">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="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">Request</span><span class="o">::</span><span class="nf">setAllowedHttpMethodOverride</span><span class="p">([</span><span class="s1">'PUT'</span><span class="p">,</span> <span class="s1">'PATCH'</span><span class="p">,</span> <span class="s1">'DELETE'</span><span class="p">]);</span>
</code></pre></div></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 class="language-plaintext highlighter-rouge">!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="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">framework</span><span class="pi">:</span>
    <span class="na">workflows</span><span class="pi">:</span>
        <span class="na">blog_publishing</span><span class="pi">:</span>
            <span class="na">initial_marking</span><span class="pi">:</span> <span class="kt">!php/enum</span> <span class="s">App\Status\PostStatus::Draft</span>
            <span class="na">places</span><span class="pi">:</span> <span class="kt">!php/enum</span> <span class="s">App\Status\PostStatus</span>
            <span class="na">transitions</span><span class="pi">:</span>
                <span class="na">publish</span><span class="pi">:</span>
                    <span class="na">from</span><span class="pi">:</span> <span class="kt">!php/enum</span> <span class="s">App\Status\PostStatus::Review</span>
                    <span class="na">to</span><span class="pi">:</span> <span class="kt">!php/enum</span> <span class="s">App\Status\PostStatus::Published</span>
</code></pre></div></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’t own? 7.4 has <code class="language-plaintext highlighter-rouge">#[ExtendsValidationFor]</code> and <code class="language-plaintext highlighter-rouge">#[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="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">#[ExtendsValidationFor(UserRegistration::class)]</span>
<span class="k">abstract</span> <span class="kd">class</span> <span class="nc">UserRegistrationValidation</span>
<span class="p">{</span>
    <span class="c1">#[Assert\NotBlank(groups: ['my_app'])]</span>
    <span class="c1">#[Assert\Length(min: 3, groups: ['my_app'])]</span>
    <span class="k">public</span> <span class="kt">string</span> <span class="nv">$name</span> <span class="o">=</span> <span class="s1">''</span><span class="p">;</span>

    <span class="c1">#[Assert\Email(groups: ['my_app'])]</span>
    <span class="k">public</span> <span class="kt">string</span> <span class="nv">$email</span> <span class="o">=</span> <span class="s1">''</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Symfony verifies at compile time that the declared properties actually exist on the target class. A rename won’t silently break your validation.</p>

<h2 id="dx-the-things-that-dont-headline-but-matter">DX: the things that don’t headline but matter</h2>

<p>The Question helper in Console accepts a timeout. Ask the user to confirm something, and if they don’t respond in N seconds, the default answer kicks in. Very handy in deployment scripts that can’t afford to wait forever for a human.</p>

<p><code class="language-plaintext highlighter-rouge">messenger:consume</code> gets <code class="language-plaintext highlighter-rouge">--exclude-receivers</code>. Combined with <code class="language-plaintext highlighter-rouge">--all</code>, it lets you consume from every transport except specific ones:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/console messenger:consume <span class="nt">--all</span> <span class="nt">--exclude-receivers</span><span class="o">=</span>low_priority
</code></pre></div></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 class="language-plaintext highlighter-rouge">debug:router</code> command hides the <code class="language-plaintext highlighter-rouge">Scheme</code> and <code class="language-plaintext highlighter-rouge">Host</code> columns when all routes use <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">$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="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$session</span> <span class="o">=</span> <span class="nv">$client</span><span class="o">-&gt;</span><span class="nf">getSession</span><span class="p">();</span>
<span class="nv">$session</span><span class="o">-&gt;</span><span class="nf">set</span><span class="p">(</span><span class="s1">'_csrf/checkout'</span><span class="p">,</span> <span class="s1">'test-token'</span><span class="p">);</span>
<span class="nv">$session</span><span class="o">-&gt;</span><span class="nf">save</span><span class="p">();</span>
</code></pre></div></div>

<h2 id="lock-dynamodb-store">Lock: DynamoDB store</h2>

<p><code class="language-plaintext highlighter-rouge">DynamoDbStore</code> lands as a new Lock backend. Useful in AWS-native deployments where Redis isn’t in the stack, and it works exactly like any other store:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$store</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">DynamoDbStore</span><span class="p">(</span><span class="s1">'dynamodb://default/locks'</span><span class="p">);</span>
<span class="nv">$factory</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">LockFactory</span><span class="p">(</span><span class="nv">$store</span><span class="p">);</span>
</code></pre></div></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 class="language-plaintext highlighter-rouge">day_point</code> stores a date-only value (no time component) and <code class="language-plaintext highlighter-rouge">time_point</code> stores a time-only value, both mapping to <code class="language-plaintext highlighter-rouge">DatePoint</code>. Good when your domain genuinely separates date from time:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">#[ORM\Column(type: 'day_point')]</span>
<span class="k">public</span> <span class="kt">DatePoint</span> <span class="nv">$birthDate</span><span class="p">;</span>

<span class="c1">#[ORM\Column(type: 'time_point')]</span>
<span class="k">public</span> <span class="kt">DatePoint</span> <span class="nv">$openingTime</span><span class="p">;</span>
</code></pre></div></div>

<h2 id="routing-explicit-query-parameters">Routing: explicit query parameters</h2>

<p>The <code class="language-plaintext highlighter-rouge">_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="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$url</span> <span class="o">=</span> <span class="nv">$urlGenerator</span><span class="o">-&gt;</span><span class="nf">generate</span><span class="p">(</span><span class="s1">'report'</span><span class="p">,</span> <span class="p">[</span>
    <span class="s1">'site'</span> <span class="o">=&gt;</span> <span class="s1">'fr'</span><span class="p">,</span>
    <span class="s1">'_query'</span> <span class="o">=&gt;</span> <span class="p">[</span><span class="s1">'site'</span> <span class="o">=&gt;</span> <span class="s1">'us'</span><span class="p">],</span>
<span class="p">]);</span>
<span class="c1">// /report/fr?site=us</span>
</code></pre></div></div>

<h2 id="weblink-parsing-incoming-link-headers">WebLink: parsing incoming Link headers</h2>

<p><code class="language-plaintext highlighter-rouge">HttpHeaderParser</code> parses <code class="language-plaintext highlighter-rouge">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’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’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 class="language-plaintext highlighter-rouge">StaticMessage</code> implements <code class="language-plaintext highlighter-rouge">TranslatableInterface</code> but intentionally doesn’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’s locale, or audit log entries where you need to preserve the original text as-is.</p>]]></content><author><name>Guillaume Delré</name><email>delre.guillaume@gmail.com</email></author><category term="development" /><category term="symfony" /><category term="lts" /><category term="messenger" /><category term="security" /><category term="configuration" /><summary type="html"><![CDATA[Symfony 7.4 LTS adds Messenger message signing, PHP array-based configuration, and closes out the 7.x line.]]></summary></entry><entry><title type="html">PHP 8.5: the pipe operator, a URI library, and a lot of cleanup</title><link href="https://guillaumedelre.github.io/2026/01/04/php-8-5/" rel="alternate" type="text/html" title="PHP 8.5: the pipe operator, a URI library, and a lot of cleanup" /><published>2026-01-04T00:00:00+00:00</published><updated>2026-01-04T00:00:00+00:00</updated><id>https://guillaumedelre.github.io/2026/01/04/php-8-5</id><content type="html" xml:base="https://guillaumedelre.github.io/2026/01/04/php-8-5/"><![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="arrow_right-the-pipe-operator">:arrow_right: 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="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// before — read right to left</span>
<span class="nv">$result</span> <span class="o">=</span> <span class="nb">array_sum</span><span class="p">(</span><span class="nb">array_map</span><span class="p">(</span><span class="s1">'strlen'</span><span class="p">,</span> <span class="nb">array_filter</span><span class="p">(</span><span class="nv">$strings</span><span class="p">,</span> <span class="s1">'strlen'</span><span class="p">)));</span>

<span class="c1">// or verbose but readable</span>
<span class="nv">$filtered</span>   <span class="o">=</span> <span class="nb">array_filter</span><span class="p">(</span><span class="nv">$strings</span><span class="p">,</span> <span class="s1">'strlen'</span><span class="p">);</span>
<span class="nv">$lengths</span>    <span class="o">=</span> <span class="nb">array_map</span><span class="p">(</span><span class="s1">'strlen'</span><span class="p">,</span> <span class="nv">$filtered</span><span class="p">);</span>
<span class="nv">$result</span>     <span class="o">=</span> <span class="nb">array_sum</span><span class="p">(</span><span class="nv">$lengths</span><span class="p">);</span>

<span class="c1">// after — read left to right</span>
<span class="nv">$result</span> <span class="o">=</span> <span class="nv">$strings</span>
    <span class="o">|&gt;</span> <span class="nb">array_filter</span><span class="p">(</span><span class="o">?</span><span class="p">,</span> <span class="s1">'strlen'</span><span class="p">)</span>
    <span class="o">|&gt;</span> <span class="nb">array_map</span><span class="p">(</span><span class="s1">'strlen'</span><span class="p">,</span> <span class="o">?</span><span class="p">)</span>
    <span class="o">|&gt;</span> <span class="nb">array_sum</span><span class="p">(</span><span class="o">?</span><span class="p">);</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">|&gt;</code> operator passes the left-hand value into the right-hand expression. The <code class="language-plaintext highlighter-rouge">?</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="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$result</span> <span class="o">=</span> <span class="nv">$input</span> <span class="o">|&gt;</span> <span class="nb">trim</span><span class="p">(</span><span class="mf">...</span><span class="p">)</span> <span class="o">|&gt;</span> <span class="nb">strtolower</span><span class="p">(</span><span class="mf">...</span><span class="p">)</span> <span class="o">|&gt;</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">normalize</span><span class="p">(</span><span class="mf">...</span><span class="p">);</span>
</code></pre></div></div>

<h2 id="link-the-uri-extension">:link: The URI extension</h2>

<p>Handling URIs in PHP has always meant either reaching for a third-party library or cobbling together <code class="language-plaintext highlighter-rouge">parse_url()</code> (returns an array, not an object), <code class="language-plaintext highlighter-rouge">http_build_query()</code>, and manual string concatenation.</p>

<p>The new <code class="language-plaintext highlighter-rouge">Uri</code> extension gives you a proper object-oriented API:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$uri</span> <span class="o">=</span> <span class="nc">Uri\Uri</span><span class="o">::</span><span class="nf">parse</span><span class="p">(</span><span class="s1">'https://example.com/path?query=value#fragment'</span><span class="p">);</span>
<span class="nv">$modified</span> <span class="o">=</span> <span class="nv">$uri</span><span class="o">-&gt;</span><span class="nf">withPath</span><span class="p">(</span><span class="s1">'/new-path'</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">withQuery</span><span class="p">(</span><span class="s1">'key=val'</span><span class="p">);</span>
<span class="k">echo</span> <span class="nv">$modified</span><span class="p">;</span> <span class="c1">// https://example.com/new-path?key=val#fragment</span>
</code></pre></div></div>

<p>Immutable value objects, RFC-compliant parsing, modify individual components without parsing and reconstructing the whole string. Long overdue.</p>

<h2 id="no_entry_sign-nodiscard">:no_entry_sign: #[\NoDiscard]</h2>

<p>A new attribute that generates a warning when the return value is ignored:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">#[\NoDiscard("Use the returned collection, the original is unchanged")]</span>
<span class="k">public</span> <span class="k">function</span> <span class="n">filter</span><span class="p">(</span><span class="kt">callable</span> <span class="nv">$fn</span><span class="p">):</span> <span class="kt">static</span> <span class="p">{</span> <span class="mf">...</span> <span class="p">}</span>
</code></pre></div></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="baby-clone-with">:baby: clone with</h2>

<p>Cloning an object with modified properties without using property hooks or a custom <code class="language-plaintext highlighter-rouge">with()</code> method:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$updated</span> <span class="o">=</span> <span class="k">clone</span><span class="p">(</span><span class="nv">$point</span><span class="p">)</span> <span class="n">with</span> <span class="p">{</span> <span class="n">x</span><span class="o">:</span> <span class="mi">10</span><span class="p">,</span> <span class="n">y</span><span class="o">:</span> <span class="mi">20</span> <span class="p">};</span>
</code></pre></div></div>

<p>Clean syntax for a pattern readonly objects needed: you clone to “modify” since direct mutation isn’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’s been baked in since PHP 5: constant expressions (attribute arguments, property defaults, parameter defaults, <code class="language-plaintext highlighter-rouge">const</code> declarations) couldn’t contain closures or first-class callables. 8.5 removes that.</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">#[Validate(fn($v) =&gt; $v &gt; 0)]</span>
<span class="k">public</span> <span class="kt">int</span> <span class="nv">$count</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>

<span class="k">const</span> <span class="no">NORMALIZER</span> <span class="o">=</span> <span class="nb">strtolower</span><span class="p">(</span><span class="mf">...</span><span class="p">);</span>

<span class="kd">class</span> <span class="nc">Config</span> <span class="p">{</span>
    <span class="k">public</span> <span class="k">function</span> <span class="n">__construct</span><span class="p">(</span>
        <span class="k">public</span> <span class="k">readonly</span> <span class="kt">Closure</span> <span class="nv">$transform</span> <span class="o">=</span> <span class="nb">trim</span><span class="p">(</span><span class="mf">...</span><span class="p">),</span>
    <span class="p">)</span> <span class="p">{}</span>
<span class="p">}</span>
</code></pre></div></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 class="language-plaintext highlighter-rouge">#[\Deprecated]</code> attribute from 8.4 couldn’t be applied to <code class="language-plaintext highlighter-rouge">const</code> declarations. 8.5 adds attribute support for constants generally:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">const</span> <span class="no">OLD_LIMIT</span> <span class="o">=</span> <span class="mi">100</span><span class="p">;</span>

<span class="c1">#[\Deprecated('Use RATE_LIMIT instead', since: '3.0')]</span>
<span class="k">const</span> <span class="no">API_TIMEOUT</span> <span class="o">=</span> <span class="mi">30</span><span class="p">;</span>

<span class="k">const</span> <span class="no">RATE_LIMIT</span> <span class="o">=</span> <span class="mi">60</span><span class="p">;</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">ReflectionConstant</code>, a new reflection class in 8.5, exposes <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">#[\Override]</code> for methods. 8.5 extends it to properties:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">class</span> <span class="nc">Base</span> <span class="p">{</span>
    <span class="k">public</span> <span class="kt">string</span> <span class="nv">$name</span> <span class="o">=</span> <span class="s1">'default'</span><span class="p">;</span>
<span class="p">}</span>

<span class="kd">class</span> <span class="nc">Derived</span> <span class="kd">extends</span> <span class="nc">Base</span> <span class="p">{</span>
    <span class="c1">#[\Override]</span>
    <span class="k">public</span> <span class="kt">string</span> <span class="nv">$name</span> <span class="o">=</span> <span class="s1">'derived'</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>If the property doesn’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’s.</p>

<h2 id="static-asymmetric-visibility">Static asymmetric visibility</h2>

<p>8.4 introduced asymmetric visibility (<code class="language-plaintext highlighter-rouge">public private(set)</code>) for instance properties. 8.5 brings that to <code class="language-plaintext highlighter-rouge">static</code> properties too:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">class</span> <span class="nc">Registry</span> <span class="p">{</span>
    <span class="k">public</span> <span class="k">static</span> <span class="kt">private</span><span class="p">(</span><span class="n">set</span><span class="p">)</span> <span class="k">array</span> <span class="nv">$items</span> <span class="o">=</span> <span class="p">[];</span>

    <span class="k">public</span> <span class="k">static</span> <span class="k">function</span> <span class="n">register</span><span class="p">(</span><span class="kt">string</span> <span class="nv">$key</span><span class="p">,</span> <span class="kt">mixed</span> <span class="nv">$value</span><span class="p">):</span> <span class="kt">void</span> <span class="p">{</span>
        <span class="k">self</span><span class="o">::</span><span class="nv">$items</span><span class="p">[</span><span class="nv">$key</span><span class="p">]</span> <span class="o">=</span> <span class="nv">$value</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="k">echo</span> <span class="nc">Registry</span><span class="o">::</span><span class="nv">$items</span><span class="p">[</span><span class="s1">'foo'</span><span class="p">];</span> <span class="c1">// readable</span>
<span class="nc">Registry</span><span class="o">::</span><span class="nv">$items</span><span class="p">[</span><span class="s1">'bar'</span><span class="p">]</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span> <span class="c1">// Error: cannot write outside class</span>
</code></pre></div></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 class="language-plaintext highlighter-rouge">final</code> modifier on promoted properties was the missing piece, 8.5 adds it:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">class</span> <span class="nc">ValueObject</span> <span class="p">{</span>
    <span class="k">public</span> <span class="k">function</span> <span class="n">__construct</span><span class="p">(</span>
        <span class="k">public</span> <span class="kt">final</span> <span class="k">readonly</span> <span class="n">string</span> <span class="nv">$id</span><span class="p">,</span>
        <span class="k">public</span> <span class="kt">final</span> <span class="k">readonly</span> <span class="n">string</span> <span class="nv">$name</span><span class="p">,</span>
    <span class="p">)</span> <span class="p">{}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>A subclass can’t override <code class="language-plaintext highlighter-rouge">$id</code> or <code class="language-plaintext highlighter-rouge">$name</code> with a property of the same name. The <code class="language-plaintext highlighter-rouge">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="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">const</span> <span class="no">PRECISION</span> <span class="o">=</span> <span class="p">(</span><span class="n">int</span><span class="p">)</span> <span class="mf">3.7</span><span class="p">;</span>      <span class="c1">// 3</span>
<span class="k">const</span> <span class="no">THRESHOLD</span> <span class="o">=</span> <span class="p">(</span><span class="n">float</span><span class="p">)</span> <span class="s1">'1.5'</span><span class="p">;</span>  <span class="c1">// 1.5</span>
<span class="k">const</span> <span class="no">FLAG</span> <span class="o">=</span> <span class="p">(</span><span class="n">bool</span><span class="p">)</span> <span class="mi">1</span><span class="p">;</span>            <span class="c1">// true</span>
</code></pre></div></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 class="language-plaintext highlighter-rouge">fatal_error_backtraces</code>, controls the behavior. It’s on by default.</p>

<h2 id="array_first-and-array_last">array_first() and array_last()</h2>

<p>PHP has had <code class="language-plaintext highlighter-rouge">reset()</code> and <code class="language-plaintext highlighter-rouge">end()</code> for accessing the first and last elements of an array since PHP 3. Both mutate the array’s internal pointer (not safe to call on a reference), and they return <code class="language-plaintext highlighter-rouge">false</code> for empty arrays in a way that’s indistinguishable from a stored <code class="language-plaintext highlighter-rouge">false</code> value.</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$values</span> <span class="o">=</span> <span class="p">[</span><span class="mi">10</span><span class="p">,</span> <span class="mi">20</span><span class="p">,</span> <span class="mi">30</span><span class="p">];</span>

<span class="nv">$first</span> <span class="o">=</span> <span class="nf">array_first</span><span class="p">(</span><span class="nv">$values</span><span class="p">);</span>  <span class="c1">// 10</span>
<span class="nv">$last</span>  <span class="o">=</span> <span class="nf">array_last</span><span class="p">(</span><span class="nv">$values</span><span class="p">);</span>   <span class="c1">// 30</span>

<span class="nv">$first</span> <span class="o">=</span> <span class="nf">array_first</span><span class="p">([]);</span>       <span class="c1">// null</span>
</code></pre></div></div>

<p>The new functions return <code class="language-plaintext highlighter-rouge">null</code> for empty arrays, don’t touch the internal pointer, and work on any array expression without needing a variable. <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">set_error_handler()</code> and <code class="language-plaintext highlighter-rouge">set_exception_handler()</code>. Getting the current handler meant either storing it yourself before setting it, or calling <code class="language-plaintext highlighter-rouge">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="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$current</span> <span class="o">=</span> <span class="nf">get_error_handler</span><span class="p">();</span>
<span class="nv">$current</span> <span class="o">=</span> <span class="nf">get_exception_handler</span><span class="p">();</span>
</code></pre></div></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 class="language-plaintext highlighter-rouge">IntlListFormatter</code>:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$formatter</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">IntlListFormatter</span><span class="p">(</span><span class="s1">'en_US'</span><span class="p">,</span> <span class="nc">IntlListFormatter</span><span class="o">::</span><span class="no">TYPE_AND</span><span class="p">);</span>
<span class="k">echo</span> <span class="nv">$formatter</span><span class="o">-&gt;</span><span class="nf">format</span><span class="p">([</span><span class="s1">'apples'</span><span class="p">,</span> <span class="s1">'oranges'</span><span class="p">,</span> <span class="s1">'pears'</span><span class="p">]);</span>
<span class="c1">// "apples, oranges, and pears"</span>

<span class="nv">$formatter</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">IntlListFormatter</span><span class="p">(</span><span class="s1">'fr_FR'</span><span class="p">,</span> <span class="nc">IntlListFormatter</span><span class="o">::</span><span class="no">TYPE_OR</span><span class="p">);</span>
<span class="k">echo</span> <span class="nv">$formatter</span><span class="o">-&gt;</span><span class="nf">format</span><span class="p">([</span><span class="s1">'rouge'</span><span class="p">,</span> <span class="s1">'bleu'</span><span class="p">,</span> <span class="s1">'vert'</span><span class="p">]);</span>
<span class="c1">// "rouge, bleu ou vert"</span>
</code></pre></div></div>

<p>The class wraps ICU’s <code class="language-plaintext highlighter-rouge">ListFormatter</code>. Three types: <code class="language-plaintext highlighter-rouge">TYPE_AND</code>, <code class="language-plaintext highlighter-rouge">TYPE_OR</code>, <code class="language-plaintext highlighter-rouge">TYPE_UNITS</code>. Width constants control whether you get “and” or “&amp;”. 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 class="language-plaintext highlighter-rouge">filter_var()</code> returns <code class="language-plaintext highlighter-rouge">false</code> on validation failure, which produces the classic <code class="language-plaintext highlighter-rouge">false vs null vs 0</code> ambiguity when you’re filtering untrusted input. A new flag changes that:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">try</span> <span class="p">{</span>
    <span class="nv">$email</span> <span class="o">=</span> <span class="nb">filter_var</span><span class="p">(</span><span class="nv">$input</span><span class="p">,</span> <span class="no">FILTER_VALIDATE_EMAIL</span><span class="p">,</span> <span class="no">FILTER_THROW_ON_FAILURE</span><span class="p">);</span>
<span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nc">Filter\FilterFailedException</span> <span class="nv">$e</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// explicitly invalid, not ambiguously false</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">Filter\FilterFailedException</code> and <code class="language-plaintext highlighter-rouge">Filter\FilterException</code> classes are new in 8.5. The flag can’t be combined with <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">`command`</code> as an alias for <code class="language-plaintext highlighter-rouge">shell_exec()</code>) is deprecated. It’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 class="language-plaintext highlighter-rouge">(boolean)</code>, <code class="language-plaintext highlighter-rouge">(integer)</code>, <code class="language-plaintext highlighter-rouge">(double)</code>, <code class="language-plaintext highlighter-rouge">(binary)</code>) are deprecated in favor of their short forms: <code class="language-plaintext highlighter-rouge">(bool)</code>, <code class="language-plaintext highlighter-rouge">(int)</code>, <code class="language-plaintext highlighter-rouge">(float)</code>, <code class="language-plaintext highlighter-rouge">(string)</code>. The long forms have been undocumented for years; 8.5 starts the formal removal.</p>

<p>Semicolon-terminated <code class="language-plaintext highlighter-rouge">case</code> statements are deprecated:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// deprecated</span>
<span class="k">switch</span> <span class="p">(</span><span class="nv">$x</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">case</span> <span class="mi">1</span><span class="p">;</span>
        <span class="k">break</span><span class="p">;</span>
<span class="p">}</span>

<span class="c1">// correct</span>
<span class="k">switch</span> <span class="p">(</span><span class="nv">$x</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">case</span> <span class="mi">1</span><span class="o">:</span>
        <span class="k">break</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The semicolon form has been syntactically valid since PHP 4 but nobody uses it on purpose. It’s a typo PHP happened to accept.</p>

<p><code class="language-plaintext highlighter-rouge">__sleep()</code> and <code class="language-plaintext highlighter-rouge">__wakeup()</code> are deprecated in favor of <code class="language-plaintext highlighter-rouge">__serialize()</code> and <code class="language-plaintext highlighter-rouge">__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 class="language-plaintext highlighter-rouge">max_memory_limit</code>. It sets a ceiling that <code class="language-plaintext highlighter-rouge">memory_limit</code> can’t exceed at runtime. If a script calls <code class="language-plaintext highlighter-rouge">ini_set('memory_limit', '10G')</code> and <code class="language-plaintext highlighter-rouge">max_memory_limit</code> is <code class="language-plaintext highlighter-rouge">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’t convince PHP to raise its own limit and eat the whole machine’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 class="language-plaintext highlighter-rouge">opcache.enable=0</code> works fine. What changes is the guarantee that the Opcache API (<code class="language-plaintext highlighter-rouge">opcache_get_status()</code>, <code class="language-plaintext highlighter-rouge">opcache_invalidate()</code>, etc.) is always available, regardless of how PHP was compiled. Any code that checks <code class="language-plaintext highlighter-rouge">extension_loaded('opcache')</code> before calling Opcache functions can drop the check.</p>]]></content><author><name>Guillaume Delré</name><email>delre.guillaume@gmail.com</email></author><category term="development" /><category term="php" /><category term="php85" /><category term="functional" /><summary type="html"><![CDATA[PHP 8.5 adds a pipe operator for readable functional pipelines and a native URI class that ends fragile string parsing.]]></summary></entry><entry><title type="html">Local HTTPS with Traefik: traefik.me is dead, long live sslip.io</title><link href="https://guillaumedelre.github.io/2025/04/17/traefik-local-https-mkcert-sslip/" rel="alternate" type="text/html" title="Local HTTPS with Traefik: traefik.me is dead, long live sslip.io" /><published>2025-04-17T00:00:00+00:00</published><updated>2025-04-17T00:00:00+00:00</updated><id>https://guillaumedelre.github.io/2025/04/17/traefik-local-https-mkcert-sslip</id><content type="html" xml:base="https://guillaumedelre.github.io/2025/04/17/traefik-local-https-mkcert-sslip/"><![CDATA[<p>The setup seemed perfect. Point <code class="language-plaintext highlighter-rouge">*.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’s Encrypt rate limits, no <code class="language-plaintext highlighter-rouge">mkcert</code> to explain to teammates, no self-signed warnings to click through. Just <code class="language-plaintext highlighter-rouge">https://myapp.traefik.me</code> and a green padlock.</p>

<p>Then in March 2025, Let’s Encrypt revoked the certificate. The wildcard cert for traefik.me is gone and it’s not coming back.</p>

<h2 id="label-what-traefikme-was-actually-selling">:label: What traefik.me was actually selling</h2>

<p>traefik.me is a wildcard DNS resolver. Type <code class="language-plaintext highlighter-rouge">anything.traefik.me</code> and it resolves to 127.0.0.1. Type <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">*.traefik.me</code> that pyrou, the maintainer, generated with Let’s Encrypt and distributed at <code class="language-plaintext highlighter-rouge">https://traefik.me/cert.pem</code> and <code class="language-plaintext highlighter-rouge">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 “maintain sole control” over their private key. Distributing it to anyone who visits a URL is the exact opposite of sole control. Let’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="twisted_rightwards_arrows-sslipio-does-the-same-thing-differently">:twisted_rightwards_arrows: 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 class="language-plaintext highlighter-rouge">10-0-0-1.sslip.io</code> resolves to <code class="language-plaintext highlighter-rouge">10.0.0.1</code>. <code class="language-plaintext highlighter-rouge">myapp.192-168-1-10.sslip.io</code> resolves to <code class="language-plaintext highlighter-rouge">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’s only remaining advantage is the 127.0.0.1 fallback: URLs without an IP segment. That matters if you really want <code class="language-plaintext highlighter-rouge">myapp.traefik.me</code> instead of <code class="language-plaintext highlighter-rouge">myapp.127-0-0-1.sslip.io</code>. Whether that difference is worth the infrastructure uncertainty is a short conversation.</p>

<h2 id="key-mkcert-fills-the-gap">:key: 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 “proceed anyway”.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mkcert <span class="nt">-install</span>
</code></pre></div></div>

<p>That’s the one-time setup. After that, generating a certificate is one command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mkcert <span class="s2">"*.127-0-0-1.sslip.io"</span>
<span class="c"># produces _wildcard.127-0-0-1.sslip.io.pem</span>
<span class="c">#          _wildcard.127-0-0-1.sslip.io-key.pem</span>
</code></pre></div></div>

<p>The limitation is that mkcert’s CA is local. Other machines on the network won’t trust it by default. For a solo dev setup that’s fine. For a shared team environment, you’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="whale-the-traefik-configuration">:whale: 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="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># traefik/config/tls.yml</span>
<span class="na">tls</span><span class="pi">:</span>
  <span class="na">certificates</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">certFile</span><span class="pi">:</span> <span class="s">/certs/cert.pem</span>
      <span class="na">keyFile</span><span class="pi">:</span> <span class="s">/certs/key.pem</span>
  <span class="na">stores</span><span class="pi">:</span>
    <span class="na">default</span><span class="pi">:</span>
      <span class="na">defaultCertificate</span><span class="pi">:</span>
        <span class="na">certFile</span><span class="pi">:</span> <span class="s">/certs/cert.pem</span>
        <span class="na">keyFile</span><span class="pi">:</span> <span class="s">/certs/key.pem</span>
</code></pre></div></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="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker network create traefik-public
</code></pre></div></div>

<p><strong><code class="language-plaintext highlighter-rouge">traefik/compose.yml</code></strong> - Traefik alone, owning the network:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">services</span><span class="pi">:</span>
  <span class="na">traefik</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">traefik:v3</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">80:80"</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">443:443"</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">/var/run/docker.sock:/var/run/docker.sock</span>
      <span class="pi">-</span> <span class="s">./config:/etc/traefik/config</span>
      <span class="pi">-</span> <span class="s">./certs:/certs</span>
    <span class="na">command</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">--entrypoints.web.address=:80</span>
      <span class="pi">-</span> <span class="s">--entrypoints.websecure.address=:443</span>
      <span class="pi">-</span> <span class="s">--providers.docker=true</span>
      <span class="pi">-</span> <span class="s">--providers.docker.network=traefik-public</span>
      <span class="pi">-</span> <span class="s">--providers.file.directory=/etc/traefik/config</span>
    <span class="na">networks</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">traefik-public</span>

<span class="na">networks</span><span class="pi">:</span>
  <span class="na">traefik-public</span><span class="pi">:</span>
    <span class="na">external</span><span class="pi">:</span> <span class="no">true</span>
</code></pre></div></div>

<p>Copy the mkcert output into <code class="language-plaintext highlighter-rouge">./certs/</code>, rename to <code class="language-plaintext highlighter-rouge">cert.pem</code> and <code class="language-plaintext highlighter-rouge">key.pem</code>, then:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose <span class="nt">-f</span> traefik/compose.yml up <span class="nt">-d</span>
</code></pre></div></div>

<p>Traefik is up, listening on 80 and 443, watching Docker for new containers. Nothing is routed yet.</p>

<p><strong><code class="language-plaintext highlighter-rouge">whoami/compose.yml</code></strong> - a service that joins the same network:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">services</span><span class="pi">:</span>
  <span class="na">whoami</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">traefik/whoami</span>
    <span class="na">labels</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">traefik.enable=true"</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">traefik.http.routers.whoami.rule=Host(`whoami.127-0-0-1.sslip.io`)"</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">traefik.http.routers.whoami.tls=true"</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">traefik.http.routers.whoami.entrypoints=websecure"</span>
    <span class="na">networks</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">traefik-public</span>

<span class="na">networks</span><span class="pi">:</span>
  <span class="na">traefik-public</span><span class="pi">:</span>
    <span class="na">external</span><span class="pi">:</span> <span class="no">true</span>
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose <span class="nt">-f</span> <span class="nb">whoami</span>/compose.yml up <span class="nt">-d</span>
</code></pre></div></div>

<p>Traefik detects the new container via the Docker provider, reads its labels, and adds the route. <code class="language-plaintext highlighter-rouge">https://whoami.127-0-0-1.sslip.io</code> responds immediately. Bring <code class="language-plaintext highlighter-rouge">whoami</code> down and the route disappears. Traefik keeps running without noticing.</p>

<p>The <code class="language-plaintext highlighter-rouge">external: true</code> declaration is the load-bearing line. Without it, Compose creates a project-scoped network: Traefik and <code class="language-plaintext highlighter-rouge">whoami</code> end up on different networks and can’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="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mkcert <span class="s2">"*.traefik.me"</span>
</code></pre></div></div>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="s2">"</span><span class="s">traefik.http.routers.whoami.rule=Host(`whoami.traefik.me`)"</span>
</code></pre></div></div>

<p>The DNS fallback to 127.0.0.1 handles the rest.</p>

<h2 id="bulb-what-the-traefikme-story-actually-teaches">:bulb: What the traefik.me story actually teaches</h2>

<p>The certificate distribution model was always fragile. A “public-private key pair” 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’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’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’t.</p>]]></content><author><name>Guillaume Delré</name><email>delre.guillaume@gmail.com</email></author><category term="devops" /><category term="docker" /><category term="traefik" /><category term="mkcert" /><category term="tls" /><category term="devops" /><summary type="html"><![CDATA[traefik.me's wildcard cert was revoked in 2025. Here's how to replace it with sslip.io, mkcert, and a local Traefik setup.]]></summary></entry><entry><title type="html">PHP 8.4: property hooks and the end of the getter/setter ceremony</title><link href="https://guillaumedelre.github.io/2025/01/05/php-8-4/" rel="alternate" type="text/html" title="PHP 8.4: property hooks and the end of the getter/setter ceremony" /><published>2025-01-05T00:00:00+00:00</published><updated>2025-01-05T00:00:00+00:00</updated><id>https://guillaumedelre.github.io/2025/01/05/php-8-4</id><content type="html" xml:base="https://guillaumedelre.github.io/2025/01/05/php-8-4/"><![CDATA[<p>PHP 8.4 released November 21st. Property hooks are the feature. Everything else, and there’s quite a bit of it, is secondary.</p>

<h2 id="hook-property-hooks">:hook: 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="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">class</span> <span class="nc">User</span> <span class="p">{</span>
    <span class="k">private</span> <span class="kt">string</span> <span class="nv">$_name</span><span class="p">;</span>
    
    <span class="k">public</span> <span class="k">function</span> <span class="n">getName</span><span class="p">():</span> <span class="kt">string</span> <span class="p">{</span> <span class="k">return</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">_name</span><span class="p">;</span> <span class="p">}</span>
    <span class="k">public</span> <span class="k">function</span> <span class="n">setName</span><span class="p">(</span><span class="kt">string</span> <span class="nv">$name</span><span class="p">):</span> <span class="kt">void</span> <span class="p">{</span>
        <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">_name</span> <span class="o">=</span> <span class="nb">strtoupper</span><span class="p">(</span><span class="nv">$name</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>PHP 8.4 adds hooks directly on the property:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">class</span> <span class="nc">User</span> <span class="p">{</span>
    <span class="k">public</span> <span class="kt">string</span> <span class="nv">$name</span> <span class="p">{</span>
        <span class="nf">set</span><span class="p">(</span><span class="n">string</span> <span class="nv">$name</span><span class="p">)</span> <span class="p">{</span>
            <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">name</span> <span class="o">=</span> <span class="nb">strtoupper</span><span class="p">(</span><span class="nv">$name</span><span class="p">);</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>You can define <code class="language-plaintext highlighter-rouge">get</code> and <code class="language-plaintext highlighter-rouge">set</code> hooks independently. A property with only a <code class="language-plaintext highlighter-rouge">get</code> hook is computed on access:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">class</span> <span class="nc">Circle</span> <span class="p">{</span>
    <span class="k">public</span> <span class="kt">float</span> <span class="nv">$area</span> <span class="p">{</span>
        <span class="n">get</span> <span class="o">=&gt;</span> <span class="no">M_PI</span> <span class="o">*</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">radius</span> <span class="o">**</span> <span class="mi">2</span><span class="p">;</span>
    <span class="p">}</span>
    
    <span class="k">public</span> <span class="k">function</span> <span class="n">__construct</span><span class="p">(</span><span class="k">public</span> <span class="kt">float</span> <span class="nv">$radius</span><span class="p">)</span> <span class="p">{}</span>
<span class="p">}</span>
</code></pre></div></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="eyes-asymmetric-visibility">:eyes: Asymmetric visibility</h2>

<p>A lighter option for when you just want public read, private write:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">class</span> <span class="nc">Version</span> <span class="p">{</span>
    <span class="k">public</span> <span class="kt">private</span><span class="p">(</span><span class="n">set</span><span class="p">)</span> <span class="n">string</span> <span class="nv">$value</span> <span class="o">=</span> <span class="s1">'1.0.0'</span><span class="p">;</span>
<span class="p">}</span>

<span class="nv">$v</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Version</span><span class="p">();</span>
<span class="k">echo</span> <span class="nv">$v</span><span class="o">-&gt;</span><span class="n">value</span><span class="p">;</span>      <span class="c1">// works</span>
<span class="nv">$v</span><span class="o">-&gt;</span><span class="n">value</span> <span class="o">=</span> <span class="s1">'2.0'</span><span class="p">;</span>  <span class="c1">// Error</span>
</code></pre></div></div>

<p>Kills the <code class="language-plaintext highlighter-rouge">private $x</code> + <code class="language-plaintext highlighter-rouge">public getX()</code> pattern for read-only public properties without needing full readonly semantics.</p>

<h2 id="mag-array_find-and-friends">:mag: array_find() and friends</h2>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$first</span> <span class="o">=</span> <span class="nf">array_find</span><span class="p">(</span><span class="nv">$users</span><span class="p">,</span> <span class="k">fn</span><span class="p">(</span><span class="nv">$u</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nv">$u</span><span class="o">-&gt;</span><span class="nf">isActive</span><span class="p">());</span>
<span class="nv">$any</span>   <span class="o">=</span> <span class="nf">array_any</span><span class="p">(</span><span class="nv">$users</span><span class="p">,</span> <span class="k">fn</span><span class="p">(</span><span class="nv">$u</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nv">$u</span><span class="o">-&gt;</span><span class="nf">isPremium</span><span class="p">());</span>
<span class="nv">$all</span>   <span class="o">=</span> <span class="nf">array_all</span><span class="p">(</span><span class="nv">$users</span><span class="p">,</span> <span class="k">fn</span><span class="p">(</span><span class="nv">$u</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nv">$u</span><span class="o">-&gt;</span><span class="nf">isVerified</span><span class="p">());</span>
</code></pre></div></div>

<p>These have been in every other language’s standard library for decades. In PHP, you had to use <code class="language-plaintext highlighter-rouge">array_filter()</code> + index access or write a manual loop. They exist now: <code class="language-plaintext highlighter-rouge">array_find()</code>, <code class="language-plaintext highlighter-rouge">array_find_key()</code>, <code class="language-plaintext highlighter-rouge">array_any()</code>, <code class="language-plaintext highlighter-rouge">array_all()</code>.</p>

<h2 id="new-instantiation-without-extra-parentheses">:new: Instantiation without extra parentheses</h2>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// before</span>
<span class="p">(</span><span class="k">new</span> <span class="nc">MyClass</span><span class="p">())</span><span class="o">-&gt;</span><span class="nf">method</span><span class="p">();</span>

<span class="c1">// after</span>
<span class="k">new</span> <span class="nc">MyClass</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">method</span><span class="p">();</span>
</code></pre></div></div>

<p>A syntax restriction that was always annoying and never justified is gone.</p>

<h2 id="zzz-lazy-objects">:zzz: Lazy objects</h2>

<p>Objects whose initialization is deferred until first property access:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$user</span> <span class="o">=</span> <span class="nv">$reflector</span><span class="o">-&gt;</span><span class="nf">newLazyProxy</span><span class="p">(</span><span class="k">fn</span><span class="p">()</span> <span class="o">=&gt;</span> <span class="nv">$repository</span><span class="o">-&gt;</span><span class="nf">find</span><span class="p">(</span><span class="nv">$id</span><span class="p">));</span>
<span class="c1">// No database call yet</span>
<span class="nv">$user</span><span class="o">-&gt;</span><span class="n">name</span><span class="p">;</span> <span class="c1">// Now the proxy initializes</span>
</code></pre></div></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’re not a workaround, they’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="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">class</span> <span class="nc">ApiClient</span> <span class="p">{</span>
    <span class="c1">#[\Deprecated(</span>
        <span class="n">message</span><span class="o">:</span> <span class="s1">'Use fetchJson() instead'</span><span class="p">,</span>
        <span class="n">since</span><span class="o">:</span> <span class="s1">'2.0'</span><span class="p">,</span>
    <span class="p">)]</span>
    <span class="k">public</span> <span class="k">function</span> <span class="n">get</span><span class="p">(</span><span class="kt">string</span> <span class="nv">$url</span><span class="p">):</span> <span class="kt">string</span> <span class="p">{</span> <span class="mf">...</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Calling a deprecated method now emits <code class="language-plaintext highlighter-rouge">E_USER_DEPRECATED</code>, just like calling <code class="language-plaintext highlighter-rouge">mysql_connect()</code>. IDEs pick it up, static analyzers flag it, the error log captures it. Before this, the only option was a <code class="language-plaintext highlighter-rouge">@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 class="language-plaintext highlighter-rouge">bcmath</code> functions have been in PHP since forever, but their procedural API makes chaining anything painful. 8.4 adds <code class="language-plaintext highlighter-rouge">BcMath\Number</code>, an object wrapper with operator overloading:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$a</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">BcMath\Number</span><span class="p">(</span><span class="s1">'10.5'</span><span class="p">);</span>
<span class="nv">$b</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">BcMath\Number</span><span class="p">(</span><span class="s1">'3.2'</span><span class="p">);</span>

<span class="nv">$result</span> <span class="o">=</span> <span class="nv">$a</span> <span class="o">+</span> <span class="nv">$b</span><span class="p">;</span>             <span class="c1">// BcMath\Number('13.7')</span>
<span class="nv">$result</span> <span class="o">=</span> <span class="nv">$a</span> <span class="o">*</span> <span class="nv">$b</span> <span class="o">-</span> <span class="k">new</span> <span class="nc">BcMath\Number</span><span class="p">(</span><span class="s1">'1'</span><span class="p">);</span>
<span class="k">echo</span> <span class="nv">$result</span><span class="p">;</span>                  <span class="c1">// 32.6</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">+</code>, <code class="language-plaintext highlighter-rouge">-</code>, <code class="language-plaintext highlighter-rouge">*</code>, <code class="language-plaintext highlighter-rouge">/</code>, <code class="language-plaintext highlighter-rouge">**</code>, <code class="language-plaintext highlighter-rouge">%</code> operators all work. The object is immutable. Scale propagates automatically through operations. Financial calculations, which used to mean chains of <code class="language-plaintext highlighter-rouge">bcadd(bcmul(...), ...)</code>, now just read like arithmetic.</p>

<p>New procedural functions complete the picture: <code class="language-plaintext highlighter-rouge">bcceil()</code>, <code class="language-plaintext highlighter-rouge">bcfloor()</code>, <code class="language-plaintext highlighter-rouge">bcround()</code>, <code class="language-plaintext highlighter-rouge">bcdivmod()</code>.</p>

<h2 id="roundingmode-enum-replaces-php_round_-constants">RoundingMode enum replaces PHP_ROUND_* constants</h2>

<p><code class="language-plaintext highlighter-rouge">round()</code> has always taken a <code class="language-plaintext highlighter-rouge">$mode</code> int from a set of <code class="language-plaintext highlighter-rouge">PHP_ROUND_*</code> constants. 8.4 replaces that with a <code class="language-plaintext highlighter-rouge">RoundingMode</code> enum with cleaner names and four additional modes that weren’t available before:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">round</span><span class="p">(</span><span class="mf">2.5</span><span class="p">,</span> <span class="n">mode</span><span class="o">:</span> <span class="nc">RoundingMode</span><span class="o">::</span><span class="nc">HalfAwayFromZero</span><span class="p">);</span>  <span class="c1">// 3</span>
<span class="nb">round</span><span class="p">(</span><span class="mf">2.5</span><span class="p">,</span> <span class="n">mode</span><span class="o">:</span> <span class="nc">RoundingMode</span><span class="o">::</span><span class="nc">HalfTowardsZero</span><span class="p">);</span>   <span class="c1">// 2</span>
<span class="nb">round</span><span class="p">(</span><span class="mf">2.5</span><span class="p">,</span> <span class="n">mode</span><span class="o">:</span> <span class="nc">RoundingMode</span><span class="o">::</span><span class="nc">HalfEven</span><span class="p">);</span>          <span class="c1">// 2 (banker's rounding)</span>
<span class="nb">round</span><span class="p">(</span><span class="mf">2.5</span><span class="p">,</span> <span class="n">mode</span><span class="o">:</span> <span class="nc">RoundingMode</span><span class="o">::</span><span class="nc">HalfOdd</span><span class="p">);</span>           <span class="c1">// 3</span>

<span class="c1">// The four new modes (only available via the enum)</span>
<span class="nb">round</span><span class="p">(</span><span class="mf">2.3</span><span class="p">,</span> <span class="n">mode</span><span class="o">:</span> <span class="nc">RoundingMode</span><span class="o">::</span><span class="nc">TowardsZero</span><span class="p">);</span>       <span class="c1">// 2</span>
<span class="nb">round</span><span class="p">(</span><span class="mf">2.7</span><span class="p">,</span> <span class="n">mode</span><span class="o">:</span> <span class="nc">RoundingMode</span><span class="o">::</span><span class="nc">AwayFromZero</span><span class="p">);</span>      <span class="c1">// 3</span>
<span class="nb">round</span><span class="p">(</span><span class="mf">2.3</span><span class="p">,</span> <span class="n">mode</span><span class="o">:</span> <span class="nc">RoundingMode</span><span class="o">::</span><span class="nc">PositiveInfinity</span><span class="p">);</span>  <span class="c1">// 3</span>
<span class="nb">round</span><span class="p">(</span><span class="mf">2.3</span><span class="p">,</span> <span class="n">mode</span><span class="o">:</span> <span class="nc">RoundingMode</span><span class="o">::</span><span class="nc">NegativeInfinity</span><span class="p">);</span>  <span class="c1">// 2</span>
</code></pre></div></div>

<p>The old <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">mb_trim()</code>, <code class="language-plaintext highlighter-rouge">mb_ltrim()</code>, <code class="language-plaintext highlighter-rouge">mb_rtrim()</code>: trim functions that respect multibyte character boundaries, not just ASCII whitespace. Also new: <code class="language-plaintext highlighter-rouge">mb_ucfirst()</code> and <code class="language-plaintext highlighter-rouge">mb_lcfirst()</code> for proper title-casing of multibyte strings.</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$s</span> <span class="o">=</span> <span class="s2">"</span><span class="se">\u{200B}</span><span class="s2">hello</span><span class="se">\u{200B}</span><span class="s2">"</span><span class="p">;</span> <span class="c1">// Zero-width spaces</span>
<span class="k">echo</span> <span class="nf">mb_trim</span><span class="p">(</span><span class="nv">$s</span><span class="p">);</span>              <span class="c1">// "hello"</span>
<span class="k">echo</span> <span class="nf">mb_ucfirst</span><span class="p">(</span><span class="s1">'über'</span><span class="p">);</span>       <span class="c1">// "Über"</span>
</code></pre></div></div>

<p>These fill gaps that have been sitting there since <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">application/x-www-form-urlencoded</code> and <code class="language-plaintext highlighter-rouge">multipart/form-data</code> into <code class="language-plaintext highlighter-rouge">$_POST</code> and <code class="language-plaintext highlighter-rouge">$_FILES</code>, but only for POST requests. PATCH and PUT requests with the same content types needed manual parsing with <code class="language-plaintext highlighter-rouge">file_get_contents('php://input')</code> and custom code.</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Inside a PATCH handler</span>
<span class="p">[</span><span class="nv">$_POST</span><span class="p">,</span> <span class="nv">$_FILES</span><span class="p">]</span> <span class="o">=</span> <span class="nf">request_parse_body</span><span class="p">();</span>
</code></pre></div></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 class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">Dom\</code> namespace that implements the WHATWG Living Standard:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$doc</span> <span class="o">=</span> <span class="nc">Dom\HTMLDocument</span><span class="o">::</span><span class="nf">createFromString</span><span class="p">(</span><span class="s1">'&lt;p class="lead"&gt;Hello&lt;/p&gt;'</span><span class="p">);</span>
<span class="nv">$p</span> <span class="o">=</span> <span class="nv">$doc</span><span class="o">-&gt;</span><span class="nf">querySelector</span><span class="p">(</span><span class="s1">'p'</span><span class="p">);</span>
<span class="k">echo</span> <span class="nv">$p</span><span class="o">-&gt;</span><span class="n">classList</span><span class="p">;</span>  <span class="c1">// "lead"</span>
<span class="k">echo</span> <span class="nv">$p</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">;</span>         <span class="c1">// ""</span>

<span class="nv">$doc2</span> <span class="o">=</span> <span class="nc">Dom\HTMLDocument</span><span class="o">::</span><span class="nf">createFromFile</span><span class="p">(</span><span class="s1">'page.html'</span><span class="p">);</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Dom\HTMLDocument</code> parses HTML5 correctly, tag soup included. <code class="language-plaintext highlighter-rouge">Dom\XMLDocument</code> handles strict XML. The new classes are strict about types, return proper node types, and expose modern properties like <code class="language-plaintext highlighter-rouge">classList</code>, <code class="language-plaintext highlighter-rouge">id</code>, <code class="language-plaintext highlighter-rouge">className</code>. The old <code class="language-plaintext highlighter-rouge">DOMDocument</code> stays, unchanged, for backward compatibility.</p>

<h2 id="pdo-gets-driver-specific-subclasses">PDO gets driver-specific subclasses</h2>

<p><code class="language-plaintext highlighter-rouge">PDO::connect()</code> and direct instantiation now return driver-specific subclasses when available:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$pdo</span> <span class="o">=</span> <span class="no">PDO</span><span class="o">::</span><span class="nf">connect</span><span class="p">(</span><span class="s1">'mysql:host=localhost;dbname=test'</span><span class="p">,</span> <span class="s1">'user'</span><span class="p">,</span> <span class="s1">'pass'</span><span class="p">);</span>
<span class="c1">// $pdo is now a Pdo\Mysql instance</span>

<span class="nv">$pdo</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Pdo\Pgsql</span><span class="p">(</span><span class="s1">'pgsql:host=localhost;dbname=test'</span><span class="p">,</span> <span class="s1">'user'</span><span class="p">,</span> <span class="s1">'pass'</span><span class="p">);</span>
</code></pre></div></div>

<p>Each driver subclass (<code class="language-plaintext highlighter-rouge">Pdo\Mysql</code>, <code class="language-plaintext highlighter-rouge">Pdo\Pgsql</code>, <code class="language-plaintext highlighter-rouge">Pdo\Sqlite</code>, <code class="language-plaintext highlighter-rouge">Pdo\Firebird</code>, <code class="language-plaintext highlighter-rouge">Pdo\Odbc</code>, <code class="language-plaintext highlighter-rouge">Pdo\DbLib</code>) can expose driver-specific methods without polluting the base <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">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="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$key</span> <span class="o">=</span> <span class="nb">openssl_pkey_new</span><span class="p">([</span><span class="s1">'curve_name'</span> <span class="o">=&gt;</span> <span class="s1">'ed25519'</span><span class="p">,</span> <span class="s1">'private_key_type'</span> <span class="o">=&gt;</span> <span class="no">OPENSSL_KEYTYPE_EC</span><span class="p">]);</span>
<span class="nv">$details</span> <span class="o">=</span> <span class="nb">openssl_pkey_get_details</span><span class="p">(</span><span class="nv">$key</span><span class="p">);</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">x25519</code> and <code class="language-plaintext highlighter-rouge">x448</code> for key exchange, <code class="language-plaintext highlighter-rouge">ed25519</code> and <code class="language-plaintext highlighter-rouge">ed448</code> for signatures. All four now work with <code class="language-plaintext highlighter-rouge">openssl_sign()</code> and <code class="language-plaintext highlighter-rouge">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’t do:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Match "bar" only when preceded by "foo" or "foooo"</span>
<span class="nb">preg_match</span><span class="p">(</span><span class="s1">'/(?&lt;=foo+)bar/'</span><span class="p">,</span> <span class="s1">'foooobar'</span><span class="p">,</span> <span class="nv">$matches</span><span class="p">);</span>
</code></pre></div></div>

<p>Lookbehind assertions used to require a fixed-width pattern. Now they can match patterns of variable length. The <code class="language-plaintext highlighter-rouge">r</code> modifier (<code class="language-plaintext highlighter-rouge">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="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$dt</span> <span class="o">=</span> <span class="nc">DateTimeImmutable</span><span class="o">::</span><span class="nf">createFromTimestamp</span><span class="p">(</span><span class="mf">1700000000.5</span><span class="p">);</span>
<span class="k">echo</span> <span class="nv">$dt</span><span class="o">-&gt;</span><span class="nf">getMicrosecond</span><span class="p">();</span> <span class="c1">// 500000</span>

<span class="nv">$with_micros</span> <span class="o">=</span> <span class="nv">$dt</span><span class="o">-&gt;</span><span class="nf">setMicrosecond</span><span class="p">(</span><span class="mi">123456</span><span class="p">);</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">createFromTimestamp()</code> accepts a float for sub-second precision. <code class="language-plaintext highlighter-rouge">getMicrosecond()</code> and <code class="language-plaintext highlighter-rouge">setMicrosecond()</code> round out the API for the microsecond component that’s been inside <code class="language-plaintext highlighter-rouge">DateTime</code> internally but inaccessible directly.</p>

<h2 id="fpow-for-ieee-754-compliance">fpow() for IEEE 754 compliance</h2>

<p><code class="language-plaintext highlighter-rouge">pow(0, -2)</code> in PHP has historically returned an implementation-defined value. 8.4 deprecates <code class="language-plaintext highlighter-rouge">pow()</code> with a zero base and negative exponent and introduces <code class="language-plaintext highlighter-rouge">fpow()</code>, which strictly follows IEEE 754: <code class="language-plaintext highlighter-rouge">fpow(0, -2)</code> returns <code class="language-plaintext highlighter-rouge">INF</code>, as the standard defines:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">echo</span> <span class="nf">fpow</span><span class="p">(</span><span class="mf">2.0</span><span class="p">,</span> <span class="mf">3.0</span><span class="p">);</span>   <span class="c1">// 8.0</span>
<span class="k">echo</span> <span class="nf">fpow</span><span class="p">(</span><span class="mf">0.0</span><span class="p">,</span> <span class="o">-</span><span class="mf">1.0</span><span class="p">);</span>  <span class="c1">// INF</span>
<span class="k">echo</span> <span class="nf">fpow</span><span class="p">(</span><span class="o">-</span><span class="mf">1.0</span><span class="p">,</span> <span class="no">INF</span><span class="p">);</span>  <span class="c1">// 1.0</span>
</code></pre></div></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 class="language-plaintext highlighter-rouge">password_hash()</code> with <code class="language-plaintext highlighter-rouge">PASSWORD_BCRYPT</code> went from <code class="language-plaintext highlighter-rouge">10</code> to <code class="language-plaintext highlighter-rouge">12</code>. This hits any code calling <code class="language-plaintext highlighter-rouge">password_hash($pass, PASSWORD_BCRYPT)</code> without an explicit cost. The goal is to keep the default roughly “a few hundred milliseconds on modern hardware” as hardware gets faster.</p>

<p>If you store bcrypt hashes and upgrade to 8.4, existing hashes stay valid: <code class="language-plaintext highlighter-rouge">password_verify()</code> reads the cost from the hash itself. New hashes use cost 12. <code class="language-plaintext highlighter-rouge">password_needs_rehash()</code> returns true for old hashes if you pass <code class="language-plaintext highlighter-rouge">['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 class="language-plaintext highlighter-rouge">null</code>, the type has to say so explicitly:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Deprecated in 8.4</span>
<span class="k">function</span> <span class="n">foo</span><span class="p">(</span><span class="kt">string</span> <span class="nv">$s</span> <span class="o">=</span> <span class="kc">null</span><span class="p">)</span> <span class="p">{}</span>

<span class="c1">// Correct</span>
<span class="k">function</span> <span class="n">foo</span><span class="p">(</span><span class="kt">?string</span> <span class="nv">$s</span> <span class="o">=</span> <span class="kc">null</span><span class="p">)</span> <span class="p">{}</span>
<span class="k">function</span> <span class="n">foo</span><span class="p">(</span><span class="kt">string</span><span class="o">|</span><span class="kc">null</span> <span class="nv">$s</span> <span class="o">=</span> <span class="kc">null</span><span class="p">)</span> <span class="p">{}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">trigger_error()</code> with <code class="language-plaintext highlighter-rouge">E_USER_ERROR</code> is deprecated: replace it with an exception or <code class="language-plaintext highlighter-rouge">exit()</code>. The <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">lcg_value()</code> is deprecated too. Use <code class="language-plaintext highlighter-rouge">Random\Randomizer::getFloat()</code> instead. The LCG generator had poor randomness properties and no seeding control.</p>]]></content><author><name>Guillaume Delré</name><email>delre.guillaume@gmail.com</email></author><category term="development" /><category term="php" /><category term="php84" /><category term="types" /><category term="oop" /><summary type="html"><![CDATA[PHP 8.4 brings property hooks: get/set logic directly on properties, replacing twenty years of getter/setter boilerplate.]]></summary></entry><entry><title type="html">Symfony 7.0: PHP 8.2 minimum and annotations finally gone</title><link href="https://guillaumedelre.github.io/2024/01/12/symfony-7-0/" rel="alternate" type="text/html" title="Symfony 7.0: PHP 8.2 minimum and annotations finally gone" /><published>2024-01-12T00:00:00+00:00</published><updated>2024-01-12T00:00:00+00:00</updated><id>https://guillaumedelre.github.io/2024/01/12/symfony-7-0</id><content type="html" xml:base="https://guillaumedelre.github.io/2024/01/12/symfony-7-0/"><![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 class="language-plaintext highlighter-rouge">@Route</code>, <code class="language-plaintext highlighter-rouge">@ORM\Column</code>, <code class="language-plaintext highlighter-rouge">@Assert</code> - gone. Native PHP attributes have been the recommended approach since Symfony 5.2. 7.0 just makes it official.</p>

<h2 id="label-attributes-everywhere">:label: Attributes everywhere</h2>

<p>The migration from annotations to attributes is mostly mechanical: syntax changes from <code class="language-plaintext highlighter-rouge">@</code> to <code class="language-plaintext highlighter-rouge">#[]</code>, and the class references move from Doctrine annotation classes to PHP attribute classes:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// before</span>
<span class="cd">/** @Route('/users', methods={"GET"}) */</span>

<span class="c1">// after</span>
<span class="c1">#[Route('/users', methods: ['GET'])]</span>
</code></pre></div></div>

<p>The real win isn’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 “it fails silently at runtime because of a typo in a comment.”</p>

<h2 id="workflow-workflow-with-php-attributes">:workflow: Workflow with PHP attributes</h2>

<p>Workflow event listeners and guards can now be registered via attributes:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">#[AsGuard(workflow: 'order', transition: 'ship')]</span>
<span class="k">public</span> <span class="k">function</span> <span class="n">canShip</span><span class="p">(</span><span class="kt">Event</span> <span class="nv">$event</span><span class="p">):</span> <span class="kt">void</span>
<span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nv">$event</span><span class="o">-&gt;</span><span class="nf">getSubject</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">isPaymentConfirmed</span><span class="p">())</span> <span class="p">{</span>
        <span class="nv">$event</span><span class="o">-&gt;</span><span class="nf">setBlocked</span><span class="p">(</span><span class="kc">true</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The workflow profiler, a dedicated panel showing the current marking and available transitions, is a genuinely useful debugging tool if you’re working with complex state machines.</p>

<h2 id="clock1-datepoint-in-the-clock-component">:clock1: DatePoint in the Clock component</h2>

<p><code class="language-plaintext highlighter-rouge">DatePoint</code>, the immutable <code class="language-plaintext highlighter-rouge">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’s readonly properties and date value objects in domain code become almost trivially clean:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">readonly</span> <span class="kd">class</span> <span class="nc">Order</span> <span class="p">{</span>
    <span class="k">public</span> <span class="k">function</span> <span class="n">__construct</span><span class="p">(</span>
        <span class="k">public</span> <span class="kt">DatePoint</span> <span class="nv">$createdAt</span><span class="p">,</span>
        <span class="k">public</span> <span class="kt">?DatePoint</span> <span class="nv">$shippedAt</span> <span class="o">=</span> <span class="kc">null</span><span class="p">,</span>
    <span class="p">)</span> <span class="p">{}</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="wastebasket-what-70-removes">:wastebasket: What 7.0 removes</h2>

<p>The full removal list: Doctrine annotations support, the <code class="language-plaintext highlighter-rouge">Templating</code> component bridge, <code class="language-plaintext highlighter-rouge">ProxyManager</code> bridge, the <code class="language-plaintext highlighter-rouge">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’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 class="language-plaintext highlighter-rouge">@experimental</code> caveats, and they show up properly in the upgrade guide. You can actually rely on them now.</p>

<p>Scheduler gets <code class="language-plaintext highlighter-rouge">#[AsCronTask]</code> and <code class="language-plaintext highlighter-rouge">#[AsPeriodicTask]</code> for attribute-based task registration, runtime schedule modification with heap recalculation, <code class="language-plaintext highlighter-rouge">FailureEvent</code>, and a <code class="language-plaintext highlighter-rouge">--date</code> option on <code class="language-plaintext highlighter-rouge">schedule:debug</code>. AssetMapper adds CSS file support in importmap, an <code class="language-plaintext highlighter-rouge">outdated</code> command, an <code class="language-plaintext highlighter-rouge">audit</code> command, and automatic preloading via WebLink.</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">#[AsCronTask('0 2 * * *')]</span>
<span class="kd">class</span> <span class="nc">NightlyReportMessage</span> <span class="p">{}</span>

<span class="c1">#[AsPeriodicTask(frequency: '1 hour')]</span>
<span class="kd">class</span> <span class="nc">HourlyCleanupMessage</span> <span class="p">{}</span>
</code></pre></div></div>

<h2 id="service-wiring-gets-two-new-attributes">Service wiring gets two new attributes</h2>

<p><code class="language-plaintext highlighter-rouge">#[AutowireLocator]</code> and <code class="language-plaintext highlighter-rouge">#[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="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">class</span> <span class="nc">HandlerRegistry</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">function</span> <span class="n">__construct</span><span class="p">(</span>
        <span class="c1">#[AutowireLocator('app.handler', indexAttribute: 'key')]</span>
        <span class="k">private</span> <span class="kt">ContainerInterface</span> <span class="nv">$handlers</span><span class="p">,</span>
    <span class="p">)</span> <span class="p">{}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">#[Target]</code> also gets smarter: when a service has a named autowiring alias like <code class="language-plaintext highlighter-rouge">invoice.lock.factory</code>, you can now write <code class="language-plaintext highlighter-rouge">#[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 class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">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>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>redis-sentinel://host1:26379,host2:26379,host3:26379/mymaster
</code></pre></div></div>

<h2 id="console-gets-signal-names-and-command-profiling">Console gets signal names and command profiling</h2>

<p><code class="language-plaintext highlighter-rouge">SignalMap</code> maps signal integers to their POSIX names. When a worker catches <code class="language-plaintext highlighter-rouge">SIGTERM</code>, the log now says <code class="language-plaintext highlighter-rouge">SIGTERM</code> instead of <code class="language-plaintext highlighter-rouge">15</code>. Small thing, real improvement. <code class="language-plaintext highlighter-rouge">ConsoleTerminateEvent</code> is dispatched even when the process exits via signal, which wasn’t the case before 7.0.</p>

<p>Command profiling lands too: pass <code class="language-plaintext highlighter-rouge">--profile</code> to <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">ChoiceType</code> gets a <code class="language-plaintext highlighter-rouge">duplicate_preferred_choices</code> option. Set it to <code class="language-plaintext highlighter-rouge">false</code> and you stop showing the same option twice when preferred choices overlap with the full list. <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">&lt;input&gt;</code> elements is also gone: <code class="language-plaintext highlighter-rouge">&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 class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">Response::send()</code> gets a <code class="language-plaintext highlighter-rouge">$flush</code> parameter. Pass <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">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’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 class="language-plaintext highlighter-rouge">config/packages/translation.yaml</code> and the <code class="language-plaintext highlighter-rouge">translation:push</code> / <code class="language-plaintext highlighter-rouge">translation:pull</code> commands handle the sync.</p>

<p><code class="language-plaintext highlighter-rouge">translation:pull</code> gets an <code class="language-plaintext highlighter-rouge">--as-tree</code> option that writes translation files in nested YAML rather than flat dot-notation keys. Whether that’s actually better depends entirely on your team.</p>

<p><code class="language-plaintext highlighter-rouge">LocaleSwitcher::runWithLocale()</code> now passes the current locale as an argument to the callback, saving you a <code class="language-plaintext highlighter-rouge">getLocale()</code> call inside:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$switcher</span><span class="o">-&gt;</span><span class="nf">runWithLocale</span><span class="p">(</span><span class="s1">'fr'</span><span class="p">,</span> <span class="k">function</span> <span class="p">(</span><span class="kt">string</span> <span class="nv">$locale</span><span class="p">)</span> <span class="k">use</span> <span class="p">(</span><span class="nv">$mailer</span><span class="p">)</span> <span class="p">{</span>
    <span class="nv">$mailer</span><span class="o">-&gt;</span><span class="nf">send</span><span class="p">(</span><span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">buildEmail</span><span class="p">(</span><span class="nv">$locale</span><span class="p">));</span>
<span class="p">});</span>
</code></pre></div></div>

<h2 id="a-few-things-in-serializer-and-domcrawler">A few things in Serializer and DomCrawler</h2>

<p>The Serializer’s <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">TranslatableNormalizer</code> lands for normalizing objects that implement <code class="language-plaintext highlighter-rouge">TranslatableInterface</code>: the translator is called during normalization, not before.</p>

<p><code class="language-plaintext highlighter-rouge">Crawler::attr()</code> gains a <code class="language-plaintext highlighter-rouge">$default</code> parameter. Instead of null-checking the return value, pass a fallback:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$src</span> <span class="o">=</span> <span class="nv">$crawler</span><span class="o">-&gt;</span><span class="nf">attr</span><span class="p">(</span><span class="s1">'src'</span><span class="p">,</span> <span class="s1">'/placeholder.png'</span><span class="p">);</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">assertAnySelectorText()</code> and <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">MockResponse</code> now accepts HAR (HTTP Archive) files. Record real HTTP interactions in your browser or with a proxy, drop the <code class="language-plaintext highlighter-rouge">.har</code> file in your test fixtures, and replay them:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$client</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">MockHttpClient</span><span class="p">(</span><span class="nc">HarFileResponseFactory</span><span class="o">::</span><span class="nf">createFromFile</span><span class="p">(</span><span class="k">__DIR__</span><span class="mf">.</span><span class="s1">'/fixtures/api.har'</span><span class="p">));</span>
</code></pre></div></div>

<p>Much better than writing response stubs by hand when you’re dealing with a complex API.</p>]]></content><author><name>Guillaume Delré</name><email>delre.guillaume@gmail.com</email></author><category term="development" /><category term="symfony" /><category term="php82" /><category term="attributes" /><category term="workflow" /><summary type="html"><![CDATA[Symfony 7.0 requires PHP 8.2, drops Doctrine annotations entirely, and ships a rebuilt Workflow component.]]></summary></entry><entry><title type="html">Symfony 6.4 LTS: AssetMapper, Scheduler, Webhook, and the long-term release</title><link href="https://guillaumedelre.github.io/2024/01/10/symfony-6-4-lts/" rel="alternate" type="text/html" title="Symfony 6.4 LTS: AssetMapper, Scheduler, Webhook, and the long-term release" /><published>2024-01-10T00:00:00+00:00</published><updated>2024-01-10T00:00:00+00:00</updated><id>https://guillaumedelre.github.io/2024/01/10/symfony-6-4-lts</id><content type="html" xml:base="https://guillaumedelre.github.io/2024/01/10/symfony-6-4-lts/"><![CDATA[<p>Symfony 6.4 landed November 29, 2023. It’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="package-assetmapper">:package: 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="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>composer require symfony/asset-mapper
php bin/console importmap:require lodash
</code></pre></div></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="calendar-scheduler">:calendar: 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="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">#[AsCronTask('0 * * * *')]</span>
<span class="kd">class</span> <span class="nc">HourlyReport</span> <span class="kd">implements</span> <span class="nc">ScheduledTaskInterface</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">function</span> <span class="n">run</span><span class="p">():</span> <span class="kt">void</span> <span class="p">{</span> <span class="mf">...</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></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 class="language-plaintext highlighter-rouge">cron</code> entry + console command pattern.</p>

<h2 id="hook-webhook-and-remoteevent">:hook: 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 class="language-plaintext highlighter-rouge">DatePoint</code> class in the Clock component: an immutable <code class="language-plaintext highlighter-rouge">DateTime</code> wrapper that throws exceptions on invalid modifiers instead of silently returning <code class="language-plaintext highlighter-rouge">false</code>. Small thing, but meaningful for code that manipulates dates and actually wants to know when something goes wrong.</p>

<h2 id="calendar-the-support-window">:calendar: 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="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Previously: only 'blog_index' worked</span>
<span class="c1">// Now: both work identically</span>
<span class="nv">$this</span><span class="o">-&gt;</span><span class="n">urlGenerator</span><span class="o">-&gt;</span><span class="nf">generate</span><span class="p">(</span><span class="s1">'blog_index'</span><span class="p">);</span>
<span class="nv">$this</span><span class="o">-&gt;</span><span class="n">urlGenerator</span><span class="o">-&gt;</span><span class="nf">generate</span><span class="p">(</span><span class="nc">BlogController</span><span class="o">::</span><span class="n">class</span><span class="mf">.</span><span class="s1">'::index'</span><span class="p">);</span>
</code></pre></div></div>

<p>For invokable controllers, the alias is just the class name. The practical benefit is IDE navigation and refactoring safety: you’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 class="language-plaintext highlighter-rouge">#[AutowireLocator]</code> and <code class="language-plaintext highlighter-rouge">#[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="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">function</span> <span class="n">__construct</span><span class="p">(</span>
    <span class="c1">#[AutowireLocator([FooHandler::class, BarHandler::class])]</span>
    <span class="k">private</span> <span class="kt">ContainerInterface</span> <span class="nv">$handlers</span><span class="p">,</span>
<span class="p">)</span> <span class="p">{}</span>
</code></pre></div></div>

<p>Aliases, optional services (prefixed with <code class="language-plaintext highlighter-rouge">?</code>), and parameter injection via <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">RunProcessMessage</code> dispatches a <code class="language-plaintext highlighter-rouge">Process</code> command through the bus. <code class="language-plaintext highlighter-rouge">RunCommandMessage</code> does the same for console commands. Both return a context object with the exit code and output. <code class="language-plaintext highlighter-rouge">PingWebhookMessage</code> pings a URL, which is useful for monitoring scheduled tasks without spinning up a dedicated health-check service:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$this</span><span class="o">-&gt;</span><span class="n">bus</span><span class="o">-&gt;</span><span class="nf">dispatch</span><span class="p">(</span><span class="k">new</span> <span class="nc">RunCommandMessage</span><span class="p">(</span><span class="s1">'cache:clear'</span><span class="p">));</span>
<span class="nv">$this</span><span class="o">-&gt;</span><span class="n">bus</span><span class="o">-&gt;</span><span class="nf">dispatch</span><span class="p">(</span><span class="k">new</span> <span class="nc">PingWebhookMessage</span><span class="p">(</span><span class="s1">'GET'</span><span class="p">,</span> <span class="s1">'https://healthchecks.io/ping/abc123'</span><span class="p">));</span>
</code></pre></div></div>

<p>The subprocess inheritance problem also got addressed with <code class="language-plaintext highlighter-rouge">PhpSubprocess</code>. When you run PHP with a custom memory limit (<code class="language-plaintext highlighter-rouge">-d memory_limit=-1</code>), child processes launched with <code class="language-plaintext highlighter-rouge">Process</code> don’t inherit it. <code class="language-plaintext highlighter-rouge">PhpSubprocess</code> does:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$sub</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">PhpSubprocess</span><span class="p">([</span><span class="s1">'bin/console'</span><span class="p">,</span> <span class="s1">'app:heavy-import'</span><span class="p">]);</span>
</code></pre></div></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’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’re written. No config needed, no regex on log lines.</p>

<p>Firewall patterns now accept arrays:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">firewalls</span><span class="pi">:</span>
    <span class="na">no_security</span><span class="pi">:</span>
        <span class="na">pattern</span><span class="pi">:</span>
            <span class="pi">-</span> <span class="s2">"</span><span class="s">^/register$"</span>
            <span class="pi">-</span> <span class="s2">"</span><span class="s">^/api/webhooks/"</span>
</code></pre></div></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="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes/security.yaml</span>
<span class="na">_security_logout</span><span class="pi">:</span>
    <span class="na">resource</span><span class="pi">:</span> <span class="s">security.route_loader.logout</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">service</span>
</code></pre></div></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 class="language-plaintext highlighter-rouge">#[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’s <code class="language-plaintext highlighter-rouge">TranslatableInterface</code>) get translated to the locale passed via <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">seld/jsonlint</code> for better messages. Instead of “Syntax error”, you get the line and what actually went wrong:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Parse error on line 1: {'foo': 'bar'}
           ^ Invalid string, used single quotes instead of double quotes
</code></pre></div></div>

<h2 id="profilers-for-the-things-that-werent-http-requests">Profilers for the things that weren’t HTTP requests</h2>

<p>The command profiler extends the existing profiler to console commands. Add <code class="language-plaintext highlighter-rouge">--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 class="language-plaintext highlighter-rouge">--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 class="language-plaintext highlighter-rouge">renderBlock()</code> and <code class="language-plaintext highlighter-rouge">renderBlockView()</code> on <code class="language-plaintext highlighter-rouge">AbstractController</code> let you render a named Twig block and return it as a <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">defined</code> env var processor returns a boolean rather than the value: <code class="language-plaintext highlighter-rouge">true</code> if the variable exists and is non-empty, <code class="language-plaintext highlighter-rouge">false</code> otherwise. Useful for feature flags driven by environment variables:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">parameters</span><span class="pi">:</span>
    <span class="na">is_feature_enabled</span><span class="pi">:</span> <span class="s1">'</span><span class="s">%env(defined:FEATURE_FLAG_KEY)%'</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">HttpClient</code> now accepts <code class="language-plaintext highlighter-rouge">max_retries</code> per request, overriding the global retry strategy. The Finder component’s <code class="language-plaintext highlighter-rouge">filter()</code> method accepts a second argument to prune entire directories early, which matters when you’re searching large trees.</p>

<p>The <code class="language-plaintext highlighter-rouge">BrowserKit</code> <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">impersonation_path()</code> and <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">TemplatedEmail</code> now has a <code class="language-plaintext highlighter-rouge">locale()</code> method for rendering emails in the recipient’s language. The locale switcher’s <code class="language-plaintext highlighter-rouge">runWithLocale()</code> now passes the locale as an argument to the callback, so you don’t have to capture it from the outer scope. And <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">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’t. The <code class="language-plaintext highlighter-rouge">MicroKernelTrait</code> uses it automatically. The <code class="language-plaintext highlighter-rouge">WarmableInterface</code> gained a <code class="language-plaintext highlighter-rouge">$buildDir</code> parameter to support this separation: custom cache warmers that write read-only artifacts should update accordingly.</p>]]></content><author><name>Guillaume Delré</name><email>delre.guillaume@gmail.com</email></author><category term="development" /><category term="symfony" /><category term="lts" /><category term="assetmapper" /><category term="scheduler" /><category term="webhook" /><summary type="html"><![CDATA[Symfony 6.4 LTS stabilizes AssetMapper — a bundler-free frontend approach — alongside the Scheduler and Webhook components.]]></summary></entry><entry><title type="html">PHP 8.3: typed constants and the small wins that stick</title><link href="https://guillaumedelre.github.io/2024/01/07/php-8-3/" rel="alternate" type="text/html" title="PHP 8.3: typed constants and the small wins that stick" /><published>2024-01-07T00:00:00+00:00</published><updated>2024-01-07T00:00:00+00:00</updated><id>https://guillaumedelre.github.io/2024/01/07/php-8-3</id><content type="html" xml:base="https://guillaumedelre.github.io/2024/01/07/php-8-3/"><![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="label-typed-class-constants">:label: Typed class constants</h2>

<p>Class constants have been untyped since their introduction. PHP 8.3 fixes that:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">interface</span> <span class="nc">HasVersion</span> <span class="p">{</span>
    <span class="k">const</span> <span class="no">string</span> <span class="no">VERSION</span><span class="p">;</span>
<span class="p">}</span>

<span class="kd">class</span> <span class="nc">App</span> <span class="kd">implements</span> <span class="nc">HasVersion</span> <span class="p">{</span>
    <span class="k">const</span> <span class="no">string</span> <span class="no">VERSION</span> <span class="o">=</span> <span class="s1">'1.0.0'</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></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="arrows_counterclockwise-dynamic-class-constant-access">:arrows_counterclockwise: Dynamic class constant access</h2>

<p>A gap that required a workaround since constants were introduced:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$name</span> <span class="o">=</span> <span class="s1">'STATUS'</span><span class="p">;</span>
<span class="k">echo</span> <span class="nc">MyClass</span><span class="o">::</span><span class="p">{</span><span class="nv">$name</span><span class="p">};</span> <span class="c1">// now works</span>
</code></pre></div></div>

<p>Before, accessing a constant with a dynamic name meant calling <code class="language-plaintext highlighter-rouge">constant('MyClass::STATUS')</code>. The new syntax is consistent with how PHP already handles variable variables and dynamic method calls.</p>

<h2 id="pencil-readonly-can-now-be-amended-in-clone">:pencil: readonly can now be amended in clone</h2>

<p>A specific but genuinely annoying limitation of 8.1 readonly: you couldn’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="white_check_mark-json_validate">:white_check_mark: json_validate()</h2>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="p">(</span><span class="nf">json_validate</span><span class="p">(</span><span class="nv">$string</span><span class="p">))</span> <span class="p">{</span>
    <span class="nv">$data</span> <span class="o">=</span> <span class="nb">json_decode</span><span class="p">(</span><span class="nv">$string</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Before 8.3, the only way to validate a JSON string was to decode it and check for errors. <code class="language-plaintext highlighter-rouge">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’s in it.</p>

<h2 id="game_die-randomizer-improvements">:game_die: Randomizer improvements</h2>

<p><code class="language-plaintext highlighter-rouge">getBytesFromString()</code> generates a random string composed only of characters from a given set:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$rng</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Random\Randomizer</span><span class="p">();</span>
<span class="nv">$token</span> <span class="o">=</span> <span class="nv">$rng</span><span class="o">-&gt;</span><span class="nf">getBytesFromString</span><span class="p">(</span><span class="s1">'abcdefghijklmnopqrstuvwxyz0123456789'</span><span class="p">,</span> <span class="mi">32</span><span class="p">);</span>
</code></pre></div></div>

<p>The previous approach: <code class="language-plaintext highlighter-rouge">str_split</code>, <code class="language-plaintext highlighter-rouge">array_map</code>, random selection, <code class="language-plaintext highlighter-rouge">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’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="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">class</span> <span class="nc">Cache</span> <span class="p">{</span>
    <span class="c1">#[\Override]</span>
    <span class="k">public</span> <span class="k">function</span> <span class="n">get</span><span class="p">(</span><span class="kt">string</span> <span class="nv">$key</span><span class="p">):</span> <span class="kt">mixed</span> <span class="p">{</span>
        <span class="c1">// Engine verifies this method exists in a parent or interface</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>If the method doesn’t exist in any parent class or implemented interface, PHP throws an error. Same concept as Java’s <code class="language-plaintext highlighter-rouge">@Override</code> or C#’s <code class="language-plaintext highlighter-rouge">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’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="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">trait</span> <span class="nc">Singleton</span> <span class="p">{</span>
    <span class="k">final</span> <span class="k">public</span> <span class="k">static</span> <span class="k">function</span> <span class="n">getInstance</span><span class="p">():</span> <span class="kt">static</span> <span class="p">{</span>
        <span class="c1">// ...</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Now a class using the trait cannot override <code class="language-plaintext highlighter-rouge">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="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$point</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">readonly</span> <span class="nf">class</span><span class="p">(</span><span class="mi">3</span><span class="p">,</span> <span class="mi">4</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">public</span> <span class="k">function</span> <span class="n">__construct</span><span class="p">(</span>
        <span class="k">public</span> <span class="kt">float</span> <span class="nv">$x</span><span class="p">,</span>
        <span class="k">public</span> <span class="kt">float</span> <span class="nv">$y</span><span class="p">,</span>
    <span class="p">)</span> <span class="p">{}</span>
<span class="p">};</span>
</code></pre></div></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="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">function</span> <span class="n">connection</span><span class="p">():</span> <span class="kt">PDO</span> <span class="p">{</span>
    <span class="k">static</span> <span class="nv">$pdo</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">PDO</span><span class="p">(</span><span class="nb">getenv</span><span class="p">(</span><span class="s1">'DATABASE_URL'</span><span class="p">));</span>
    <span class="k">return</span> <span class="nv">$pdo</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></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 class="language-plaintext highlighter-rouge">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="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$padded</span> <span class="o">=</span> <span class="nf">mb_str_pad</span><span class="p">(</span><span class="s1">'日本'</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="s1">'*'</span><span class="p">,</span> <span class="no">STR_PAD_BOTH</span><span class="p">);</span>
</code></pre></div></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’s <code class="language-plaintext highlighter-rouge">++</code> operator on strings has a history of quirks: it increments letter sequences (<code class="language-plaintext highlighter-rouge">'a'</code> → <code class="language-plaintext highlighter-rouge">'b'</code>, <code class="language-plaintext highlighter-rouge">'z'</code> → <code class="language-plaintext highlighter-rouge">'aa'</code>), but <code class="language-plaintext highlighter-rouge">--</code> never worked symmetrically. The behavior was surprising enough that 8.3 deprecates <code class="language-plaintext highlighter-rouge">++</code>/<code class="language-plaintext highlighter-rouge">--</code> on non-alphanumeric strings and introduces explicit functions:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">echo</span> <span class="nf">str_increment</span><span class="p">(</span><span class="s1">'a'</span><span class="p">);</span>  <span class="c1">// b</span>
<span class="k">echo</span> <span class="nf">str_increment</span><span class="p">(</span><span class="s1">'Az'</span><span class="p">);</span> <span class="c1">// Ba</span>
<span class="k">echo</span> <span class="nf">str_decrement</span><span class="p">(</span><span class="s1">'b'</span><span class="p">);</span>  <span class="c1">// a</span>
<span class="k">echo</span> <span class="nf">str_decrement</span><span class="p">(</span><span class="s1">'Ba'</span><span class="p">);</span> <span class="c1">// Az</span>
</code></pre></div></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="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$rng</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Random\Randomizer</span><span class="p">();</span>

<span class="c1">// A float in [0.0, 1.0)</span>
<span class="nv">$f</span> <span class="o">=</span> <span class="nv">$rng</span><span class="o">-&gt;</span><span class="nf">nextFloat</span><span class="p">();</span>

<span class="c1">// A float in a specific range with controlled boundary inclusion</span>
<span class="nv">$f</span> <span class="o">=</span> <span class="nv">$rng</span><span class="o">-&gt;</span><span class="nf">getFloat</span><span class="p">(</span><span class="mf">1.5</span><span class="p">,</span> <span class="mf">3.5</span><span class="p">,</span> <span class="nc">Random\IntervalBoundary</span><span class="o">::</span><span class="nc">ClosedOpen</span><span class="p">);</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">IntervalBoundary</code> is a new enum with four values: <code class="language-plaintext highlighter-rouge">ClosedOpen</code>, <code class="language-plaintext highlighter-rouge">ClosedClosed</code>, <code class="language-plaintext highlighter-rouge">OpenClosed</code>, <code class="language-plaintext highlighter-rouge">OpenOpen</code>. This matters for statistical correctness: the naive approach of <code class="language-plaintext highlighter-rouge">rand() / getrandmax()</code> doesn’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 “malformed string” from “invalid timezone” without parsing the message yourself. 8.3 adds a proper hierarchy:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">try</span> <span class="p">{</span>
    <span class="k">new</span> <span class="nc">DateTimeImmutable</span><span class="p">(</span><span class="s1">'not a date'</span><span class="p">);</span>
<span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nc">DateMalformedStringException</span> <span class="nv">$e</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// specifically a parsing failure</span>
<span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nc">DateException</span> <span class="nv">$e</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// other date-related errors</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The full tree: <code class="language-plaintext highlighter-rouge">DateError</code> (engine-level), <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">gc_status()</code> now returns eight additional fields: <code class="language-plaintext highlighter-rouge">running</code>, <code class="language-plaintext highlighter-rouge">protected</code>, <code class="language-plaintext highlighter-rouge">full</code>, <code class="language-plaintext highlighter-rouge">buffer_size</code>, and timing breakdowns (<code class="language-plaintext highlighter-rouge">application_time</code>, <code class="language-plaintext highlighter-rouge">collector_time</code>, <code class="language-plaintext highlighter-rouge">destructor_time</code>, <code class="language-plaintext highlighter-rouge">free_time</code>). If you’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 class="language-plaintext highlighter-rouge">strrchr()</code> (find the last occurrence of a character, return from there to end) now accepts a <code class="language-plaintext highlighter-rouge">$before_needle</code> boolean, matching the API of <code class="language-plaintext highlighter-rouge">strstr()</code>:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$path</span> <span class="o">=</span> <span class="s1">'/var/www/html/index.php'</span><span class="p">;</span>
<span class="k">echo</span> <span class="nb">strrchr</span><span class="p">(</span><span class="nv">$path</span><span class="p">,</span> <span class="s1">'/'</span><span class="p">,</span> <span class="n">before_needle</span><span class="o">:</span> <span class="kc">true</span><span class="p">);</span>  <span class="c1">// /var/www/html</span>
<span class="k">echo</span> <span class="nb">strrchr</span><span class="p">(</span><span class="nv">$path</span><span class="p">,</span> <span class="s1">'/'</span><span class="p">);</span>                        <span class="c1">// /index.php</span>
</code></pre></div></div>

<p>A function that’s been in PHP since 1994, finally consistent with its sibling.</p>

<h2 id="deprecations-worth-noting">Deprecations worth noting</h2>

<p><code class="language-plaintext highlighter-rouge">get_class()</code> and <code class="language-plaintext highlighter-rouge">get_parent_class()</code> without arguments now emit deprecation notices. The argumentless forms relied on implicit <code class="language-plaintext highlighter-rouge">$this</code> context, which was easy to misread. Pass the object explicitly.</p>

<p><code class="language-plaintext highlighter-rouge">assert_options()</code> and the <code class="language-plaintext highlighter-rouge">ASSERT_*</code> constants are deprecated in favor of the <code class="language-plaintext highlighter-rouge">zend.assertions</code> INI directive, which is the right tool for controlling assertion behavior across environments.</p>

<p>The <code class="language-plaintext highlighter-rouge">++</code>/<code class="language-plaintext highlighter-rouge">--</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 class="language-plaintext highlighter-rouge">zend.max_allowed_stack_size</code> sets a hard limit on PHP’s stack depth, and <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">Error</code> with a useful message.</p>]]></content><author><name>Guillaume Delré</name><email>delre.guillaume@gmail.com</email></author><category term="development" /><category term="php" /><category term="php83" /><category term="types" /><summary type="html"><![CDATA[PHP 8.3 adds typed class constants, a json_validate function, and a cleaner way to fetch class constants dynamically.]]></summary></entry><entry><title type="html">PHP 8.2: readonly classes and the deprecation that matters</title><link href="https://guillaumedelre.github.io/2023/01/22/php-8-2/" rel="alternate" type="text/html" title="PHP 8.2: readonly classes and the deprecation that matters" /><published>2023-01-22T00:00:00+00:00</published><updated>2023-01-22T00:00:00+00:00</updated><id>https://guillaumedelre.github.io/2023/01/22/php-8-2</id><content type="html" xml:base="https://guillaumedelre.github.io/2023/01/22/php-8-2/"><![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="no_entry_sign-dynamic-properties-deprecated">:no_entry_sign: Dynamic properties deprecated</h2>

<p>PHP has always allowed adding properties to objects that weren’t declared in the class:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">class</span> <span class="nc">User</span> <span class="p">{}</span>
<span class="nv">$user</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">User</span><span class="p">();</span>
<span class="nv">$user</span><span class="o">-&gt;</span><span class="n">name</span> <span class="o">=</span> <span class="s1">'Alice'</span><span class="p">;</span> <span class="c1">// no declaration, no error... until now</span>
</code></pre></div></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 class="language-plaintext highlighter-rouge">$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 class="language-plaintext highlighter-rouge">#[AllowDynamicProperties]</code> on legacy classes you can’t touch yet.</p>

<h2 id="pencil-readonly-classes">:pencil: Readonly classes</h2>

<p>8.1 added <code class="language-plaintext highlighter-rouge">readonly</code> for individual properties. 8.2 adds it to the class declaration itself:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">readonly</span> <span class="kd">class</span> <span class="nc">Point</span> <span class="p">{</span>
    <span class="k">public</span> <span class="k">function</span> <span class="n">__construct</span><span class="p">(</span>
        <span class="k">public</span> <span class="kt">float</span> <span class="nv">$x</span><span class="p">,</span>
        <span class="k">public</span> <span class="kt">float</span> <span class="nv">$y</span><span class="p">,</span>
        <span class="k">public</span> <span class="kt">float</span> <span class="nv">$z</span><span class="p">,</span>
    <span class="p">)</span> <span class="p">{}</span>
<span class="p">}</span>
</code></pre></div></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’t have non-typed properties, which were already a bad idea with readonly anyway.</p>

<h2 id="twisted_rightwards_arrows-dnf-types">:twisted_rightwards_arrows: DNF types</h2>

<p>Disjunctive Normal Form types let you combine union and intersection types:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">function</span> <span class="n">process</span><span class="p">(</span><span class="kt">Countable</span><span class="o">&amp;</span><span class="nc">Iterator</span><span class="o">|</span><span class="kc">null</span> <span class="nv">$collection</span><span class="p">):</span> <span class="kt">void</span> <span class="p">{</span> <span class="mf">...</span> <span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">(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’t represent together.</p>

<h2 id="game_die-the-random-extension">:game_die: The Random extension</h2>

<p>A dedicated <code class="language-plaintext highlighter-rouge">Random</code> extension replaces the scattered <code class="language-plaintext highlighter-rouge">rand()</code>, <code class="language-plaintext highlighter-rouge">mt_rand()</code>, <code class="language-plaintext highlighter-rouge">random_int()</code> functions with an object-oriented API:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$rng</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Random\Randomizer</span><span class="p">();</span>
<span class="nv">$rng</span><span class="o">-&gt;</span><span class="nf">getInt</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="mi">100</span><span class="p">);</span>
<span class="nv">$rng</span><span class="o">-&gt;</span><span class="nf">shuffleArray</span><span class="p">(</span><span class="nv">$items</span><span class="p">);</span>
</code></pre></div></div>

<p>Engines are swappable: <code class="language-plaintext highlighter-rouge">Mersenne Twister</code>, <code class="language-plaintext highlighter-rouge">PCG64</code>, <code class="language-plaintext highlighter-rouge">Xoshiro256StarStar</code>, or <code class="language-plaintext highlighter-rouge">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="label-null-false-and-true-as-standalone-types">:label: <code class="language-plaintext highlighter-rouge">null</code>, <code class="language-plaintext highlighter-rouge">false</code>, and <code class="language-plaintext highlighter-rouge">true</code> as standalone types</h2>

<p>PHP has had <code class="language-plaintext highlighter-rouge">nullable</code> types since 7.1 and union types since 8.0, but <code class="language-plaintext highlighter-rouge">null</code> as a standalone type declaration wasn’t valid. 8.2 fixes that:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">function</span> <span class="n">alwaysNull</span><span class="p">():</span> <span class="kt">null</span> <span class="p">{</span>
    <span class="k">return</span> <span class="kc">null</span><span class="p">;</span>
<span class="p">}</span>

<span class="k">function</span> <span class="n">disabled</span><span class="p">():</span> <span class="kt">false</span> <span class="p">{</span>
    <span class="k">return</span> <span class="kc">false</span><span class="p">;</span>
<span class="p">}</span>

<span class="k">function</span> <span class="n">enabled</span><span class="p">():</span> <span class="kt">true</span> <span class="p">{</span>
    <span class="k">return</span> <span class="kc">true</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">false</code> and <code class="language-plaintext highlighter-rouge">true</code> as standalone types are useful when you need to be precise about what a function can actually return. It’s narrow but correct: a function that returns <code class="language-plaintext highlighter-rouge">false</code> on failure and a string on success should declare <code class="language-plaintext highlighter-rouge">string|false</code>, and now both sides of that union are real types.</p>

<h2 id="package-constants-in-traits">:package: Constants in traits</h2>

<p>Traits could hold properties and methods. Constants were the odd gap. 8.2 closes it:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">trait</span> <span class="nc">Timestamps</span> <span class="p">{</span>
    <span class="k">public</span> <span class="k">const</span> <span class="no">DATE_FORMAT</span> <span class="o">=</span> <span class="s1">'Y-m-d H:i:s'</span><span class="p">;</span>

    <span class="k">public</span> <span class="k">function</span> <span class="n">formatCreatedAt</span><span class="p">():</span> <span class="kt">string</span> <span class="p">{</span>
        <span class="k">return</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">createdAt</span><span class="o">-&gt;</span><span class="nf">format</span><span class="p">(</span><span class="k">self</span><span class="o">::</span><span class="no">DATE_FORMAT</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="kd">class</span> <span class="nc">Article</span> <span class="p">{</span>
    <span class="kn">use</span> <span class="nc">Timestamps</span><span class="p">;</span>
<span class="p">}</span>

<span class="k">echo</span> <span class="nc">Article</span><span class="o">::</span><span class="no">DATE_FORMAT</span><span class="p">;</span> <span class="c1">// 'Y-m-d H:i:s'</span>
</code></pre></div></div>

<p>The constant belongs to the class that uses the trait, not the trait itself, so you can’t access <code class="language-plaintext highlighter-rouge">Timestamps::DATE_FORMAT</code> directly. Expected scoping behavior, consistent with how trait methods already work.</p>

<h2 id="zipper_mouth_face-sensitiveparameter">:zipper_mouth_face: <code class="language-plaintext highlighter-rouge">#[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="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">function</span> <span class="n">authenticate</span><span class="p">(</span>
    <span class="kt">string</span> <span class="nv">$user</span><span class="p">,</span>
    <span class="c1">#[\SensitiveParameter] string $password,</span>
<span class="p">):</span> <span class="kt">bool</span> <span class="p">{</span>
    <span class="c1">// if this throws, the stack trace shows:</span>
    <span class="c1">// authenticate('alice', Object(SensitiveParameterValue))</span>
    <span class="k">return</span> <span class="nb">hash</span><span class="p">(</span><span class="s1">'sha256'</span><span class="p">,</span> <span class="nv">$password</span><span class="p">)</span> <span class="o">===</span> <span class="nf">getStoredHash</span><span class="p">(</span><span class="nv">$user</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The parameter value in the trace gets replaced with a <code class="language-plaintext highlighter-rouge">SensitiveParameterValue</code> object. One attribute, zero excuses not to add it to every function that touches credentials.</p>

<h2 id="scissors-deprecated-string-interpolation-syntaxes">:scissors: Deprecated string interpolation syntaxes</h2>

<p>Two ways to interpolate expressions inside strings are deprecated in 8.2:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$name</span> <span class="o">=</span> <span class="s1">'world'</span><span class="p">;</span>

<span class="c1">// These are deprecated:</span>
<span class="k">echo</span> <span class="s2">"Hello ${name}"</span><span class="p">;</span>       <span class="c1">// use "$name" or "{$name}"</span>
<span class="k">echo</span> <span class="s2">"Hello ${getName()}"</span><span class="p">;</span>  <span class="c1">// use "{$this-&gt;getName()}"</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">${...}</code> forms created ambiguity between variable variables and expressions. The cleaner <code class="language-plaintext highlighter-rouge">{$...}</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="wastebasket-utf8_encode-and-utf8_decode-deprecated">:wastebasket: <code class="language-plaintext highlighter-rouge">utf8_encode()</code> and <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">utf8_encode()</code> converts ISO-8859-1 to UTF-8, not “any encoding to UTF-8.”</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Deprecated in 8.2:</span>
<span class="nv">$utf8</span> <span class="o">=</span> <span class="nb">utf8_encode</span><span class="p">(</span><span class="nv">$latin1String</span><span class="p">);</span>

<span class="c1">// Use instead:</span>
<span class="nv">$utf8</span> <span class="o">=</span> <span class="nb">mb_convert_encoding</span><span class="p">(</span><span class="nv">$latin1String</span><span class="p">,</span> <span class="s1">'UTF-8'</span><span class="p">,</span> <span class="s1">'ISO-8859-1'</span><span class="p">);</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">mb_convert_encoding()</code> or <code class="language-plaintext highlighter-rouge">iconv()</code> handle the general case. If you’re actually dealing with Latin-1 input, the replacement is a direct swap.</p>

<h2 id="globe_with_meridians-locale-independent-string-functions">:globe_with_meridians: 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’re locale-independent and ASCII-only:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// strtolower, strtoupper, stristr, stripos, strripos,</span>
<span class="c1">// lcfirst, ucfirst, ucwords, str_ireplace now do ASCII case conversion only.</span>
<span class="c1">// For locale-aware behavior, use mb_* equivalents:</span>
<span class="nv">$lowered</span> <span class="o">=</span> <span class="nb">mb_strtolower</span><span class="p">(</span><span class="nv">$text</span><span class="p">,</span> <span class="s1">'UTF-8'</span><span class="p">);</span>
</code></pre></div></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="arrows_counterclockwise-str_split-on-empty-string">:arrows_counterclockwise: <code class="language-plaintext highlighter-rouge">str_split()</code> on empty string</h2>

<p>A quiet behavior change worth noting:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// PHP 8.1: str_split('') === ['']</span>
<span class="c1">// PHP 8.2: str_split('') === []</span>
</code></pre></div></div>

<p>The new behavior makes more sense: splitting nothing produces nothing. If you’re checking <code class="language-plaintext highlighter-rouge">count(str_split($input))</code>, an empty input no longer produces a count of 1.</p>]]></content><author><name>Guillaume Delré</name><email>delre.guillaume@gmail.com</email></author><category term="development" /><category term="php" /><category term="php82" /><category term="readonly" /><category term="types" /><summary type="html"><![CDATA[PHP 8.2 introduces readonly classes, deprecates dynamic properties, and adds disjunctive normal form types.]]></summary></entry><entry><title type="html">From Vagrant to Docker Compose: a retrospective</title><link href="https://guillaumedelre.github.io/2022/04/18/from-vagrant-to-docker-compose-a-retrospective/" rel="alternate" type="text/html" title="From Vagrant to Docker Compose: a retrospective" /><published>2022-04-18T00:00:00+00:00</published><updated>2022-04-18T00:00:00+00:00</updated><id>https://guillaumedelre.github.io/2022/04/18/from-vagrant-to-docker-compose-a-retrospective</id><content type="html" xml:base="https://guillaumedelre.github.io/2022/04/18/from-vagrant-to-docker-compose-a-retrospective/"><![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="inbox_tray-the-vagrant-years">:inbox_tray: 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 class="language-plaintext highlighter-rouge">vagrant up</code> and you’re done.</p>

<p>In practice, it was <code class="language-plaintext highlighter-rouge">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 “run a web server.”</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="whale-the-project-that-changed-the-model">:whale: The project that changed the model</h2>

<p>The shift wasn’t a decision we made. It was a project that arrived already containerized.</p>

<p>A new client project had a <code class="language-plaintext highlighter-rouge">docker-compose.yml</code> at the root, a <code class="language-plaintext highlighter-rouge">Dockerfile</code>, and a README that said <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">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="toolbox-what-docker-compose-actually-changed">:toolbox: 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 class="language-plaintext highlighter-rouge">services</code> declaration also replaced the VM provisioning problem entirely. If a new developer joins, they don’t run a provision script that may or may not work on their OS version. They run <code class="language-plaintext highlighter-rouge">docker compose up</code> and get the exact same images everyone else runs.</p>

<p>CI/CD got simpler too. The same <code class="language-plaintext highlighter-rouge">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="ghost-the-quiet-deprecation">:ghost: The quiet deprecation</h2>

<p>For years, the command was <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">docker</code> CLI. The new command was <code class="language-plaintext highlighter-rouge">docker compose</code>, no hyphen, Go rewrite, bundled with Docker Desktop. The old <code class="language-plaintext highlighter-rouge">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 class="language-plaintext highlighter-rouge">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’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’t paying attention.</p>

<h2 id="bulb-the-actual-retrospective">:bulb: The actual retrospective</h2>

<p>Looking back across Vagrant → <code class="language-plaintext highlighter-rouge">docker-compose</code> → <code class="language-plaintext highlighter-rouge">docker compose</code>, the pattern is less about the tools and more about the defaults.</p>

<p>Vagrant defaulted to “it works on my VM.” The overhead of sharing that VM was permanent.</p>

<p>Compose defaulted to “it works in these containers.” The images are the artifacts; the host machine is irrelevant.</p>

<p>The hyphen between <code class="language-plaintext highlighter-rouge">docker</code> and <code class="language-plaintext highlighter-rouge">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><author><name>Guillaume Delré</name><email>delre.guillaume@gmail.com</email></author><category term="devops" /><category term="docker" /><category term="vagrant" /><category term="docker-compose" /><category term="devops" /><summary type="html"><![CDATA[Why we replaced Vagrant with Docker Compose: the real friction points, the migration path, and what we'd do differently.]]></summary></entry></feed>