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");