Commit b16cab7da8e for php.net
commit b16cab7da8e5748b173542da42fc8b602ae59dea
Author: Khaled Alam <khaledalam.net@gmail.com>
Date: Thu Aug 13 12:25:40 2026 +0200
Allow direct mutation of objects stored in constants or class constants via OBJ->prop = $val
RFC: https://wiki.php.net/rfc/override_constants
Fixes GH-10497
Closes GH-20903
Co-authored-by: Ilija Tovilo <ilija.tovilo@me.com>
diff --git a/NEWS b/NEWS
index 3a5f0849161..930f40fc25e 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)
+ . Fixed GH-10497 (Allow direct mutation of objects stored in constants or
+ class constants via OBJ->prop = $val). (Khaled Alam)
- PDO_PGSQL:
. Fixed several lazy fetch (PDO::ATTR_PREFETCH => 0) defects: an infinite
diff --git a/UPGRADING b/UPGRADING
index a4cc44f9a00..271238bf404 100644
--- a/UPGRADING
+++ b/UPGRADING
@@ -309,6 +309,9 @@ 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
+ . Allow direct mutation of objects stored in constants or class constants via
+ OBJ->prop = $val.
+ RFC: https://wiki.php.net/rfc/const_object_property_write
- Curl:
. curl_getinfo() return array now includes a new size_delivered key, which
diff --git a/Zend/Optimizer/zend_optimizer.c b/Zend/Optimizer/zend_optimizer.c
index 862236c923b..d437a3fbe53 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/enum/no-unsed-value.phpt b/Zend/tests/enum/no-unsed-value.phpt
index a1cbdd43cad..eb058cb1b26 100644
--- a/Zend/tests/enum/no-unsed-value.phpt
+++ b/Zend/tests/enum/no-unsed-value.phpt
@@ -11,4 +11,7 @@ enum Foo: int {
?>
--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 00000000000..bcefffe3a8b
--- /dev/null
+++ b/Zend/tests/gh10497.phpt
@@ -0,0 +1,84 @@
+--TEST--
+GH-10497: Allow direct modification of object properties on constants
+--FILE--
+<?php
+
+const OBJ = new stdClass;
+OBJ->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 00000000000..0b24a857303
--- /dev/null
+++ b/Zend/tests/gh10497_class_const.phpt
@@ -0,0 +1,73 @@
+--TEST--
+GH-10497: Allow direct modification of object properties on class constants
+--FILE--
+<?php
+
+const BACKING = new stdClass;
+
+class C {
+ const O = BACKING;
+}
+
+// Access via class name.
+C::O->prop = 123;
+var_dump(C::O->prop);
+
+// Access via instance.
+$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.
+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_dim_arr.phpt b/Zend/tests/gh10497_dim_arr.phpt
new file mode 100644
index 00000000000..5c1562616c3
--- /dev/null
+++ b/Zend/tests/gh10497_dim_arr.phpt
@@ -0,0 +1,9 @@
+--TEST--
+GH-10497: Array dim write on constant fails
+--FILE--
+<?php
+const ARR = [1, 2, 3];
+ARR[0] = 9;
+?>
+--EXPECTF--
+Fatal error: Cannot use temporary expression in write context in %s on line %d
diff --git a/Zend/tests/gh10497_dim_obj.phpt b/Zend/tests/gh10497_dim_obj.phpt
new file mode 100644
index 00000000000..73552af828c
--- /dev/null
+++ b/Zend/tests/gh10497_dim_obj.phpt
@@ -0,0 +1,9 @@
+--TEST--
+GH-10497: Dim write on constant object fails
+--FILE--
+<?php
+const OBJ = new stdClass;
+OBJ["x"] = 1;
+?>
+--EXPECTF--
+Fatal error: Cannot use temporary expression in write context in %s on line %d
diff --git a/Zend/tests/gh10497_func_arg.phpt b/Zend/tests/gh10497_func_arg.phpt
new file mode 100644
index 00000000000..047cf4cfe45
--- /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--
+<?php
+
+// Forward-reference: function declared after call site, so the compiler
+// uses BP_VAR_FUNC_ARG rather than BP_VAR_W for the property fetch.
+const OBJ = new stdClass;
+OBJ->val = 10;
+modify(OBJ->val);
+var_dump(OBJ->val);
+
+function modify(&$v) {
+ $v = 42;
+}
+
+?>
+--EXPECT--
+int(42)
diff --git a/Zend/tests/gh10497_non_object.phpt b/Zend/tests/gh10497_non_object.phpt
new file mode 100644
index 00000000000..546e9718443
--- /dev/null
+++ b/Zend/tests/gh10497_non_object.phpt
@@ -0,0 +1,111 @@
+--TEST--
+GH-10497: Writing to a property of a non-object constant reports a runtime error
+--FILE--
+<?php
+
+try {
+ TRUE->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";
+
+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
+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)
+ErrorAttempt to assign property "prop" on int
diff --git a/Zend/tests/gh10497_write_targets.phpt b/Zend/tests/gh10497_write_targets.phpt
new file mode 100644
index 00000000000..9b544035f64
--- /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--
+<?php
+
+const BACKING = new stdClass;
+
+class C {
+ const O = BACKING;
+}
+
+// By-reference foreach must write through to the referenced object, as it does
+// for a plain variable holding the same object.
+BACKING->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 00000000000..f8763d7b465
--- /dev/null
+++ b/Zend/tests/gh10497_write_targets_guardrail.phpt
@@ -0,0 +1,9 @@
+--TEST--
+GH-10497: Destructuring into a constant without property assignment
+--FILE--
+<?php
+const ARR = [1, 2, 3];
+[ARR[0]] = [9];
+?>
+--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 91822e88668..c7ac93f8d34 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();
@@ -2794,13 +2808,20 @@ 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];
}
+ if ((ast->kind == ZEND_AST_CONST || ast->kind == ZEND_AST_CLASS_CONST) && via_prop) {
+ return true;
+ }
+
return zend_is_variable_or_call(ast) && !zend_ast_is_short_circuited(ast);
}
/* }}} */
@@ -3186,6 +3207,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 +11598,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 +11618,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 +11651,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 +11694,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 +12548,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 +12696,31 @@ 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);
+ 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;
}
+
+ return zend_compile_var(result, ast, type, false);
}
/* }}} */