From 36c0be399c43b6d91e54e8984425808b5e090976 Mon Sep 17 00:00:00 2001 From: Khaled Alam Date: Sun, 11 Jan 2026 06:08:43 +0400 Subject: [PATCH 1/5] Fix GH-10497: Allow direct modification of object properties in constants Co-authored-by: Ilija Tovilo --- Zend/tests/enum/no-unsed-value.phpt | 5 +- Zend/tests/gh10497.phpt | 84 ++++++++++++++++++++++ Zend/tests/gh10497_class_const.phpt | 76 ++++++++++++++++++++ Zend/tests/gh10497_func_arg.phpt | 19 +++++ Zend/tests/gh10497_guardrails.phpt | 9 +++ Zend/tests/gh10497_guardrails_dim_obj.phpt | 9 +++ Zend/tests/gh10497_guardrails_rebind.phpt | 9 +++ Zend/zend_compile.c | 62 ++++++++++++---- 8 files changed, 259 insertions(+), 14 deletions(-) create mode 100644 Zend/tests/gh10497.phpt create mode 100644 Zend/tests/gh10497_class_const.phpt create mode 100644 Zend/tests/gh10497_func_arg.phpt create mode 100644 Zend/tests/gh10497_guardrails.phpt create mode 100644 Zend/tests/gh10497_guardrails_dim_obj.phpt create mode 100644 Zend/tests/gh10497_guardrails_rebind.phpt diff --git a/Zend/tests/enum/no-unsed-value.phpt b/Zend/tests/enum/no-unsed-value.phpt index a1cbdd43cad4..eb058cb1b26f 100644 --- a/Zend/tests/enum/no-unsed-value.phpt +++ b/Zend/tests/enum/no-unsed-value.phpt @@ -11,4 +11,7 @@ unset(Foo::Bar->value); ?> --EXPECTF-- -Fatal error: Cannot use temporary expression in write context in %s on line %d +Fatal error: Uncaught Error: Cannot unset readonly property Foo::$value in %s:%d +Stack trace: +#0 {main} + thrown in %s on line %d diff --git a/Zend/tests/gh10497.phpt b/Zend/tests/gh10497.phpt new file mode 100644 index 000000000000..bcefffe3a8b9 --- /dev/null +++ b/Zend/tests/gh10497.phpt @@ -0,0 +1,84 @@ +--TEST-- +GH-10497: Allow direct modification of object properties on constants +--FILE-- +prop = 123; +var_dump(OBJ->prop); + +OBJ->foo = 'bar'; +OBJ->baz = 456; +var_dump(OBJ->foo, OBJ->baz); + +OBJ->prop = 'overwritten'; +var_dump(OBJ->prop); + +OBJ->inner = new stdClass; +OBJ->inner->value = 999; +var_dump(OBJ->inner->value); + +OBJ->counter = 0; +OBJ->counter++; +OBJ->counter++; +OBJ->counter--; +var_dump(OBJ->counter); + +OBJ->str = 'hello'; +OBJ->str .= ' world'; +var_dump(OBJ->str); + +OBJ->temp = 'remove me'; +var_dump(isset(OBJ->temp)); +unset(OBJ->temp); +var_dump(isset(OBJ->temp)); + +var_dump(isset(OBJ->foo)); +var_dump(empty(OBJ->foo)); +var_dump(isset(OBJ->nonexistent)); +var_dump(empty(OBJ->nonexistent)); + +function incr(&$v) { $v++; } +OBJ->reftest = 10; +incr(OBJ->reftest); +var_dump(OBJ->reftest); + +OBJ->arr = []; +OBJ->arr[0] = 42; +OBJ->arr[] = 43; +var_dump(OBJ->arr); + +OBJ->coalesce ??= 42; +var_dump(OBJ->coalesce); +OBJ->coalesce ??= 43; +var_dump(OBJ->coalesce); + +const OBJS = [new stdClass]; +OBJS[0]->prop = 42; +var_dump(OBJS[0]->prop); + +?> +--EXPECT-- +int(123) +string(3) "bar" +int(456) +string(11) "overwritten" +int(999) +int(1) +string(11) "hello world" +bool(true) +bool(false) +bool(true) +bool(false) +bool(false) +bool(true) +int(11) +array(2) { + [0]=> + int(42) + [1]=> + int(43) +} +int(42) +int(42) +int(42) diff --git a/Zend/tests/gh10497_class_const.phpt b/Zend/tests/gh10497_class_const.phpt new file mode 100644 index 000000000000..4f2861009de5 --- /dev/null +++ b/Zend/tests/gh10497_class_const.phpt @@ -0,0 +1,76 @@ +--TEST-- +GH-10497: Allow direct modification of object properties on class constants +--FILE-- +prop = 123; +var_dump(C::O->prop); + +// Access via instance (static-property-like syntax on an object). +$c = new C; +$c::O->prop = 'overwritten'; +var_dump(C::O->prop); + +// Compound assignment, increment/decrement and concatenation. +C::O->counter = 0; +C::O->counter++; +C::O->counter++; +C::O->counter--; +var_dump(C::O->counter); + +C::O->str = 'hello'; +C::O->str .= ' world'; +var_dump(C::O->str); + +// Nested property chains. +C::O->inner = new stdClass; +C::O->inner->value = 999; +var_dump(C::O->inner->value); + +// isset()/unset(). +C::O->temp = 'remove me'; +var_dump(isset(C::O->temp)); +unset(C::O->temp); +var_dump(isset(C::O->temp)); + +// Passing by reference. +function incr(&$v) { $v++; } +C::O->reftest = 10; +incr(C::O->reftest); +var_dump(C::O->reftest); + +// self:: from within a method. The same backing object is reachable through +// the class constant and the global constant, so the mutation is visible. +class D { + const O = BACKING; + + public static function set(): void { + self::O->fromSelf = 'yes'; + } +} + +D::set(); +var_dump(D::O->fromSelf); +var_dump(BACKING->fromSelf); + +?> +--EXPECT-- +int(123) +string(11) "overwritten" +int(1) +string(11) "hello world" +int(999) +bool(true) +bool(false) +int(11) +string(3) "yes" +string(3) "yes" diff --git a/Zend/tests/gh10497_func_arg.phpt b/Zend/tests/gh10497_func_arg.phpt new file mode 100644 index 000000000000..047cf4cfe45f --- /dev/null +++ b/Zend/tests/gh10497_func_arg.phpt @@ -0,0 +1,19 @@ +--TEST-- +GH-10497: Passing constant object property by reference via FUNC_ARG +--FILE-- +val = 10; +modify(OBJ->val); +var_dump(OBJ->val); + +function modify(&$v) { + $v = 42; +} + +?> +--EXPECT-- +int(42) diff --git a/Zend/tests/gh10497_guardrails.phpt b/Zend/tests/gh10497_guardrails.phpt new file mode 100644 index 000000000000..a04b563b15e7 --- /dev/null +++ b/Zend/tests/gh10497_guardrails.phpt @@ -0,0 +1,9 @@ +--TEST-- +GH-10497: Guardrail - array dim write on constant still fails +--FILE-- + +--EXPECTF-- +Fatal error: Cannot use temporary expression in write context in %s on line %d diff --git a/Zend/tests/gh10497_guardrails_dim_obj.phpt b/Zend/tests/gh10497_guardrails_dim_obj.phpt new file mode 100644 index 000000000000..c6ff2f63d565 --- /dev/null +++ b/Zend/tests/gh10497_guardrails_dim_obj.phpt @@ -0,0 +1,9 @@ +--TEST-- +GH-10497: Guardrail - dim write on constant object still fails +--FILE-- + +--EXPECTF-- +Fatal error: Cannot use temporary expression in write context in %s on line %d diff --git a/Zend/tests/gh10497_guardrails_rebind.phpt b/Zend/tests/gh10497_guardrails_rebind.phpt new file mode 100644 index 000000000000..aa548b16d7d2 --- /dev/null +++ b/Zend/tests/gh10497_guardrails_rebind.phpt @@ -0,0 +1,9 @@ +--TEST-- +GH-10497: Guardrail - constant rebinding still fails +--FILE-- + +--EXPECTF-- +Parse error: syntax error, unexpected token "=" in %s on line %d diff --git a/Zend/zend_compile.c b/Zend/zend_compile.c index 91822e886684..f5dbc534ddb0 100644 --- a/Zend/zend_compile.c +++ b/Zend/zend_compile.c @@ -2594,6 +2594,20 @@ static void zend_short_circuiting_commit(uint32_t checkpoint, znode *result, con } } +/* Same value as ZEND_SHORT_CIRCUITING_INNER, AST kinds must not clash. */ +#define ZEND_CONST_OBJECT_FETCH 0x8000 + +static void zend_mark_const_object_fetch(zend_ast *ast) +{ + while (ast->kind == ZEND_AST_DIM) { + ast = ast->child[0]; + } + + if (ast->kind == ZEND_AST_CONST || ast->kind == ZEND_AST_CLASS_CONST) { + ast->attr |= ZEND_CONST_OBJECT_FETCH; + } +} + static void zend_emit_jmp_null(znode *obj_node, uint32_t bp_type) { uint32_t jmp_null_opnum = get_next_op_number(); @@ -3186,6 +3200,7 @@ static zend_op *zend_delayed_compile_prop(znode *result, zend_ast *ast, uint32_t * check for a nullsafe access. */ } else { zend_short_circuiting_mark_inner(obj_ast); + zend_mark_const_object_fetch(obj_ast); opline = zend_delayed_compile_var(&obj_node, obj_ast, type, false); if (opline && (opline->opcode == ZEND_FETCH_DIM_W || opline->opcode == ZEND_FETCH_DIM_RW @@ -11576,9 +11591,14 @@ static void zend_compile_array(znode *result, zend_ast *ast) /* {{{ */ } /* }}} */ -static void zend_emit_fetch_constant(znode *result, zend_string *resolved_name, bool unqualified_in_namespace) +static void zend_emit_fetch_constant(znode *result, zend_string *resolved_name, bool unqualified_in_namespace, uint8_t type) { - zend_op *opline = zend_emit_op_tmp(result, ZEND_FETCH_CONSTANT, NULL, NULL); + zend_op *opline = zend_emit_op(result, ZEND_FETCH_CONSTANT, NULL, NULL); + if (type == BP_VAR_R || type == BP_VAR_IS) { + opline->result_type = IS_TMP_VAR; + result->op_type = IS_TMP_VAR; + } + opline->op2_type = IS_CONST; if (unqualified_in_namespace) { @@ -11591,7 +11611,7 @@ static void zend_emit_fetch_constant(znode *result, zend_string *resolved_name, opline->extended_value = zend_alloc_cache_slot(); } -static void zend_compile_const(znode *result, const zend_ast *ast) /* {{{ */ +static void zend_compile_const(znode *result, const zend_ast *ast, uint8_t type) /* {{{ */ { zend_ast *name_ast = ast->child[0]; @@ -11624,19 +11644,19 @@ static void zend_compile_const(znode *result, const zend_ast *ast) /* {{{ */ } zend_emit_fetch_constant(result, resolved_name, - !is_fully_qualified && FC(current_namespace)); + !is_fully_qualified && FC(current_namespace), type); } /* }}} */ -static void zend_compile_constant(znode *result, zend_ast *ast) +static void zend_compile_constant(znode *result, zend_ast *ast, uint8_t type) { zend_string *name = zend_ast_get_constant_name(ast); zend_emit_fetch_constant(result, zend_string_copy(name), - (ast->attr & IS_CONSTANT_UNQUALIFIED_IN_NAMESPACE) != 0); + (ast->attr & IS_CONSTANT_UNQUALIFIED_IN_NAMESPACE) != 0, type); } -static void zend_compile_class_const(znode *result, zend_ast *ast) /* {{{ */ +static void zend_compile_class_const(znode *result, zend_ast *ast, uint8_t type) /* {{{ */ { zend_ast *class_ast; zend_ast *const_ast; @@ -11667,7 +11687,11 @@ static void zend_compile_class_const(znode *result, zend_ast *ast) /* {{{ */ zend_compile_expr(&const_node, const_ast); - opline = zend_emit_op_tmp(result, ZEND_FETCH_CLASS_CONSTANT, NULL, &const_node); + opline = zend_emit_op(result, ZEND_FETCH_CLASS_CONSTANT, NULL, &const_node); + if (type == BP_VAR_R || type == BP_VAR_IS) { + opline->result_type = IS_TMP_VAR; + result->op_type = IS_TMP_VAR; + } zend_set_class_name_op1(opline, &class_node); @@ -12517,13 +12541,13 @@ static void zend_compile_expr_inner(znode *result, zend_ast *ast) /* {{{ */ zend_compile_array(result, ast); return; case ZEND_AST_CONST: - zend_compile_const(result, ast); + zend_compile_const(result, ast, BP_VAR_R); return; case ZEND_AST_CONSTANT: - zend_compile_constant(result, ast); + zend_compile_constant(result, ast, BP_VAR_R); return; case ZEND_AST_CLASS_CONST: - zend_compile_class_const(result, ast); + zend_compile_class_const(result, ast, BP_VAR_R); return; case ZEND_AST_CLASS_NAME: zend_compile_class_name(result, ast); @@ -12665,9 +12689,21 @@ static zend_op *zend_delayed_compile_var(znode *result, zend_ast *ast, uint32_t } case ZEND_AST_STATIC_PROP: return zend_compile_static_prop(result, ast, type, by_ref, true); - default: - return zend_compile_var(result, ast, type, false); + case ZEND_AST_CONST: + if (!(ast->attr & ZEND_CONST_OBJECT_FETCH)) { + break; + } + zend_compile_const(result, ast, type); + return NULL; + case ZEND_AST_CLASS_CONST: + if (!(ast->attr & ZEND_CONST_OBJECT_FETCH)) { + break; + } + zend_compile_class_const(result, ast, type); + return NULL; } + + return zend_compile_var(result, ast, type, false); } /* }}} */ From aa0e9fc0fcfa04faccdfde0657f8a9662942a94e Mon Sep 17 00:00:00 2001 From: Khaled Alam Date: Wed, 12 Aug 2026 12:48:54 +0300 Subject: [PATCH 2/5] Fix invalid opcode when writing to a property of a compile-time evaluated constant --- Zend/Optimizer/pass1.c | 10 +++ Zend/tests/gh10497_non_object.phpt | 105 +++++++++++++++++++++++++++++ Zend/zend_compile.c | 23 +++++-- 3 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 Zend/tests/gh10497_non_object.phpt diff --git a/Zend/Optimizer/pass1.c b/Zend/Optimizer/pass1.c index 962bdb6e4be3..9cdf00f6006d 100644 --- a/Zend/Optimizer/pass1.c +++ b/Zend/Optimizer/pass1.c @@ -124,6 +124,12 @@ void zend_optimizer_pass1(zend_op_array *op_array, zend_optimizer_ctx *ctx) break; case ZEND_FETCH_CONSTANT: + if (opline->result_type == IS_VAR) { + /* The constant is fetched in write context (e.g. CONST->prop = 1). + * Substituting it would leave the following property opcode with an + * IS_CONST container, for which no handler exists. */ + break; + } if (opline->op2_type == IS_CONST && Z_TYPE(ZEND_OP2_LITERAL(opline)) == IS_STRING && zend_string_equals_literal(Z_STR(ZEND_OP2_LITERAL(opline)), "__COMPILER_HALT_OFFSET__")) { @@ -161,6 +167,10 @@ void zend_optimizer_pass1(zend_op_array *op_array, zend_optimizer_ctx *ctx) case ZEND_FETCH_CLASS_CONSTANT: { bool is_prototype; + if (opline->result_type == IS_VAR) { + /* Fetched in write context, see ZEND_FETCH_CONSTANT above. */ + break; + } const zend_class_constant *cc = zend_fetch_class_const_info(ctx->script, op_array, opline, &is_prototype); if (!cc || is_prototype) { break; diff --git a/Zend/tests/gh10497_non_object.phpt b/Zend/tests/gh10497_non_object.phpt new file mode 100644 index 000000000000..11fd67649c2f --- /dev/null +++ b/Zend/tests/gh10497_non_object.phpt @@ -0,0 +1,105 @@ +--TEST-- +GH-10497: Writing to a property of a non-object constant reports a runtime error +--FILE-- +prop = 1; +} catch (Error $e) { + echo $e->getMessage(), "\n"; +} + +try { + NULL->prop = 1; +} catch (Error $e) { + echo $e->getMessage(), "\n"; +} + +try { + PHP_INT_MAX->prop = 1; +} catch (Error $e) { + echo $e->getMessage(), "\n"; +} + +class C { + const INT = 5; + const STR = 'str'; + const ARR = [1, 2]; + + public static function fromSelf(): void { + self::INT->prop = 1; + } +} + +try { + C::INT->prop = 1; +} catch (Error $e) { + echo $e->getMessage(), "\n"; +} + +try { + C::STR->prop = 1; +} catch (Error $e) { + echo $e->getMessage(), "\n"; +} + +try { + C::ARR->prop = 1; +} catch (Error $e) { + echo $e->getMessage(), "\n"; +} + +try { + C::fromSelf(); +} catch (Error $e) { + echo $e->getMessage(), "\n"; +} + +// Other write contexts: compound assignment, unset() and by-reference arguments. +try { + C::INT->prop++; +} catch (Error $e) { + echo $e->getMessage(), "\n"; +} + +try { + C::INT->prop .= 'x'; +} catch (Error $e) { + echo $e->getMessage(), "\n"; +} + +function byRef(&$v) {} + +try { + byRef(C::INT->prop); +} catch (Error $e) { + echo $e->getMessage(), "\n"; +} + +// As for plain variables, unsetting a property of a non-object is a silent no-op. +unset(C::INT->prop); +echo "unset() did not error\n"; + +// Read context still substitutes constants at compile time. +var_dump(TRUE, C::INT, C::STR); +var_dump(isset(C::INT->prop)); + +?> +--EXPECT-- +Attempt to assign property "prop" on true +Attempt to assign property "prop" on null +Attempt to assign property "prop" on int +Attempt to assign property "prop" on int +Attempt to assign property "prop" on string +Attempt to assign property "prop" on array +Attempt to assign property "prop" on int +Attempt to increment/decrement property "prop" on int +Attempt to assign property "prop" on int +Attempt to modify property "prop" on int +unset() did not error +bool(true) +int(5) +string(3) "str" +bool(false) diff --git a/Zend/zend_compile.c b/Zend/zend_compile.c index f5dbc534ddb0..ed737ee4e8b7 100644 --- a/Zend/zend_compile.c +++ b/Zend/zend_compile.c @@ -11591,10 +11591,20 @@ static void zend_compile_array(znode *result, zend_ast *ast) /* {{{ */ } /* }}} */ +/* In read context a constant is compiled to a temporary, and may be substituted at + * compile time. In write context (e.g. CONST->prop = 1) it must be fetched at runtime + * into a VAR instead, so that the write targets the referenced object. Compile-time + * substitution must be skipped there, as the resulting IS_CONST operand is not a valid + * container for the property opcodes. */ +static zend_always_inline bool zend_is_const_read_context(uint8_t type) +{ + return type == BP_VAR_R || type == BP_VAR_IS; +} + static void zend_emit_fetch_constant(znode *result, zend_string *resolved_name, bool unqualified_in_namespace, uint8_t type) { zend_op *opline = zend_emit_op(result, ZEND_FETCH_CONSTANT, NULL, NULL); - if (type == BP_VAR_R || type == BP_VAR_IS) { + if (zend_is_const_read_context(type)) { opline->result_type = IS_TMP_VAR; result->op_type = IS_TMP_VAR; } @@ -11629,7 +11639,8 @@ static void zend_compile_const(znode *result, const zend_ast *ast, uint8_t type) } last = list->child[list->children-1]; } - if (last && last->kind == ZEND_AST_HALT_COMPILER) { + if (last && last->kind == ZEND_AST_HALT_COMPILER + && zend_is_const_read_context(type)) { result->op_type = IS_CONST; ZVAL_LONG(&result->u.constant, Z_LVAL_P(zend_ast_get_zval(last->child[0]))); zend_string_release_ex(resolved_name, 0); @@ -11637,7 +11648,8 @@ static void zend_compile_const(znode *result, const zend_ast *ast, uint8_t type) } } - if (zend_try_ct_eval_const(&result->u.constant, resolved_name, is_fully_qualified)) { + if (zend_is_const_read_context(type) + && zend_try_ct_eval_const(&result->u.constant, resolved_name, is_fully_qualified)) { result->op_type = IS_CONST; zend_string_release_ex(resolved_name, 0); return; @@ -11674,7 +11686,8 @@ static void zend_compile_class_const(znode *result, zend_ast *ast, uint8_t type) if (Z_TYPE_P(const_zv) == IS_STRING) { zend_string *const_str = Z_STR_P(const_zv); zend_string *resolved_name = zend_resolve_class_name_ast(class_ast); - if (zend_try_ct_eval_class_const(&result->u.constant, resolved_name, const_str)) { + if (zend_is_const_read_context(type) + && zend_try_ct_eval_class_const(&result->u.constant, resolved_name, const_str)) { result->op_type = IS_CONST; zend_string_release_ex(resolved_name, 0); return; @@ -11688,7 +11701,7 @@ static void zend_compile_class_const(znode *result, zend_ast *ast, uint8_t type) zend_compile_expr(&const_node, const_ast); opline = zend_emit_op(result, ZEND_FETCH_CLASS_CONSTANT, NULL, &const_node); - if (type == BP_VAR_R || type == BP_VAR_IS) { + if (zend_is_const_read_context(type)) { opline->result_type = IS_TMP_VAR; result->op_type = IS_TMP_VAR; } From f59373407cc6ab5ea0ead53cc4a6aed1fbe6ed01 Mon Sep 17 00:00:00 2001 From: Ilija Tovilo Date: Wed, 12 Aug 2026 15:18:40 +0200 Subject: [PATCH 3/5] Tweak previous fix Use QM_ASSIGN instead of FETCH_CONSTANT, which avoids issues for pseudo-constants like __COMPILER_HALT_OFFSET__ and special handling in the optimizer. Also fix zend_optimizer_pass1() for ops that don't support op1=CONST. --- Zend/Optimizer/pass1.c | 10 --------- Zend/Optimizer/zend_optimizer.c | 17 +++++++++++---- Zend/tests/gh10497_non_object.phpt | 9 ++++++++ Zend/zend_compile.c | 33 ++++++++++++++---------------- 4 files changed, 37 insertions(+), 32 deletions(-) diff --git a/Zend/Optimizer/pass1.c b/Zend/Optimizer/pass1.c index 9cdf00f6006d..962bdb6e4be3 100644 --- a/Zend/Optimizer/pass1.c +++ b/Zend/Optimizer/pass1.c @@ -124,12 +124,6 @@ void zend_optimizer_pass1(zend_op_array *op_array, zend_optimizer_ctx *ctx) break; case ZEND_FETCH_CONSTANT: - if (opline->result_type == IS_VAR) { - /* The constant is fetched in write context (e.g. CONST->prop = 1). - * Substituting it would leave the following property opcode with an - * IS_CONST container, for which no handler exists. */ - break; - } if (opline->op2_type == IS_CONST && Z_TYPE(ZEND_OP2_LITERAL(opline)) == IS_STRING && zend_string_equals_literal(Z_STR(ZEND_OP2_LITERAL(opline)), "__COMPILER_HALT_OFFSET__")) { @@ -167,10 +161,6 @@ void zend_optimizer_pass1(zend_op_array *op_array, zend_optimizer_ctx *ctx) case ZEND_FETCH_CLASS_CONSTANT: { bool is_prototype; - if (opline->result_type == IS_VAR) { - /* Fetched in write context, see ZEND_FETCH_CONSTANT above. */ - break; - } const zend_class_constant *cc = zend_fetch_class_const_info(ctx->script, op_array, opline, &is_prototype); if (!cc || is_prototype) { break; diff --git a/Zend/Optimizer/zend_optimizer.c b/Zend/Optimizer/zend_optimizer.c index 862236c923b2..d437a3fbe53e 100644 --- a/Zend/Optimizer/zend_optimizer.c +++ b/Zend/Optimizer/zend_optimizer.c @@ -280,6 +280,19 @@ bool zend_optimizer_update_op1_const(zend_op_array *op_array, case ZEND_SEPARATE: case ZEND_SEND_VAR_NO_REF: case ZEND_SEND_VAR_NO_REF_EX: + case ZEND_ASSIGN_OP: + case ZEND_ASSIGN_DIM_OP: + case ZEND_ASSIGN_OBJ: + case ZEND_ASSIGN_OBJ_OP: + case ZEND_ASSIGN_OBJ_REF: + case ZEND_UNSET_OBJ: + case ZEND_FETCH_OBJ_W: + case ZEND_FETCH_OBJ_RW: + case ZEND_FETCH_OBJ_UNSET: + case ZEND_PRE_INC_OBJ: + case ZEND_PRE_DEC_OBJ: + case ZEND_POST_INC_OBJ: + case ZEND_POST_DEC_OBJ: return false; case ZEND_CATCH: REQUIRES_STRING(val); @@ -320,10 +333,6 @@ bool zend_optimizer_update_op1_const(zend_op_array *op_array, } zend_optimizer_add_literal_string(op_array, zend_string_tolower(Z_STR_P(val))); break; - case ZEND_ASSIGN_OP: - case ZEND_ASSIGN_DIM_OP: - case ZEND_ASSIGN_OBJ_OP: - break; case ZEND_ASSIGN_STATIC_PROP_OP: case ZEND_ASSIGN_STATIC_PROP: case ZEND_ASSIGN_STATIC_PROP_REF: diff --git a/Zend/tests/gh10497_non_object.phpt b/Zend/tests/gh10497_non_object.phpt index 11fd67649c2f..b7c6f9750436 100644 --- a/Zend/tests/gh10497_non_object.phpt +++ b/Zend/tests/gh10497_non_object.phpt @@ -86,6 +86,14 @@ echo "unset() did not error\n"; var_dump(TRUE, C::INT, C::STR); var_dump(isset(C::INT->prop)); +try { + __COMPILER_HALT_OFFSET__->prop = 1; +} catch (Error $e) { + echo $e::class, $e->getMessage(), "\n"; +} + +__halt_compiler(); + ?> --EXPECT-- Attempt to assign property "prop" on true @@ -103,3 +111,4 @@ bool(true) int(5) string(3) "str" bool(false) +ErrorAttempt to assign property "prop" on int diff --git a/Zend/zend_compile.c b/Zend/zend_compile.c index ed737ee4e8b7..c1a1e49447bc 100644 --- a/Zend/zend_compile.c +++ b/Zend/zend_compile.c @@ -11591,20 +11591,10 @@ static void zend_compile_array(znode *result, zend_ast *ast) /* {{{ */ } /* }}} */ -/* In read context a constant is compiled to a temporary, and may be substituted at - * compile time. In write context (e.g. CONST->prop = 1) it must be fetched at runtime - * into a VAR instead, so that the write targets the referenced object. Compile-time - * substitution must be skipped there, as the resulting IS_CONST operand is not a valid - * container for the property opcodes. */ -static zend_always_inline bool zend_is_const_read_context(uint8_t type) -{ - return type == BP_VAR_R || type == BP_VAR_IS; -} - static void zend_emit_fetch_constant(znode *result, zend_string *resolved_name, bool unqualified_in_namespace, uint8_t type) { zend_op *opline = zend_emit_op(result, ZEND_FETCH_CONSTANT, NULL, NULL); - if (zend_is_const_read_context(type)) { + if (type == BP_VAR_R || type == BP_VAR_IS) { opline->result_type = IS_TMP_VAR; result->op_type = IS_TMP_VAR; } @@ -11639,8 +11629,7 @@ static void zend_compile_const(znode *result, const zend_ast *ast, uint8_t type) } last = list->child[list->children-1]; } - if (last && last->kind == ZEND_AST_HALT_COMPILER - && zend_is_const_read_context(type)) { + if (last && last->kind == ZEND_AST_HALT_COMPILER) { result->op_type = IS_CONST; ZVAL_LONG(&result->u.constant, Z_LVAL_P(zend_ast_get_zval(last->child[0]))); zend_string_release_ex(resolved_name, 0); @@ -11648,8 +11637,7 @@ static void zend_compile_const(znode *result, const zend_ast *ast, uint8_t type) } } - if (zend_is_const_read_context(type) - && zend_try_ct_eval_const(&result->u.constant, resolved_name, is_fully_qualified)) { + if (zend_try_ct_eval_const(&result->u.constant, resolved_name, is_fully_qualified)) { result->op_type = IS_CONST; zend_string_release_ex(resolved_name, 0); return; @@ -11686,8 +11674,7 @@ static void zend_compile_class_const(znode *result, zend_ast *ast, uint8_t type) if (Z_TYPE_P(const_zv) == IS_STRING) { zend_string *const_str = Z_STR_P(const_zv); zend_string *resolved_name = zend_resolve_class_name_ast(class_ast); - if (zend_is_const_read_context(type) - && zend_try_ct_eval_class_const(&result->u.constant, resolved_name, const_str)) { + if (zend_try_ct_eval_class_const(&result->u.constant, resolved_name, const_str)) { result->op_type = IS_CONST; zend_string_release_ex(resolved_name, 0); return; @@ -11701,7 +11688,7 @@ static void zend_compile_class_const(znode *result, zend_ast *ast, uint8_t type) zend_compile_expr(&const_node, const_ast); opline = zend_emit_op(result, ZEND_FETCH_CLASS_CONSTANT, NULL, &const_node); - if (zend_is_const_read_context(type)) { + if (type == BP_VAR_R || type == BP_VAR_IS) { opline->result_type = IS_TMP_VAR; result->op_type = IS_TMP_VAR; } @@ -12707,12 +12694,22 @@ static zend_op *zend_delayed_compile_var(znode *result, zend_ast *ast, uint32_t break; } zend_compile_const(result, ast, type); + if (!(type == BP_VAR_R || type == BP_VAR_IS) && result->op_type == IS_CONST) { + znode op1 = *result; + /* Intentionally IS_VAR result. */ + zend_emit_op(result, ZEND_QM_ASSIGN, &op1, NULL); + } return NULL; case ZEND_AST_CLASS_CONST: if (!(ast->attr & ZEND_CONST_OBJECT_FETCH)) { break; } zend_compile_class_const(result, ast, type); + if (!(type == BP_VAR_R || type == BP_VAR_IS) && result->op_type == IS_CONST) { + znode op1 = *result; + /* Intentionally IS_VAR result. */ + zend_emit_op(result, ZEND_QM_ASSIGN, &op1, NULL); + } return NULL; } From d696b7d58f584eaf3409076add4faf7aed2d7052 Mon Sep 17 00:00:00 2001 From: Khaled Alam Date: Thu, 13 Aug 2026 06:03:33 +0300 Subject: [PATCH 4/5] Add UPGRADING and NEWS entries for GH-10497 --- NEWS | 2 ++ UPGRADING | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/NEWS b/NEWS index 3a5f08491611..1278ee88629a 100644 --- a/NEWS +++ b/NEWS @@ -5,6 +5,8 @@ PHP NEWS - Core: . Fixed bug GH-15375 (Nested "yield from" skips items after a valid() or next() call on the inner generator). (iliaal) + . Implemented GH-10497 (Allowed writing to object properties held in + constants). (Khaled Alam) - PDO_PGSQL: . Fixed several lazy fetch (PDO::ATTR_PREFETCH => 0) defects: an infinite diff --git a/UPGRADING b/UPGRADING index a4cc44f9a004..eaa7d6a0da94 100644 --- a/UPGRADING +++ b/UPGRADING @@ -23,6 +23,16 @@ PHP 8.6 UPGRADE NOTES . ??/empty() on a magic property no longer call __get() when __isset() has materialised the property by writing into the property table. The freshly-written value is returned directly. isset() is unaffected. + . Writing to a property of a constant is no longer rejected at compile time + with "Cannot use temporary expression in write context". Such code now + compiles, and behaves as it does for a plain variable holding the same + value: the write succeeds if the constant holds an object, and otherwise + raises an Error at runtime (unset() on a non-object is a silent no-op). + Code that previously failed to compile therefore now runs up to the point + of the write. Enum cases are class constants, so writing to or unsetting + an enum case property now raises the usual readonly property Error at + runtime; enum case properties remain immutable. + RFC: https://wiki.php.net/rfc/const_object_property_write - Curl: . The callback registered with CURLOPT_READFUNCTION now throws a ValueError @@ -309,6 +319,18 @@ PHP 8.6 UPGRADE NOTES RFC: https://wiki.php.net/rfc/override_constants . Implemented partial function application RFC: https://wiki.php.net/rfc/partial_function_application_v2 + . Object properties may now be written through a constant holding the + object, for both global and class constants: + + const OBJ = new stdClass; + OBJ->prop = 42; + Cls::OBJ->prop = 42; + + This includes compound assignment, increment/decrement, isset()/unset(), + passing by reference and nested property chains. The constant binding + itself remains immutable, and dimension writes on constants + (e.g. CONST[0] = 1) continue to be rejected. + RFC: https://wiki.php.net/rfc/const_object_property_write - Curl: . curl_getinfo() return array now includes a new size_delivered key, which From aaa521834b1a96509b7288e81a80077d7d2f23a6 Mon Sep 17 00:00:00 2001 From: Khaled Alam Date: Thu, 13 Aug 2026 06:14:50 +0300 Subject: [PATCH 5/5] Allow destructuring and by-ref foreach to target constant object properties --- UPGRADING | 6 +- Zend/tests/gh10497_write_targets.phpt | 97 +++++++++++++++++++ .../gh10497_write_targets_guardrail.phpt | 9 ++ Zend/zend_compile.c | 11 +++ 4 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 Zend/tests/gh10497_write_targets.phpt create mode 100644 Zend/tests/gh10497_write_targets_guardrail.phpt diff --git a/UPGRADING b/UPGRADING index eaa7d6a0da94..108bf4d94319 100644 --- a/UPGRADING +++ b/UPGRADING @@ -327,9 +327,9 @@ PHP 8.6 UPGRADE NOTES Cls::OBJ->prop = 42; This includes compound assignment, increment/decrement, isset()/unset(), - passing by reference and nested property chains. The constant binding - itself remains immutable, and dimension writes on constants - (e.g. CONST[0] = 1) continue to be rejected. + passing by reference, destructuring assignment, by-reference foreach and + nested property chains. The constant binding itself remains immutable, and + dimension writes on constants (e.g. CONST[0] = 1) continue to be rejected. RFC: https://wiki.php.net/rfc/const_object_property_write - Curl: diff --git a/Zend/tests/gh10497_write_targets.phpt b/Zend/tests/gh10497_write_targets.phpt new file mode 100644 index 000000000000..9b544035f644 --- /dev/null +++ b/Zend/tests/gh10497_write_targets.phpt @@ -0,0 +1,97 @@ +--TEST-- +GH-10497: Destructuring and by-reference foreach targeting constant object properties +--FILE-- +arr = [1, 2, 3]; +foreach (BACKING->arr as &$v) { + $v *= 2; +} +unset($v); +var_dump(BACKING->arr); + +C::O->arr = [1, 2, 3]; +foreach (C::O->arr as &$v) { + $v *= 2; +} +unset($v); +var_dump(C::O->arr); + +// Nested chains and a dimension after the property fetch. +BACKING->inner = new stdClass; +BACKING->inner->arr = [1, 2, 3]; +foreach (BACKING->inner->arr as &$v) { + $v *= 2; +} +unset($v); +var_dump(BACKING->inner->arr); + +BACKING->matrix = [[1, 2]]; +foreach (BACKING->matrix[0] as &$v) { + $v *= 2; +} +unset($v); +var_dump(BACKING->matrix[0]); + +// Destructuring assignment. +[BACKING->p, BACKING->q] = [1, 2]; +var_dump(BACKING->p, BACKING->q); + +['k' => BACKING->keyed] = ['k' => 9]; +var_dump(BACKING->keyed); + +BACKING->list = []; +[BACKING->list[0]] = [7]; +var_dump(BACKING->list); + +[C::O->viaClassConst] = ['yes']; +var_dump(BACKING->viaClassConst); + +?> +--EXPECT-- +array(3) { + [0]=> + int(2) + [1]=> + int(4) + [2]=> + int(6) +} +array(3) { + [0]=> + int(2) + [1]=> + int(4) + [2]=> + int(6) +} +array(3) { + [0]=> + int(2) + [1]=> + int(4) + [2]=> + int(6) +} +array(2) { + [0]=> + int(2) + [1]=> + int(4) +} +int(1) +int(2) +int(9) +array(1) { + [0]=> + int(7) +} +string(3) "yes" diff --git a/Zend/tests/gh10497_write_targets_guardrail.phpt b/Zend/tests/gh10497_write_targets_guardrail.phpt new file mode 100644 index 000000000000..2bb4c0718540 --- /dev/null +++ b/Zend/tests/gh10497_write_targets_guardrail.phpt @@ -0,0 +1,9 @@ +--TEST-- +GH-10497: Guardrail - destructuring into a constant that is not reached via a property +--FILE-- + +--EXPECTF-- +Fatal error: Assignments can only happen to writable values in %s on line %d diff --git a/Zend/zend_compile.c b/Zend/zend_compile.c index c1a1e49447bc..c057d3f3542a 100644 --- a/Zend/zend_compile.c +++ b/Zend/zend_compile.c @@ -2808,13 +2808,24 @@ static inline bool zend_is_unticked_stmt(const zend_ast *ast) /* {{{ */ static inline bool zend_can_write_to_variable(const zend_ast *ast) /* {{{ */ { + bool via_prop = false; + while ( ast->kind == ZEND_AST_DIM || ast->kind == ZEND_AST_PROP ) { + via_prop |= ast->kind == ZEND_AST_PROP; ast = ast->child[0]; } + /* Object properties may be written through a constant holding the object, + * as long as the write passes through at least one property fetch. A write + * to the constant itself, or to a dimension of it, still targets a + * temporary and remains illegal. */ + if (via_prop && (ast->kind == ZEND_AST_CONST || ast->kind == ZEND_AST_CLASS_CONST)) { + return true; + } + return zend_is_variable_or_call(ast) && !zend_ast_is_short_circuited(ast); } /* }}} */