Commit 100e8f353ea for php.net

commit 100e8f353ea8ef841339ba4c74a0966a7eea5157
Author: Máté Kocsis <kocsismate@woohoolabs.com>
Date:   Tue Sep 8 21:33:21 2026 +0200

    ext/uri: Preserve empty query and fragment in URL builders

    Keep empty components distinct from null so serialization retains the trailing question mark or hashmark.

diff --git a/ext/uri/tests/whatwg/builder/fragment_success_empty_string.phpt b/ext/uri/tests/whatwg/builder/fragment_success_empty_string.phpt
new file mode 100644
index 00000000000..2fe13ffd702
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/fragment_success_empty_string.phpt
@@ -0,0 +1,38 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setFragment() - success - empty string
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("https");
+$builder->setHost("example.com");
+$builder->setFragment("foo");
+$builder->setFragment("");
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(21) "https://example.com/#"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(5) "https"
+  ["username"]=>
+  NULL
+  ["password"]=>
+  NULL
+  ["host"]=>
+  string(11) "example.com"
+  ["port"]=>
+  NULL
+  ["path"]=>
+  string(1) "/"
+  ["query"]=>
+  NULL
+  ["fragment"]=>
+  string(0) ""
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/query_success_empty_string.phpt b/ext/uri/tests/whatwg/builder/query_success_empty_string.phpt
new file mode 100644
index 00000000000..01cdb62a11c
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/query_success_empty_string.phpt
@@ -0,0 +1,38 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setQuery() - success - empty string
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("https");
+$builder->setHost("example.com");
+$builder->setQuery("foo");
+$builder->setQuery("");
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(21) "https://example.com/?"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(5) "https"
+  ["username"]=>
+  NULL
+  ["password"]=>
+  NULL
+  ["host"]=>
+  string(11) "example.com"
+  ["port"]=>
+  NULL
+  ["path"]=>
+  string(1) "/"
+  ["query"]=>
+  string(0) ""
+  ["fragment"]=>
+  NULL
+}
+bool(true)
diff --git a/ext/uri/uri_parser_whatwg.c b/ext/uri/uri_parser_whatwg.c
index b1bd2bcd865..0e5d9e8a66f 100644
--- a/ext/uri/uri_parser_whatwg.c
+++ b/ext/uri/uri_parser_whatwg.c
@@ -1068,13 +1068,27 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser_wh
 		goto failure;
 	}

-	result = php_uri_parser_whatwg_query_write(lexbor_url, query, NULL);
+	if (Z_TYPE_P(query) == IS_STRING && Z_STRLEN_P(query) == 0) {
+		/* The URL API setter treats an empty string as removal. The builder
+		 * distinguishes an empty component from an absent one. */
+		lexbor_str_destroy(&lexbor_url->query, lexbor_url->mraw, false);
+		lexbor_str_init(&lexbor_url->query, lexbor_url->mraw, 1);
+	} else {
+		result = php_uri_parser_whatwg_query_write(lexbor_url, query, NULL);
+	}
 	php_uri_parser_whatwg_build_errors(&errors);
 	if (result == FAILURE) {
 		goto failure;
 	}

-	result = php_uri_parser_whatwg_fragment_write(lexbor_url, fragment, NULL);
+	if (Z_TYPE_P(fragment) == IS_STRING && Z_STRLEN_P(fragment) == 0) {
+		/* The URL API setter treats an empty string as removal. The builder
+		 * distinguishes an empty component from an absent one. */
+		lexbor_str_destroy(&lexbor_url->fragment, lexbor_url->mraw, false);
+		lexbor_str_init(&lexbor_url->fragment, lexbor_url->mraw, 1);
+	} else {
+		result = php_uri_parser_whatwg_fragment_write(lexbor_url, fragment, NULL);
+	}
 	php_uri_parser_whatwg_build_errors(&errors);
 	if (result == FAILURE) {
 		goto failure;