{"id":3081,"date":"2026-01-17T13:32:22","date_gmt":"2026-01-17T11:32:22","guid":{"rendered":"https:\/\/nyamsprod.com\/blog\/?p=3081"},"modified":"2026-01-18T22:43:42","modified_gmt":"2026-01-18T20:43:42","slug":"dealing-with-a-php-bc-break","status":"publish","type":"post","link":"https:\/\/nyamsprod.com\/blog\/dealing-with-a-php-bc-break\/","title":{"rendered":"Dealing with a PHP BC break"},"content":{"rendered":"\n<h2 class=\"wp-block-heading\">Context<\/h2>\n\n\n\n<p>I&#8217;ve recently released version&nbsp;<code>7.8<\/code>&nbsp;of my&nbsp;<a href=\"https:\/\/uri.thephpleague.com\/\">PHP URI toolkit<\/a>.&nbsp;Three of the features introduced in this release were the result of a backward compatibility&nbsp;(BC)&nbsp;break in PHP that went unnoticed during the PHP 8.4 release cycle.<\/p>\n\n\n\n<p>For those with sharp eyes\u2014and who carefully read the full&nbsp;<a href=\"https:\/\/www.php.net\/ChangeLog-8.php#8.4.1\">PHP8.4,1 release CHANGELOG<\/a>\u2014the following line may&nbsp;have stood out:<\/p>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<ul class=\"wp-block-list\">\n<li>Add support for backed enums in http_build_query().<\/li>\n<\/ul>\n<\/blockquote>\n\n\n\n<p>At first glance,&nbsp;this change seems harmless.&nbsp;Unfortunately,&nbsp;its implications went largely unnoticed&nbsp;for several reasons.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"what-went-wrong\">What Went Wrong<\/h2>\n\n\n\n<p>First,&nbsp;the new behavior was only properly documented almost a year after it had landed in the PHP&nbsp;codebase.&nbsp;Second,&nbsp;the discussion around the change was confined to the pull request that introduced it.&nbsp;Third\u2014and most importantly\u2014the change never reached the PHP Internals mailing list.&nbsp;As a result,&nbsp;its&nbsp;implementation and potential consequences were never openly discussed.<\/p>\n\n\n\n<p>This lack of visibility matters.<\/p>\n\n\n\n<p>To be clear,&nbsp;I fully understand&nbsp;<em>why<\/em>&nbsp;support for backed enums in&nbsp;<code>http_build_query()<\/code>&nbsp;was added.&nbsp;The feature itself makes sense.&nbsp;However,&nbsp;because there was no broader discussion prior to its&nbsp;implementation,&nbsp;developers with deep experience and historical context around&nbsp;<code>http_build_query()<\/code>&nbsp;were never given the opportunity to provide feedback,&nbsp;raise concerns,&nbsp;or suggest alternative&nbsp;approaches.<\/p>\n\n\n\n<p>The outcome was a subtle\u2014but significant\u2014BC break,&nbsp;one that only surfaced later in codebase or&nbsp;libraries like mine that still depend on&nbsp;<code>http_build_query()<\/code>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"the-breaking-change\">The Breaking Change<\/h2>\n\n\n\n<p>To illustrate the backward compatibility break,&nbsp;consider the following enums:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: php; title: ; notranslate\" title=\"\">\nenum Feature: string\n{\n    case Enabled = &#039;enabled&#039;;\n    case Disabled = &#039;disabled&#039;;\n}\n\nenum Coercion\n{\n    case On;\n    case Off;\n}\n\n$data = (object) &#x5B;\n    &#039;feature&#039; =&gt; Feature::Enabled,\n    &#039;coercion&#039; =&gt; Coercion::Off,\n];\n<\/pre><\/div>\n\n\n<p>When passing this&nbsp;<code>$data<\/code>&nbsp;object to&nbsp;<code>http_build_query()<\/code>,&nbsp;the behavior differs between&nbsp;PHP versions prior to 8.4 and PHP 8.4+.<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: php; title: ; notranslate\" title=\"\">\necho http_build_query($data);\n\/\/ in PHP8.4- returns feature%5Bname%5D=Enabled&amp;feature%5Bvalue%5D=enabled&amp;coercion%5Bname%5D=Off\n\/\/ in PHP8.4- decoded feature&#x5B;name]=Enabled&amp;feature&#x5B;value]=enabled&amp;coercion&#x5B;name]=Off\n\/\/ in PHP8.4+ a ValueError is thrown\n<\/pre><\/div>\n\n\n<p>This change is particularly surprising because the previous behavior\u2014while arguably imperfect\u2014was&nbsp;stable and widely relied upon.&nbsp;Code that had worked consistently across multiple PHP versions can&nbsp;now fail at runtime,&nbsp;even when dealing with simple,&nbsp;well-formed enum values.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"the-explanation\">The Explanation<\/h2>\n\n\n\n<p>The&nbsp;<code>http_build_query()<\/code>&nbsp;function was introduced in PHP 5.&nbsp;It accepts an array or an object as input&nbsp;and serializes the provided data into a query string.&nbsp;When an object is passed,&nbsp;the function first&nbsp;converts it into an associative array by calling&nbsp;<code>get_object_vars()<\/code>&nbsp;before performing the&nbsp;serialization.<\/p>\n\n\n\n<p>This conversion respects property visibility,&nbsp;which means that serialization results may be surprising&nbsp;when objects such as&nbsp;<code>Traversable<\/code>&nbsp;or&nbsp;<code>DateTimeInterface<\/code>&nbsp;instances are provided.&nbsp;The output depends&nbsp;on the object\u2019s internal structure and\u2014particularly for internal classes\u2014may vary across PHP versions&nbsp;as implementations evolve.<\/p>\n\n\n\n<p>Until PHP 8.4,&nbsp;enums were no exception to this rule.&nbsp;Since PHP enums are specialized objects,&nbsp;<code>http_build_query()<\/code>&nbsp;treated them like any other object and serialized them using&nbsp;<code>get_object_vars()<\/code>.&nbsp;However,&nbsp;starting with PHP 8.4,&nbsp;this behavior changed:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Backed enums<\/strong>&nbsp;are now serialized using their backed value, in a way similar to&nbsp;<code>json_encode<\/code><\/li>\n\n\n\n<li><strong>Non-backed enums<\/strong>&nbsp;triggers a&nbsp;<code>ValueError<\/code>&nbsp;when encountered as a value inside the input array or object.<\/li>\n\n\n\n<li><strong>Non-backed enums<\/strong>&nbsp;triggers a&nbsp;<code>TypeError<\/code>&nbsp;when passed directly as the function&#8217;s input.<\/li>\n<\/ul>\n\n\n\n<p>This already represents a significant behavioral shift,&nbsp;but there is more.<\/p>\n\n\n\n<p>Prior to these changes,&nbsp;<code>http_build_query()<\/code>&nbsp;never threw exceptions.&nbsp;When it encountered a value it&nbsp;could not serialize,&nbsp;it would simply ignore it and silently skip the corresponding array entry or&nbsp;object property.<\/p>\n\n\n\n<p>For example,&nbsp;if we replace the&nbsp;<code>Coercion<\/code>&nbsp;enum from the previous example with a PHP resource\u2014which&nbsp;cannot be serialized\u2014the function quietly drops it:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: php; title: ; notranslate\" title=\"\">\n$data = (object) &#x5B;\n    &#039;feature&#039; =&gt; Feature::Enabled,\n    &#039;coercion&#039; =&gt; tmpfile(),\n];\n\necho http_build_query($data);\n\/\/ in PHP8.4- returns &quot;feature%5Bname%5D=Enabled&amp;feature%5Bvalue%5D=enabled&quot;\n\/\/ in PHP8.4+ returns &quot;feature=enabled&quot;\n<\/pre><\/div>\n\n\n<p>This highlights the full extent of the change.&nbsp;Not only has&nbsp;<code>http_build_query()<\/code>&nbsp;adopted a new&nbsp;serialization strategy for enums,&nbsp;it has also introduced inconsistent error-handling semantics.&nbsp;Depending on the type of invalid data encountered,&nbsp;the function may now either throw an exception&nbsp;or silently ignore the offending value.<\/p>\n\n\n\n<p>That combination\u2014changed output and changed failure modes\u2014constitutes a subtle but impactful backward&nbsp;compatibility break.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"the-mitigation\">The Mitigation<\/h2>\n\n\n\n<p>To allow for a controlled migration\u2014and to reduce the impact of this BC break\u2014I took three steps in&nbsp;my URI packages:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Implemented a userland equivalent to&nbsp;<code>http_build_query<\/code>&nbsp;named&nbsp;<code>QueryString::compose()<\/code>.<\/li>\n\n\n\n<li>Introduced a new&nbsp;<code>QueryComposeMode<\/code>&nbsp;enum to explicitly model all supported serialization behaviors.<\/li>\n\n\n\n<li>Added first-class support for&nbsp;<code>BackedEnum<\/code>&nbsp;across the entire URI toolkit.<\/li>\n<\/ul>\n\n\n\n<p>As a result,&nbsp;the latest version of the packages give you explicit control over how query strings are&nbsp;composed.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"preserve-prephp-84-behavior-in-newer-php-versions\">Preserve pre\u2013PHP 8.4 behavior in newer PHP versions<\/h3>\n\n\n\n<p>You can opt in to the legacy behavior using&nbsp;<code>QueryComposeMode::Compatible<\/code>:<\/p>\n\n\n\n<p>This mode mirrors the historical behavior of&nbsp;<code>http_build_query()<\/code>&nbsp;prior to PHP 8.4 and is&nbsp;useful when maintaining backward compatibility across PHP versions.<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: php; title: ; notranslate\" title=\"\">\nuse League\\Uri\\QueryComposeMode;\nuse League\\Uri\\QueryString;\n\necho QueryString::compose($data, composeMode: QueryComposeMode::Compatible);\n\/\/ returns feature%5Bname%5D=Enabled&amp;feature%5Bvalue%5D=enabled&amp;coercion%5Bname%5D=Off\n\/\/ decoded feature&#x5B;name]=Enabled&amp;feature&#x5B;value]=enabled&amp;coercion&#x5B;name]=Off\n<\/pre><\/div>\n\n\n<h3 class=\"wp-block-heading\" id=\"opt-in-to-enum-aware-behavior-on-older-php-versions\">Opt in to enum-aware behavior on older PHP versions<\/h3>\n\n\n\n<p>You can also enable the new PHP 8.4 enum semantics explicitly using&nbsp;<code>QueryComposeMode::EnumCompatible<\/code>:<\/p>\n\n\n\n<p>This mode enforces strict enum handling and fails fast when non-backed enums are encountered.<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: php; title: ; notranslate\" title=\"\">\necho QueryString::compose($data, composeMode: QueryComposeMode::EnumCompatible);\n\/\/ throw an Errror if a non BackedEnum is present\necho QueryString::compose(\n    &#x5B;&#039;feature&#039; =&gt; Feature::Enabled],\n    composeMode: QueryComposeMode::EnumCompatible\n);\n\/\/ returns feature=enabled\n\/\/ BackedEnum are serialized using their backed value\n<\/pre><\/div>\n\n\n<h3 class=\"wp-block-heading\" id=\"use-enum-aware-behavior-without-exceptions\">Use enum-aware behavior without exceptions<\/h3>\n\n\n\n<p>If you prefer the new serialization semantics without introducing exceptions,&nbsp;use&nbsp;<code>QueryComposeMode::EnumLenient<\/code>.&nbsp;In this mode,&nbsp;non-backed enums are silently&nbsp;ignored\u2014just like resources:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: php; title: ; notranslate\" title=\"\">\necho QueryString::compose($data, composeMode: QueryComposeMode::EnumLenient);\n\/\/ returns feature=enabled\n\/\/ non BackedEnum are ignore like resources\n<\/pre><\/div>\n\n\n<h3 class=\"wp-block-heading\" id=\"mirror-native-php-behavior\">Mirror native PHP behavior<\/h3>\n\n\n\n<p>Finally,&nbsp;for completeness,&nbsp;<code>QueryComposeMode::Native<\/code>&nbsp;directly mirrors&nbsp;<code>http_build_query()<\/code>&nbsp;and&nbsp;therefore inherits its PHP-version\u2013dependent behavior:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: php; title: ; notranslate\" title=\"\">\necho QueryString::compose(\n    &#x5B;&#039;feature&#039; =&gt; Feature::Enabled],\n    composeMode: QueryComposeMode::Native\n);\n\/\/ http_build_query behavior (PHP version dependent)\n<\/pre><\/div>\n\n\n<h2 class=\"wp-block-heading\" id=\"the-evolution\">The Evolution<\/h2>\n\n\n\n<p>One might argue that all of this is useful for migrating to PHP 8.4+,&nbsp;but once a codebase has been&nbsp;fully patched,&nbsp;what remains in it for developers?<\/p>\n\n\n\n<p>Looking at the amount of complexity required to account for this change,&nbsp;it becomes clear that the&nbsp;rules governing&nbsp;<code>http_build_query()<\/code>&nbsp;are already complex\u2014and are now capable of contradicting&nbsp;themselves.&nbsp;Continuously adapting&nbsp;<code>http_build_query()<\/code>&nbsp;to new edge cases seems,&nbsp;to me,&nbsp;like a poor&nbsp;long-term strategy.<\/p>\n\n\n\n<p>The function should likely be left untouched.&nbsp;That does not mean,&nbsp;however,&nbsp;that we should continue&nbsp;to rely on it.<\/p>\n\n\n\n<p>What we need instead is a new,&nbsp;simpler,&nbsp;and more predictable algorithm.<\/p>\n\n\n\n<p>This is why I introduced an additional mode,&nbsp;named&nbsp;<code>QueryComposeMode::Safe<\/code>.&nbsp;This mode is entirely&nbsp;independent of&nbsp;<code>http_build_query()<\/code>.&nbsp;Its goal is not backward compatibility,&nbsp;but clarity,&nbsp;predictability,&nbsp;and correctness.&nbsp;In&nbsp;<code>QueryComposeMode::Safe<\/code>&nbsp;mode,&nbsp;the following rules apply:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>The only values that can be serialized are:&nbsp;<code>BackedEnum<\/code>,&nbsp;<code>string<\/code>,&nbsp;<code>int<\/code>,&nbsp;<code>float<\/code>,&nbsp;<code>boolean<\/code>&nbsp;and&nbsp;<code>null<\/code>.<\/li>\n\n\n\n<li>Only&nbsp;<code>arrays<\/code>&nbsp;may be used for composition.<\/li>\n\n\n\n<li>Any unsupported type triggers a&nbsp;<code>TypeError<\/code>.<\/li>\n\n\n\n<li>When an array is used as a list, its numeric indexes&nbsp;<strong>do not appear<\/strong>&nbsp;in the query string.<\/li>\n\n\n\n<li>When a value is&nbsp;<code>null<\/code>&nbsp;the key is present but no&nbsp;<code>=<\/code>&nbsp;is appended<\/li>\n<\/ul>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: php; title: ; notranslate\" title=\"\">\n$data = &#x5B;\n    &#039;foo&#039; =&gt; &#x5B;\n        Feature::Enabled,\n        null,\n        true\n    ],\n];\n\necho QueryString::compose($data, composeMode: QueryComposeMode::Safe);\n\/\/ returns foo%5B%5D=enabled&amp;foo%5B%5D&amp;foo%5B%5D=1\n\/\/ decoded foo&#x5B;]=enabled&amp;foo&#x5B;]&amp;foo&#x5B;]=1\n<\/pre><\/div>\n\n\n<p>This mode preserves data,&nbsp;handles value lists correctly,&nbsp;and gives&nbsp;<code>null<\/code>&nbsp;a well-defined and&nbsp;consistent representation.<\/p>\n\n\n\n<p>These rules were not chosen arbitrarily.&nbsp;They are directly inspired by the behavior currently proposed&nbsp;in the RFC introducing new QueryParams classes as part of the&nbsp;<code>ext\/uri<\/code>&nbsp;extension.<\/p>\n\n\n\n<p>If you are interested in this direction\u2014or if you have opinions,&nbsp;concerns,&nbsp;or improvements to suggest\u2014I&nbsp;strongly encourage you to engage in the discussion on the PHP Internals mailing list,&nbsp;where the&nbsp;<a href=\"https:\/\/wiki.php.net\/rfc\/uri_followup\">PHP RFC: Followup Improvements for ext\/uri<\/a>&nbsp;is being discussed.<\/p>\n\n\n\n<p>These&nbsp;\u201cboring\u201d&nbsp;changes will shape how PHP parses and encodes URL query strings for the foreseeable future.&nbsp;If we want a cleaner,&nbsp;saner,&nbsp;and more predictable API,&nbsp;now is the time to make our voices heard.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"post-mortem\">Post-Mortem<\/h2>\n\n\n\n<p>Backward compatibility breaks happen in every language and library\u2014sometimes by accident,&nbsp;sometimes by&nbsp;design.&nbsp;Those breaks are acceptable,&nbsp;as long as users are aware of them and understand their intent.<\/p>\n\n\n\n<p>PHP has historically done a good job at avoiding accidental BC breaks,&nbsp;which makes the rare ones all&nbsp;the more disruptive.&nbsp;When they occur,&nbsp;they often surface in unexpected places and create real&nbsp;challenges for users and library authors alike\u2014especially when long-standing,&nbsp;foundational functions&nbsp;are involved.&nbsp;This is why changes to legacy PHP APIs require extra care.&nbsp;The evolution of&nbsp;<code>http_build_query()<\/code>&nbsp;highlights this tension well.&nbsp;While the change itself is understandable,&nbsp;its impact shows how fragile the current API is.<\/p>\n\n\n\n<p>Ultimately,&nbsp;this is an opportunity.&nbsp;With RFC like&nbsp;<a href=\"https:\/\/wiki.php.net\/rfc\/uri_followup\">PHP RFC: Followup Improvements for ext\/uri<\/a>&nbsp;being discussed&nbsp;we have the chance to all collectively shape clearer,&nbsp;more predictable foundations for PHP\u2019s future URI API.<\/p>\n\n\n\n<p><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Context I&#8217;ve recently released version&nbsp;7.8&nbsp;of my&nbsp;PHP URI toolkit.&nbsp;Three of the features introduced in this release were the result of a backward compatibility&nbsp;(BC)&nbsp;break in PHP that went unnoticed during the PHP 8.4 release cycle. For those with sharp eyes\u2014and who carefully read the full&nbsp;PHP8.4,1 release CHANGELOG\u2014the following line may&nbsp;have stood out: At first glance,&nbsp;this change seems [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"_monsterinsights_skip_tracking":false,"_monsterinsights_sitenote_active":false,"_monsterinsights_sitenote_note":"","_monsterinsights_sitenote_category":0,"footnotes":""},"categories":[5],"tags":[877,878,874,412,875,876,794],"class_list":["post-3081","post","type-post","status-publish","format-standard","hentry","category-web","tag-backward-compatibility-break","tag-bc-break","tag-http_build_query","tag-php","tag-query-string-2","tag-rfc","tag-uri"],"aioseo_notices":[],"_links":{"self":[{"href":"https:\/\/nyamsprod.com\/blog\/wp-json\/wp\/v2\/posts\/3081","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/nyamsprod.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/nyamsprod.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/nyamsprod.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/nyamsprod.com\/blog\/wp-json\/wp\/v2\/comments?post=3081"}],"version-history":[{"count":5,"href":"https:\/\/nyamsprod.com\/blog\/wp-json\/wp\/v2\/posts\/3081\/revisions"}],"predecessor-version":[{"id":3090,"href":"https:\/\/nyamsprod.com\/blog\/wp-json\/wp\/v2\/posts\/3081\/revisions\/3090"}],"wp:attachment":[{"href":"https:\/\/nyamsprod.com\/blog\/wp-json\/wp\/v2\/media?parent=3081"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/nyamsprod.com\/blog\/wp-json\/wp\/v2\/categories?post=3081"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/nyamsprod.com\/blog\/wp-json\/wp\/v2\/tags?post=3081"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}