Author: David Carlier (devnexen)
Date: 2026-10-03T19:23:28+01:00
Commit: https://github.com/php/php-src/commit/99098ecc63a98580d7baedbd8c9582d4e42aa193
Raw diff: https://github.com/php/php-src/commit/99098ecc63a98580d7baedbd8c9582d4e42aa193.diff
Implement GH-24074: Locale::acceptFromHttp() available locales list
Add an optional $availableLocales argument so the Accept-Language header is
matched against the locales the application supports rather than every
locale known to ICU.
Close GH-24091
Changed paths:
A ext/intl/tests/gh24074.phpt
A ext/intl/tests/locale_accept_from_http_error_reset.phpt
M NEWS
M UPGRADING
M ext/intl/locale/locale.stub.php
M ext/intl/locale/locale_arginfo.h
M ext/intl/locale/locale_methods.cpp
M ext/intl/php_intl.stub.php
M ext/intl/php_intl_arginfo.h
M ext/intl/tests/locale_bug74439.phpt
Diff:
diff --git a/NEWS b/NEWS
index 124d6c516383..2595e055ddfe 100644
--- a/NEWS
+++ b/NEWS
@@ -13,6 +13,8 @@ PHP NEWS
- Intl:
. Fixed Collator attribute and strength methods not rejecting an
unconstructed Collator. (Ilia Alshanetsky)
+ . Added optional $availableLocales argument to Locale::acceptFromHttp()
+ (GH-24074). (David Carlier)
- Lexbor:
. Merge patches lexbor/lexbor@8a14bc0 and lexbor/lexbor@f67ce4b, fixing a
diff --git a/UPGRADING b/UPGRADING
index 5fa66cfc8749..a6cdf9829ab1 100644
--- a/UPGRADING
+++ b/UPGRADING
@@ -49,6 +49,14 @@ PHP 8.7 UPGRADE NOTES
5. Changed Functions
========================================
+- Intl:
+ . Locale::acceptFromHttp() and locale_accept_from_http() now accept an
+ optional $availableLocales array argument. When given, the
+ Accept-Language header is matched against these locales instead of all
+ locales known to ICU, and the matching entry is returned as passed.
+ A ValueError is thrown if the array is empty or contains an invalid
+ locale, and a TypeError if it contains a non-string value.
+
========================================
6. New Functions
========================================
diff --git a/ext/intl/locale/locale.stub.php b/ext/intl/locale/locale.stub.php
index fc012768de9f..3fd11034d272 100644
--- a/ext/intl/locale/locale.stub.php
+++ b/ext/intl/locale/locale.stub.php
@@ -142,7 +142,7 @@ public static function canonicalize(string $locale): ?string {}
* @tentative-return-type
* @alias locale_accept_from_http
*/
- public static function acceptFromHttp(string $header): string|false {}
+ public static function acceptFromHttp(string $header, ?array $availableLocales = null):
string|false {}
/**
* @alias locale_is_right_to_left
diff --git a/ext/intl/locale/locale_arginfo.h b/ext/intl/locale/locale_arginfo.h
index a01f583b0451..a95494580c0f 100644
--- a/ext/intl/locale/locale_arginfo.h
+++ b/ext/intl/locale/locale_arginfo.h
@@ -1,5 +1,5 @@
/* This is a generated file, edit locale.stub.php instead.
- * Stub hash: 43dbc3aa294044e9cef923a5f39249dae86b67f0 */
+ * Stub hash: 2f2de79772630fd0a2399b88120bd968bfed1144 */
ZEND_BEGIN_ARG_WITH_TENTATIVE_RETURN_TYPE_INFO_EX(arginfo_class_Locale_getDefault, 0, 0, IS_STRING,
0)
ZEND_END_ARG_INFO()
@@ -71,6 +71,7 @@ ZEND_END_ARG_INFO()
ZEND_BEGIN_ARG_WITH_TENTATIVE_RETURN_TYPE_MASK_EX(arginfo_class_Locale_acceptFromHttp, 0, 1,
MAY_BE_STRING|MAY_BE_FALSE)
ZEND_ARG_TYPE_INFO(0, header, IS_STRING, 0)
+ ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, availableLocales, IS_ARRAY, 1, "null")
ZEND_END_ARG_INFO()
ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Locale_isRightToLeft, 0, 1, _IS_BOOL, 0)
diff --git a/ext/intl/locale/locale_methods.cpp b/ext/intl/locale/locale_methods.cpp
index 911abfdc5c11..8130593b8938 100644
--- a/ext/intl/locale/locale_methods.cpp
+++ b/ext/intl/locale/locale_methods.cpp
@@ -22,6 +22,8 @@
#include <unicode/udata.h>
#include <unicode/putil.h>
#include <unicode/ures.h>
+#include <unicode/uenum.h>
+#include <unicode/locid.h>
extern "C" {
#include "php_intl.h"
@@ -1661,7 +1663,7 @@ PHP_INTL_FUNCTION_WITH_ERROR_RESET(locale_lookup)
/* }}} */
/* {{{ Tries to find out best available locale based on HTTP "Accept-Language" header */
-U_CFUNC PHP_FUNCTION(locale_accept_from_http)
+PHP_INTL_FUNCTION_WITH_ERROR_RESET(locale_accept_from_http)
{
UEnumeration *available;
char *http_accept = nullptr;
@@ -1670,10 +1672,52 @@ U_CFUNC PHP_FUNCTION(locale_accept_from_http)
int len;
char resultLocale[INTL_MAX_LOCALE_LEN+1];
UAcceptResult outResult;
+ HashTable *available_locales = nullptr;
+ std::unique_ptr<const char *[]> available_list;
+ std::unique_ptr<icu::Locale[]> available_canonical;
+ uint32_t available_count = 0;
- ZEND_PARSE_PARAMETERS_START(1, 1)
+ ZEND_PARSE_PARAMETERS_START(1, 2)
Z_PARAM_STRING(http_accept, http_accept_len)
+ Z_PARAM_OPTIONAL
+ Z_PARAM_ARRAY_HT_OR_NULL(available_locales)
ZEND_PARSE_PARAMETERS_END();
+
+ if (available_locales) {
+ uint32_t i = 0;
+ zval *entry;
+
+ available_count = zend_hash_num_elements(available_locales);
+ if (available_count == 0) {
+ zend_argument_must_not_be_empty_error(2);
+ RETURN_THROWS();
+ }
+
+ available_list.reset(new const char *[available_count]);
+ available_canonical.reset(new icu::Locale[available_count]);
+ ZEND_HASH_FOREACH_VAL(available_locales, entry) {
+ ZVAL_DEREF(entry);
+ if (Z_TYPE_P(entry) != IS_STRING) {
+ zend_argument_type_error(2, "must only contain string values");
+ RETURN_THROWS();
+ }
+ if (zend_str_has_nul_byte(Z_STR_P(entry))) {
+ zend_argument_value_error(2, "must not contain any null bytes");
+ RETURN_THROWS();
+ }
+ if (Z_STRLEN_P(entry) > INTL_MAX_LOCALE_LEN) {
+ zend_argument_value_error(2, "must not contain locales longer than %d characters",
INTL_MAX_LOCALE_LEN);
+ RETURN_THROWS();
+ }
+ available_canonical[i] = icu::Locale(Z_STRVAL_P(entry));
+ if (available_canonical[i].isBogus()) {
+ zend_argument_value_error(2, "must only contain valid locales");
+ RETURN_THROWS();
+ }
+ available_list[i++] = Z_STRVAL_P(entry);
+ } ZEND_HASH_FOREACH_END();
+ }
+
if (UNEXPECTED(http_accept_len > ULOC_FULLNAME_CAPACITY)) {
/* check each fragment, if any bigger than capacity, can't do it due to bug #72533 */
char *start = http_accept;
@@ -1693,7 +1737,11 @@ U_CFUNC PHP_FUNCTION(locale_accept_from_http)
} while(end != nullptr);
}
- available = ures_openAvailableLocales(nullptr, &status);
+ if (available_list) {
+ available = uenum_openCharStringsEnumeration(available_list.get(), available_count, &status);
+ } else {
+ available = ures_openAvailableLocales(nullptr, &status);
+ }
INTL_CHECK_STATUS(status, "failed to retrieve locale list");
len = uloc_acceptLanguageFromHTTP(resultLocale, INTL_MAX_LOCALE_LEN,
&outResult, http_accept, available, &status);
@@ -1702,6 +1750,12 @@ U_CFUNC PHP_FUNCTION(locale_accept_from_http)
if (UNEXPECTED(len < 0 || outResult == ULOC_ACCEPT_FAILED)) {
RETURN_FALSE;
}
+ resultLocale[len] = '\0';
+ for (uint32_t i = 0; i < available_count; i++) {
+ if (strcmp(available_list[i], resultLocale) == 0 || strcmp(available_canonical[i].getName(),
resultLocale) == 0) {
+ RETURN_STRING(available_list[i]);
+ }
+ }
RETURN_STRINGL(resultLocale, len);
}
/* }}} */
diff --git a/ext/intl/php_intl.stub.php b/ext/intl/php_intl.stub.php
index 1e6c5cacf203..a30c2d987608 100644
--- a/ext/intl/php_intl.stub.php
+++ b/ext/intl/php_intl.stub.php
@@ -503,7 +503,7 @@ function locale_canonicalize(string $locale): ?string {}
function locale_lookup(array $languageTag, string $locale, bool $canonicalize = false, ?string
$defaultLocale = null): ?string {}
-function locale_accept_from_http(string $header): string|false {}
+function locale_accept_from_http(string $header, ?array $availableLocales = null): string|false {}
function locale_is_right_to_left(string $locale): bool {}
diff --git a/ext/intl/php_intl_arginfo.h b/ext/intl/php_intl_arginfo.h
index f46192618b91..9478a818d383 100644
--- a/ext/intl/php_intl_arginfo.h
+++ b/ext/intl/php_intl_arginfo.h
@@ -1,5 +1,5 @@
/* This is a generated file, edit php_intl.stub.php instead.
- * Stub hash: f94e7c9cc372878f1f8bd0e948092ea72076e687 */
+ * Stub hash: bb8c86f1c7f0dcc264e5968fe9548f20ac20908b */
#include "zend_attributes.h"
#include "zend_constants.h"
@@ -594,6 +594,7 @@ ZEND_END_ARG_INFO()
ZEND_BEGIN_ARG_WITH_RETURN_TYPE_MASK_EX(arginfo_locale_accept_from_http, 0, 1,
MAY_BE_STRING|MAY_BE_FALSE)
ZEND_ARG_TYPE_INFO(0, header, IS_STRING, 0)
+ ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, availableLocales, IS_ARRAY, 1, "null")
ZEND_END_ARG_INFO()
ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_locale_is_right_to_left, 0, 1, _IS_BOOL, 0)
diff --git a/ext/intl/tests/gh24074.phpt b/ext/intl/tests/gh24074.phpt
new file mode 100644
index 000000000000..f9a734b0dc0c
--- /dev/null
+++ b/ext/intl/tests/gh24074.phpt
@@ -0,0 +1,60 @@
+--TEST--
+GH-24074 (Locale::acceptFromHttp() with a list of available locales)
+--EXTENSIONS--
+intl
+--SKIPIF--
+<?php if (version_compare(INTL_ICU_VERSION, '67.1') < 0) die('skip for ICU
>= 67.1'); ?>
+--FILE--
+<?php
+
+$header = 'fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7';
+
+var_dump(Locale::acceptFromHttp($header, ['en_US', 'de_DE']));
+var_dump(Locale::acceptFromHttp($header, ['de', 'en']));
+var_dump(Locale::acceptFromHttp($header, ['de']));
+var_dump(Locale::acceptFromHttp('de-CH, en;q=0.5', ['en', 'de']));
+var_dump(Locale::acceptFromHttp('en-us', ['de', 'en_US']));
+var_dump(Locale::acceptFromHttp('en-us', ['de', 'en-US']));
+var_dump(Locale::acceptFromHttp('ja', ['en', 'de']));
+var_dump(Locale::acceptFromHttp('en-us,en;q=0.5', null));
+var_dump(locale_accept_from_http($header, ['de', 'en']));
+
+var_dump(Locale::acceptFromHttp(str_repeat('a', 200), ['en']));
+var_dump(Locale::acceptFromHttp('en', ['en']));
+var_dump(intl_get_error_message());
+
+$invalid = [
+ [],
+ [1],
+ ["e\0n"],
+ [str_repeat('a', 200)],
+ ['en', str_repeat('a', 20)],
+];
+
+foreach ($invalid as $locales) {
+ try {
+ Locale::acceptFromHttp('en', $locales);
+ } catch (\Throwable $e) {
+ echo $e::class, ': ', $e->getMessage(), PHP_EOL;
+ }
+}
+
+?>
+--EXPECT--
+string(5) "en_US"
+string(2) "en"
+string(2) "de"
+string(2) "de"
+string(5) "en_US"
+string(5) "en-US"
+bool(false)
+string(5) "en_US"
+string(2) "en"
+bool(false)
+string(2) "en"
+string(12) "U_ZERO_ERROR"
+ValueError: Locale::acceptFromHttp(): Argument #2 ($availableLocales) must not be empty
+TypeError: Locale::acceptFromHttp(): Argument #2 ($availableLocales) must only contain string
values
+ValueError: Locale::acceptFromHttp(): Argument #2 ($availableLocales) must not contain any null
bytes
+ValueError: Locale::acceptFromHttp(): Argument #2 ($availableLocales) must not contain locales
longer than 156 characters
+ValueError: Locale::acceptFromHttp(): Argument #2 ($availableLocales) must only contain valid
locales
diff --git a/ext/intl/tests/locale_accept_from_http_error_reset.phpt
b/ext/intl/tests/locale_accept_from_http_error_reset.phpt
new file mode 100644
index 000000000000..f4472cf9f49f
--- /dev/null
+++ b/ext/intl/tests/locale_accept_from_http_error_reset.phpt
@@ -0,0 +1,32 @@
+--TEST--
+Locale::acceptFromHttp() resets intl error before argument parsing
+--EXTENSIONS--
+intl
+--INI--
+intl.error_level=0
+intl.use_exceptions=0
+--FILE--
+<?php
+foreach (['Locale::acceptFromHttp', 'locale_accept_from_http'] as $func) {
+ $func(str_repeat('a', 200));
+ var_dump(intl_get_error_code() === U_ILLEGAL_ARGUMENT_ERROR);
+
+ try {
+ $func('en', 123);
+ } catch (\Throwable $e) {
+ echo $e::class, "\n";
+ }
+
+ var_dump(intl_get_error_code() === U_ZERO_ERROR);
+ var_dump(intl_get_error_message());
+}
+?>
+--EXPECT--
+bool(true)
+TypeError
+bool(true)
+string(12) "U_ZERO_ERROR"
+bool(true)
+TypeError
+bool(true)
+string(12) "U_ZERO_ERROR"
diff --git a/ext/intl/tests/locale_bug74439.phpt b/ext/intl/tests/locale_bug74439.phpt
index 8fe1f2645b9e..5d9a8086be6d 100644
--- a/ext/intl/tests/locale_bug74439.phpt
+++ b/ext/intl/tests/locale_bug74439.phpt
@@ -33,7 +33,7 @@ foreach ($methods as $method) {
?>
--EXPECT--
getDefault: 0, 0
-acceptFromHttp: 1, 1
+acceptFromHttp: 2, 1
canonicalize: 1, 1
composeLocale: 1, 1
getAllVariants: 1, 1