Commit a14b9f352ff for php

commit a14b9f352ff0f8f550dc20569faea1d6ade4e664
Author: David Carlier <devnexen@gmail.com>
Date:   Mon Oct 19 18:06:49 2026 +0100

    ext/intl: Add IntlNumberFormatter skeleton based formatter.

    Add IntlNumberFormatter::createFromSkeleton() so format() can render
    units such as "5 km/h", which NumberFormatter cannot do. Needs ICU 63+.

    Fix GH-24165
    Close GH-24266

diff --git a/NEWS b/NEWS
index 5240b0c6d1d..9a23a0f14c6 100644
--- a/NEWS
+++ b/NEWS
@@ -32,6 +32,7 @@ PHP                                                                        NEWS
   . Fixed a double-free when Calendar::createInstance() or
     IntlCalendar::fromDateTime() fails after adopting a TimeZone.
     (Ilia Alshanetsky)
+  . Added IntlNumberFormatter::createFromSkeleton() (GH-24165). (David Carlier)

 - Lexbor:
   . Merge patches lexbor/lexbor@8a14bc0 and lexbor/lexbor@f67ce4b, fixing a
diff --git a/UPGRADING b/UPGRADING
index fed93f01d56..fbc8ddce787 100644
--- a/UPGRADING
+++ b/UPGRADING
@@ -94,6 +94,14 @@ PHP 8.7 UPGRADE NOTES
 7. New Classes and Interfaces
 ========================================

+- Intl:
+  . Added IntlNumberFormatter class to format a number with a given locale
+    and ICU number skeleton, which also covers measurement units, e.g.
+    "measure-unit/speed-kilometer-per-hour unit-width-full-name".
+    IntlNumberFormatter::format() accepts an int, a float or a numeric
+    string, the latter being formatted as a decimal of arbitrary precision.
+    It is supported from icu 63.
+
 ========================================
 8. Removed Extensions and SAPIs
 ========================================
diff --git a/ext/intl/CREDITS b/ext/intl/CREDITS
index 0e7fd55dc12..02a0f12a139 100644
--- a/ext/intl/CREDITS
+++ b/ext/intl/CREDITS
@@ -1,2 +1,2 @@
 Internationalization
-Ed Batutis, Vladimir Iordanov, Dmitry Lakhtyuk, Stanislav Malyshev, Vadim Savchuk, Kirti Velankar
+Ed Batutis, David Carlier, Vladimir Iordanov, Dmitry Lakhtyuk, Stanislav Malyshev, Vadim Savchuk, Kirti Velankar
diff --git a/ext/intl/config.m4 b/ext/intl/config.m4
index f6a01053df2..0f426e45af7 100644
--- a/ext/intl/config.m4
+++ b/ext/intl/config.m4
@@ -48,6 +48,7 @@ if test "$PHP_INTL" != "no"; then
     grapheme/grapheme_string.cpp \
     grapheme/grapheme_util.cpp \
     msgformat/msgformat_helpers.cpp \
+    numberformatter/numberformatter_class.cpp \
     rangeformatter/rangeformatter_class.cpp \
     timezone/timezone_class.cpp \
     timezone/timezone_methods.cpp \
@@ -126,6 +127,7 @@ if test "$PHP_INTL" != "no"; then
     $ext_builddir/listformatter
     $ext_builddir/msgformat
     $ext_builddir/normalizer
+    $ext_builddir/numberformatter
     $ext_builddir/rangeformatter
     $ext_builddir/resourcebundle
     $ext_builddir/spoofchecker
diff --git a/ext/intl/config.w32 b/ext/intl/config.w32
index 3cde431ce66..8ca7096152b 100644
--- a/ext/intl/config.w32
+++ b/ext/intl/config.w32
@@ -63,6 +63,9 @@ if (PHP_INTL != "no") {
 				normalizer_class.cpp \
 				normalizer_normalize.cpp \
 				", "intl");
+		ADD_SOURCES(configure_module_dirname + "/numberformatter", "\
+				numberformatter_class.cpp \
+				", "intl");
 		ADD_SOURCES(configure_module_dirname + "/rangeformatter", "\
 				rangeformatter_class.cpp \
 				", "intl");
diff --git a/ext/intl/numberformatter/numberformatter.stub.php b/ext/intl/numberformatter/numberformatter.stub.php
new file mode 100644
index 00000000000..c98bb3cee38
--- /dev/null
+++ b/ext/intl/numberformatter/numberformatter.stub.php
@@ -0,0 +1,19 @@
+<?php
+
+/** @generate-class-entries */
+
+/**
+ * @not-serializable
+ * @strict-properties
+ */
+final class IntlNumberFormatter {
+    private function __construct() {}
+
+    public static function createFromSkeleton(string $skeleton, string $locale): ?IntlNumberFormatter {}
+
+    public function format(float|int|string $value): string|false {}
+
+    public function getErrorCode(): int {}
+
+    public function getErrorMessage(): string {}
+}
diff --git a/ext/intl/numberformatter/numberformatter_arginfo.h b/ext/intl/numberformatter/numberformatter_arginfo.h
new file mode 100644
index 00000000000..065367dd2b2
Binary files /dev/null and b/ext/intl/numberformatter/numberformatter_arginfo.h differ
diff --git a/ext/intl/numberformatter/numberformatter_class.cpp b/ext/intl/numberformatter/numberformatter_class.cpp
new file mode 100644
index 00000000000..a7d68993bec
--- /dev/null
+++ b/ext/intl/numberformatter/numberformatter_class.cpp
@@ -0,0 +1,177 @@
+/*
+   +----------------------------------------------------------------------+
+   | Copyright © The PHP Group and Contributors.                          |
+   +----------------------------------------------------------------------+
+   | This source file is subject to the Modified BSD License that is      |
+   | bundled with this package in the file LICENSE, and is available      |
+   | through the World Wide Web at <https://www.php.net/license/>.        |
+   |                                                                      |
+   | SPDX-License-Identifier: BSD-3-Clause                                |
+   +----------------------------------------------------------------------+
+   | Authors: David Carlier <devnexen@gmail.com>                          |
+   +----------------------------------------------------------------------+
+*/
+
+extern "C" {
+#include "php.h"
+#include "zend_API.h"
+#include "../intl_common.h"
+}
+
+#if U_ICU_VERSION_MAJOR_NUM >= 63
+#include <unicode/numberformatter.h>
+#include <unicode/unistr.h>
+#include <unicode/locid.h>
+#include "../intl_convertcpp.h"
+
+extern "C" {
+#include "../php_intl.h"
+#include "numberformatter_arginfo.h"
+}
+#include "numberformatter_class.h"
+
+using icu::number::NumberFormatter;
+using icu::number::UnlocalizedNumberFormatter;
+using icu::UnicodeString;
+
+static zend_object_handlers numberformatter_handlers;
+zend_class_entry *IntlNumberFormatter_ce_ptr;
+
+static void numberformatter_free_obj(zend_object *object)
+{
+	IntlNumberFormatter_object *nfo = php_intl_numberformatter_fetch_object(object);
+
+	delete NUMBERFORMATTER_OBJECT(nfo);
+	NUMBERFORMATTER_OBJECT(nfo) = nullptr;
+	intl_error_reset(INTL_DATA_ERROR_P(nfo));
+
+	zend_object_std_dtor(&nfo->zo);
+}
+
+static zend_object *numberformatter_create_object(zend_class_entry *ce)
+{
+	IntlNumberFormatter_object *nfo = reinterpret_cast<IntlNumberFormatter_object *>(zend_object_alloc(sizeof(IntlNumberFormatter_object), ce));
+
+	intl_error_init(INTL_DATA_ERROR_P(nfo));
+	NUMBERFORMATTER_OBJECT(nfo) = nullptr;
+
+	zend_object_std_init(&nfo->zo, ce);
+	object_properties_init(&nfo->zo, ce);
+	nfo->zo.handlers = &numberformatter_handlers;
+
+	return &nfo->zo;
+}
+
+U_CFUNC PHP_METHOD(IntlNumberFormatter, __construct)
+{
+	ZEND_PARSE_PARAMETERS_NONE();
+
+	zend_throw_error(nullptr, "Cannot directly construct %s, use createFromSkeleton method instead", ZSTR_VAL(Z_OBJCE_P(ZEND_THIS)->name));
+}
+
+U_CFUNC PHP_METHOD(IntlNumberFormatter, createFromSkeleton)
+{
+	char *skeleton, *locale;
+	size_t skeleton_len, locale_len;
+
+	ZEND_PARSE_PARAMETERS_START(2, 2)
+		Z_PARAM_STRING(skeleton, skeleton_len)
+		Z_PARAM_STRING(locale, locale_len)
+	ZEND_PARSE_PARAMETERS_END();
+
+	intl_error_reset(nullptr);
+
+	INTL_CHECK_LOCALE_LEN(locale_len);
+
+	if (locale_len == 0) {
+		locale = (char *)intl_locale_get_default();
+	}
+
+	if (icu::Locale(locale).getISO3Language()[0] == '\0') {
+		zend_argument_value_error(2, "\"%s\" is invalid", locale);
+		RETURN_THROWS();
+	}
+
+	UErrorCode status = U_ZERO_ERROR;
+	UnicodeString uskeleton;
+
+	intl_stringFromChar(uskeleton, skeleton, skeleton_len, &status);
+	INTL_CHECK_STATUS_OR_NULL(status, "Error converting skeleton to UTF-16");
+
+	UnlocalizedNumberFormatter nf = NumberFormatter::forSkeleton(uskeleton, status);
+	INTL_CHECK_STATUS_OR_NULL(status, "Number formatter creation failed");
+
+	object_init_ex(return_value, IntlNumberFormatter_ce_ptr);
+	NUMBERFORMATTER_OBJECT(Z_INTL_INTLNUMBERFORMATTER_P(return_value)) = new LocalizedNumberFormatter(nf.locale(icu::Locale::createCanonical(locale)));
+}
+
+U_CFUNC PHP_METHOD(IntlNumberFormatter, format)
+{
+	zval *value;
+
+	ZEND_PARSE_PARAMETERS_START(1, 1)
+		Z_PARAM_NUMBER_OR_STR(value)
+	ZEND_PARSE_PARAMETERS_END();
+
+	IntlNumberFormatter_object *nfo = Z_INTL_INTLNUMBERFORMATTER_P(ZEND_THIS);
+	intl_errors_reset(INTL_DATA_ERROR_P(nfo));
+
+	UErrorCode status = U_ZERO_ERROR;
+	UnicodeString result;
+
+	if (Z_TYPE_P(value) == IS_STRING) {
+		if (UNEXPECTED(zend_str_has_nul_byte(Z_STR_P(value)))) {
+			zend_argument_value_error(1, "must not contain any null bytes");
+			RETURN_THROWS();
+		}
+		if (UNEXPECTED(Z_STRLEN_P(value) > INT32_MAX)) {
+			zend_argument_value_error(1, "is too long");
+			RETURN_THROWS();
+		}
+		result = NUMBERFORMATTER_OBJECT(nfo)->formatDecimal(icu::StringPiece(Z_STRVAL_P(value), static_cast<int32_t>(Z_STRLEN_P(value))), status).toString(status);
+	} else if (Z_TYPE_P(value) == IS_DOUBLE) {
+		result = NUMBERFORMATTER_OBJECT(nfo)->formatDouble(Z_DVAL_P(value), status).toString(status);
+	} else {
+		result = NUMBERFORMATTER_OBJECT(nfo)->formatInt(static_cast<int64_t>(Z_LVAL_P(value)), status).toString(status);
+	}
+
+	if (U_FAILURE(status)) {
+		intl_errors_set(INTL_DATA_ERROR_P(nfo), status, "Number formatting failed");
+		RETURN_FALSE;
+	}
+
+	zend_string *ret = intl_charFromString(result, &status);
+
+	if (!ret) {
+		intl_errors_set(INTL_DATA_ERROR_P(nfo), status, "Error converting value to UTF-8");
+		RETURN_FALSE;
+	}
+
+	RETVAL_STR(ret);
+}
+
+U_CFUNC PHP_METHOD(IntlNumberFormatter, getErrorCode)
+{
+	ZEND_PARSE_PARAMETERS_NONE();
+
+	RETURN_LONG(INTL_DATA_ERROR_CODE(Z_INTL_INTLNUMBERFORMATTER_P(ZEND_THIS)));
+}
+
+U_CFUNC PHP_METHOD(IntlNumberFormatter, getErrorMessage)
+{
+	ZEND_PARSE_PARAMETERS_NONE();
+
+	RETURN_STR(intl_error_get_message(INTL_DATA_ERROR_P(Z_INTL_INTLNUMBERFORMATTER_P(ZEND_THIS))));
+}
+
+void numberformatter_register_class(void)
+{
+	IntlNumberFormatter_ce_ptr = register_class_IntlNumberFormatter();
+	IntlNumberFormatter_ce_ptr->create_object = numberformatter_create_object;
+
+	memcpy(&numberformatter_handlers, zend_get_std_object_handlers(), sizeof(zend_object_handlers));
+	numberformatter_handlers.offset = offsetof(IntlNumberFormatter_object, zo);
+	numberformatter_handlers.free_obj = numberformatter_free_obj;
+	numberformatter_handlers.clone_obj = nullptr;
+}
+#endif
diff --git a/ext/intl/numberformatter/numberformatter_class.h b/ext/intl/numberformatter/numberformatter_class.h
new file mode 100644
index 00000000000..79ec5895ac5
--- /dev/null
+++ b/ext/intl/numberformatter/numberformatter_class.h
@@ -0,0 +1,60 @@
+/*
+   +----------------------------------------------------------------------+
+   | Copyright © The PHP Group and Contributors.                          |
+   +----------------------------------------------------------------------+
+   | This source file is subject to the Modified BSD License that is      |
+   | bundled with this package in the file LICENSE, and is available      |
+   | through the World Wide Web at <https://www.php.net/license/>.        |
+   |                                                                      |
+   | SPDX-License-Identifier: BSD-3-Clause                                |
+   +----------------------------------------------------------------------+
+   | Authors: David Carlier <devnexen@gmail.com>                          |
+   +----------------------------------------------------------------------+
+*/
+
+#ifndef NUMBERFORMATTER_CLASS_H
+#define NUMBERFORMATTER_CLASS_H
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+#include <php.h>
+#include "intl_common.h"
+#include "intl_error.h"
+#include "intl_data.h"
+#ifdef __cplusplus
+}
+#endif
+
+#ifdef __cplusplus
+#include <unicode/numberformatter.h>
+using icu::number::LocalizedNumberFormatter;
+#else
+typedef void LocalizedNumberFormatter;
+#endif
+
+typedef struct {
+	intl_error                error;
+	LocalizedNumberFormatter *unumf;
+} numberformatter_data;
+
+typedef struct {
+	numberformatter_data nf_data;
+	zend_object          zo;
+} IntlNumberFormatter_object;
+
+#define php_intl_numberformatter_fetch_object(obj) ZEND_CONTAINER_OF(obj, IntlNumberFormatter_object, zo)
+#define Z_INTL_INTLNUMBERFORMATTER_P(zv) php_intl_numberformatter_fetch_object(Z_OBJ_P(zv))
+
+#define NUMBERFORMATTER_OBJECT(nfo) (nfo)->nf_data.unumf
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+void numberformatter_register_class(void);
+extern zend_class_entry *IntlNumberFormatter_ce_ptr;
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/ext/intl/php_intl.c b/ext/intl/php_intl.c
index ce938b91559..5b51d98dba0 100644
--- a/ext/intl/php_intl.c
+++ b/ext/intl/php_intl.c
@@ -42,6 +42,7 @@
 #include "locale/locale_class.h"

 #include "listformatter/listformatter_class.h"
+#include "numberformatter/numberformatter_class.h"
 #include "rangeformatter/rangeformatter_class.h"

 #include "dateformat/dateformat.h"
@@ -190,6 +191,9 @@ PHP_MINIT_FUNCTION( intl )
 	listformatter_register_class(  );

 #if U_ICU_VERSION_MAJOR_NUM >= 63
+	/* Register 'IntlNumberFormatter' PHP class */
+	numberformatter_register_class( );
+
 	/* Register 'NumberRangeFormatter' PHP class */
 	rangeformatter_register_class( );
 #endif
diff --git a/ext/intl/tests/numberformatter/basic.phpt b/ext/intl/tests/numberformatter/basic.phpt
new file mode 100644
index 00000000000..e499571962f
--- /dev/null
+++ b/ext/intl/tests/numberformatter/basic.phpt
@@ -0,0 +1,58 @@
+--TEST--
+Basic test for IntlNumberFormatter
+--EXTENSIONS--
+intl
+--SKIPIF--
+<?php
+if (!class_exists('IntlNumberFormatter')) {
+    die('skip IntlNumberFormatter not available');
+}
+?>
+--FILE--
+<?php
+
+$cases = [
+    ['measure-unit/speed-kilometer-per-hour', 'en_US', 5],
+    ['measure-unit/speed-kilometer-per-hour unit-width-full-name', 'en_US', 5],
+    ['measure-unit/speed-kilometer-per-hour unit-width-full-name', 'en_US', 1],
+    ['measure-unit/speed-kilometer-per-hour unit-width-full-name', 'en_US', 1.5],
+    ['measure-unit/speed-kilometer-per-hour unit-width-full-name', 'de', 1.5],
+    ['measure-unit/length-meter per-measure-unit/duration-second', 'en_US', 5],
+    ['measure-unit/digital-gigabyte unit-width-narrow', 'en_US', 1.5],
+    ['currency/EUR unit-width-full-name', 'en_US', 1234.5],
+    ['percent', 'en_US', 25],
+    ['compact-short', 'en_US', 1234567],
+    ['.00', 'en_US', 1234.5],
+    ['group-off', 'en_US', 1234567],
+    ['sign-always', 'en_US', 5],
+    ['', 'en_US', 1234.5678],
+    ['', 'en_US', NAN],
+];
+
+foreach ($cases as [$skeleton, $locale, $value]) {
+    $formatter = IntlNumberFormatter::createFromSkeleton($skeleton, $locale);
+    var_dump($formatter->format($value));
+}
+
+var_dump($formatter->getErrorCode());
+var_dump($formatter->getErrorMessage());
+
+?>
+--EXPECT--
+string(6) "5 km/h"
+string(21) "5 kilometers per hour"
+string(20) "1 kilometer per hour"
+string(23) "1.5 kilometers per hour"
+string(24) "1,5 Kilometer pro Stunde"
+string(5) "5 m/s"
+string(5) "1.5GB"
+string(14) "1,234.50 euros"
+string(3) "25%"
+string(4) "1.2M"
+string(8) "1,234.50"
+string(7) "1234567"
+string(2) "+5"
+string(10) "1,234.5678"
+string(3) "NaN"
+int(0)
+string(12) "U_ZERO_ERROR"
diff --git a/ext/intl/tests/numberformatter/default_locale.phpt b/ext/intl/tests/numberformatter/default_locale.phpt
new file mode 100644
index 00000000000..2e6671c7087
--- /dev/null
+++ b/ext/intl/tests/numberformatter/default_locale.phpt
@@ -0,0 +1,29 @@
+--TEST--
+IntlNumberFormatter::createFromSkeleton() with an empty locale uses the default locale
+--EXTENSIONS--
+intl
+--SKIPIF--
+<?php
+if (!class_exists('IntlNumberFormatter')) {
+    die('skip IntlNumberFormatter not available');
+}
+?>
+--INI--
+intl.default_locale=en_US
+--FILE--
+<?php
+
+$skeleton = 'measure-unit/speed-kilometer-per-hour unit-width-full-name';
+
+var_dump(IntlNumberFormatter::createFromSkeleton($skeleton, '')->format(1234.5));
+
+Locale::setDefault('de');
+
+var_dump(IntlNumberFormatter::createFromSkeleton($skeleton, '')->format(1234.5));
+var_dump(IntlNumberFormatter::createFromSkeleton($skeleton, 'en_US')->format(1234.5));
+
+?>
+--EXPECT--
+string(27) "1,234.5 kilometers per hour"
+string(28) "1.234,5 Kilometer pro Stunde"
+string(27) "1,234.5 kilometers per hour"
diff --git a/ext/intl/tests/numberformatter/errors.phpt b/ext/intl/tests/numberformatter/errors.phpt
new file mode 100644
index 00000000000..308f29faf5b
--- /dev/null
+++ b/ext/intl/tests/numberformatter/errors.phpt
@@ -0,0 +1,86 @@
+--TEST--
+Errors for IntlNumberFormatter
+--EXTENSIONS--
+intl
+--SKIPIF--
+<?php
+if (!class_exists('IntlNumberFormatter')) {
+    die('skip IntlNumberFormatter not available');
+}
+?>
+--FILE--
+<?php
+
+var_dump(IntlNumberFormatter::createFromSkeleton('invalid skeleton here', 'ro'));
+var_dump(intl_get_error_code());
+var_dump(intl_get_error_message());
+
+var_dump(IntlNumberFormatter::createFromSkeleton('measure-unit/length-nope', 'en_US'));
+var_dump(intl_get_error_message());
+
+var_dump(IntlNumberFormatter::createFromSkeleton('', 'ro_' . str_repeat('a', 160)));
+var_dump(intl_get_error_message());
+
+var_dump(IntlNumberFormatter::createFromSkeleton("percent\xFF", 'en_US'));
+var_dump(intl_get_error_message());
+
+$formatter = IntlNumberFormatter::createFromSkeleton('', 'en_US');
+
+var_dump(intl_get_error_code());
+
+ini_set('intl.use_exceptions', '1');
+
+try {
+    IntlNumberFormatter::createFromSkeleton('invalid skeleton here', 'ro');
+} catch (IntlException $e) {
+    echo $e::class, ': ', $e->getMessage(), PHP_EOL;
+}
+
+ini_set('intl.use_exceptions', '0');
+
+try {
+    new IntlNumberFormatter();
+} catch (Error $e) {
+    echo $e::class, ': ', $e->getMessage(), PHP_EOL;
+}
+
+try {
+    IntlNumberFormatter::createFromSkeleton('', 'invalid-language');
+} catch (ValueError $e) {
+    echo $e::class, ': ', $e->getMessage(), PHP_EOL;
+}
+
+IntlNumberFormatter::createFromSkeleton('invalid skeleton here', 'ro');
+
+try {
+    $formatter->format([]);
+} catch (TypeError $e) {
+    echo $e::class, ': ', $e->getMessage(), PHP_EOL;
+}
+
+var_dump(intl_get_error_code());
+
+try {
+    clone $formatter;
+} catch (Error $e) {
+    echo $e::class, ': ', $e->getMessage(), PHP_EOL;
+}
+
+?>
+--EXPECT--
+NULL
+int(65811)
+string(107) "IntlNumberFormatter::createFromSkeleton(): Number formatter creation failed: U_NUMBER_SKELETON_SYNTAX_ERROR"
+NULL
+string(107) "IntlNumberFormatter::createFromSkeleton(): Number formatter creation failed: U_NUMBER_SKELETON_SYNTAX_ERROR"
+NULL
+string(132) "IntlNumberFormatter::createFromSkeleton(): Locale string too long, should be no longer than 156 characters: U_ILLEGAL_ARGUMENT_ERROR"
+NULL
+string(100) "IntlNumberFormatter::createFromSkeleton(): Error converting skeleton to UTF-16: U_INVALID_CHAR_FOUND"
+int(0)
+IntlException: IntlNumberFormatter::createFromSkeleton(): Number formatter creation failed
+Error: Call to private IntlNumberFormatter::__construct() from global scope
+ValueError: IntlNumberFormatter::createFromSkeleton(): Argument #2 ($locale) "invalid-language" is invalid
+TypeError: IntlNumberFormatter::format(): Argument #1 ($value) must be of type string|int|float, array given
+int(65811)
+Error: Trying to clone an uncloneable object of class IntlNumberFormatter
diff --git a/ext/intl/tests/numberformatter/format_failure.phpt b/ext/intl/tests/numberformatter/format_failure.phpt
new file mode 100644
index 00000000000..ff91a9ca1bb
--- /dev/null
+++ b/ext/intl/tests/numberformatter/format_failure.phpt
@@ -0,0 +1,48 @@
+--TEST--
+IntlNumberFormatter::format() with a failing formatter
+--EXTENSIONS--
+intl
+--SKIPIF--
+<?php
+if (!class_exists('IntlNumberFormatter')) {
+    die('skip IntlNumberFormatter not available');
+}
+?>
+--FILE--
+<?php
+
+$formatter = IntlNumberFormatter::createFromSkeleton('', 'en_US@numbers=foobar');
+
+var_dump($formatter->format(1));
+var_dump($formatter->getErrorCode() === U_UNSUPPORTED_ERROR);
+var_dump($formatter->getErrorMessage());
+var_dump(intl_get_error_code() === U_UNSUPPORTED_ERROR);
+
+ini_set('intl.use_exceptions', '1');
+
+try {
+    $formatter->format(1);
+} catch (IntlException $e) {
+    echo $e::class, ': ', $e->getMessage(), PHP_EOL;
+}
+
+ini_set('intl.use_exceptions', '0');
+
+$formatter = IntlNumberFormatter::createFromSkeleton('', 'en_US');
+
+var_dump($formatter->format(1));
+var_dump(intl_get_error_code());
+var_dump($formatter->getErrorCode());
+var_dump($formatter->getErrorMessage());
+
+?>
+--EXPECT--
+bool(false)
+bool(true)
+string(76) "IntlNumberFormatter::format(): Number formatting failed: U_UNSUPPORTED_ERROR"
+bool(true)
+IntlException: IntlNumberFormatter::format(): Number formatting failed
+string(1) "1"
+int(0)
+int(0)
+string(12) "U_ZERO_ERROR"
diff --git a/ext/intl/tests/numberformatter/format_int64.phpt b/ext/intl/tests/numberformatter/format_int64.phpt
new file mode 100644
index 00000000000..28e70623243
--- /dev/null
+++ b/ext/intl/tests/numberformatter/format_int64.phpt
@@ -0,0 +1,25 @@
+--TEST--
+IntlNumberFormatter::format() preserves int64 precision
+--EXTENSIONS--
+intl
+--SKIPIF--
+<?php
+if (!class_exists('IntlNumberFormatter')) {
+    die('skip IntlNumberFormatter not available');
+}
+if (PHP_INT_SIZE < 8) {
+    die('skip 64-bit only');
+}
+?>
+--FILE--
+<?php
+
+$formatter = IntlNumberFormatter::createFromSkeleton('', 'en_US');
+
+var_dump($formatter->format(9007199254740993));
+var_dump($formatter->format(PHP_INT_MIN));
+
+?>
+--EXPECT--
+string(21) "9,007,199,254,740,993"
+string(26) "-9,223,372,036,854,775,808"
diff --git a/ext/intl/tests/numberformatter/format_string.phpt b/ext/intl/tests/numberformatter/format_string.phpt
new file mode 100644
index 00000000000..51ac283c096
--- /dev/null
+++ b/ext/intl/tests/numberformatter/format_string.phpt
@@ -0,0 +1,56 @@
+--TEST--
+IntlNumberFormatter::format() with a numeric string
+--EXTENSIONS--
+intl
+--SKIPIF--
+<?php
+if (!class_exists('IntlNumberFormatter')) {
+    die('skip IntlNumberFormatter not available');
+}
+?>
+--FILE--
+<?php
+
+$formatter = IntlNumberFormatter::createFromSkeleton('precision-unlimited', 'en_US');
+
+var_dump($formatter->format('12345678901234567890.123456789'));
+var_dump($formatter->format('9223372036854775808'));
+var_dump($formatter->format('-0.1'));
+var_dump($formatter->format('1e3'));
+
+var_dump(IntlNumberFormatter::createFromSkeleton('', 'en_US')->format('12345678901234567890.123456789'));
+var_dump(IntlNumberFormatter::createFromSkeleton('measure-unit/length-meter .00', 'de')->format('1234.5'));
+
+foreach (['abc', '', ' 12', '1,5'] as $value) {
+    var_dump($formatter->format($value));
+    var_dump($formatter->getErrorMessage());
+}
+
+var_dump($formatter->format('12'));
+var_dump($formatter->getErrorCode());
+
+try {
+    $formatter->format("1\0002");
+} catch (ValueError $e) {
+    echo $e::class, ': ', $e->getMessage(), PHP_EOL;
+}
+
+?>
+--EXPECT--
+string(36) "12,345,678,901,234,567,890.123456789"
+string(25) "9,223,372,036,854,775,808"
+string(4) "-0.1"
+string(5) "1,000"
+string(33) "12,345,678,901,234,567,890.123457"
+string(10) "1.234,50 m"
+bool(false)
+string(86) "IntlNumberFormatter::format(): Number formatting failed: U_DECIMAL_NUMBER_SYNTAX_ERROR"
+bool(false)
+string(86) "IntlNumberFormatter::format(): Number formatting failed: U_DECIMAL_NUMBER_SYNTAX_ERROR"
+bool(false)
+string(86) "IntlNumberFormatter::format(): Number formatting failed: U_DECIMAL_NUMBER_SYNTAX_ERROR"
+bool(false)
+string(86) "IntlNumberFormatter::format(): Number formatting failed: U_DECIMAL_NUMBER_SYNTAX_ERROR"
+string(2) "12"
+int(0)
+ValueError: IntlNumberFormatter::format(): Argument #1 ($value) must not contain any null bytes
diff --git a/ext/intl/tests/numberformatter/locale_posix_modifier.phpt b/ext/intl/tests/numberformatter/locale_posix_modifier.phpt
new file mode 100644
index 00000000000..89db9664811
--- /dev/null
+++ b/ext/intl/tests/numberformatter/locale_posix_modifier.phpt
@@ -0,0 +1,21 @@
+--TEST--
+IntlNumberFormatter preserves the region of a locale with a POSIX modifier
+--EXTENSIONS--
+intl
+--SKIPIF--
+<?php
+if (!class_exists('IntlNumberFormatter')) {
+    die('skip IntlNumberFormatter not available');
+}
+?>
+--FILE--
+<?php
+$skeleton = 'measure-unit/length-kilometer unit-width-full-name';
+
+$plain = IntlNumberFormatter::createFromSkeleton($skeleton, 'pt_PT');
+$modified = IntlNumberFormatter::createFromSkeleton($skeleton, 'pt_PT@euro');
+
+var_dump($plain->format(12345.5) === $modified->format(12345.5));
+?>
+--EXPECT--
+bool(true)
diff --git a/ext/standard/credits_ext.h b/ext/standard/credits_ext.h
index 3e1ba70a71e..45f8ce89c8d 100644
--- a/ext/standard/credits_ext.h
+++ b/ext/standard/credits_ext.h
@@ -31,7 +31,7 @@ CREDIT_LINE("GetText", "Alex Plotnick");
 CREDIT_LINE("GNU GMP support", "Stanislav Malyshev");
 CREDIT_LINE("Iconv", "Rui Hirokawa, Stig Bakken, Moriyoshi Koizumi");
 CREDIT_LINE("Input Filter", "Rasmus Lerdorf, Derick Rethans, Pierre-Alain Joye, Ilia Alshanetsky");
-CREDIT_LINE("Internationalization", "Ed Batutis, Vladimir Iordanov, Dmitry Lakhtyuk, Stanislav Malyshev, Vadim Savchuk, Kirti Velankar");
+CREDIT_LINE("Internationalization", "Ed Batutis, David Carlier, Vladimir Iordanov, Dmitry Lakhtyuk, Stanislav Malyshev, Vadim Savchuk, Kirti Velankar");
 CREDIT_LINE("JSON", "Jakub Zelenka, Omar Kilani, Scott MacVicar");
 CREDIT_LINE("LDAP", "Amitay Isaacs, Eric Warnke, Rasmus Lerdorf, Gerrit Thomson, Stig Venaas");
 CREDIT_LINE("LIBXML", "Christian Stocker, Rob Richards, Marcus Boerger, Wez Furlong, Shane Caraveo");