Commit c2f686a21d9 for php.net

commit c2f686a21d98c3aab08e020bda7198ad76da7679
Author: Máté Kocsis <kocsismate@woohoolabs.com>
Date:   Sun Sep 20 12:31:00 2026 +0200

    Implement "Followup improvements for ext/uri" RFC - URL building with base URL (#23526)

    Add support for passing a non-null $baseUrl parameter for Uri\WhatWg\UrlBuilder::build().

    RFC: https://wiki.php.net/rfc/uri_followup#uri_building

diff --git a/ext/uri/php_uri.c b/ext/uri/php_uri.c
index aab94e94618..bdf1201e425 100644
--- a/ext/uri/php_uri.c
+++ b/ext/uri/php_uri.c
@@ -1425,8 +1425,6 @@ PHP_METHOD(Uri_WhatWg_UrlBuilder, build)

 	lxb_url_t *base_url = NULL;
 	if (base_url_zv != NULL) {
-		zend_argument_error(NULL, 1, "is not supported yet, and therefore, null must be passed");
-		RETURN_THROWS();
 		base_url = Z_URI_OBJECT_P(base_url_zv)->uri;
 	}

diff --git a/ext/uri/tests/whatwg/builder/authority_success_with_base.phpt b/ext/uri/tests/whatwg/builder/authority_success_with_base.phpt
new file mode 100644
index 00000000000..ad8bc581e11
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/authority_success_with_base.phpt
@@ -0,0 +1,21 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::build() - success - replaces the base authority with encoded credentials
+--FILE--
+<?php
+
+$base = new Uri\WhatWg\Url("https://old:secret@example.com:81/a?old#old");
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setHost("example.net");
+$builder->setUsername("a@b");
+$builder->setPassword("c:d");
+$url = $builder->build($base);
+
+var_dump($url->toAsciiString());
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+var_dump($base->toAsciiString());
+
+?>
+--EXPECT--
+string(32) "https://a%40b:c%3Ad@example.net/"
+bool(true)
+string(43) "https://old:secret@example.com:81/a?old#old"
diff --git a/ext/uri/tests/whatwg/builder/basic_error_empty_reference_with_opaque_base.phpt b/ext/uri/tests/whatwg/builder/basic_error_empty_reference_with_opaque_base.phpt
new file mode 100644
index 00000000000..d3fa225a61b
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/basic_error_empty_reference_with_opaque_base.phpt
@@ -0,0 +1,19 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::build() - error - empty reference with an opaque base
+--FILE--
+<?php
+
+$base = new Uri\WhatWg\Url("foo:opaque?old#old");
+$builder = new Uri\WhatWg\UrlBuilder();
+
+try {
+    $builder->build($base);
+} catch (Throwable $e) {
+    echo $e::class, ': ', $e->getMessage(), "\n";
+    var_dump($e->errors[0]->type);
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified path is malformed (MissingSchemeNonRelativeUrl)
+enum(Uri\WhatWg\UrlValidationErrorType::MissingSchemeNonRelativeUrl)
diff --git a/ext/uri/tests/whatwg/builder/basic_error_with_opaque_base.phpt b/ext/uri/tests/whatwg/builder/basic_error_with_opaque_base.phpt
new file mode 100644
index 00000000000..3e73978b86e
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/basic_error_with_opaque_base.phpt
@@ -0,0 +1,17 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder basic - error - with base URL containing opaque path
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setPath("/foo/bar/baz");
+
+try {
+    $builder->build(new Uri\WhatWg\Url("scheme:opaque-path"));
+} catch (Throwable $e) {
+    echo $e::class, ': ', $e->getMessage(), "\n";
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified path is malformed (MissingSchemeNonRelativeUrl)
diff --git a/ext/uri/tests/whatwg/builder/basic_success_empty_reference_with_base.phpt b/ext/uri/tests/whatwg/builder/basic_success_empty_reference_with_base.phpt
new file mode 100644
index 00000000000..6d40f4b96f8
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/basic_success_empty_reference_with_base.phpt
@@ -0,0 +1,17 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::build() - success - inherits the path and query but not the fragment
+--FILE--
+<?php
+
+$base = new Uri\WhatWg\Url("https://example.com/a?old#old");
+$builder = new Uri\WhatWg\UrlBuilder();
+
+$url = $builder->build($base);
+
+var_dump($url->toAsciiString());
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECT--
+string(25) "https://example.com/a?old"
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/basic_success_with_base.phpt b/ext/uri/tests/whatwg/builder/basic_success_with_base.phpt
index 1fc354581db..fcf4c42085e 100644
--- a/ext/uri/tests/whatwg/builder/basic_success_with_base.phpt
+++ b/ext/uri/tests/whatwg/builder/basic_success_with_base.phpt
@@ -1,7 +1,5 @@
 --TEST--
 Test Uri\WhatWg\UrlBuilder basic - success - with base URL
---XFAIL--
-Support for passing $baseUrl to Uri\WhatWg\UrlBuilder::build() is not implemented yet.
 --FILE--
 <?php

diff --git a/ext/uri/tests/whatwg/builder/basic_success_with_scheme_relative_url.phpt b/ext/uri/tests/whatwg/builder/basic_success_with_scheme_relative_url.phpt
new file mode 100644
index 00000000000..1f590e8a93c
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/basic_success_with_scheme_relative_url.phpt
@@ -0,0 +1,37 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder basic - success - with scheme relative URL
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setHost("example.net");
+$builder->setPath("/foo/bar/baz");
+$builder->setPort(124);
+$url = $builder->build(new Uri\WhatWg\Url("https://user:pass@example.com:123/foo/bar?query#hash"));
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(35) "https://example.net:124/foo/bar/baz"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(5) "https"
+  ["username"]=>
+  NULL
+  ["password"]=>
+  NULL
+  ["host"]=>
+  string(11) "example.net"
+  ["port"]=>
+  int(124)
+  ["path"]=>
+  string(12) "/foo/bar/baz"
+  ["query"]=>
+  NULL
+  ["fragment"]=>
+  NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/build_error_soft_errors_reset_with_base.phpt b/ext/uri/tests/whatwg/builder/build_error_soft_errors_reset_with_base.phpt
new file mode 100644
index 00000000000..b1a8b221849
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/build_error_soft_errors_reset_with_base.phpt
@@ -0,0 +1,27 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::build() - error - preserves soft errors output with an opaque base URL
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setPath("/a\tb");
+$softErrors = ["previous error"];
+
+try {
+    $builder->build(new Uri\WhatWg\Url("foo:opaque"), $softErrors);
+} catch (Throwable $e) {
+    echo $e::class, ': ', $e->getMessage(), "\n";
+    foreach ($e->errors as $error) {
+        var_dump($error->type);
+    }
+}
+
+var_dump($softErrors);
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified path is malformed (MissingSchemeNonRelativeUrl)
+enum(Uri\WhatWg\UrlValidationErrorType::MissingSchemeNonRelativeUrl)
+enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit)
+array(0) {
+}
diff --git a/ext/uri/tests/whatwg/builder/build_error_soft_errors_reset.phpt b/ext/uri/tests/whatwg/builder/build_error_soft_errors_reset_without_base.phpt
similarity index 100%
rename from ext/uri/tests/whatwg/builder/build_error_soft_errors_reset.phpt
rename to ext/uri/tests/whatwg/builder/build_error_soft_errors_reset_without_base.phpt
diff --git a/ext/uri/tests/whatwg/builder/build_error_soft_errors_typed_reference_with_base.phpt b/ext/uri/tests/whatwg/builder/build_error_soft_errors_typed_reference_with_base.phpt
new file mode 100644
index 00000000000..10b7e101dc9
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/build_error_soft_errors_typed_reference_with_base.phpt
@@ -0,0 +1,23 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::build() - error - soft errors assigned to a typed property with a base URL
+--FILE--
+<?php
+
+class Foo {
+    public string $errors = "unchanged";
+}
+
+$foo = new Foo();
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setFragment("a\tb");
+try {
+    $builder->build(new Uri\WhatWg\Url("https://example.com/"), $foo->errors);
+} catch (Throwable $e) {
+    echo $e::class, ': ', $e->getMessage(), "\n";
+}
+var_dump($foo->errors);
+
+?>
+--EXPECT--
+TypeError: Cannot assign array to reference held by property Foo::$errors of type string
+string(9) "unchanged"
diff --git a/ext/uri/tests/whatwg/builder/build_success_soft_errors_with_base.phpt b/ext/uri/tests/whatwg/builder/build_success_soft_errors_with_base.phpt
new file mode 100644
index 00000000000..7f2b3714662
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/build_success_soft_errors_with_base.phpt
@@ -0,0 +1,46 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::build() - success - collects and resets soft errors with a base URL
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setPath("/a\tb");
+$builder->setFragment("x\ny");
+$base = new Uri\WhatWg\Url("https://example.com/");
+$softErrors = ["previous error"];
+$url = $builder->build($base, $softErrors);
+
+var_dump($url->toAsciiString());
+var_dump($softErrors);
+
+$builder->setPath("/ab");
+$builder->setFragment("xy");
+$builder->build($base, $softErrors);
+var_dump($softErrors);
+
+?>
+--EXPECTF--
+string(25) "https://example.com/ab#xy"
+array(2) {
+  [0]=>
+  object(Uri\WhatWg\UrlValidationError)#%d (%d) {
+    ["context"]=>
+    string(2) "	b"
+    ["type"]=>
+    enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit)
+    ["failure"]=>
+    bool(false)
+  }
+  [1]=>
+  object(Uri\WhatWg\UrlValidationError)#%d (%d) {
+    ["context"]=>
+    string(2) "
+y"
+    ["type"]=>
+    enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit)
+    ["failure"]=>
+    bool(false)
+  }
+}
+array(0) {
+}
diff --git a/ext/uri/tests/whatwg/builder/fragment_success_empty_with_base.phpt b/ext/uri/tests/whatwg/builder/fragment_success_empty_with_base.phpt
new file mode 100644
index 00000000000..04f92a8f776
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/fragment_success_empty_with_base.phpt
@@ -0,0 +1,17 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::build() - success - replaces the base fragment with an empty fragment
+--FILE--
+<?php
+
+$base = new Uri\WhatWg\Url("https://example.com/a?old#old");
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setFragment("");
+$url = $builder->build($base);
+
+var_dump($url->toAsciiString());
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECT--
+string(26) "https://example.com/a?old#"
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/fragment_success_empty_with_opaque_base.phpt b/ext/uri/tests/whatwg/builder/fragment_success_empty_with_opaque_base.phpt
new file mode 100644
index 00000000000..bcdb4d277b9
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/fragment_success_empty_with_opaque_base.phpt
@@ -0,0 +1,21 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::build() - success - resolves an empty fragment against an opaque base
+--FILE--
+<?php
+
+$base = new Uri\WhatWg\Url("foo:opaque?old#old");
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setPath("\t\n");
+$builder->setFragment("");
+$url = $builder->build($base, $softErrors);
+var_dump($url->toAsciiString());
+var_dump($softErrors[0]->type);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+var_dump($base->toAsciiString());
+
+?>
+--EXPECT--
+string(15) "foo:opaque?old#"
+enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit)
+bool(true)
+string(18) "foo:opaque?old#old"
diff --git a/ext/uri/tests/whatwg/builder/fragment_success_with_base.phpt b/ext/uri/tests/whatwg/builder/fragment_success_with_base.phpt
new file mode 100644
index 00000000000..7d2a84b59ea
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/fragment_success_with_base.phpt
@@ -0,0 +1,35 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setFragment() - success - with base URL
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setFragment("foo");
+$url = $builder->build(new Uri\WhatWg\Url("https://example.com/#bar"));
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(24) "https://example.com/#foo"
+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(3) "foo"
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/fragment_success_with_opaque_base.phpt b/ext/uri/tests/whatwg/builder/fragment_success_with_opaque_base.phpt
new file mode 100644
index 00000000000..53e0af4f66f
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/fragment_success_with_opaque_base.phpt
@@ -0,0 +1,35 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setFragment() - success - with base URL with opaque path
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setFragment("foo");
+$url = $builder->build(new Uri\WhatWg\Url("scheme:opaque-path"));
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(22) "scheme:opaque-path#foo"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(6) "scheme"
+  ["username"]=>
+  NULL
+  ["password"]=>
+  NULL
+  ["host"]=>
+  NULL
+  ["port"]=>
+  NULL
+  ["path"]=>
+  string(11) "opaque-path"
+  ["query"]=>
+  NULL
+  ["fragment"]=>
+  string(3) "foo"
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/host_error_normalized_empty_with_base.phpt b/ext/uri/tests/whatwg/builder/host_error_normalized_empty_with_base.phpt
new file mode 100644
index 00000000000..7c16d8d5706
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/host_error_normalized_empty_with_base.phpt
@@ -0,0 +1,22 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::build() - error - rejects credentials after host normalization with a base URL
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setHost("\t");
+$builder->setUsername("user");
+
+try {
+    $builder->build(new Uri\WhatWg\Url("foo://example.com/"));
+} catch (Throwable $e) {
+    echo $e::class, ': ', $e->getMessage(), "\n";
+    foreach ($e->errors as $error) {
+        var_dump($error->type);
+    }
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified URL cannot have username
+enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit)
diff --git a/ext/uri/tests/whatwg/builder/host_error_with_opaque_base.phpt b/ext/uri/tests/whatwg/builder/host_error_with_opaque_base.phpt
new file mode 100644
index 00000000000..5194b7e6b6f
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/host_error_with_opaque_base.phpt
@@ -0,0 +1,20 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::build() - error - new authority with an opaque base
+--FILE--
+<?php
+
+$base = new Uri\WhatWg\Url("foo:opaque?old#old");
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setHost("example.com");
+
+try {
+    $builder->build($base);
+} catch (Throwable $e) {
+    echo $e::class, ': ', $e->getMessage(), "\n";
+    var_dump($e->errors[0]->type);
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified host is malformed (MissingSchemeNonRelativeUrl)
+enum(Uri\WhatWg\UrlValidationErrorType::MissingSchemeNonRelativeUrl)
diff --git a/ext/uri/tests/whatwg/builder/password_error_with_base.phpt b/ext/uri/tests/whatwg/builder/password_error_with_base.phpt
new file mode 100644
index 00000000000..c6ade2d69d3
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/password_error_with_base.phpt
@@ -0,0 +1,17 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setPassword() - error - missing opaque host with base URL
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setPassword("password");
+
+try {
+    $builder->build(new Uri\WhatWg\Url("https://example.com"));
+} catch (Throwable $e) {
+    echo $e::class, ': ', $e->getMessage(), "\n";
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified URL cannot have password
diff --git a/ext/uri/tests/whatwg/builder/path_error_tab_newline_with_opaque_base.phpt b/ext/uri/tests/whatwg/builder/path_error_tab_newline_with_opaque_base.phpt
new file mode 100644
index 00000000000..0f53f745487
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/path_error_tab_newline_with_opaque_base.phpt
@@ -0,0 +1,20 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::build() - error - ignored path characters without a fragment and with an opaque base
+--FILE--
+<?php
+
+$base = new Uri\WhatWg\Url("foo:opaque?old#old");
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setPath("\t\n");
+
+try {
+    $builder->build($base);
+} catch (Throwable $e) {
+    echo $e::class, ': ', $e->getMessage(), "\n";
+    var_dump($e->errors[0]->type);
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified path is malformed (MissingSchemeNonRelativeUrl)
+enum(Uri\WhatWg\UrlValidationErrorType::MissingSchemeNonRelativeUrl)
diff --git a/ext/uri/tests/whatwg/builder/path_success_absolute_with_file_base.phpt b/ext/uri/tests/whatwg/builder/path_success_absolute_with_file_base.phpt
new file mode 100644
index 00000000000..92a85ff9d15
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/path_success_absolute_with_file_base.phpt
@@ -0,0 +1,17 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::build() - success - inherits the file drive for an absolute path
+--FILE--
+<?php
+
+$base = new Uri\WhatWg\Url("file:///C:/a/b");
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setPath("/c");
+$url = $builder->build($base);
+
+var_dump($url->toAsciiString());
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECT--
+string(12) "file:///C:/c"
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/path_success_drive_letter_with_file_base.phpt b/ext/uri/tests/whatwg/builder/path_success_drive_letter_with_file_base.phpt
new file mode 100644
index 00000000000..7d15133b421
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/path_success_drive_letter_with_file_base.phpt
@@ -0,0 +1,17 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::build() - success - replaces the file drive
+--FILE--
+<?php
+
+$base = new Uri\WhatWg\Url("file:///C:/a/b");
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setPath("D:/c");
+$url = $builder->build($base);
+
+var_dump($url->toAsciiString());
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECT--
+string(12) "file:///D:/c"
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/path_success_first_segment_colon_with_base.phpt b/ext/uri/tests/whatwg/builder/path_success_first_segment_colon_with_base.phpt
new file mode 100644
index 00000000000..6e56b4c75f8
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/path_success_first_segment_colon_with_base.phpt
@@ -0,0 +1,17 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::build() - success - keeps a colon in the first path segment
+--FILE--
+<?php
+
+$base = new Uri\WhatWg\Url("https://example.com/a/b");
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setPath("c:d");
+$url = $builder->build($base);
+
+var_dump($url->toAsciiString());
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECT--
+string(25) "https://example.com/a/c:d"
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/path_success_hierarchical_delimiters_with_base.phpt b/ext/uri/tests/whatwg/builder/path_success_hierarchical_delimiters_with_base.phpt
new file mode 100644
index 00000000000..ea942178d05
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/path_success_hierarchical_delimiters_with_base.phpt
@@ -0,0 +1,17 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::build() - success - keeps question mark and hashmark in the path
+--FILE--
+<?php
+
+$base = new Uri\WhatWg\Url("https://example.com/a/b");
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setPath("a?b#c");
+$url = $builder->build($base);
+
+var_dump($url->toAsciiString());
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECT--
+string(31) "https://example.com/a/a%3Fb%23c"
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/path_success_leading_double_slash_with_base.phpt b/ext/uri/tests/whatwg/builder/path_success_leading_double_slash_with_base.phpt
new file mode 100644
index 00000000000..b38ec002d79
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/path_success_leading_double_slash_with_base.phpt
@@ -0,0 +1,17 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::build() - success - keeps a leading double slash in the path
+--FILE--
+<?php
+
+$base = new Uri\WhatWg\Url("https://example.com/a/b");
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setPath("//other/x");
+$url = $builder->build($base);
+
+var_dump($url->toAsciiString());
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECT--
+string(28) "https://example.com//other/x"
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/path_success_leading_double_slash_with_file_base.phpt b/ext/uri/tests/whatwg/builder/path_success_leading_double_slash_with_file_base.phpt
new file mode 100644
index 00000000000..4b1cbf1289c
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/path_success_leading_double_slash_with_file_base.phpt
@@ -0,0 +1,17 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::build() - success - keeps a leading double slash in a file path
+--FILE--
+<?php
+
+$base = new Uri\WhatWg\Url("file:///C:/a/b");
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setPath("//other/x");
+$url = $builder->build($base);
+
+var_dump($url->toAsciiString());
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECT--
+string(16) "file:////other/x"
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/path_success_parent_segment_with_base.phpt b/ext/uri/tests/whatwg/builder/path_success_parent_segment_with_base.phpt
new file mode 100644
index 00000000000..9c418b25ae6
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/path_success_parent_segment_with_base.phpt
@@ -0,0 +1,17 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::build() - success - resolves a parent path segment
+--FILE--
+<?php
+
+$base = new Uri\WhatWg\Url("https://example.com/a/b");
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setPath("../c");
+$url = $builder->build($base);
+
+var_dump($url->toAsciiString());
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECT--
+string(21) "https://example.com/c"
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/path_success_relative_with_base.phpt b/ext/uri/tests/whatwg/builder/path_success_relative_with_base.phpt
new file mode 100644
index 00000000000..cf8ee60288a
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/path_success_relative_with_base.phpt
@@ -0,0 +1,17 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::build() - success - resolves a path against the base directory and clears its query and fragment
+--FILE--
+<?php
+
+$base = new Uri\WhatWg\Url("https://user:pass@example.com:81/a/b?old#old");
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setPath("c");
+$url = $builder->build($base);
+
+var_dump($url->toAsciiString());
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECT--
+string(36) "https://user:pass@example.com:81/a/c"
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/path_success_tab_newline_with_base.phpt b/ext/uri/tests/whatwg/builder/path_success_tab_newline_with_base.phpt
new file mode 100644
index 00000000000..ce299716c0a
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/path_success_tab_newline_with_base.phpt
@@ -0,0 +1,19 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::build() - success - inherits the path and query after removing tabs and newlines
+--FILE--
+<?php
+
+$base = new Uri\WhatWg\Url("https://example.com/a?old#old");
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setPath("\t\n");
+$url = $builder->build($base, $softErrors);
+
+var_dump($url->toAsciiString());
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+var_dump($softErrors[0]->type);
+
+?>
+--EXPECT--
+string(25) "https://example.com/a?old"
+bool(true)
+enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit)
diff --git a/ext/uri/tests/whatwg/builder/port_error_missing_opaque_host_with_base.phpt b/ext/uri/tests/whatwg/builder/port_error_missing_opaque_host_with_base.phpt
new file mode 100644
index 00000000000..dc6b021f084
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/port_error_missing_opaque_host_with_base.phpt
@@ -0,0 +1,17 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setPort() - error - missing opaque host with base URL
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setPort(123);
+
+try {
+    $builder->build(new Uri\WhatWg\Url("https://example.com"));
+} catch (Throwable $e) {
+    echo $e::class, ': ', $e->getMessage(), "\n";
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified URL cannot have port
diff --git a/ext/uri/tests/whatwg/builder/query_error_empty_with_opaque_base.phpt b/ext/uri/tests/whatwg/builder/query_error_empty_with_opaque_base.phpt
new file mode 100644
index 00000000000..f970ba3081c
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/query_error_empty_with_opaque_base.phpt
@@ -0,0 +1,22 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::build() - error - empty query with an opaque base and ignored path characters
+--FILE--
+<?php
+
+$base = new Uri\WhatWg\Url("foo:opaque?old#old");
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setPath("\t\n");
+$builder->setQuery("");
+$builder->setFragment("new");
+
+try {
+    $builder->build($base);
+} catch (Throwable $e) {
+    echo $e::class, ': ', $e->getMessage(), "\n";
+    var_dump($e->errors[0]->type);
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified query is malformed (MissingSchemeNonRelativeUrl)
+enum(Uri\WhatWg\UrlValidationErrorType::MissingSchemeNonRelativeUrl)
diff --git a/ext/uri/tests/whatwg/builder/query_success_empty_with_base.phpt b/ext/uri/tests/whatwg/builder/query_success_empty_with_base.phpt
new file mode 100644
index 00000000000..e2e41cb7a12
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/query_success_empty_with_base.phpt
@@ -0,0 +1,17 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::build() - success - replaces the base query with an empty query
+--FILE--
+<?php
+
+$base = new Uri\WhatWg\Url("https://example.com/a?old#old");
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setQuery("");
+$url = $builder->build($base);
+
+var_dump($url->toAsciiString());
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECT--
+string(22) "https://example.com/a?"
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/query_success_hashmark_with_base.phpt b/ext/uri/tests/whatwg/builder/query_success_hashmark_with_base.phpt
new file mode 100644
index 00000000000..ef17d3e9410
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/query_success_hashmark_with_base.phpt
@@ -0,0 +1,17 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::build() - success - keeps a hashmark in the query
+--FILE--
+<?php
+
+$base = new Uri\WhatWg\Url("https://example.com/a?old#old");
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setQuery("a#b");
+$url = $builder->build($base);
+
+var_dump($url->toAsciiString());
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECT--
+string(27) "https://example.com/a?a%23b"
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/query_success_with_base.phpt b/ext/uri/tests/whatwg/builder/query_success_with_base.phpt
new file mode 100644
index 00000000000..5dc39a92ecf
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/query_success_with_base.phpt
@@ -0,0 +1,35 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setQuery() - success - with base URL
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setQuery("foo=bar");
+$url = $builder->build(new Uri\WhatWg\Url("https://example.com/?baz"));
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(28) "https://example.com/?foo=bar"
+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(7) "foo=bar"
+  ["fragment"]=>
+  NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/username_error_with_base.phpt b/ext/uri/tests/whatwg/builder/username_error_with_base.phpt
new file mode 100644
index 00000000000..f5870330945
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/username_error_with_base.phpt
@@ -0,0 +1,17 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setUsername() - error - missing opaque host with base URL
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setUsername("username");
+
+try {
+    $builder->build(new Uri\WhatWg\Url("https://example.com"));
+} catch (Throwable $e) {
+    echo $e::class, ': ', $e->getMessage(), "\n";
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified URL cannot have username
diff --git a/ext/uri/uri_parser_whatwg.c b/ext/uri/uri_parser_whatwg.c
index 8fee839b1cc..1a258c6c940 100644
--- a/ext/uri/uri_parser_whatwg.c
+++ b/ext/uri/uri_parser_whatwg.c
@@ -203,10 +203,8 @@ ZEND_ATTRIBUTE_NONNULL static const char *fill_errors(zval *errors)
 	return fill_errors_inner(Z_ARRVAL_P(errors));
 }

-static void throw_invalid_url_exception_during_write(zval *errors, const char *component)
+static void throw_invalid_url_exception_with_reason(zval *errors, const char *component, const char *reason, zval *err)
 {
-	zval err;
-	const char *reason = fill_errors(&err);
 	zend_object *exception = zend_throw_exception_ex(
 		php_uri_ce_whatwg_invalid_url_exception,
 		0,
@@ -216,15 +214,23 @@ static void throw_invalid_url_exception_during_write(zval *errors, const char *c
 		reason ? reason : "",
 		reason ? ")" : ""
 	);
-	zend_update_property(exception->ce, exception, ZEND_STRL("errors"), &err);
+	zend_update_property(exception->ce, exception, ZEND_STRL("errors"), err);
 	if (errors) {
 		zval_ptr_dtor(errors);
-		ZVAL_COPY_VALUE(errors, &err);
+		ZVAL_COPY_VALUE(errors, err);
 	} else {
-		zval_ptr_dtor(&err);
+		zval_ptr_dtor(err);
 	}
 }

+static void throw_invalid_url_exception_during_write(zval *errors, const char *component)
+{
+	zval err;
+	const char *reason = fill_errors(&err);
+
+	throw_invalid_url_exception_with_reason(errors, component, reason, &err);
+}
+
 static lxb_status_t serialize_to_smart_str_callback(const lxb_char_t *data, const size_t length, void *ctx)
 {
 	smart_str *uri_str = ctx;
@@ -968,12 +974,12 @@ ZEND_ATTRIBUTE_NONNULL static lxb_url_scheme_type_t php_uri_parser_whatwg_get_sp
 	return LXB_URL_SCHEMEL_TYPE__UNDEF;
 }

-ZEND_ATTRIBUTE_NONNULL static void php_uri_parser_whatwg_build_errors(zval *errors)
+ZEND_ATTRIBUTE_NONNULL static const char *php_uri_parser_whatwg_build_errors(zval *errors)
 {
 	size_t log_len;

 	if (lexbor_parser.log == NULL || (log_len = lexbor_plog_length(lexbor_parser.log)) == 0) {
-		return;
+		return NULL;
 	}

 	if (Z_TYPE_P(errors) != IS_ARRAY) {
@@ -981,7 +987,222 @@ ZEND_ATTRIBUTE_NONNULL static void php_uri_parser_whatwg_build_errors(zval *erro
 		array_init_size(errors, log_len);
 	}

-	fill_errors_inner(Z_ARRVAL_P(errors));
+	return fill_errors_inner(Z_ARRVAL_P(errors));
+}
+
+ZEND_ATTRIBUTE_NONNULL static void php_uri_parser_whatwg_build_errors_into_exception(zval *errors)
+{
+	/* Include errors from earlier components in the exception raised by a later component. */
+	if (zend_hash_num_elements(Z_ARRVAL_P(errors)) > 0 && EG(exception)
+		&& instanceof_function(EG(exception)->ce, php_uri_ce_whatwg_invalid_url_exception)) {
+		zval rv;
+		zval *exception_errors = zend_read_property(php_uri_ce_whatwg_invalid_url_exception,
+			EG(exception), ZEND_STRL("errors"), true, &rv);
+		ZEND_ASSERT(Z_TYPE_P(exception_errors) == IS_ARRAY);
+
+		zval *error;
+		ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(exception_errors), error) {
+			Z_TRY_ADDREF_P(error);
+			zend_hash_next_index_insert(Z_ARRVAL_P(errors), error);
+		} ZEND_HASH_FOREACH_END();
+
+		zval_ptr_dtor(exception_errors);
+		ZVAL_COPY(exception_errors, errors);
+	}
+}
+
+ZEND_ATTRIBUTE_NONNULL static void php_uri_parser_whatwg_build_errors_and_throw(const lxb_status_t status, const char *component, zval *errors)
+{
+	if (status != LXB_STATUS_OK) {
+		throw_invalid_url_exception_during_write(NULL, component);
+	} else {
+		php_uri_parser_whatwg_build_errors(errors);
+	}
+}
+
+/* TODO: Replace with lxb_url_path_set_null() once https://github.com/lexbor/lexbor/pull/415 is available. */
+ZEND_ATTRIBUTE_NONNULL static void php_uri_parser_whatwg_path_set_null(lxb_url_t *url)
+{
+	if (url->path.str.data == NULL) {
+		return;
+	}
+
+	(void) lexbor_str_destroy(&url->path.str, url->mraw, false);
+
+	url->path.str.length = 0;
+	url->path.length = 0;
+	url->path.opaque = false;
+}
+
+/* TODO: Replace with lxb_url_query_set_null() once https://github.com/lexbor/lexbor/pull/415 is available. */
+ZEND_ATTRIBUTE_NONNULL static void php_uri_parser_whatwg_query_set_null(lxb_url_t *url)
+{
+	if (url->query.data != NULL) {
+		(void) lexbor_str_destroy(&url->query, url->mraw, false);
+	}
+}
+
+/* TODO: Replace with lxb_url_fragment_set_null() once https://github.com/lexbor/lexbor/pull/415 is available. */
+ZEND_ATTRIBUTE_NONNULL static void php_uri_parser_whatwg_fragment_set_null(lxb_url_t *url)
+{
+	if (url->fragment.data != NULL) {
+		(void) lexbor_str_destroy(&url->fragment, url->mraw, false);
+	}
+}
+
+ZEND_ATTRIBUTE_NONNULL_ARGS(1, 2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser_whatwg_resolve_reference_from_zval(
+	lxb_url_t *lexbor_base_url, const zval *scheme, const zval *username, const zval *password,
+	const zval *host, const zval *port, const zval *path, const zval *query, const zval *fragment,
+	zval *soft_errors_zv
+) {
+	if (Z_TYPE_P(host) == IS_STRING) {
+		if (lexbor_base_url->path.opaque) {
+			php_uri_parser_whatwg_component_error("host", LXB_URL_ERROR_TYPE_MISSING_SCHEME_NON_RELATIVE_URL);
+			return NULL;
+		}
+
+		/* A new authority inherits only the scheme, not the base URL's other components. */
+		zval base_scheme;
+		php_uri_parser_whatwg_scheme_read(lexbor_base_url, PHP_URI_COMPONENT_READ_MODE_NORMALIZED_ASCII, &base_scheme);
+		lxb_url_t *url = php_uri_parser_whatwg_build_from_zval(NULL, &base_scheme,
+			username, password, host, port, path, query, fragment, soft_errors_zv);
+		zval_ptr_dtor(&base_scheme);
+		return url;
+	}
+
+	/* Credentials and ports require an authority in the reference itself. */
+	if (Z_TYPE_P(username) == IS_STRING) {
+		php_uri_parser_whatwg_throw_exception("The specified URL cannot have username");
+		return NULL;
+	}
+
+	if (Z_TYPE_P(password) == IS_STRING) {
+		php_uri_parser_whatwg_throw_exception("The specified URL cannot have password");
+		return NULL;
+	}
+
+	if (Z_TYPE_P(port) == IS_LONG) {
+		php_uri_parser_whatwg_throw_exception("The specified URL cannot have port");
+		return NULL;
+	}
+
+	const char *first = Z_STRVAL_P(path);
+	const char *end = first + Z_STRLEN_P(path);
+	while (first < end && php_uri_whatwg_is_ascii_tab_or_newline(*first)) {
+		first++;
+	}
+
+	lxb_status_t status;
+	zval errors;
+	array_init(&errors);
+
+	lxb_url_t *lexbor_url = php_uri_parser_whatwg_clone(lexbor_base_url);
+	if (lexbor_url == NULL) {
+		zend_throw_exception(php_uri_ce_error, "Memory allocation error", 0);
+		goto failure;
+	}
+	if (lexbor_base_url->path.opaque && first == end
+		&& (Z_TYPE_P(query) == IS_STRING || Z_TYPE_P(fragment) == IS_NULL)) {
+		php_uri_parser_whatwg_component_error(Z_TYPE_P(query) == IS_STRING ? "query" : "path",
+			LXB_URL_ERROR_TYPE_MISSING_SCHEME_NON_RELATIVE_URL);
+		goto failure;
+	}
+
+	/* Discard the base fragment; the reference fragment is applied below. */
+	php_uri_parser_whatwg_fragment_set_null(lexbor_url);
+
+	if (Z_STRLEN_P(path) > 0) {
+		/* Resolve relative paths against the base directory. Absolute paths use
+		 * a path state so leading slashes cannot introduce a new authority. */
+		lxb_url_state_t state = LXB_URL_STATE_NO_SCHEME_STATE;
+		if (!lexbor_base_url->path.opaque && first < end
+			&& (*first == '/' || (lxb_url_is_special(lexbor_base_url) && *first == '\\'))) {
+			/* A path beginning with // must not replace the authority. */
+			state = LXB_URL_STATE_PATH_START_STATE;
+			if (lexbor_base_url->scheme.type == LXB_URL_SCHEMEL_TYPE_FILE) {
+				const char *second = first + 1;
+				while (second < end && php_uri_whatwg_is_ascii_tab_or_newline(*second)) {
+					second++;
+				}
+				if (second == end || (*second != '/' && *second != '\\')) {
+					state = LXB_URL_STATE_FILE_STATE;
+				}
+			}
+		}
+
+		/* Keep delimiters inside the path when entering the reference parser. */
+		smart_str reference = {0};
+		for (const char *p = Z_STRVAL_P(path); p < end; p++) {
+			if (*p == '?') {
+				smart_str_appends(&reference, "%3F");
+			} else if (*p == '#') {
+				smart_str_appends(&reference, "%23");
+			} else {
+				smart_str_appendc(&reference, *p);
+			}
+		}
+
+		/* After removing tabs and newlines, the parser must see a fragment
+		 * reference to accept an opaque base. Its actual value is applied below. */
+		if (lexbor_base_url->path.opaque && first == end
+			&& Z_TYPE_P(query) == IS_NULL && Z_TYPE_P(fragment) != IS_NULL) {
+			smart_str_appendc(&reference, '#');
+		}
+
+		zend_string *input = smart_str_extract(&reference);
+		php_uri_parser_whatwg_path_set_null(lexbor_url);
+		lxb_url_parser_clean(&lexbor_parser);
+		status = lxb_url_parse_basic(&lexbor_parser, lexbor_url, lexbor_base_url,
+			(const lxb_char_t *) ZSTR_VAL(input), ZSTR_LEN(input), state, LXB_ENCODING_UTF_8);
+		php_uri_parser_whatwg_build_errors_and_throw(status, "path", &errors);
+		zend_string_release(input);
+		if (status != LXB_STATUS_OK) {
+			goto failure;
+		}
+		if (first < end) {
+			php_uri_parser_whatwg_query_set_null(lexbor_url);
+		}
+	}
+
+	if (Z_TYPE_P(query) == IS_STRING) {
+		php_uri_parser_whatwg_query_set_null(lexbor_url);
+		lxb_url_parser_clean(&lexbor_parser);
+		status = lxb_url_parse_basic(&lexbor_parser, lexbor_url, lexbor_base_url,
+			(lxb_char_t *) Z_STRVAL_P(query), Z_STRLEN_P(query),
+			LXB_URL_STATE_QUERY_STATE, LXB_ENCODING_AUTO
+		);
+		php_uri_parser_whatwg_build_errors_and_throw(status, "query", &errors);
+		if (status != LXB_STATUS_OK) {
+			goto failure;
+		}
+	}
+
+	if (Z_TYPE_P(fragment) == IS_STRING) {
+		php_uri_parser_whatwg_fragment_set_null(lexbor_url);
+		lxb_url_parser_clean(&lexbor_parser);
+		status = lxb_url_parse_basic(&lexbor_parser, lexbor_url, lexbor_base_url,
+			(lxb_char_t *) Z_STRVAL_P(fragment), Z_STRLEN_P(fragment),
+			LXB_URL_STATE_FRAGMENT_STATE, LXB_ENCODING_AUTO
+		);
+		php_uri_parser_whatwg_build_errors_and_throw(status, "fragment", &errors);
+		if (status != LXB_STATUS_OK) {
+			goto failure;
+		}
+	}
+
+	if (php_uri_pass_errors_by_ref_and_free(soft_errors_zv, &errors) == FAILURE) {
+		/* The errors zval was already consumed; goto failure would destroy it again. */
+		lxb_url_destroy(lexbor_url);
+		return NULL;
+	}
+
+	return lexbor_url;
+
+failure:
+	php_uri_parser_whatwg_build_errors_into_exception(&errors);
+	zval_ptr_dtor(&errors);
+	lxb_url_destroy(lexbor_url);
+	return NULL;
 }

 ZEND_ATTRIBUTE_NONNULL static zend_result php_uri_parser_whatwg_build_path(
@@ -1041,6 +1262,12 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser_wh
 ) {
 	lxb_url_parser_clean(&lexbor_parser);

+	if (lexbor_base_url != NULL && Z_TYPE_P(scheme) == IS_STRING && Z_STRLEN_P(scheme) == 0) {
+		return php_uri_parser_whatwg_resolve_reference_from_zval(
+			lexbor_base_url, scheme, username, password, host, port, path, query, fragment, soft_errors_zv
+		);
+	}
+
 	lxb_url_t *lexbor_url = lexbor_mraw_calloc(lexbor_parser.mraw, sizeof(*lexbor_url));
 	if (lexbor_url == NULL) {
 		zend_throw_exception(php_uri_ce_error, "Memory allocation error", 0);
@@ -1148,10 +1375,6 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser_wh
 		}
 	}

-	if (lexbor_base_url != NULL) {
-		/* TODO */
-	}
-
 	if (php_uri_pass_errors_by_ref_and_free(soft_errors_zv, &errors) == FAILURE) {
 		/* The errors zval was already consumed; goto failure would destroy it again. */
 		lxb_url_destroy(lexbor_url);
@@ -1159,26 +1382,9 @@ ZEND_ATTRIBUTE_NONNULL_ARGS(2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser_wh
 	}

 	return lexbor_url;
-
 failure:
 	ZEND_ASSERT(EG(exception));
-
-	/* Include errors from earlier components in the exception raised by a later component. */
-	if (zend_hash_num_elements(Z_ARRVAL(errors)) > 0
-		&& instanceof_function(EG(exception)->ce, php_uri_ce_whatwg_invalid_url_exception)) {
-		zval rv;
-		zval *exception_errors = zend_read_property(php_uri_ce_whatwg_invalid_url_exception,
-			EG(exception), ZEND_STRL("errors"), true, &rv);
-		ZEND_ASSERT(Z_TYPE_P(exception_errors) == IS_ARRAY);
-
-		ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(exception_errors), zval *error) {
-			Z_TRY_ADDREF_P(error);
-			zend_hash_next_index_insert(Z_ARRVAL(errors), error);
-		} ZEND_HASH_FOREACH_END();
-
-		zval_ptr_dtor(exception_errors);
-		ZVAL_COPY(exception_errors, &errors);
-	}
+	php_uri_parser_whatwg_build_errors_into_exception(&errors);
 	zval_ptr_dtor(&errors);
 	lxb_url_destroy(lexbor_url);
 	return NULL;