Dealing with a PHP BC break

Context

I’ve recently released version 7.8 of my PHP URI toolkit. Three of the features introduced in this release were the result of a backward compatibility (BC) break in PHP that went unnoticed during the PHP 8.4 release cycle.

For those with sharp eyes—and who carefully read the full PHP8.4,1 release CHANGELOG—the following line may have stood out:

  • Add support for backed enums in http_build_query().

At first glance, this change seems harmless. Unfortunately, its implications went largely unnoticed for several reasons.

What Went Wrong

First, the new behavior was only properly documented almost a year after it had landed in the PHP codebase. Second, the discussion around the change was confined to the pull request that introduced it. Third—and most importantly—the change never reached the PHP Internals mailing list. As a result, its implementation and potential consequences were never openly discussed.

This lack of visibility matters.

To be clear, I fully understand why support for backed enums in http_build_query() was added. The feature itself makes sense. However, because there was no broader discussion prior to its implementation, developers with deep experience and historical context around http_build_query() were never given the opportunity to provide feedback, raise concerns, or suggest alternative approaches.

The outcome was a subtle—but significant—BC break, one that only surfaced later in codebase or libraries like mine that still depend on http_build_query().

The Breaking Change

To illustrate the backward compatibility break, consider the following enums:

enum Feature: string
{
    case Enabled = 'enabled';
    case Disabled = 'disabled';
}

enum Coercion
{
    case On;
    case Off;
}

$data = (object) [
    'feature' => Feature::Enabled,
    'coercion' => Coercion::Off,
];

When passing this $data object to http_build_query(), the behavior differs between PHP versions prior to 8.4 and PHP 8.4+.

echo http_build_query($data);
// in PHP8.4- returns feature%5Bname%5D=Enabled&feature%5Bvalue%5D=enabled&coercion%5Bname%5D=Off
// in PHP8.4- decoded feature[name]=Enabled&feature[value]=enabled&coercion[name]=Off
// in PHP8.4+ a ValueError is thrown

This change is particularly surprising because the previous behavior—while arguably imperfect—was stable and widely relied upon. Code that had worked consistently across multiple PHP versions can now fail at runtime, even when dealing with simple, well-formed enum values.

The Explanation

The http_build_query() function was introduced in PHP 5. It accepts an array or an object as input and serializes the provided data into a query string. When an object is passed, the function first converts it into an associative array by calling get_object_vars() before performing the serialization.

This conversion respects property visibility, which means that serialization results may be surprising when objects such as Traversable or DateTimeInterface instances are provided. The output depends on the object’s internal structure and—particularly for internal classes—may vary across PHP versions as implementations evolve.

Until PHP 8.4, enums were no exception to this rule. Since PHP enums are specialized objects, http_build_query() treated them like any other object and serialized them using get_object_vars(). However, starting with PHP 8.4, this behavior changed:

  • Backed enums are now serialized using their backed value, in a way similar to json_encode
  • Non-backed enums triggers a ValueError when encountered as a value inside the input array or object.
  • Non-backed enums triggers a TypeError when passed directly as the function’s input.

This already represents a significant behavioral shift, but there is more.

Prior to these changes, http_build_query() never threw exceptions. When it encountered a value it could not serialize, it would simply ignore it and silently skip the corresponding array entry or object property.

For example, if we replace the Coercion enum from the previous example with a PHP resource—which cannot be serialized—the function quietly drops it:

$data = (object) [
    'feature' => Feature::Enabled,
    'coercion' => tmpfile(),
];

echo http_build_query($data);
// in PHP8.4- returns "feature%5Bname%5D=Enabled&feature%5Bvalue%5D=enabled"
// in PHP8.4+ returns "feature=enabled"

This highlights the full extent of the change. Not only has http_build_query() adopted a new serialization strategy for enums, it has also introduced inconsistent error-handling semantics. Depending on the type of invalid data encountered, the function may now either throw an exception or silently ignore the offending value.

That combination—changed output and changed failure modes—constitutes a subtle but impactful backward compatibility break.

The Mitigation

To allow for a controlled migration—and to reduce the impact of this BC break—I took three steps in my URI packages:

  • Implemented a userland equivalent to http_build_query named QueryString::compose().
  • Introduced a new QueryComposeMode enum to explicitly model all supported serialization behaviors.
  • Added first-class support for BackedEnum across the entire URI toolkit.

As a result, the latest version of the packages give you explicit control over how query strings are composed.

Preserve pre–PHP 8.4 behavior in newer PHP versions

You can opt in to the legacy behavior using QueryComposeMode::Compatible:

This mode mirrors the historical behavior of http_build_query() prior to PHP 8.4 and is useful when maintaining backward compatibility across PHP versions.

use League\Uri\QueryComposeMode;
use League\Uri\QueryString;

echo QueryString::compose($data, composeMode: QueryComposeMode::Compatible);
// returns feature%5Bname%5D=Enabled&feature%5Bvalue%5D=enabled&coercion%5Bname%5D=Off
// decoded feature[name]=Enabled&feature[value]=enabled&coercion[name]=Off

Opt in to enum-aware behavior on older PHP versions

You can also enable the new PHP 8.4 enum semantics explicitly using QueryComposeMode::EnumCompatible:

This mode enforces strict enum handling and fails fast when non-backed enums are encountered.

echo QueryString::compose($data, composeMode: QueryComposeMode::EnumCompatible);
// throw an Errror if a non BackedEnum is present
echo QueryString::compose(
    ['feature' => Feature::Enabled],
    composeMode: QueryComposeMode::EnumCompatible
);
// returns feature=enabled
// BackedEnum are serialized using their backed value

Use enum-aware behavior without exceptions

If you prefer the new serialization semantics without introducing exceptions, use QueryComposeMode::EnumLenient. In this mode, non-backed enums are silently ignored—just like resources:

echo QueryString::compose($data, composeMode: QueryComposeMode::EnumLenient);
// returns feature=enabled
// non BackedEnum are ignore like resources

Mirror native PHP behavior

Finally, for completeness, QueryComposeMode::Native directly mirrors http_build_query() and therefore inherits its PHP-version–dependent behavior:

echo QueryString::compose(
    ['feature' => Feature::Enabled],
    composeMode: QueryComposeMode::Native
);
// http_build_query behavior (PHP version dependent)

The Evolution

One might argue that all of this is useful for migrating to PHP 8.4+, but once a codebase has been fully patched, what remains in it for developers?

Looking at the amount of complexity required to account for this change, it becomes clear that the rules governing http_build_query() are already complex—and are now capable of contradicting themselves. Continuously adapting http_build_query() to new edge cases seems, to me, like a poor long-term strategy.

The function should likely be left untouched. That does not mean, however, that we should continue to rely on it.

What we need instead is a new, simpler, and more predictable algorithm.

This is why I introduced an additional mode, named QueryComposeMode::Safe. This mode is entirely independent of http_build_query(). Its goal is not backward compatibility, but clarity, predictability, and correctness. In QueryComposeMode::Safe mode, the following rules apply:

  • The only values that can be serialized are: BackedEnumstringintfloatboolean and null.
  • Only arrays may be used for composition.
  • Any unsupported type triggers a TypeError.
  • When an array is used as a list, its numeric indexes do not appear in the query string.
  • When a value is null the key is present but no = is appended
$data = [
    'foo' => [
        Feature::Enabled,
        null,
        true
    ],
];

echo QueryString::compose(, composeMode: QueryComposeMode::Safe);
// returns foo%5B%5D=enabled&foo%5B%5D&foo%5B%5D=1
// decoded 'foo[]=enabled&foo[]&foo[]=1'

This mode preserves data, handles value lists correctly, and gives null a well-defined and consistent representation.

These rules were not chosen arbitrarily. They are directly inspired by the behavior currently proposed in the RFC introducing new QueryParams classes as part of the ext/uri extension.

If you are interested in this direction—or if you have opinions, concerns, or improvements to suggest—I strongly encourage you to engage in the discussion on the PHP Internals mailing list, where the PHP RFC: Followup Improvements for ext/uri is being discussed.

These “boring” changes will shape how PHP parses and encodes URL query strings for the foreseeable future. If we want a cleaner, saner, and more predictable API, now is the time to make our voices heard.

Post-Mortem

Backward compatibility breaks happen in every language and library—sometimes by accident, sometimes by design. Those breaks are acceptable, as long as users are aware of them and understand their intent.

PHP has historically done a good job at avoiding accidental BC breaks, which makes the rare ones all the more disruptive. When they occur, they often surface in unexpected places and create real challenges for users and library authors alike—especially when long-standing, foundational functions are involved. This is why changes to legacy PHP APIs require extra care. The evolution of http_build_query() highlights this tension well. While the change itself is understandable, its impact shows how fragile the current API is.

Ultimately, this is an opportunity. With RFC like PHP RFC: Followup Improvements for ext/uri being discussed we have the chance to all collectively shape clearer, more predictable foundations for PHP’s future URI API.