[PHP-CVS] [php-src] master: Implement "Followup improvements for ext/uri" RFC - WHATWG URL building (#22268)

[email protected] (Máté Kocsis via GitHub)
Newsgroups php.cvs
Message-ID <[email protected]>
Author: Máté Kocsis (kocsismate)
Committer: GitHub (web-flow)
Pusher: kocsismate
Date: 2026-08-19T16:28:53+02:00

Commit: https://github.com/php/php-src/commit/bce6e73122972cd54a0a8ce2abeb48de425764cf
Raw diff: https://github.com/php/php-src/commit/bce6e73122972cd54a0a8ce2abeb48de425764cf.diff

Implement "Followup improvements for ext/uri" RFC - WHATWG URL building (#22268)

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

For now, supplying a (non-null) base URL to Uri\WhatWg\UrlBuilder::build() is not supported due to implementation complexity. This is going to be resolved as a followup.

Changed paths:
  A  ext/uri/tests/whatwg/builder/all_success_with_reset.phpt
  A  ext/uri/tests/whatwg/builder/basic_error_with_base.phpt
  A  ext/uri/tests/whatwg/builder/basic_success_with_base.phpt
  A  ext/uri/tests/whatwg/builder/fragment_success_hashmark.phpt
  A  ext/uri/tests/whatwg/builder/fragment_success_ignorable_char.phpt
  A  ext/uri/tests/whatwg/builder/fragment_success_null.phpt
  A  ext/uri/tests/whatwg/builder/fragment_success_special_char.phpt
  A  ext/uri/tests/whatwg/builder/fragment_success_unicode_char.phpt
  A  ext/uri/tests/whatwg/builder/host_error_ipv6_closing_brace_opaque.phpt
  A  ext/uri/tests/whatwg/builder/host_error_ipv6_closing_brace_special.phpt
  A  ext/uri/tests/whatwg/builder/host_error_null_special.phpt
  A  ext/uri/tests/whatwg/builder/host_error_percent_encoding1.phpt
  A  ext/uri/tests/whatwg/builder/host_error_percent_encoding2.phpt
  A  ext/uri/tests/whatwg/builder/host_error_percent_encoding3.phpt
  A  ext/uri/tests/whatwg/builder/host_success_empty_opaque.phpt
  A  ext/uri/tests/whatwg/builder/host_success_file.phpt
  A  ext/uri/tests/whatwg/builder/host_success_ipv4.phpt
  A  ext/uri/tests/whatwg/builder/host_success_ipv4_percent_encoding.phpt
  A  ext/uri/tests/whatwg/builder/host_success_ipv6.phpt
  A  ext/uri/tests/whatwg/builder/host_success_opaque_null.phpt
  A  ext/uri/tests/whatwg/builder/host_success_regname.phpt
  A  ext/uri/tests/whatwg/builder/password_error_empty_opaque_host.phpt
  A  ext/uri/tests/whatwg/builder/password_error_file_scheme.phpt
  A  ext/uri/tests/whatwg/builder/password_error_missing_host.phpt
  A  ext/uri/tests/whatwg/builder/password_error_missing_opaque_host.phpt
  A  ext/uri/tests/whatwg/builder/password_success_null.phpt
  A  ext/uri/tests/whatwg/builder/path_success_empty_string.phpt
  A  ext/uri/tests/whatwg/builder/path_success_first_segment_colon.phpt
  A  ext/uri/tests/whatwg/builder/path_success_leading_double_slash1.phpt
  A  ext/uri/tests/whatwg/builder/path_success_leading_double_slash2.phpt
  A  ext/uri/tests/whatwg/builder/path_success_special_char.phpt
  A  ext/uri/tests/whatwg/builder/path_success_without_leading_slash.phpt
  A  ext/uri/tests/whatwg/builder/port_error_empty_opaque_host.phpt
  A  ext/uri/tests/whatwg/builder/port_error_file_scheme.phpt
  A  ext/uri/tests/whatwg/builder/port_error_file_with_host_and_port.phpt
  A  ext/uri/tests/whatwg/builder/port_error_large.phpt
  A  ext/uri/tests/whatwg/builder/port_error_missing_opaque_host.phpt
  A  ext/uri/tests/whatwg/builder/port_error_negative.phpt
  A  ext/uri/tests/whatwg/builder/port_success_default.phpt
  A  ext/uri/tests/whatwg/builder/port_success_non_default.phpt
  A  ext/uri/tests/whatwg/builder/port_success_null.phpt
  A  ext/uri/tests/whatwg/builder/query_success_basic.phpt
  A  ext/uri/tests/whatwg/builder/query_success_null.phpt
  A  ext/uri/tests/whatwg/builder/query_success_question_mark.phpt
  A  ext/uri/tests/whatwg/builder/query_success_unicode_char.phpt
  A  ext/uri/tests/whatwg/builder/scheme_error_c0_control_space_char.phpt
  A  ext/uri/tests/whatwg/builder/scheme_error_empty.phpt
  A  ext/uri/tests/whatwg/builder/scheme_error_first_char.phpt
  A  ext/uri/tests/whatwg/builder/scheme_error_special_char.phpt
  A  ext/uri/tests/whatwg/builder/scheme_success_ignorable_char.phpt
  A  ext/uri/tests/whatwg/builder/scheme_success_non_special.phpt
  A  ext/uri/tests/whatwg/builder/scheme_success_special.phpt
  A  ext/uri/tests/whatwg/builder/username_error_empty_opaque_host.phpt
  A  ext/uri/tests/whatwg/builder/username_error_file_scheme.phpt
  A  ext/uri/tests/whatwg/builder/username_error_missing_opaque_host.phpt
  A  ext/uri/tests/whatwg/builder/username_success_invalid_percent_encoding.phpt
  A  ext/uri/tests/whatwg/builder/username_success_null.phpt
  A  ext/uri/tests/whatwg/builder/username_success_special_char.phpt
  A  ext/uri/tests/whatwg/builder/username_success_tab_newline.phpt
  M  UPGRADING
  M  ext/uri/php_uri.c
  M  ext/uri/php_uri.stub.php
  M  ext/uri/php_uri_arginfo.h
  M  ext/uri/php_uri_common.c
  M  ext/uri/php_uri_common.h
  M  ext/uri/php_uri_decl.h
  M  ext/uri/tests/rfc3986/builder/host_error_ipv6_closing_brace.phpt
  M  ext/uri/uri_parser_whatwg.c
  M  ext/uri/uri_parser_whatwg.h


Diff:

diff --git a/UPGRADING b/UPGRADING
index 350d30b12631..67024bad7e66 100644
--- a/UPGRADING
+++ b/UPGRADING
@@ -445,6 +445,8 @@ PHP 8.6 UPGRADE NOTES
     RFC: https://wiki.php.net/rfc/uri_followup#host_type_detection
   . Added Uri\Rfc3986\UriBuilder.
     RFC: https://wiki.php.net/rfc/uri_followup#uri_building
+  . Added Uri\WhatWg\UrlBuilder.
+    RFC: https://wiki.php.net/rfc/uri_followup#uri_building
 
 ========================================
 3. Changes in SAPI modules
diff --git a/ext/uri/php_uri.c b/ext/uri/php_uri.c
index c774130f5237..4caeca9f1465 100644
--- a/ext/uri/php_uri.c
+++ b/ext/uri/php_uri.c
@@ -34,6 +34,7 @@ zend_class_entry *php_uri_ce_rfc3986_uri_builder;
 zend_class_entry *php_uri_ce_rfc3986_uri;
 zend_class_entry *php_uri_ce_rfc3986_uri_type;
 zend_class_entry *php_uri_ce_rfc3986_uri_host_type;
+zend_class_entry *php_uri_ce_whatwg_url_builder;
 zend_class_entry *php_uri_ce_whatwg_url;
 zend_class_entry *php_uri_ce_comparison_mode;
 zend_class_entry *php_uri_ce_exception;
@@ -75,6 +76,17 @@ static zend_always_inline zval *php_uri_deref(zval *zv)
 #define Z_RFC3986_URI_PROP_QUERY_DEREF_P(zv) php_uri_deref(OBJ_PROP_NUM(Z_OBJ_P(zv), 5))
 #define Z_RFC3986_URI_PROP_FRAGMENT_DEREF_P(zv) php_uri_deref(OBJ_PROP_NUM(Z_OBJ_P(zv), 6))
 
+#define Z_WHATWG_URL_PROP_SCHEME_P(zv) OBJ_PROP_NUM(Z_OBJ_P(zv), 0)
+#define Z_WHATWG_URL_PROP_SCHEME_DEREF_P(zv) php_uri_deref(Z_WHATWG_URL_PROP_SCHEME_P(zv))
+#define Z_WHATWG_URL_PROP_USERNAME_DEREF_P(zv) php_uri_deref(OBJ_PROP_NUM(Z_OBJ_P(zv), 1))
+#define Z_WHATWG_URL_PROP_PASSWORD_DEREF_P(zv) php_uri_deref(OBJ_PROP_NUM(Z_OBJ_P(zv), 2))
+#define Z_WHATWG_URL_PROP_HOST_DEREF_P(zv) php_uri_deref(OBJ_PROP_NUM(Z_OBJ_P(zv), 3))
+#define Z_WHATWG_URL_PROP_PORT_DEREF_P(zv) php_uri_deref(OBJ_PROP_NUM(Z_OBJ_P(zv), 4))
+#define Z_WHATWG_URL_PROP_PATH_P(zv) OBJ_PROP_NUM(Z_OBJ_P(zv), 5)
+#define Z_WHATWG_URL_PROP_PATH_DEREF_P(zv) php_uri_deref(Z_WHATWG_URL_PROP_PATH_P(zv))
+#define Z_WHATWG_URL_PROP_QUERY_DEREF_P(zv) php_uri_deref(OBJ_PROP_NUM(Z_OBJ_P(zv), 6))
+#define Z_WHATWG_URL_PROP_FRAGMENT_DEREF_P(zv) php_uri_deref(OBJ_PROP_NUM(Z_OBJ_P(zv), 7))
+
 static HashTable *uri_get_debug_properties(php_uri_object *object)
 {
 	const HashTable *std_properties = zend_std_get_properties(&object->std);
@@ -1244,6 +1256,139 @@ PHP_METHOD(Uri_Rfc3986_UriBuilder, build)
 	uri_object->uri = uriparser_uris;
 }
 
+PHP_METHOD(Uri_WhatWg_UrlBuilder, reset)
+{
+	ZEND_PARSE_PARAMETERS_NONE();
+
+	zend_object *object = Z_OBJ_P(ZEND_THIS);
+	zval *property = object->properties_table;
+	const zval *end = property + object->ce->default_properties_count;
+
+	while (property != end) {
+		zend_object_dtor_property(object, property);
+		ZVAL_NULL(property);
+		property++;
+	}
+
+	ZVAL_EMPTY_STRING(Z_WHATWG_URL_PROP_SCHEME_P(ZEND_THIS));
+	ZVAL_EMPTY_STRING(Z_WHATWG_URL_PROP_PATH_P(ZEND_THIS));
+
+	RETVAL_COPY(ZEND_THIS);
+}
+
+PHP_METHOD(Uri_WhatWg_UrlBuilder, setScheme)
+{
+	php_uri_builder_set_component_string(
+		INTERNAL_FUNCTION_PARAM_PASSTHRU,
+		ZEND_STRL("scheme"),
+		php_uri_parser_whatwg_validate_scheme
+	);
+}
+
+PHP_METHOD(Uri_WhatWg_UrlBuilder, setUsername)
+{
+	php_uri_builder_set_component_string_or_null(
+		INTERNAL_FUNCTION_PARAM_PASSTHRU,
+		ZEND_STRL("username"),
+		php_uri_parser_whatwg_validate_none
+	);
+}
+
+PHP_METHOD(Uri_WhatWg_UrlBuilder, setPassword)
+{
+	php_uri_builder_set_component_string_or_null(
+		INTERNAL_FUNCTION_PARAM_PASSTHRU,
+		ZEND_STRL("password"),
+		php_uri_parser_whatwg_validate_none
+	);
+}
+
+PHP_METHOD(Uri_WhatWg_UrlBuilder, setHost)
+{
+	php_uri_builder_set_component_string_or_null(
+		INTERNAL_FUNCTION_PARAM_PASSTHRU,
+		ZEND_STRL("host"),
+		php_uri_parser_whatwg_validate_host
+	);
+}
+
+PHP_METHOD(Uri_WhatWg_UrlBuilder, setPort)
+{
+	php_uri_builder_set_component_long_or_null(
+		INTERNAL_FUNCTION_PARAM_PASSTHRU,
+		ZEND_STRL("port"),
+		php_uri_parser_whatwg_validate_port
+	);
+}
+
+PHP_METHOD(Uri_WhatWg_UrlBuilder, setPath)
+{
+	php_uri_builder_set_component_string(
+		INTERNAL_FUNCTION_PARAM_PASSTHRU,
+		ZEND_STRL("path"),
+		php_uri_parser_whatwg_validate_none
+	);
+}
+
+PHP_METHOD(Uri_WhatWg_UrlBuilder, setQuery)
+{
+	php_uri_builder_set_component_string_or_null(
+		INTERNAL_FUNCTION_PARAM_PASSTHRU,
+		ZEND_STRL("query"),
+		php_uri_parser_whatwg_validate_none
+	);
+}
+
+PHP_METHOD(Uri_WhatWg_UrlBuilder, setFragment)
+{
+	php_uri_builder_set_component_string_or_null(
+		INTERNAL_FUNCTION_PARAM_PASSTHRU,
+		ZEND_STRL("fragment"),
+		php_uri_parser_whatwg_validate_none
+	);
+}
+
+PHP_METHOD(Uri_WhatWg_UrlBuilder, build)
+{
+	zval *base_url_zv = NULL;
+	zval *errors = NULL;
+
+	ZEND_PARSE_PARAMETERS_START(0, 2)
+		Z_PARAM_OPTIONAL
+		Z_PARAM_OBJECT_OF_CLASS_OR_NULL(base_url_zv, php_uri_ce_whatwg_url)
+		Z_PARAM_ZVAL(errors)
+	ZEND_PARSE_PARAMETERS_END();
+
+	const zval *scheme = Z_WHATWG_URL_PROP_SCHEME_DEREF_P(ZEND_THIS);
+	const zval *username = Z_WHATWG_URL_PROP_USERNAME_DEREF_P(ZEND_THIS);
+	const zval *password = Z_WHATWG_URL_PROP_PASSWORD_DEREF_P(ZEND_THIS);
+	const zval *host = Z_WHATWG_URL_PROP_HOST_DEREF_P(ZEND_THIS);
+	const zval *port = Z_WHATWG_URL_PROP_PORT_DEREF_P(ZEND_THIS);
+	const zval *path = Z_WHATWG_URL_PROP_PATH_DEREF_P(ZEND_THIS);
+	const zval *query = Z_WHATWG_URL_PROP_QUERY_DEREF_P(ZEND_THIS);
+	const zval *fragment = Z_WHATWG_URL_PROP_FRAGMENT_DEREF_P(ZEND_THIS);
+
+	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;
+	}
+
+	lxb_url_t *lexbor_url = php_uri_parser_whatwg_build_from_zval(
+		base_url, scheme, username, password, host, port, path, query, fragment,
+		errors
+	);
+	if (lexbor_url == NULL) {
+		RETURN_THROWS();
+	}
+
+	object_init_ex(return_value, php_uri_ce_whatwg_url);
+	php_uri_object *uri_object = Z_URI_OBJECT_P(return_value);
+	uri_object->parser = &php_uri_parser_whatwg;
+	uri_object->uri = lexbor_url;
+}
+
 PHPAPI php_uri_object *php_uri_object_create(zend_class_entry *class_type, const php_uri_parser *parser)
 {
 	php_uri_object *uri_object = zend_object_alloc(sizeof(*uri_object), class_type);
@@ -1326,6 +1471,8 @@ static PHP_MINIT_FUNCTION(uri)
 	php_uri_ce_rfc3986_uri_type = register_class_Uri_Rfc3986_UriType();
 	php_uri_ce_rfc3986_uri_host_type = register_class_Uri_Rfc3986_UriHostType();
 
+	php_uri_ce_whatwg_url_builder = register_class_Uri_WhatWg_UrlBuilder();
+
 	php_uri_ce_whatwg_url = register_class_Uri_WhatWg_Url();
 	php_uri_ce_whatwg_url->create_object = php_uri_object_create_whatwg;
 	php_uri_ce_whatwg_url->default_object_handlers = &object_handlers_whatwg_uri;
diff --git a/ext/uri/php_uri.stub.php b/ext/uri/php_uri.stub.php
index 81a766c0e16d..98f8873d9845 100644
--- a/ext/uri/php_uri.stub.php
+++ b/ext/uri/php_uri.stub.php
@@ -211,6 +211,39 @@ enum UrlHostType
         case Empty;
     }
 
+    final class UrlBuilder
+    {
+        private string $scheme = "";
+        private ?string $username = null;
+        private ?string $password = null;
+        private ?string $host = null;
+        private ?int $port = null;
+        private string $path = "";
+        private ?string $query = null;
+        private ?string $fragment = null;
+
+        public function reset(): static {}
+
+        public function setScheme(string $scheme): static {}
+
+        public function setUsername(?string $username): static {}
+
+        public function setPassword(#[\SensitiveParameter] ?string $password): static {}
+
+        public function setHost(?string $host): static {}
+
+        public function setPort(?int $port): static {}
+
+        public function setPath(string $path): static {}
+
+        public function setQuery(?string $query): static {}
+
+        public function setFragment(?string $fragment): static {}
+
+        /** @param array $errors */
+        public function build(?\Uri\WhatWg\Url $baseUrl = null, &$errors = null): \Uri\WhatWg\Url {}
+    }
+
     /** @strict-properties */
     final readonly class Url
     {
diff --git a/ext/uri/php_uri_arginfo.h b/ext/uri/php_uri_arginfo.h
index 1231408fbeb6..93cc3ee45a2b 100644
--- a/ext/uri/php_uri_arginfo.h
+++ b/ext/uri/php_uri_arginfo.h
@@ -1,5 +1,5 @@
 /* This is a generated file, edit php_uri.stub.php instead.
- * Stub hash: bfd9247fa79baf877d600134c9ef615ec5ca9cea
+ * Stub hash: 54e953b1da0d08c64509666b9278c59483d1e171
  * Has decl header: yes */
 
 ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Uri_Rfc3986_UriBuilder_reset, 0, 0, IS_STATIC, 0)
@@ -141,6 +141,35 @@ ZEND_BEGIN_ARG_INFO_EX(arginfo_class_Uri_WhatWg_UrlValidationError___construct,
 	ZEND_ARG_TYPE_INFO(0, failure, _IS_BOOL, 0)
 ZEND_END_ARG_INFO()
 
+#define arginfo_class_Uri_WhatWg_UrlBuilder_reset arginfo_class_Uri_Rfc3986_UriBuilder_reset
+
+ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Uri_WhatWg_UrlBuilder_setScheme, 0, 1, IS_STATIC, 0)
+	ZEND_ARG_TYPE_INFO(0, scheme, IS_STRING, 0)
+ZEND_END_ARG_INFO()
+
+ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Uri_WhatWg_UrlBuilder_setUsername, 0, 1, IS_STATIC, 0)
+	ZEND_ARG_TYPE_INFO(0, username, IS_STRING, 1)
+ZEND_END_ARG_INFO()
+
+ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Uri_WhatWg_UrlBuilder_setPassword, 0, 1, IS_STATIC, 0)
+	ZEND_ARG_TYPE_INFO(0, password, IS_STRING, 1)
+ZEND_END_ARG_INFO()
+
+#define arginfo_class_Uri_WhatWg_UrlBuilder_setHost arginfo_class_Uri_Rfc3986_UriBuilder_setHost
+
+#define arginfo_class_Uri_WhatWg_UrlBuilder_setPort arginfo_class_Uri_Rfc3986_UriBuilder_setPort
+
+#define arginfo_class_Uri_WhatWg_UrlBuilder_setPath arginfo_class_Uri_Rfc3986_UriBuilder_setPath
+
+#define arginfo_class_Uri_WhatWg_UrlBuilder_setQuery arginfo_class_Uri_Rfc3986_UriBuilder_setQuery
+
+#define arginfo_class_Uri_WhatWg_UrlBuilder_setFragment arginfo_class_Uri_Rfc3986_UriBuilder_setFragment
+
+ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_Uri_WhatWg_UrlBuilder_build, 0, 0, Uri\\WhatWg\\\125rl, 0)
+	ZEND_ARG_OBJ_INFO_WITH_DEFAULT_VALUE(0, baseUrl, Uri\\WhatWg\\\125rl, 1, "null")
+	ZEND_ARG_INFO_WITH_DEFAULT_VALUE(1, errors, "null")
+ZEND_END_ARG_INFO()
+
 ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Uri_WhatWg_Url_parse, 0, 1, IS_STATIC, 1)
 	ZEND_ARG_TYPE_INFO(0, uri, IS_STRING, 0)
 	ZEND_ARG_OBJ_INFO_WITH_DEFAULT_VALUE(0, baseUrl, Uri\\WhatWg\\\125rl, 1, "null")
@@ -155,24 +184,18 @@ ZEND_END_ARG_INFO()
 
 #define arginfo_class_Uri_WhatWg_Url_getScheme arginfo_class_Uri_Rfc3986_Uri_getPath
 
-ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Uri_WhatWg_Url_withScheme, 0, 1, IS_STATIC, 0)
-	ZEND_ARG_TYPE_INFO(0, scheme, IS_STRING, 0)
-ZEND_END_ARG_INFO()
+#define arginfo_class_Uri_WhatWg_Url_withScheme arginfo_class_Uri_WhatWg_UrlBuilder_setScheme
 
 ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Uri_WhatWg_Url_isSpecialScheme, 0, 0, _IS_BOOL, 0)
 ZEND_END_ARG_INFO()
 
 #define arginfo_class_Uri_WhatWg_Url_getUsername arginfo_class_Uri_Rfc3986_Uri_getScheme
 
-ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Uri_WhatWg_Url_withUsername, 0, 1, IS_STATIC, 0)
-	ZEND_ARG_TYPE_INFO(0, username, IS_STRING, 1)
-ZEND_END_ARG_INFO()
+#define arginfo_class_Uri_WhatWg_Url_withUsername arginfo_class_Uri_WhatWg_UrlBuilder_setUsername
 
 #define arginfo_class_Uri_WhatWg_Url_getPassword arginfo_class_Uri_Rfc3986_Uri_getScheme
 
-ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Uri_WhatWg_Url_withPassword, 0, 1, IS_STATIC, 0)
-	ZEND_ARG_TYPE_INFO(0, password, IS_STRING, 1)
-ZEND_END_ARG_INFO()
+#define arginfo_class_Uri_WhatWg_Url_withPassword arginfo_class_Uri_WhatWg_UrlBuilder_setPassword
 
 #define arginfo_class_Uri_WhatWg_Url_getAsciiHost arginfo_class_Uri_Rfc3986_Uri_getScheme
 
@@ -265,6 +288,16 @@ ZEND_METHOD(Uri_Rfc3986_Uri, __unserialize);
 ZEND_METHOD(Uri_Rfc3986_Uri, __debugInfo);
 ZEND_METHOD(Uri_WhatWg_InvalidUrlException, __construct);
 ZEND_METHOD(Uri_WhatWg_UrlValidationError, __construct);
+ZEND_METHOD(Uri_WhatWg_UrlBuilder, reset);
+ZEND_METHOD(Uri_WhatWg_UrlBuilder, setScheme);
+ZEND_METHOD(Uri_WhatWg_UrlBuilder, setUsername);
+ZEND_METHOD(Uri_WhatWg_UrlBuilder, setPassword);
+ZEND_METHOD(Uri_WhatWg_UrlBuilder, setHost);
+ZEND_METHOD(Uri_WhatWg_UrlBuilder, setPort);
+ZEND_METHOD(Uri_WhatWg_UrlBuilder, setPath);
+ZEND_METHOD(Uri_WhatWg_UrlBuilder, setQuery);
+ZEND_METHOD(Uri_WhatWg_UrlBuilder, setFragment);
+ZEND_METHOD(Uri_WhatWg_UrlBuilder, build);
 ZEND_METHOD(Uri_WhatWg_Url, parse);
 ZEND_METHOD(Uri_WhatWg_Url, __construct);
 ZEND_METHOD(Uri_WhatWg_Url, getScheme);
@@ -345,6 +378,20 @@ static const zend_function_entry class_Uri_WhatWg_UrlValidationError_methods[] =
 	ZEND_FE_END
 };
 
+static const zend_function_entry class_Uri_WhatWg_UrlBuilder_methods[] = {
+	ZEND_ME(Uri_WhatWg_UrlBuilder, reset, arginfo_class_Uri_WhatWg_UrlBuilder_reset, ZEND_ACC_PUBLIC)
+	ZEND_ME(Uri_WhatWg_UrlBuilder, setScheme, arginfo_class_Uri_WhatWg_UrlBuilder_setScheme, ZEND_ACC_PUBLIC)
+	ZEND_ME(Uri_WhatWg_UrlBuilder, setUsername, arginfo_class_Uri_WhatWg_UrlBuilder_setUsername, ZEND_ACC_PUBLIC)
+	ZEND_ME(Uri_WhatWg_UrlBuilder, setPassword, arginfo_class_Uri_WhatWg_UrlBuilder_setPassword, ZEND_ACC_PUBLIC)
+	ZEND_ME(Uri_WhatWg_UrlBuilder, setHost, arginfo_class_Uri_WhatWg_UrlBuilder_setHost, ZEND_ACC_PUBLIC)
+	ZEND_ME(Uri_WhatWg_UrlBuilder, setPort, arginfo_class_Uri_WhatWg_UrlBuilder_setPort, ZEND_ACC_PUBLIC)
+	ZEND_ME(Uri_WhatWg_UrlBuilder, setPath, arginfo_class_Uri_WhatWg_UrlBuilder_setPath, ZEND_ACC_PUBLIC)
+	ZEND_ME(Uri_WhatWg_UrlBuilder, setQuery, arginfo_class_Uri_WhatWg_UrlBuilder_setQuery, ZEND_ACC_PUBLIC)
+	ZEND_ME(Uri_WhatWg_UrlBuilder, setFragment, arginfo_class_Uri_WhatWg_UrlBuilder_setFragment, ZEND_ACC_PUBLIC)
+	ZEND_ME(Uri_WhatWg_UrlBuilder, build, arginfo_class_Uri_WhatWg_UrlBuilder_build, ZEND_ACC_PUBLIC)
+	ZEND_FE_END
+};
+
 static const zend_function_entry class_Uri_WhatWg_Url_methods[] = {
 	ZEND_ME(Uri_WhatWg_Url, parse, arginfo_class_Uri_WhatWg_Url_parse, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC)
 	ZEND_ME(Uri_WhatWg_Url, __construct, arginfo_class_Uri_WhatWg_Url___construct, ZEND_ACC_PUBLIC)
@@ -629,6 +676,51 @@ static zend_class_entry *register_class_Uri_WhatWg_UrlHostType(void)
 	return class_entry;
 }
 
+static zend_class_entry *register_class_Uri_WhatWg_UrlBuilder(void)
+{
+	zend_class_entry ce, *class_entry;
+
+	INIT_NS_CLASS_ENTRY(ce, "Uri\\WhatWg", "UrlBuilder", class_Uri_WhatWg_UrlBuilder_methods);
+	class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL);
+
+	zval property_scheme_default_value;
+	ZVAL_EMPTY_STRING(&property_scheme_default_value);
+	zend_declare_typed_property(class_entry, ZSTR_KNOWN(ZEND_STR_SCHEME), &property_scheme_default_value, ZEND_ACC_PRIVATE, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING));
+
+	zval property_username_default_value;
+	ZVAL_NULL(&property_username_default_value);
+	zend_declare_typed_property(class_entry, ZSTR_KNOWN(ZEND_STR_USERNAME), &property_username_default_value, ZEND_ACC_PRIVATE, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING|MAY_BE_NULL));
+
+	zval property_password_default_value;
+	ZVAL_NULL(&property_password_default_value);
+	zend_declare_typed_property(class_entry, ZSTR_KNOWN(ZEND_STR_PASSWORD), &property_password_default_value, ZEND_ACC_PRIVATE, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING|MAY_BE_NULL));
+
+	zval property_host_default_value;
+	ZVAL_NULL(&property_host_default_value);
+	zend_declare_typed_property(class_entry, ZSTR_KNOWN(ZEND_STR_HOST), &property_host_default_value, ZEND_ACC_PRIVATE, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING|MAY_BE_NULL));
+
+	zval property_port_default_value;
+	ZVAL_NULL(&property_port_default_value);
+	zend_declare_typed_property(class_entry, ZSTR_KNOWN(ZEND_STR_PORT), &property_port_default_value, ZEND_ACC_PRIVATE, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_LONG|MAY_BE_NULL));
+
+	zval property_path_default_value;
+	ZVAL_EMPTY_STRING(&property_path_default_value);
+	zend_declare_typed_property(class_entry, ZSTR_KNOWN(ZEND_STR_PATH), &property_path_default_value, ZEND_ACC_PRIVATE, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING));
+
+	zval property_query_default_value;
+	ZVAL_NULL(&property_query_default_value);
+	zend_declare_typed_property(class_entry, ZSTR_KNOWN(ZEND_STR_QUERY), &property_query_default_value, ZEND_ACC_PRIVATE, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING|MAY_BE_NULL));
+
+	zval property_fragment_default_value;
+	ZVAL_NULL(&property_fragment_default_value);
+	zend_declare_typed_property(class_entry, ZSTR_KNOWN(ZEND_STR_FRAGMENT), &property_fragment_default_value, ZEND_ACC_PRIVATE, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING|MAY_BE_NULL));
+
+
+	zend_add_parameter_attribute(zend_hash_str_find_ptr(&class_entry->function_table, "setpassword", sizeof("setpassword") - 1), 0, ZSTR_KNOWN(ZEND_STR_SENSITIVEPARAMETER), 0);
+
+	return class_entry;
+}
+
 static zend_class_entry *register_class_Uri_WhatWg_Url(void)
 {
 	zend_class_entry ce, *class_entry;
diff --git a/ext/uri/php_uri_common.c b/ext/uri/php_uri_common.c
index 0644afda3105..e4850d8059ee 100644
--- a/ext/uri/php_uri_common.c
+++ b/ext/uri/php_uri_common.c
@@ -40,6 +40,31 @@ static zend_string *get_known_string_by_property_name(php_uri_property_name prop
 	}
 }
 
+zend_result php_uri_pass_errors_by_ref_and_free(zval *errors_zv, zval *errors)
+{
+	ZEND_ASSERT(Z_TYPE_P(errors) == IS_UNDEF || Z_TYPE_P(errors) == IS_ARRAY);
+
+	/* There was no error during parsing */
+	if (Z_ISUNDEF_P(errors)) {
+		return SUCCESS;
+	}
+
+	/* The errors parameter is an array, but the pass-by ref argument stored by
+	 * errors_zv was not passed - the URI implementation either doesn't support
+	 * returning additional error information, or the caller is not interested in it */
+	if (errors_zv == NULL) {
+		zval_ptr_dtor(errors);
+		return SUCCESS;
+	}
+
+	ZEND_TRY_ASSIGN_REF_TMP(errors_zv, errors);
+	if (EG(exception)) {
+		return FAILURE;
+	}
+
+	return SUCCESS;
+}
+
 void php_uri_property_read_helper(INTERNAL_FUNCTION_PARAMETERS, php_uri_property_name property_name, php_uri_component_read_mode component_read_mode)
 {
 	ZEND_PARSE_PARAMETERS_NONE();
diff --git a/ext/uri/php_uri_common.h b/ext/uri/php_uri_common.h
index 9106f6acd15f..31ef1dd2130c 100644
--- a/ext/uri/php_uri_common.h
+++ b/ext/uri/php_uri_common.h
@@ -21,6 +21,7 @@ extern zend_class_entry *php_uri_ce_rfc3986_uri_builder;
 extern zend_class_entry *php_uri_ce_rfc3986_uri;
 extern zend_class_entry *php_uri_ce_rfc3986_uri_type;
 extern zend_class_entry *php_uri_ce_rfc3986_uri_host_type;
+extern zend_class_entry *php_uri_ce_whatwg_url_builder;
 extern zend_class_entry *php_uri_ce_whatwg_url;
 extern zend_class_entry *php_uri_ce_comparison_mode;
 extern zend_class_entry *php_uri_ce_exception;
@@ -186,6 +187,7 @@ static inline const php_uri_property_handler *php_uri_parser_property_handler_by
 	}
 }
 
+zend_result php_uri_pass_errors_by_ref_and_free(zval *errors_zv, zval *errors);
 void php_uri_property_read_helper(INTERNAL_FUNCTION_PARAMETERS, php_uri_property_name property_name, php_uri_component_read_mode component_read_mode);
 void php_uri_property_write_str_helper(INTERNAL_FUNCTION_PARAMETERS, php_uri_property_name property_name);
 void php_uri_property_write_str_or_null_helper(INTERNAL_FUNCTION_PARAMETERS, php_uri_property_name property_name);
diff --git a/ext/uri/php_uri_decl.h b/ext/uri/php_uri_decl.h
index 9c817659cf76..71f748b71070 100644
--- a/ext/uri/php_uri_decl.h
+++ b/ext/uri/php_uri_decl.h
@@ -1,8 +1,8 @@
 /* This is a generated file, edit php_uri.stub.php instead.
- * Stub hash: bfd9247fa79baf877d600134c9ef615ec5ca9cea */
+ * Stub hash: 54e953b1da0d08c64509666b9278c59483d1e171 */
 
-#ifndef ZEND_PHP_URI_DECL_bfd9247fa79baf877d600134c9ef615ec5ca9cea_H
-#define ZEND_PHP_URI_DECL_bfd9247fa79baf877d600134c9ef615ec5ca9cea_H
+#ifndef ZEND_PHP_URI_DECL_54e953b1da0d08c64509666b9278c59483d1e171_H
+#define ZEND_PHP_URI_DECL_54e953b1da0d08c64509666b9278c59483d1e171_H
 
 typedef enum zend_enum_Uri_UriComparisonMode {
 	ZEND_ENUM_Uri_UriComparisonMode_IncludeFragment = 1,
@@ -63,4 +63,4 @@ typedef enum zend_enum_Uri_WhatWg_UrlHostType {
 	ZEND_ENUM_Uri_WhatWg_UrlHostType_Empty = 5,
 } zend_enum_Uri_WhatWg_UrlHostType;
 
-#endif /* ZEND_PHP_URI_DECL_bfd9247fa79baf877d600134c9ef615ec5ca9cea_H */
+#endif /* ZEND_PHP_URI_DECL_54e953b1da0d08c64509666b9278c59483d1e171_H */
diff --git a/ext/uri/tests/rfc3986/builder/host_error_ipv6_closing_brace.phpt b/ext/uri/tests/rfc3986/builder/host_error_ipv6_closing_brace.phpt
index 45eef13faf71..feda6170b38e 100644
--- a/ext/uri/tests/rfc3986/builder/host_error_ipv6_closing_brace.phpt
+++ b/ext/uri/tests/rfc3986/builder/host_error_ipv6_closing_brace.phpt
@@ -6,7 +6,7 @@ Test Uri\Rfc3986\UriBuilder::setHost() - error - missing IPv6 closing brace
 $builder = new Uri\Rfc3986\UriBuilder();
 
 try {
-    $builder->setHost("[2001:%30db8:85a3:0000:0000:8a2e:0370:7334");
+    $builder->setHost("[2001:db8:85a3:0000:0000:8a2e:0370:7334");
 } catch (Throwable $e) {
     echo $e::class, ": ", $e->getMessage(), PHP_EOL;
 }
diff --git a/ext/uri/tests/whatwg/builder/all_success_with_reset.phpt b/ext/uri/tests/whatwg/builder/all_success_with_reset.phpt
new file mode 100644
index 000000000000..224f07b7ef91
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/all_success_with_reset.phpt
@@ -0,0 +1,72 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder all components - success - calling reset() afterwards
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder()
+    ->setScheme("https")
+    ->setUsername("user")
+    ->setPassword("pass")
+    ->setHost("example.com")
+    ->setPort(444)
+    ->setPath("/foo/bar/baz")
+    ->setQuery("foo=1&bar=baz")
+    ->setFragment("fragment");
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+$url = $builder
+    ->reset()
+    ->setScheme("scheme")
+    ->setHost("www.example.com")
+    ->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(68) "https://user:[email protected]:444/foo/bar/baz?foo=1&bar=baz#fragment"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(5) "https"
+  ["username"]=>
+  string(4) "user"
+  ["password"]=>
+  string(4) "pass"
+  ["host"]=>
+  string(11) "example.com"
+  ["port"]=>
+  int(444)
+  ["path"]=>
+  string(12) "/foo/bar/baz"
+  ["query"]=>
+  string(13) "foo=1&bar=baz"
+  ["fragment"]=>
+  string(8) "fragment"
+}
+bool(true)
+string(24) "scheme://www.example.com"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(6) "scheme"
+  ["username"]=>
+  NULL
+  ["password"]=>
+  NULL
+  ["host"]=>
+  string(15) "www.example.com"
+  ["port"]=>
+  NULL
+  ["path"]=>
+  string(0) ""
+  ["query"]=>
+  NULL
+  ["fragment"]=>
+  NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/basic_error_with_base.phpt b/ext/uri/tests/whatwg/builder/basic_error_with_base.phpt
new file mode 100644
index 000000000000..6f65cdacdb4a
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/basic_error_with_base.phpt
@@ -0,0 +1,17 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder basic - error - with base URL
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setPath("/foo/bar/baz");
+
+try {
+    $builder->build(new Uri\WhatWg\Url("/foo/bar"));
+} catch (Throwable $e) {
+    echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified URI is malformed (MissingSchemeNonRelativeUrl)
diff --git a/ext/uri/tests/whatwg/builder/basic_success_with_base.phpt b/ext/uri/tests/whatwg/builder/basic_success_with_base.phpt
new file mode 100644
index 000000000000..1fc354581db6
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/basic_success_with_base.phpt
@@ -0,0 +1,37 @@
+--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
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setPath("/foo/bar/baz");
+$url = $builder->build(new Uri\WhatWg\Url("https://example.com"));
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(31) "https://example.com/foo/bar/baz"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(5) "https"
+  ["username"]=>
+  NULL
+  ["password"]=>
+  NULL
+  ["host"]=>
+  string(11) "example.com"
+  ["port"]=>
+  NULL
+  ["path"]=>
+  string(12) "/foo/bar/baz"
+  ["query"]=>
+  NULL
+  ["fragment"]=>
+  NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/fragment_success_hashmark.phpt b/ext/uri/tests/whatwg/builder/fragment_success_hashmark.phpt
new file mode 100644
index 000000000000..430ba470ba79
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/fragment_success_hashmark.phpt
@@ -0,0 +1,37 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setFragment() - success - leading hashmark is ignored
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("https");
+$builder->setHost("example.com");
+$builder->setFragment("#foo");
+$url = $builder->build();
+
+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_ignorable_char.phpt b/ext/uri/tests/whatwg/builder/fragment_success_ignorable_char.phpt
new file mode 100644
index 000000000000..f4940291f75d
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/fragment_success_ignorable_char.phpt
@@ -0,0 +1,51 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setFragment() - success - contains tab and newline characters
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("foo");
+$builder->setHost("example.com");
+$builder->setFragment("\tfo\no");
+$errors = [];
+$url = $builder->build(errors: $errors);
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($errors);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(21) "foo://example.com#foo"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(3) "foo"
+  ["username"]=>
+  NULL
+  ["password"]=>
+  NULL
+  ["host"]=>
+  string(11) "example.com"
+  ["port"]=>
+  NULL
+  ["path"]=>
+  string(0) ""
+  ["query"]=>
+  NULL
+  ["fragment"]=>
+  string(3) "foo"
+}
+array(%d) {
+  [0]=>
+  object(Uri\WhatWg\UrlValidationError)#%d (%d) {
+    ["context"]=>
+    string(5) "	fo
+o"
+    ["type"]=>
+    enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit)
+    ["failure"]=>
+    bool(false)
+  }
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/fragment_success_null.phpt b/ext/uri/tests/whatwg/builder/fragment_success_null.phpt
new file mode 100644
index 000000000000..bff74443bbb9
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/fragment_success_null.phpt
@@ -0,0 +1,38 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setFragment() - success - null
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("https");
+$builder->setHost("example.com");
+$builder->setFragment("foo");
+$builder->setFragment(null);
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(20) "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"]=>
+  NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/fragment_success_special_char.phpt b/ext/uri/tests/whatwg/builder/fragment_success_special_char.phpt
new file mode 100644
index 000000000000..d153274de0fc
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/fragment_success_special_char.phpt
@@ -0,0 +1,37 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setFragment() - success - contains special character
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("https");
+$builder->setHost("example.com");
+$builder->setFragment(" <foo>");
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(33) "https://example.com/#%20%3Cfoo%3E"
+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(12) "%20%3Cfoo%3E"
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/fragment_success_unicode_char.phpt b/ext/uri/tests/whatwg/builder/fragment_success_unicode_char.phpt
new file mode 100644
index 000000000000..0153432eedb4
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/fragment_success_unicode_char.phpt
@@ -0,0 +1,37 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setFragment() - success - contains Unicode character
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("https");
+$builder->setHost("example.com");
+$builder->setFragment("főő");
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(34) "https://example.com/#f%C5%91%C5%91"
+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(13) "f%C5%91%C5%91"
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/host_error_ipv6_closing_brace_opaque.phpt b/ext/uri/tests/whatwg/builder/host_error_ipv6_closing_brace_opaque.phpt
new file mode 100644
index 000000000000..7f3fbb35da63
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/host_error_ipv6_closing_brace_opaque.phpt
@@ -0,0 +1,17 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setHost() - error - missing IPv6 closing brace of an opaque host
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("scheme");
+
+try {
+    $builder->setHost("[2001:db8:85a3:0000:0000:8a2e:0370:7334");
+} catch (Throwable $e) {
+    echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified host is malformed (Ipv6Unclosed)
diff --git a/ext/uri/tests/whatwg/builder/host_error_ipv6_closing_brace_special.phpt b/ext/uri/tests/whatwg/builder/host_error_ipv6_closing_brace_special.phpt
new file mode 100644
index 000000000000..94ba827ca325
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/host_error_ipv6_closing_brace_special.phpt
@@ -0,0 +1,17 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setHost() - error - missing IPv6 closing brace of a special host
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("https");
+
+try {
+    $builder->setHost("[2001:%30db8:85a3:0000:0000:8a2e:0370:7334");
+} catch (Throwable $e) {
+    echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified host is malformed (Ipv6Unclosed)
diff --git a/ext/uri/tests/whatwg/builder/host_error_null_special.phpt b/ext/uri/tests/whatwg/builder/host_error_null_special.phpt
new file mode 100644
index 000000000000..973f4ef9aca3
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/host_error_null_special.phpt
@@ -0,0 +1,18 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setHost() - success - null in case of special URLs
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("https");
+$builder->setHost(null);
+
+try {
+    $builder->build();
+} catch (Throwable $e) {
+    echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified host is malformed (HostMissing)
diff --git a/ext/uri/tests/whatwg/builder/host_error_percent_encoding1.phpt b/ext/uri/tests/whatwg/builder/host_error_percent_encoding1.phpt
new file mode 100644
index 000000000000..2dc8c76189fd
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/host_error_percent_encoding1.phpt
@@ -0,0 +1,18 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setHost() - error - invalid percent encoding octet in registered name
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("https");
+$builder->setHost("ex%3mple.co");
+
+try {
+    $builder->build();
+} catch (Throwable $e) {
+    echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified host is malformed (DomainInvalidCodePoint)
diff --git a/ext/uri/tests/whatwg/builder/host_error_percent_encoding2.phpt b/ext/uri/tests/whatwg/builder/host_error_percent_encoding2.phpt
new file mode 100644
index 000000000000..90a228ded22b
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/host_error_percent_encoding2.phpt
@@ -0,0 +1,17 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setHost() - error - invalid percent encoded octet in IPv6
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("https");
+
+try {
+    $builder->setHost("[2001:%308:85a3:0000:0000:8a2e:0370:7334]");
+} catch (Throwable $e) {
+    echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified host is malformed (Ipv6InvalidCodePoint)
diff --git a/ext/uri/tests/whatwg/builder/host_error_percent_encoding3.phpt b/ext/uri/tests/whatwg/builder/host_error_percent_encoding3.phpt
new file mode 100644
index 000000000000..aee9f95d1f44
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/host_error_percent_encoding3.phpt
@@ -0,0 +1,18 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setHost() - error - invalid percent encoded octet in IPv4
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("https");
+$builder->setHost("192.168.%8.1");
+
+try {
+    $builder->build();
+} catch (Throwable $e) {
+    echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified host is malformed (DomainInvalidCodePoint)
diff --git a/ext/uri/tests/whatwg/builder/host_success_empty_opaque.phpt b/ext/uri/tests/whatwg/builder/host_success_empty_opaque.phpt
new file mode 100644
index 000000000000..e9668f466c43
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/host_success_empty_opaque.phpt
@@ -0,0 +1,36 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setHost() - success - empty in case of opaque URLs
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("scheme");
+$builder->setHost("");
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(9) "scheme://"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(6) "scheme"
+  ["username"]=>
+  NULL
+  ["password"]=>
+  NULL
+  ["host"]=>
+  string(0) ""
+  ["port"]=>
+  NULL
+  ["path"]=>
+  string(0) ""
+  ["query"]=>
+  NULL
+  ["fragment"]=>
+  NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/host_success_file.phpt b/ext/uri/tests/whatwg/builder/host_success_file.phpt
new file mode 100644
index 000000000000..2264c2faffbe
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/host_success_file.phpt
@@ -0,0 +1,37 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setHost() - success - file scheme
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("file");
+$builder->setHost("example.com");
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(19) "file://example.com/"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(4) "file"
+  ["username"]=>
+  NULL
+  ["password"]=>
+  NULL
+  ["host"]=>
+  string(11) "example.com"
+  ["port"]=>
+  NULL
+  ["path"]=>
+  string(1) "/"
+  ["query"]=>
+  NULL
+  ["fragment"]=>
+  NULL
+}
+bool(true)
+
diff --git a/ext/uri/tests/whatwg/builder/host_success_ipv4.phpt b/ext/uri/tests/whatwg/builder/host_success_ipv4.phpt
new file mode 100644
index 000000000000..2dad23e1f214
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/host_success_ipv4.phpt
@@ -0,0 +1,36 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setHost() - success - IPv4 address
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("https");
+$builder->setHost("192.168.0.1");
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(20) "https://192.168.0.1/"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(5) "https"
+  ["username"]=>
+  NULL
+  ["password"]=>
+  NULL
+  ["host"]=>
+  string(11) "192.168.0.1"
+  ["port"]=>
+  NULL
+  ["path"]=>
+  string(1) "/"
+  ["query"]=>
+  NULL
+  ["fragment"]=>
+  NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/host_success_ipv4_percent_encoding.phpt b/ext/uri/tests/whatwg/builder/host_success_ipv4_percent_encoding.phpt
new file mode 100644
index 000000000000..955796860d66
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/host_success_ipv4_percent_encoding.phpt
@@ -0,0 +1,36 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setHost() - success - percent-encoding in IPv4 address
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("https");
+$builder->setHost("192.168.%30.1");
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(20) "https://192.168.0.1/"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(5) "https"
+  ["username"]=>
+  NULL
+  ["password"]=>
+  NULL
+  ["host"]=>
+  string(11) "192.168.0.1"
+  ["port"]=>
+  NULL
+  ["path"]=>
+  string(1) "/"
+  ["query"]=>
+  NULL
+  ["fragment"]=>
+  NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/host_success_ipv6.phpt b/ext/uri/tests/whatwg/builder/host_success_ipv6.phpt
new file mode 100644
index 000000000000..48e37d22a8ce
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/host_success_ipv6.phpt
@@ -0,0 +1,36 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setHost() - success - IPv6 address
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("https");
+$builder->setHost("[2001:0db8:85a3:0000:0000:8a2e:0370:7334]");
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(39) "https://[2001:db8:85a3::8a2e:370:7334]/"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(5) "https"
+  ["username"]=>
+  NULL
+  ["password"]=>
+  NULL
+  ["host"]=>
+  string(30) "[2001:db8:85a3::8a2e:370:7334]"
+  ["port"]=>
+  NULL
+  ["path"]=>
+  string(1) "/"
+  ["query"]=>
+  NULL
+  ["fragment"]=>
+  NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/host_success_opaque_null.phpt b/ext/uri/tests/whatwg/builder/host_success_opaque_null.phpt
new file mode 100644
index 000000000000..9921721af17c
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/host_success_opaque_null.phpt
@@ -0,0 +1,37 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setHost() - success - null in case of opaque hosts
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("scheme");
+$builder->setHost("example.com");
+$builder->setHost(null);
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(9) "scheme://"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(6) "scheme"
+  ["username"]=>
+  NULL
+  ["password"]=>
+  NULL
+  ["host"]=>
+  string(0) ""
+  ["port"]=>
+  NULL
+  ["path"]=>
+  string(0) ""
+  ["query"]=>
+  NULL
+  ["fragment"]=>
+  NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/host_success_regname.phpt b/ext/uri/tests/whatwg/builder/host_success_regname.phpt
new file mode 100644
index 000000000000..d72b919c0afb
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/host_success_regname.phpt
@@ -0,0 +1,36 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setHost() - success - Registered name
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("https");
+$builder->setHost("www.example.com");
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(24) "https://www.example.com/"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(5) "https"
+  ["username"]=>
+  NULL
+  ["password"]=>
+  NULL
+  ["host"]=>
+  string(15) "www.example.com"
+  ["port"]=>
+  NULL
+  ["path"]=>
+  string(1) "/"
+  ["query"]=>
+  NULL
+  ["fragment"]=>
+  NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/password_error_empty_opaque_host.phpt b/ext/uri/tests/whatwg/builder/password_error_empty_opaque_host.phpt
new file mode 100644
index 000000000000..5c79856b93c9
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/password_error_empty_opaque_host.phpt
@@ -0,0 +1,19 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setPassword() - error - empty opaque host
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("scheme");
+$builder->setHost("");
+$builder->setPassword("password");
+
+try {
+    $builder->build();
+} catch (Throwable $e) {
+    echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified URL cannot have password
diff --git a/ext/uri/tests/whatwg/builder/password_error_file_scheme.phpt b/ext/uri/tests/whatwg/builder/password_error_file_scheme.phpt
new file mode 100644
index 000000000000..e6f4f993c4d2
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/password_error_file_scheme.phpt
@@ -0,0 +1,19 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setPassword() - error - file scheme
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("file");
+$builder->setPath("C:/a.txt");
+$builder->setPassword("password");
+
+try {
+    $builder->build();
+} catch (Throwable $e) {
+    echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified URL cannot have password
diff --git a/ext/uri/tests/whatwg/builder/password_error_missing_host.phpt b/ext/uri/tests/whatwg/builder/password_error_missing_host.phpt
new file mode 100644
index 000000000000..9b3ba0876336
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/password_error_missing_host.phpt
@@ -0,0 +1,18 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setPassword() - error - missing host
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("https");
+$builder->setPassword("pass");
+
+try {
+    $builder->build();
+} catch (Throwable $e) {
+    echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified URL cannot have password
diff --git a/ext/uri/tests/whatwg/builder/password_error_missing_opaque_host.phpt b/ext/uri/tests/whatwg/builder/password_error_missing_opaque_host.phpt
new file mode 100644
index 000000000000..efc14b06a413
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/password_error_missing_opaque_host.phpt
@@ -0,0 +1,18 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setPassword() - error - missing opaque host
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("scheme");
+$builder->setPassword("password");
+
+try {
+    $builder->build();
+} catch (Throwable $e) {
+    echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified URL cannot have password
diff --git a/ext/uri/tests/whatwg/builder/password_success_null.phpt b/ext/uri/tests/whatwg/builder/password_success_null.phpt
new file mode 100644
index 000000000000..842b85807d79
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/password_success_null.phpt
@@ -0,0 +1,38 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setPassword() - success - null
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("https");
+$builder->setHost("example.com");
+$builder->setPassword("pass");
+$builder->setPassword(null);
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(20) "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"]=>
+  NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/path_success_empty_string.phpt b/ext/uri/tests/whatwg/builder/path_success_empty_string.phpt
new file mode 100644
index 000000000000..d3a4509cbf63
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/path_success_empty_string.phpt
@@ -0,0 +1,37 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setPath() - success - empty string
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("file");
+$builder->setPath("/foo/bar");
+$builder->setPath("");
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(8) "file:///"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(4) "file"
+  ["username"]=>
+  NULL
+  ["password"]=>
+  NULL
+  ["host"]=>
+  string(0) ""
+  ["port"]=>
+  NULL
+  ["path"]=>
+  string(1) "/"
+  ["query"]=>
+  NULL
+  ["fragment"]=>
+  NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/path_success_first_segment_colon.phpt b/ext/uri/tests/whatwg/builder/path_success_first_segment_colon.phpt
new file mode 100644
index 000000000000..544dcc0e346f
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/path_success_first_segment_colon.phpt
@@ -0,0 +1,36 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setPath() - success - contains a colon in the first segment when the scheme is present
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("file");
+$builder->setPath(":foo/bar/baz");
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(20) "file:///:foo/bar/baz"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(4) "file"
+  ["username"]=>
+  NULL
+  ["password"]=>
+  NULL
+  ["host"]=>
+  string(0) ""
+  ["port"]=>
+  NULL
+  ["path"]=>
+  string(13) "/:foo/bar/baz"
+  ["query"]=>
+  NULL
+  ["fragment"]=>
+  NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/path_success_leading_double_slash1.phpt b/ext/uri/tests/whatwg/builder/path_success_leading_double_slash1.phpt
new file mode 100644
index 000000000000..b979e704550c
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/path_success_leading_double_slash1.phpt
@@ -0,0 +1,36 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setPath() - success - begins with double slashes when the host is not present
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("file");
+$builder->setPath("//foo/bar/baz");
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(20) "file:////foo/bar/baz"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(4) "file"
+  ["username"]=>
+  NULL
+  ["password"]=>
+  NULL
+  ["host"]=>
+  string(0) ""
+  ["port"]=>
+  NULL
+  ["path"]=>
+  string(13) "//foo/bar/baz"
+  ["query"]=>
+  NULL
+  ["fragment"]=>
+  NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/path_success_leading_double_slash2.phpt b/ext/uri/tests/whatwg/builder/path_success_leading_double_slash2.phpt
new file mode 100644
index 000000000000..cc090409a39d
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/path_success_leading_double_slash2.phpt
@@ -0,0 +1,37 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setPath() - success - begins with double slashes when the opaque URL contains a host
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("scheme");
+$builder->setHost("example.com");
+$builder->setPath("//foo/bar/baz");
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(33) "scheme://example.com//foo/bar/baz"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(6) "scheme"
+  ["username"]=>
+  NULL
+  ["password"]=>
+  NULL
+  ["host"]=>
+  string(11) "example.com"
+  ["port"]=>
+  NULL
+  ["path"]=>
+  string(13) "//foo/bar/baz"
+  ["query"]=>
+  NULL
+  ["fragment"]=>
+  NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/path_success_special_char.phpt b/ext/uri/tests/whatwg/builder/path_success_special_char.phpt
new file mode 100644
index 000000000000..b47a44b1e133
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/path_success_special_char.phpt
@@ -0,0 +1,37 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setPath() - success - contains special character
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("scheme");
+$builder->setPath("#foo");
+
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(16) "scheme:///%23foo"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(6) "scheme"
+  ["username"]=>
+  NULL
+  ["password"]=>
+  NULL
+  ["host"]=>
+  string(0) ""
+  ["port"]=>
+  NULL
+  ["path"]=>
+  string(7) "/%23foo"
+  ["query"]=>
+  NULL
+  ["fragment"]=>
+  NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/path_success_without_leading_slash.phpt b/ext/uri/tests/whatwg/builder/path_success_without_leading_slash.phpt
new file mode 100644
index 000000000000..1f66344ae64d
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/path_success_without_leading_slash.phpt
@@ -0,0 +1,37 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setPath() - success - without a leading slash when the URL contains a host
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("https");
+$builder->setPath("foo/bar/baz");
+$builder->setHost("example.com");
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(31) "https://example.com/foo/bar/baz"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(5) "https"
+  ["username"]=>
+  NULL
+  ["password"]=>
+  NULL
+  ["host"]=>
+  string(11) "example.com"
+  ["port"]=>
+  NULL
+  ["path"]=>
+  string(12) "/foo/bar/baz"
+  ["query"]=>
+  NULL
+  ["fragment"]=>
+  NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/port_error_empty_opaque_host.phpt b/ext/uri/tests/whatwg/builder/port_error_empty_opaque_host.phpt
new file mode 100644
index 000000000000..acf82a1a12cb
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/port_error_empty_opaque_host.phpt
@@ -0,0 +1,19 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setPort() - error - empty opaque host
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("scheme");
+$builder->setHost("");
+$builder->setPort(443);
+
+try {
+    $builder->build();
+} catch (Throwable $e) {
+    echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified URL cannot have port
diff --git a/ext/uri/tests/whatwg/builder/port_error_file_scheme.phpt b/ext/uri/tests/whatwg/builder/port_error_file_scheme.phpt
new file mode 100644
index 000000000000..08559dbfd208
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/port_error_file_scheme.phpt
@@ -0,0 +1,19 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setPort() - error - file scheme
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("file");
+$builder->setPath("C:/a.txt");
+$builder->setPort(443);
+
+try {
+    $builder->build();
+} catch (Throwable $e) {
+    echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified URL cannot have port
diff --git a/ext/uri/tests/whatwg/builder/port_error_file_with_host_and_port.phpt b/ext/uri/tests/whatwg/builder/port_error_file_with_host_and_port.phpt
new file mode 100644
index 000000000000..9e4e17ca3ae3
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/port_error_file_with_host_and_port.phpt
@@ -0,0 +1,19 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setPort() - error - file scheme with host and port
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("file");
+$builder->setHost("example.com");
+$builder->setPort(123);
+
+try {
+    $builder->build();
+} catch (Throwable $e) {
+    echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified URL cannot have port
diff --git a/ext/uri/tests/whatwg/builder/port_error_large.phpt b/ext/uri/tests/whatwg/builder/port_error_large.phpt
new file mode 100644
index 000000000000..46e80e1689b7
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/port_error_large.phpt
@@ -0,0 +1,21 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setPort() - error - too large number
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+
+try {
+    /*
+    TODO the username/password/port is sometimes silently disregarded when no validation happens:
+     https://github.com/php/php-src/blob/27d7b799c0a13578ee0506b428b8ddc209ffb010/ext/lexbor/lexbor/url/url.c#L4382
+     So maybe setPort() shouldn't validate either.
+    */
+    $builder->setPort(PHP_INT_MAX);
+} catch (Throwable $e) {
+    echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified port is malformed (PortOutOfRange)
diff --git a/ext/uri/tests/whatwg/builder/port_error_missing_opaque_host.phpt b/ext/uri/tests/whatwg/builder/port_error_missing_opaque_host.phpt
new file mode 100644
index 000000000000..3130b27cfcb7
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/port_error_missing_opaque_host.phpt
@@ -0,0 +1,18 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setPort() - error - missing opaque host
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("scheme");
+$builder->setPort(443);
+
+try {
+    $builder->build();
+} catch (Throwable $e) {
+    echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified URL cannot have port
diff --git a/ext/uri/tests/whatwg/builder/port_error_negative.phpt b/ext/uri/tests/whatwg/builder/port_error_negative.phpt
new file mode 100644
index 000000000000..a1a723f43ca5
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/port_error_negative.phpt
@@ -0,0 +1,16 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setPort() - error - negative number
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+
+try {
+    $builder->setPort(-1);
+} catch (Throwable $e) {
+    echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified port is malformed (PortInvalid)
diff --git a/ext/uri/tests/whatwg/builder/port_success_default.phpt b/ext/uri/tests/whatwg/builder/port_success_default.phpt
new file mode 100644
index 000000000000..cb4f55c609ca
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/port_success_default.phpt
@@ -0,0 +1,37 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setPort() - success - default port
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("https");
+$builder->setPort(443);
+$builder->setHost("example.com");
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(20) "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"]=>
+  NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/port_success_non_default.phpt b/ext/uri/tests/whatwg/builder/port_success_non_default.phpt
new file mode 100644
index 000000000000..919e9eb6983c
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/port_success_non_default.phpt
@@ -0,0 +1,37 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setPort() - success - non-default port
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("https");
+$builder->setPort(444);
+$builder->setHost("example.com");
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(24) "https://example.com:444/"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(5) "https"
+  ["username"]=>
+  NULL
+  ["password"]=>
+  NULL
+  ["host"]=>
+  string(11) "example.com"
+  ["port"]=>
+  int(444)
+  ["path"]=>
+  string(1) "/"
+  ["query"]=>
+  NULL
+  ["fragment"]=>
+  NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/port_success_null.phpt b/ext/uri/tests/whatwg/builder/port_success_null.phpt
new file mode 100644
index 000000000000..2c097112092f
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/port_success_null.phpt
@@ -0,0 +1,38 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setPort() - success - null
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("https");
+$builder->setHost("example.com");
+$builder->setPort(123);
+$builder->setPort(null);
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(20) "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"]=>
+  NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/query_success_basic.phpt b/ext/uri/tests/whatwg/builder/query_success_basic.phpt
new file mode 100644
index 000000000000..e3257c5d0cef
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/query_success_basic.phpt
@@ -0,0 +1,37 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setQuery() - success - basic
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("https");
+$builder->setHost("example.com");
+$builder->setQuery("foo=1&bar=baz");
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(34) "https://example.com/?foo=1&bar=baz"
+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(13) "foo=1&bar=baz"
+  ["fragment"]=>
+  NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/query_success_null.phpt b/ext/uri/tests/whatwg/builder/query_success_null.phpt
new file mode 100644
index 000000000000..a634a5403752
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/query_success_null.phpt
@@ -0,0 +1,38 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setQuery() - success - null
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("https");
+$builder->setHost("example.com");
+$builder->setQuery("foo");
+$builder->setQuery(null);
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(20) "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"]=>
+  NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/query_success_question_mark.phpt b/ext/uri/tests/whatwg/builder/query_success_question_mark.phpt
new file mode 100644
index 000000000000..b88a4aacb627
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/query_success_question_mark.phpt
@@ -0,0 +1,37 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setQuery() - success - leading question mark is ignored
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("https");
+$builder->setHost("example.com");
+$builder->setQuery("?foo");
+$url = $builder->build();
+
+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"]=>
+  string(3) "foo"
+  ["fragment"]=>
+  NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/query_success_unicode_char.phpt b/ext/uri/tests/whatwg/builder/query_success_unicode_char.phpt
new file mode 100644
index 000000000000..9874fb9a47df
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/query_success_unicode_char.phpt
@@ -0,0 +1,37 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setQuery() - success - contains Unicode character
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("https");
+$builder->setHost("example.com");
+$builder->setQuery("főő");
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(34) "https://example.com/?f%C5%91%C5%91"
+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(13) "f%C5%91%C5%91"
+  ["fragment"]=>
+  NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/scheme_error_c0_control_space_char.phpt b/ext/uri/tests/whatwg/builder/scheme_error_c0_control_space_char.phpt
new file mode 100644
index 000000000000..285e91132469
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/scheme_error_c0_control_space_char.phpt
@@ -0,0 +1,16 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setScheme() - error - contains leading and trailing C0 control and space characters
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+
+try {
+    $builder->setScheme(" \x01https \x02");
+} catch (Throwable $e) {
+    echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified scheme is malformed (MissingSchemeNonRelativeUrl)
diff --git a/ext/uri/tests/whatwg/builder/scheme_error_empty.phpt b/ext/uri/tests/whatwg/builder/scheme_error_empty.phpt
new file mode 100644
index 000000000000..f4090a9c4aac
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/scheme_error_empty.phpt
@@ -0,0 +1,16 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setScheme() - error - empty
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+
+try {
+    $builder->setScheme("");
+} catch (Throwable $e) {
+    echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified scheme is malformed (MissingSchemeNonRelativeUrl)
diff --git a/ext/uri/tests/whatwg/builder/scheme_error_first_char.phpt b/ext/uri/tests/whatwg/builder/scheme_error_first_char.phpt
new file mode 100644
index 000000000000..34a70c043aa3
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/scheme_error_first_char.phpt
@@ -0,0 +1,16 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setScheme() - error - first character is not alpha
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+
+try {
+    $builder->setScheme("1");
+} catch (Throwable $e) {
+    echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified scheme is malformed (MissingSchemeNonRelativeUrl)
diff --git a/ext/uri/tests/whatwg/builder/scheme_error_special_char.phpt b/ext/uri/tests/whatwg/builder/scheme_error_special_char.phpt
new file mode 100644
index 000000000000..c37abc1ebb7c
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/scheme_error_special_char.phpt
@@ -0,0 +1,16 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setScheme() - error - contains invalid special character
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+
+try {
+    $builder->setScheme(":");
+} catch (Throwable $e) {
+    echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified scheme is malformed (MissingSchemeNonRelativeUrl)
diff --git a/ext/uri/tests/whatwg/builder/scheme_success_ignorable_char.phpt b/ext/uri/tests/whatwg/builder/scheme_success_ignorable_char.phpt
new file mode 100644
index 000000000000..d60b3e274b34
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/scheme_success_ignorable_char.phpt
@@ -0,0 +1,36 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setScheme() - success - contains ignorable newline and tab characters
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("ht\ttp\ns");
+$builder->setHost("example.com");
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(20) "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"]=>
+  NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/scheme_success_non_special.phpt b/ext/uri/tests/whatwg/builder/scheme_success_non_special.phpt
new file mode 100644
index 000000000000..c805105ee992
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/scheme_success_non_special.phpt
@@ -0,0 +1,36 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setScheme() - success - non-special scheme
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("foo");
+
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(6) "foo://"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(3) "foo"
+  ["username"]=>
+  NULL
+  ["password"]=>
+  NULL
+  ["host"]=>
+  string(0) ""
+  ["port"]=>
+  NULL
+  ["path"]=>
+  string(0) ""
+  ["query"]=>
+  NULL
+  ["fragment"]=>
+  NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/scheme_success_special.phpt b/ext/uri/tests/whatwg/builder/scheme_success_special.phpt
new file mode 100644
index 000000000000..60a175a5f207
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/scheme_success_special.phpt
@@ -0,0 +1,35 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setScheme() - success - contains digit & special characters
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("my-12+34.scheme");
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(18) "my-12+34.scheme://"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(15) "my-12+34.scheme"
+  ["username"]=>
+  NULL
+  ["password"]=>
+  NULL
+  ["host"]=>
+  string(0) ""
+  ["port"]=>
+  NULL
+  ["path"]=>
+  string(0) ""
+  ["query"]=>
+  NULL
+  ["fragment"]=>
+  NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/username_error_empty_opaque_host.phpt b/ext/uri/tests/whatwg/builder/username_error_empty_opaque_host.phpt
new file mode 100644
index 000000000000..c97ffd49afad
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/username_error_empty_opaque_host.phpt
@@ -0,0 +1,19 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setUsername() - error - empty opaque host
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("scheme");
+$builder->setHost("");
+$builder->setUsername("user");
+
+try {
+    $builder->build();
+} catch (Throwable $e) {
+    echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified URL cannot have username
diff --git a/ext/uri/tests/whatwg/builder/username_error_file_scheme.phpt b/ext/uri/tests/whatwg/builder/username_error_file_scheme.phpt
new file mode 100644
index 000000000000..aee81ee3b835
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/username_error_file_scheme.phpt
@@ -0,0 +1,19 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setUsername() - error - file scheme
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("file");
+$builder->setPath("C:/a.txt");
+$builder->setUsername("user");
+
+try {
+    $builder->build();
+} catch (Throwable $e) {
+    echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified URL cannot have username
diff --git a/ext/uri/tests/whatwg/builder/username_error_missing_opaque_host.phpt b/ext/uri/tests/whatwg/builder/username_error_missing_opaque_host.phpt
new file mode 100644
index 000000000000..257266f97c91
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/username_error_missing_opaque_host.phpt
@@ -0,0 +1,18 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setUsername() - error - missing opaque host
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("scheme");
+$builder->setUsername("user");
+
+try {
+    $builder->build();
+} catch (Throwable $e) {
+    echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+?>
+--EXPECT--
+Uri\WhatWg\InvalidUrlException: The specified URL cannot have username
diff --git a/ext/uri/tests/whatwg/builder/username_success_invalid_percent_encoding.phpt b/ext/uri/tests/whatwg/builder/username_success_invalid_percent_encoding.phpt
new file mode 100644
index 000000000000..273fc18c72e3
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/username_success_invalid_percent_encoding.phpt
@@ -0,0 +1,37 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setUsername() - success - invalid percent encoding is untouched
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("https");
+$builder->setHost("example.com");
+$builder->setUsername("%3");
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(23) "https://%[email protected]/"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(5) "https"
+  ["username"]=>
+  string(2) "%3"
+  ["password"]=>
+  string(0) ""
+  ["host"]=>
+  string(11) "example.com"
+  ["port"]=>
+  NULL
+  ["path"]=>
+  string(1) "/"
+  ["query"]=>
+  NULL
+  ["fragment"]=>
+  NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/username_success_null.phpt b/ext/uri/tests/whatwg/builder/username_success_null.phpt
new file mode 100644
index 000000000000..fae92f661a2d
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/username_success_null.phpt
@@ -0,0 +1,38 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setUsername() - success - null
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("https");
+$builder->setHost("example.com");
+$builder->setUsername("user");
+$builder->setUsername(null);
+$url = $builder->build();
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(20) "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"]=>
+  NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/username_success_special_char.phpt b/ext/uri/tests/whatwg/builder/username_success_special_char.phpt
new file mode 100644
index 000000000000..5e18a2c66cc4
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/username_success_special_char.phpt
@@ -0,0 +1,38 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setUsername() - success - contains special characters
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("https");
+$builder->setHost("example.com");
+$builder->setUsername("~%#");
+$errors = [];
+$url = $builder->build(null, $errors);
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECTF--
+string(26) "https://~%%[email protected]/"
+object(Uri\WhatWg\Url)#%d (%d) {
+  ["scheme"]=>
+  string(5) "https"
+  ["username"]=>
+  string(5) "~%%23"
+  ["password"]=>
+  string(0) ""
+  ["host"]=>
+  string(11) "example.com"
+  ["port"]=>
+  NULL
+  ["path"]=>
+  string(1) "/"
+  ["query"]=>
+  NULL
+  ["fragment"]=>
+  NULL
+}
+bool(true)
diff --git a/ext/uri/tests/whatwg/builder/username_success_tab_newline.phpt b/ext/uri/tests/whatwg/builder/username_success_tab_newline.phpt
new file mode 100644
index 000000000000..956bd1653ad8
--- /dev/null
+++ b/ext/uri/tests/whatwg/builder/username_success_tab_newline.phpt
@@ -0,0 +1,51 @@
+--TEST--
+Test Uri\WhatWg\UrlBuilder::setUsername() - success - contains tab and newline characters
+--FILE--
+<?php
+
+$builder = new Uri\WhatWg\UrlBuilder();
+$builder->setScheme("\tfo\no");
+$builder->setHost("example.com");
+$builder->setUsername("f\no\ro\t");
+$errors = [];
+$url = $builder->build(errors: $errors);
+
+var_dump($url->toAsciiString());
+var_dump($url);
+var_dump($errors);
+var_dump($url->equals(new Uri\WhatWg\Url($url->toAsciiString())));
+
+?>
+--EXPECT--
+string(30) "foo://f%0Ao%0Do%[email protected]"
+object(Uri\WhatWg\Url)#4 (8) {
+  ["scheme"]=>
+  string(3) "foo"
+  ["username"]=>
+  string(12) "f%0Ao%0Do%09"
+  ["password"]=>
+  string(0) ""
+  ["host"]=>
+  string(11) "example.com"
+  ["port"]=>
+  NULL
+  ["path"]=>
+  string(0) ""
+  ["query"]=>
+  NULL
+  ["fragment"]=>
+  NULL
+}
+array(1) {
+  [0]=>
+  object(Uri\WhatWg\UrlValidationError)#2 (3) {
+    ["context"]=>
+    string(5) "	fo
+o"
+    ["type"]=>
+    enum(Uri\WhatWg\UrlValidationErrorType::InvalidUrlUnit)
+    ["failure"]=>
+    bool(false)
+  }
+}
+bool(true)
diff --git a/ext/uri/uri_parser_whatwg.c b/ext/uri/uri_parser_whatwg.c
index 6d46feb51aae..b7a74fc1e231 100644
--- a/ext/uri/uri_parser_whatwg.c
+++ b/ext/uri/uri_parser_whatwg.c
@@ -28,7 +28,7 @@ ZEND_TLS lxb_unicode_idna_t lexbor_idna = {0};
 
 static const size_t lexbor_mraw_byte_size = 8192;
 
-static zend_always_inline void zval_string_or_null_to_lexbor_str(const zval *value, lexbor_str_t *lexbor_str)
+ZEND_ATTRIBUTE_NONNULL static zend_always_inline void zval_string_or_null_to_lexbor_str(const zval *value, lexbor_str_t *lexbor_str)
 {
 	if (Z_TYPE_P(value) == IS_STRING && Z_STRLEN_P(value) > 0) {
 		lexbor_str->data = (lxb_char_t *) Z_STRVAL_P(value);
@@ -40,7 +40,7 @@ static zend_always_inline void zval_string_or_null_to_lexbor_str(const zval *val
 	}
 }
 
-static zend_always_inline void zval_long_or_null_to_lexbor_str(const zval *value, lexbor_str_t *lexbor_str)
+ZEND_ATTRIBUTE_NONNULL static zend_always_inline void zval_long_or_null_to_lexbor_str(const zval *value, lexbor_str_t *lexbor_str)
 {
 	if (Z_TYPE_P(value) == IS_LONG) {
 		char buf[MAX_LENGTH_OF_LONG + 1];
@@ -53,20 +53,107 @@ static zend_always_inline void zval_long_or_null_to_lexbor_str(const zval *value
 	}
 }
 
+ZEND_ATTRIBUTE_NONNULL static bool get_reason_from_error_type(const lxb_url_error_type_t error_type, const char **error_str)
+{
+	switch (error_type) {
+		case LXB_URL_ERROR_TYPE_DOMAIN_TO_ASCII:
+			*error_str = "DomainToAscii";
+			return true;
+		case LXB_URL_ERROR_TYPE_DOMAIN_TO_UNICODE:
+			*error_str = "DomainToUnicode";
+			return false;
+		case LXB_URL_ERROR_TYPE_DOMAIN_INVALID_CODE_POINT:
+			*error_str = "DomainInvalidCodePoint";
+			return true;
+		case LXB_URL_ERROR_TYPE_HOST_INVALID_CODE_POINT:
+			*error_str = "HostInvalidCodePoint";
+			return true;
+		case LXB_URL_ERROR_TYPE_IPV4_EMPTY_PART:
+			*error_str = "Ipv4EmptyPart";
+			return false;
+		case LXB_URL_ERROR_TYPE_IPV4_TOO_MANY_PARTS:
+			*error_str = "Ipv4TooManyParts";
+			return true;
+		case LXB_URL_ERROR_TYPE_IPV4_NON_NUMERIC_PART:
+			*error_str = "Ipv4NonNumericPart";
+			return true;
+		case LXB_URL_ERROR_TYPE_IPV4_NON_DECIMAL_PART:
+			*error_str = "Ipv4NonDecimalPart";
+			return false;
+		case LXB_URL_ERROR_TYPE_IPV4_OUT_OF_RANGE_PART:
+			*error_str = "Ipv4OutOfRangePart";
+			return true;
+		case LXB_URL_ERROR_TYPE_IPV6_UNCLOSED:
+			*error_str = "Ipv6Unclosed";
+			return true;
+		case LXB_URL_ERROR_TYPE_IPV6_INVALID_COMPRESSION:
+			*error_str = "Ipv6InvalidCompression";
+			return true;
+		case LXB_URL_ERROR_TYPE_IPV6_TOO_MANY_PIECES:
+			*error_str = "Ipv6TooManyPieces";
+			return true;
+		case LXB_URL_ERROR_TYPE_IPV6_MULTIPLE_COMPRESSION:
+			*error_str = "Ipv6MultipleCompression";
+			return true;
+		case LXB_URL_ERROR_TYPE_IPV6_INVALID_CODE_POINT:
+			*error_str = "Ipv6InvalidCodePoint";
+			return true;
+		case LXB_URL_ERROR_TYPE_IPV6_TOO_FEW_PIECES:
+			*error_str = "Ipv6TooFewPieces";
+			return true;
+		case LXB_URL_ERROR_TYPE_IPV4_IN_IPV6_TOO_MANY_PIECES:
+			*error_str = "Ipv4InIpv6TooManyPieces";
+			return true;
+		case LXB_URL_ERROR_TYPE_IPV4_IN_IPV6_INVALID_CODE_POINT:
+			*error_str = "Ipv4InIpv6InvalidCodePoint";
+			return true;
+		case LXB_URL_ERROR_TYPE_IPV4_IN_IPV6_OUT_OF_RANGE_PART:
+			*error_str = "Ipv4InIpv6OutOfRangePart";
+			return true;
+		case LXB_URL_ERROR_TYPE_IPV4_IN_IPV6_TOO_FEW_PARTS:
+			*error_str = "Ipv4InIpv6TooFewParts";
+			return true;
+		case LXB_URL_ERROR_TYPE_INVALID_URL_UNIT:
+			*error_str = "InvalidUrlUnit";
+			return false;
+		case LXB_URL_ERROR_TYPE_SPECIAL_SCHEME_MISSING_FOLLOWING_SOLIDUS:
+			*error_str = "SpecialSchemeMissingFollowingSolidus";
+			return false;
+		case LXB_URL_ERROR_TYPE_MISSING_SCHEME_NON_RELATIVE_URL:
+			*error_str = "MissingSchemeNonRelativeUrl";
+			return true;
+		case LXB_URL_ERROR_TYPE_INVALID_REVERSE_SOLIDUS:
+			*error_str = "InvalidReverseSoldius";
+			return false;
+		case LXB_URL_ERROR_TYPE_INVALID_CREDENTIALS:
+			*error_str = "InvalidCredentials";
+			return false;
+		case LXB_URL_ERROR_TYPE_HOST_MISSING:
+			*error_str = "HostMissing";
+			return true;
+		case LXB_URL_ERROR_TYPE_PORT_OUT_OF_RANGE:
+			*error_str = "PortOutOfRange";
+			return true;
+		case LXB_URL_ERROR_TYPE_PORT_INVALID:
+			*error_str = "PortInvalid";
+			return true;
+		case LXB_URL_ERROR_TYPE_FILE_INVALID_WINDOWS_DRIVE_LETTER:
+			*error_str = "FileInvalidWindowsDriveLetter";
+			return false;
+		case LXB_URL_ERROR_TYPE_FILE_INVALID_WINDOWS_DRIVE_LETTER_HOST:
+			*error_str = "FileInvalidWindowsDriveLetterHost";
+			return false;
+		default: ZEND_UNREACHABLE();
+	}
+}
+
 /**
  * Creates a Uri\WhatWg\UrlValidationError class by mapping error codes listed in
  * https://url.spec.whatwg.org/#writing to a Uri\WhatWg\UrlValidationErrorType enum.
  * The result is passed by reference to the errors parameter.
  */
-static const char *fill_errors(zval *errors)
+ZEND_ATTRIBUTE_NONNULL static const char *fill_errors_inner(HashTable *errors)
 {
-	size_t log_len;
-	if (lexbor_parser.log == NULL || (log_len = lexbor_plog_length(lexbor_parser.log)) == 0) {
-		ZVAL_EMPTY_ARRAY(errors);
-		return NULL;
-	}
-
-	array_init_size(errors, log_len);
 	const char *result = NULL;
 
 	lexbor_plog_entry_t *lxb_error;
@@ -77,125 +164,8 @@ static const char *fill_errors(zval *errors)
 
 		const char *error_str;
 		zval failure;
-		switch (lxb_error->id) {
-			case LXB_URL_ERROR_TYPE_DOMAIN_TO_ASCII:
-				error_str = "DomainToAscii";
-				ZVAL_TRUE(&failure);
-				break;
-			case LXB_URL_ERROR_TYPE_DOMAIN_TO_UNICODE:
-				error_str = "DomainToUnicode";
-				ZVAL_FALSE(&failure);
-				break;
-			case LXB_URL_ERROR_TYPE_DOMAIN_INVALID_CODE_POINT:
-				error_str = "DomainInvalidCodePoint";
-				ZVAL_TRUE(&failure);
-				break;
-			case LXB_URL_ERROR_TYPE_HOST_INVALID_CODE_POINT:
-				error_str = "HostInvalidCodePoint";
-				ZVAL_TRUE(&failure);
-				break;
-			case LXB_URL_ERROR_TYPE_IPV4_EMPTY_PART:
-				error_str = "Ipv4EmptyPart";
-				ZVAL_FALSE(&failure);
-				break;
-			case LXB_URL_ERROR_TYPE_IPV4_TOO_MANY_PARTS:
-				error_str = "Ipv4TooManyParts";
-				ZVAL_TRUE(&failure);
-				break;
-			case LXB_URL_ERROR_TYPE_IPV4_NON_NUMERIC_PART:
-				error_str = "Ipv4NonNumericPart";
-				ZVAL_TRUE(&failure);
-				break;
-			case LXB_URL_ERROR_TYPE_IPV4_NON_DECIMAL_PART:
-				error_str = "Ipv4NonDecimalPart";
-				ZVAL_FALSE(&failure);
-				break;
-			case LXB_URL_ERROR_TYPE_IPV4_OUT_OF_RANGE_PART:
-				error_str = "Ipv4OutOfRangePart";
-				ZVAL_TRUE(&failure);
-				break;
-			case LXB_URL_ERROR_TYPE_IPV6_UNCLOSED:
-				error_str = "Ipv6Unclosed";
-				ZVAL_TRUE(&failure);
-				break;
-			case LXB_URL_ERROR_TYPE_IPV6_INVALID_COMPRESSION:
-				error_str = "Ipv6InvalidCompression";
-				ZVAL_TRUE(&failure);
-				break;
-			case LXB_URL_ERROR_TYPE_IPV6_TOO_MANY_PIECES:
-				error_str = "Ipv6TooManyPieces";
-				ZVAL_TRUE(&failure);
-				break;
-			case LXB_URL_ERROR_TYPE_IPV6_MULTIPLE_COMPRESSION:
-				error_str = "Ipv6MultipleCompression";
-				ZVAL_TRUE(&failure);
-				break;
-			case LXB_URL_ERROR_TYPE_IPV6_INVALID_CODE_POINT:
-				error_str = "Ipv6InvalidCodePoint";
-				ZVAL_TRUE(&failure);
-				break;
-			case LXB_URL_ERROR_TYPE_IPV6_TOO_FEW_PIECES:
-				error_str = "Ipv6TooFewPieces";
-				ZVAL_TRUE(&failure);
-				break;
-			case LXB_URL_ERROR_TYPE_IPV4_IN_IPV6_TOO_MANY_PIECES:
-				error_str = "Ipv4InIpv6TooManyPieces";
-				ZVAL_TRUE(&failure);
-				break;
-			case LXB_URL_ERROR_TYPE_IPV4_IN_IPV6_INVALID_CODE_POINT:
-				error_str = "Ipv4InIpv6InvalidCodePoint";
-				ZVAL_TRUE(&failure);
-				break;
-			case LXB_URL_ERROR_TYPE_IPV4_IN_IPV6_OUT_OF_RANGE_PART:
-				error_str = "Ipv4InIpv6OutOfRangePart";
-				ZVAL_TRUE(&failure);
-				break;
-			case LXB_URL_ERROR_TYPE_IPV4_IN_IPV6_TOO_FEW_PARTS:
-				error_str = "Ipv4InIpv6TooFewParts";
-				ZVAL_TRUE(&failure);
-				break;
-			case LXB_URL_ERROR_TYPE_INVALID_URL_UNIT:
-				error_str = "InvalidUrlUnit";
-				ZVAL_FALSE(&failure);
-				break;
-			case LXB_URL_ERROR_TYPE_SPECIAL_SCHEME_MISSING_FOLLOWING_SOLIDUS:
-				error_str = "SpecialSchemeMissingFollowingSolidus";
-				ZVAL_FALSE(&failure);
-				break;
-			case LXB_URL_ERROR_TYPE_MISSING_SCHEME_NON_RELATIVE_URL:
-				error_str = "MissingSchemeNonRelativeUrl";
-				ZVAL_TRUE(&failure);
-				break;
-			case LXB_URL_ERROR_TYPE_INVALID_REVERSE_SOLIDUS:
-				error_str = "InvalidReverseSoldius";
-				ZVAL_FALSE(&failure);
-				break;
-			case LXB_URL_ERROR_TYPE_INVALID_CREDENTIALS:
-				error_str = "InvalidCredentials";
-				ZVAL_FALSE(&failure);
-				break;
-			case LXB_URL_ERROR_TYPE_HOST_MISSING:
-				error_str = "HostMissing";
-				ZVAL_TRUE(&failure);
-				break;
-			case LXB_URL_ERROR_TYPE_PORT_OUT_OF_RANGE:
-				error_str = "PortOutOfRange";
-				ZVAL_TRUE(&failure);
-				break;
-			case LXB_URL_ERROR_TYPE_PORT_INVALID:
-				error_str = "PortInvalid";
-				ZVAL_TRUE(&failure);
-				break;
-			case LXB_URL_ERROR_TYPE_FILE_INVALID_WINDOWS_DRIVE_LETTER:
-				error_str = "FileInvalidWindowsDriveLetter";
-				ZVAL_FALSE(&failure);
-				break;
-			case LXB_URL_ERROR_TYPE_FILE_INVALID_WINDOWS_DRIVE_LETTER_HOST:
-				error_str = "FileInvalidWindowsDriveLetterHost";
-				ZVAL_FALSE(&failure);
-				break;
-			default: ZEND_UNREACHABLE();
-		}
+
+		ZVAL_BOOL(&failure, get_reason_from_error_type(lxb_error->id, &error_str));
 
 		zval error_type;
 		ZVAL_OBJ(&error_type, zend_enum_get_case_cstr(php_uri_ce_whatwg_url_validation_error_type, error_str));
@@ -207,12 +177,30 @@ static const char *fill_errors(zval *errors)
 			result = error_str;
 		}
 
-		add_next_index_zval(errors, &error);
+		zend_hash_next_index_insert(errors, &error);
 	}
 
 	return result;
 }
 
+/**
+ * Creates a Uri\WhatWg\UrlValidationError class by mapping error codes listed in
+ * https://url.spec.whatwg.org/#writing to a Uri\WhatWg\UrlValidationErrorType enum.
+ * The result is passed by reference to the errors parameter.
+ */
+ZEND_ATTRIBUTE_NONNULL static const char *fill_errors(zval *errors)
+{
+	size_t log_len;
+	if (lexbor_parser.log == NULL || (log_len = lexbor_plog_length(lexbor_parser.log)) == 0) {
+		ZVAL_EMPTY_ARRAY(errors);
+		return NULL;
+	}
+
+	array_init_size(errors, log_len);
+
+	return fill_errors_inner(Z_ARRVAL_P(errors));
+}
+
 static void throw_invalid_url_exception_during_write(zval *errors, const char *component)
 {
 	zval err;
@@ -672,6 +660,348 @@ static void php_uri_parser_whatwg_destroy(void *uri)
 	lxb_url_destroy(lexbor_uri);
 }
 
+ZEND_ATTRIBUTE_NONNULL static zend_always_inline zend_result php_uri_parser_whatwg_component_error(
+	const char *component_name, const lxb_url_error_type_t error_type
+) {
+	const char *reason = "";
+	if (error_type != LXB_URL_ERROR_TYPE__LAST_ENTRY) {
+		get_reason_from_error_type(error_type, &reason);
+	}
+
+	zend_throw_exception_ex(php_uri_ce_whatwg_invalid_url_exception,
+		0,
+		"The specified %s is malformed%s%s%s",
+		component_name,
+		reason ? " (" : "",
+		reason ? reason : "",
+		reason ? ")" : ""
+	);
+
+	return FAILURE;
+}
+
+ZEND_ATTRIBUTE_NONNULL zend_result php_uri_parser_whatwg_validate_none(const zend_string *component)
+{
+	return SUCCESS;
+}
+
+static zend_always_inline bool php_uri_whatwg_is_ascii_tab_or_newline(const unsigned char c)
+{
+	return c == '\t' || c == '\n' || c == '\r';
+}
+
+static zend_always_inline bool php_uri_parser_whatwg_is_alpha(const unsigned char c)
+{
+	return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
+}
+
+static zend_always_inline bool php_uri_parser_whatwg_is_digit(const unsigned char c)
+{
+	return c >= '0' && c <= '9';
+}
+
+static zend_always_inline unsigned char php_uri_ascii_to_lowercase(const unsigned char c)
+{
+	return c >= 'A' && c <= 'Z' ? (unsigned char) (c + ('a' - 'A')) : c;
+}
+
+ZEND_ATTRIBUTE_NONNULL zend_result php_uri_parser_whatwg_validate_scheme(const zend_string *scheme)
+{
+	const char *p = ZSTR_VAL(scheme);
+	const char *end = p + ZSTR_LEN(scheme);
+	bool seen_first = false;
+
+	for (const char *c = p; c < end; c++) {
+		const unsigned char uc = (unsigned char) *c;
+
+		if (php_uri_whatwg_is_ascii_tab_or_newline(uc)) {
+			continue;
+		}
+
+		if (!seen_first) {
+			if (!php_uri_parser_whatwg_is_alpha(uc)) {
+				return php_uri_parser_whatwg_component_error("scheme", LXB_URL_ERROR_TYPE_MISSING_SCHEME_NON_RELATIVE_URL);
+			}
+			seen_first = true;
+		} else {
+			if (!php_uri_parser_whatwg_is_alpha(uc) && !php_uri_parser_whatwg_is_digit(uc) && uc != '+' && uc != '-' && uc != '.') {
+				return php_uri_parser_whatwg_component_error("scheme", LXB_URL_ERROR_TYPE_MISSING_SCHEME_NON_RELATIVE_URL);
+			}
+		}
+	}
+
+	if (!seen_first) {
+		return php_uri_parser_whatwg_component_error("scheme", LXB_URL_ERROR_TYPE_MISSING_SCHEME_NON_RELATIVE_URL);
+	}
+
+	return SUCCESS;
+}
+
+ZEND_ATTRIBUTE_NONNULL zend_result php_uri_parser_whatwg_validate_host(const zend_string *host)
+{
+	if (ZSTR_LEN(host) == 0) {
+		/* Skip validation - an empty string may or may not be a valid host depending on whether the URL is special */
+		return SUCCESS;
+	}
+
+	const char *first = ZSTR_VAL(host);
+	const char *last = ZSTR_VAL(host) + ZSTR_LEN(host);
+
+	while (first < last && php_uri_whatwg_is_ascii_tab_or_newline((unsigned char) *first)) {
+		first++;
+	}
+
+	if (*first != '[') {
+		/* Skip validation - The host is not an IPv6 address */
+		return SUCCESS;
+	}
+
+	while (first < last && php_uri_whatwg_is_ascii_tab_or_newline((unsigned char) *last)) {
+		last--;
+	}
+
+	lxb_char_t *stripped_host = emalloc(last - first + 1);
+	size_t stripped_host_len = 0;
+
+	for (const char *i = first; i < last; i++) {
+		const unsigned char uc = (unsigned char) *i;
+
+		if (!php_uri_whatwg_is_ascii_tab_or_newline(uc)) {
+			stripped_host[stripped_host_len++] = (lxb_char_t) uc;
+		}
+	}
+	stripped_host[stripped_host_len] = '\0';
+
+	uint16_t ipv6[8];
+	const lxb_status_t result = lxb_url_parse_host_ipv6(
+		&lexbor_parser,
+		stripped_host,
+		stripped_host_len,
+		ipv6
+	);
+
+	efree(stripped_host);
+
+	if (result != LXB_STATUS_OK) {
+		lexbor_plog_entry_t *lxb_error;
+
+		if (lexbor_parser.log != NULL && (lxb_error = lexbor_array_obj_pop(&lexbor_parser.log->list)) != NULL) {
+			return php_uri_parser_whatwg_component_error("host", lxb_error->id);
+		} else {
+			return php_uri_parser_whatwg_component_error("host", LXB_URL_ERROR_TYPE__LAST_ENTRY);
+		}
+	}
+
+	return SUCCESS;
+}
+
+ZEND_ATTRIBUTE_NONNULL zend_result php_uri_parser_whatwg_validate_port(const zend_long port)
+{
+	if (port  < 0) {
+		return php_uri_parser_whatwg_component_error("port", LXB_URL_ERROR_TYPE_PORT_INVALID);
+	}
+
+	if (port > 65535) {
+		return php_uri_parser_whatwg_component_error("port", LXB_URL_ERROR_TYPE_PORT_OUT_OF_RANGE);
+	}
+
+	return SUCCESS;
+}
+
+ZEND_ATTRIBUTE_NONNULL static lxb_url_scheme_type_t php_uri_parser_whatwg_get_special_scheme(const zend_string *scheme)
+{
+    const char *p = ZSTR_VAL(scheme);
+    const char *end = p + ZSTR_LEN(scheme);
+
+    /*
+     * Create a normalized buffer from the scheme by leaving out tab and newline characters.
+     * The longest special scheme "https" is 5 characters, therefore 6 bytes is enough to store.
+     */
+    char buf[6];
+    size_t buf_len = 0;
+
+    for (const char *c = p; c < end; c++) {
+        const unsigned char uc = (unsigned char) *c;
+
+        if (php_uri_whatwg_is_ascii_tab_or_newline(uc)) {
+            continue;
+        }
+
+        if (buf_len == sizeof(buf) - 1) {
+        	/* Longer than any special scheme */
+            return false;
+        }
+
+        buf[buf_len++] = (char) php_uri_ascii_to_lowercase(uc);
+    }
+
+    switch (buf_len) {
+    	case 2:
+    		if (memcmp(buf, "ws", 2) == 0) {
+    			return LXB_URL_SCHEMEL_TYPE_WS;
+    		}
+
+    		break;
+        case 3:
+    		if (memcmp(buf, "ftp", 3) == 0) {
+    			return LXB_URL_SCHEMEL_TYPE_FTP;
+    		}
+
+    		if (memcmp(buf, "wss", 3) == 0) {
+    			return LXB_URL_SCHEMEL_TYPE_WSS;
+    		}
+
+    		break;
+    	case 4:
+    		if (memcmp(buf, "http", 4) == 0) {
+    			return LXB_URL_SCHEMEL_TYPE_HTTP;
+    		}
+
+    		if (memcmp(buf, "file", 4) == 0) {
+    			return LXB_URL_SCHEMEL_TYPE_FILE;
+    		}
+
+            break;
+        case 5:
+    		if (memcmp(buf, "https", 5) == 0) {
+    			return LXB_URL_SCHEMEL_TYPE_HTTPS;
+    		}
+
+    		break;
+	}
+
+	return LXB_URL_SCHEMEL_TYPE__UNDEF;
+}
+
+ZEND_ATTRIBUTE_NONNULL static void 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;
+	}
+
+	if (Z_TYPE_P(errors) != IS_ARRAY) {
+		zval_ptr_dtor(errors);
+		array_init_size(errors, log_len);
+	}
+
+	fill_errors_inner(Z_ARRVAL_P(errors));
+}
+
+ZEND_ATTRIBUTE_NONNULL_ARGS(2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser_whatwg_build_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 *errors_zv
+) {
+	if (Z_TYPE_P(host) == IS_NULL ||
+		Z_STRLEN_P(host) == 0 ||
+		php_uri_parser_whatwg_get_special_scheme(Z_STR_P(scheme)) == LXB_URL_SCHEMEL_TYPE_FILE
+	) {
+		if (Z_TYPE_P(username) != IS_NULL) {
+			zend_throw_exception_ex(php_uri_ce_whatwg_invalid_url_exception, 0, "The specified URL cannot have username");
+			return NULL;
+		}
+
+		if (Z_TYPE_P(password) != IS_NULL) {
+			zend_throw_exception_ex(php_uri_ce_whatwg_invalid_url_exception, 0, "The specified URL cannot have password");
+			return NULL;
+		}
+
+		if (Z_TYPE_P(port) != IS_NULL) {
+			zend_throw_exception_ex(php_uri_ce_whatwg_invalid_url_exception, 0, "The specified URL cannot have port");
+			return NULL;
+		}
+	}
+
+	lxb_url_parser_clean(&lexbor_parser);
+
+	lxb_url_t *lexbor_url = lexbor_mraw_calloc(lexbor_parser.mraw, sizeof(*lexbor_url));
+	if (lexbor_url == NULL) {
+		zend_throw_exception(php_uri_ce_whatwg_invalid_url_exception, "Memory allocation error", 0);
+		return NULL;
+	}
+
+	lexbor_url->mraw = lexbor_parser.mraw;
+
+	/*
+	 * The URL is initialized as LXB_URL_SCHEMEL_TYPE__UNDEF but this would prevent the scheme to be updated
+	 * in case of non-special schemes due to https://github.com/php/php-src/blob/27d7b799c0a13578ee0506b428b8ddc209ffb010/ext/lexbor/lexbor/url/url.c#L1402
+	 */
+	if (php_uri_parser_whatwg_get_special_scheme(Z_STR_P(scheme)) == LXB_URL_SCHEMEL_TYPE__UNDEF) {
+		lexbor_url->scheme.type = LXB_URL_SCHEMEL_TYPE__UNKNOWN;
+	}
+
+	zval errors;
+	ZVAL_UNDEF(&errors);
+
+	zend_result result = php_uri_parser_whatwg_scheme_write(lexbor_url, scheme, NULL);
+	php_uri_parser_whatwg_build_errors(&errors);
+	if (result == FAILURE) {
+		goto failure;
+	}
+
+	result = php_uri_parser_whatwg_host_write(lexbor_url, host, NULL);
+	php_uri_parser_whatwg_build_errors(&errors);
+	if (result == FAILURE) {
+		goto failure;
+	}
+
+	/* Intentionally writing username after host to avoid error when the username is set but the host is missing */
+	result = php_uri_parser_whatwg_username_write(lexbor_url, username, NULL);
+	php_uri_parser_whatwg_build_errors(&errors);
+	if (result == FAILURE) {
+		goto failure;
+	}
+
+	/* Intentionally writing password after host to avoid error when the password is set but the password is missing */
+	result = php_uri_parser_whatwg_password_write(lexbor_url, password, NULL);
+	php_uri_parser_whatwg_build_errors(&errors);
+	if (result == FAILURE) {
+		goto failure;
+	}
+
+	/* Intentionally writing port after host to avoid error when the port is set but the host is missing */
+	result = php_uri_parser_whatwg_port_write(lexbor_url, port, NULL);
+	php_uri_parser_whatwg_build_errors(&errors);
+	if (result == FAILURE) {
+		goto failure;
+	}
+
+	result = php_uri_parser_whatwg_path_write(lexbor_url, path, NULL);
+	php_uri_parser_whatwg_build_errors(&errors);
+	if (result == FAILURE) {
+		goto failure;
+	}
+
+	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);
+	php_uri_parser_whatwg_build_errors(&errors);
+	if (result == FAILURE) {
+		goto failure;
+	}
+
+	if (lexbor_base_url != NULL) {
+		/* TODO */
+	}
+
+	if (php_uri_pass_errors_by_ref_and_free(errors_zv, &errors) == FAILURE) {
+		goto failure;
+	}
+
+	return lexbor_url;
+
+failure:
+	zval_ptr_dtor(&errors);
+	lxb_url_destroy(lexbor_url);
+	return NULL;
+}
+
 PHPAPI const php_uri_parser php_uri_parser_whatwg = {
 	.name = PHP_URI_PARSER_WHATWG,
 	.parse = php_uri_parser_whatwg_parse,
diff --git a/ext/uri/uri_parser_whatwg.h b/ext/uri/uri_parser_whatwg.h
index f714ee483680..0a03c8e76a93 100644
--- a/ext/uri/uri_parser_whatwg.h
+++ b/ext/uri/uri_parser_whatwg.h
@@ -25,7 +25,19 @@ ZEND_ATTRIBUTE_NONNULL void php_uri_parser_whatwg_host_type_read(const lxb_url_t
 
 lxb_url_t *php_uri_parser_whatwg_parse_ex(const char *uri_str, size_t uri_str_len, const lxb_url_t *lexbor_base_url, zval *errors, bool silent);
 
+ZEND_ATTRIBUTE_NONNULL zend_result php_uri_parser_whatwg_validate_none(const zend_string *component);
+ZEND_ATTRIBUTE_NONNULL zend_result php_uri_parser_whatwg_validate_scheme(const zend_string *scheme);
+ZEND_ATTRIBUTE_NONNULL zend_result php_uri_parser_whatwg_validate_host(const zend_string *host);
+ZEND_ATTRIBUTE_NONNULL zend_result php_uri_parser_whatwg_validate_port(zend_long port);
+
 PHP_RINIT_FUNCTION(uri_parser_whatwg);
+
+ZEND_ATTRIBUTE_NONNULL_ARGS(2, 3, 4, 5, 6, 7, 8, 9) lxb_url_t *php_uri_parser_whatwg_build_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 *errors_zv
+);
+
 ZEND_MODULE_POST_ZEND_DEACTIVATE_D(uri_parser_whatwg);
 
 #endif
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.