Commit 8b00475b180 for php.net
commit 8b00475b1806d575ab3bd55abab971a89034e33c
Author: Arnaud Le Blanc <365207+arnaud-lb@users.noreply.github.com>
Date: Fri Aug 14 16:20:03 2026 +0200
PFA: Fix strict_types checking when a const arg is inlined (#23256)
GH-22829 added an optimization to burn literal arguments into the generated closure. An overlooked side effect is that these arguments are not checked anymore by zp_bind() (GH-22789).
Fix by checking these arguments earlier.
Bug found by Ryan @ Calif.io.
diff --git a/Zend/tests/partial_application/const_arg_opt_002.phpt b/Zend/tests/partial_application/const_arg_opt_002.phpt
new file mode 100644
index 00000000000..5f335127667
--- /dev/null
+++ b/Zend/tests/partial_application/const_arg_opt_002.phpt
@@ -0,0 +1,42 @@
+--TEST--
+Constant argument optimization - strict_types bug
+--CREDITS--
+Ryan @ Calif.io
+--INI--
+opcache.enable=1
+opcache.enable_cli=1
+opcache.optimization_level=-1
+opcache.file_update_protection=0
+--FILE--
+<?php
+
+declare(strict_types=1);
+
+function cand_86014_typed(int $bound, mixed $placeholder): void
+{
+ echo 'CALLED:', get_debug_type($bound), ':', $bound, "\n";
+}
+
+function cand_86014_make_invalid(): Closure
+{
+ return cand_86014_typed('123', ?);
+}
+
+try {
+ $partial = cand_86014_make_invalid();
+ echo "CONSTRUCTED\n";
+} catch (Throwable $e) {
+ echo 'CREATE: ', get_class($e), ': ', $e->getMessage(), "\n";
+}
+
+if (isset($partial)) {
+ try {
+ $partial(null);
+ } catch (Throwable $e) {
+ echo 'CALL: ', get_class($e), ': ', $e->getMessage(), "\n";
+ }
+}
+
+?>
+--EXPECT--
+CREATE: TypeError: cand_86014_typed(): Argument #1 ($bound) must be of type int, string given
diff --git a/Zend/tests/partial_application/const_arg_opt_003.phpt b/Zend/tests/partial_application/const_arg_opt_003.phpt
new file mode 100644
index 00000000000..0841bff9e6f
--- /dev/null
+++ b/Zend/tests/partial_application/const_arg_opt_003.phpt
@@ -0,0 +1,32 @@
+--TEST--
+Constant argument optimization - strict_types
+--INI--
+opcache.enable=1
+opcache.enable_cli=1
+opcache.optimization_level=-1
+opcache.file_update_protection=0
+--FILE--
+<?php
+declare(strict_types=1);
+
+function f(int $a, $b) { return $a; }
+
+// Check that optimizations are enabled
+$f = f(3, ?);
+$usedVars = new ReflectionFunction($f)->getClosureUsedVariables();
+if ($usedVars !== []) {
+ echo "const arg optimization was not applied\n";
+ var_dump($usedVars);
+ exit;
+}
+
+var_dump($f(0));
+
+// Actual test. Not catching this as it disables optimizations
+f("3", ?)(0);
+
+?>
+--EXPECTF--
+int(3)
+
+Fatal error: Uncaught TypeError: f(): Argument #1 ($a) must be of type int, string given in %a
diff --git a/Zend/zend_partial.c b/Zend/zend_partial.c
index b50512c238f..ce1604ddc67 100644
--- a/Zend/zend_partial.c
+++ b/Zend/zend_partial.c
@@ -561,6 +561,30 @@ static zend_ast *zp_compile_forwarding_call(
args_ast = zend_ast_list_add(args_ast, default_value_ast);
} else if (zp_is_const_arg(const_args, offset)) {
ZEND_ASSERT(Z_TYPE(argv[offset]) < IS_OBJECT);
+
+ /* This argument never changes, so we can burn it into the op_array
+ * and check its type ahead of time. */
+
+ zend_arg_info *arg_info;
+ if (offset < function->common.num_args) {
+ arg_info = &function->common.arg_info[offset];
+ } else if (function->common.fn_flags & ZEND_ACC_VARIADIC) {
+ arg_info = &function->common.arg_info[function->common.num_args];
+ } else {
+ arg_info = NULL;
+ }
+ if (arg_info && ZEND_TYPE_IS_SET(arg_info->type)
+ && UNEXPECTED(!zend_check_type_ex(&arg_info->type, &argv[offset],
+ /* current_frame */ true, /* is_internal */ false))) {
+ zend_string *need_msg = zend_type_to_string_resolved(arg_info->type,
+ function->common.scope);
+ zend_argument_type_error_ex(function, offset + 1,
+ "must be of type %s, %s given",
+ ZSTR_VAL(need_msg), zend_zval_value_name(&argv[offset]));
+ zend_string_release(need_msg);
+ goto error;
+ }
+
args_ast = zend_ast_list_add(args_ast, zend_ast_create_zval(&argv[offset]));
} else {
args_ast = zend_ast_list_add(args_ast, zend_ast_create(ZEND_AST_VAR,