References & memory
Why an object in a PHP constant can still change
The mistake
PHP 8.4 refuses this line when it compiles the file:
const DEFAULTS = new Options();
DEFAULTS->timeout = 30; // Fatal error: Cannot use temporary expression in write context
It is easy to read that error as “a constant cannot change”. The error does not say that. A constant fixes a name: the name points at one value for as long as the program runs. When that value is an object, the object can still change. On PHP 8.4 a write through a variable already changes the object. PHP 8.6 removes the error, so the write is now one line long.
The machine
Every line of PHP drawn here is run, as it is, against PHP 8.4 by npm run verify:php and against PHP 8.6 by docs/verify-php86.sh.
Drive it
The panel opens on PHP 8.4, just after the line above was tried. The file did not compile, so none of it ran. The big number reads fatal.
- Press “through a variable”, then Write. This is still PHP 8.4. All three
reads now return 30:
DEFAULTS->timeout,timeout()and$opts->timeout. - Press 8.6, then “straight at the constant”, then Write. PHP 8.6 runs the line that 8.4 refused. The constant reads 30 again.
- Press Next request. Then press “a long-lived worker”, Write and Next request. With one request per run, request 2 reads 5. In the worker, request 2 reads 30 before any of its own code has run.
- Press “an array”, then Write. PHP 8.6 still refuses this write when it
compiles the file. Press “through a variable” and Write.
$optschanges, andLIMITS['timeout']still reads 5.
The mechanism
A constant is a name that is bound once. Defining it a second time does not replace the value:
const DEFAULTS = new Options(30); // Warning: Constant DEFAULTS already defined
After that line, DEFAULTS->timeout still reads 5. PHP 8.6 adds “this will be
an error in PHP 9” to the warning.
The constant holds a handle, not the object. A PHP object variable holds a
handle: an id that points at the object. Assignment copies the handle and
never the object. After $opts = DEFAULTS, $opts and DEFAULTS point at the
same object. $opts->timeout = 30 writes to that object, so
DEFAULTS->timeout now reads 30. Every function that uses DEFAULTS also
sees 30. This has been true since PHP 8.1, the first version that allows new
in a constant’s value.
The 8.4 error is about temporaries. PHP 8.4 compiles the value of DEFAULTS
as a temporary: a value that exists only while one expression runs. It then
refuses to compile a write into that temporary. That rule is about how the line
is written. It says nothing about the object. Reach the same object another way and
PHP 8.4 lets the write through:
$opts = DEFAULTS;
$opts->timeout = 30; // runs on 8.4
constant('DEFAULTS')->timeout = 30; // runs on 8.4
What PHP 8.6 changed. A property write through a constant now compiles. The
RFC lists every form it
covers, including ++, unset() and nested properties. The change covers class
constants too:
class Config { const DEFAULTS = DEFAULTS; }
Config::DEFAULTS->timeout = 30;
On 8.6 that sets DEFAULTS->timeout to 30. On 8.4 the file does not compile.
What PHP 8.6 did not change. An array in a constant is a value, not a handle.
LIMITS['timeout'] = 30 is still refused at compile time, with the same
Cannot use temporary expression in write context. $opts = LIMITS copies the
array, so a write changes the copy only.
A readonly property still refuses every write. An enum case refuses every write
too, because its value is readonly. On 8.4 a direct write to either one fails
at compile time. On 8.6 the same write throws at runtime. For the enum, 8.6
throws Error: Cannot modify readonly property Timeout::$value. On 8.6 a write
through a reference taken straight from the constant gives
Cannot indirectly modify readonly property.
The process decides how long the change lasts. PHP-FPM runs each request
with fresh state. When a request ends, PHP throws away everything the request
defined, constants included. The next request builds DEFAULTS again, so
DEFAULTS->timeout reads 5. A long-lived worker, such as queue:work or Octane,
serves many jobs or requests in one process. PHP does not reset its state between
them. DEFAULTS is built once, so request 2 starts with whatever request 1 left
in the object. That makes a constant object one instance shared by the whole
process. Nothing in the code registered it as shared. The
container binding page shows the same leak with
a container singleton.
In your code
If a constant holds settings, make the class readonly. To change one setting, return a new object:
final readonly class HttpOptions
{
public function __construct(public int $timeout = 5) {}
public function withTimeout(int $timeout): self
{
return new self($timeout);
}
}
const DEFAULTS = new HttpOptions();
$opts = DEFAULTS->withTimeout(30); // a new object
$opts->timeout reads 30, and DEFAULTS->timeout still reads 5. A write to the
object itself fails on 8.4 and on 8.6, by any route. Press “a readonly object” in
the simulator to see each failure. The simulator names the class FrozenOptions
and the constant FROZEN.
The fine print
- Every result on this page was measured on PHP 8.4.25 and PHP 8.6.0RC2. The
lines the simulator draws, and every PHP block above, are run as printed: by
npm run verify:phpon 8.4 and bydocs/verify-php86.shon 8.6. PHP 8.6 is due as a stable release on 19/11/2026. The behaviour is frozen, but the wording of an error can still change before then. - One request per run was measured with
php -S. It serves every request from one process, and each request still starts with fresh state. The worker was measured as a loop that serves two requests in one process.queue:workruns jobs the same way. Octane itself was not run. - A class constant cannot hold
newat all. Both versions refuseclass Config { const DEFAULTS = new Options(); }withNew expressions are not supported in this context. That is why the class constant above points at the global one. staticproperties are also shared by the whole process. They were always writable. They are a different story, and this page leaves them out.- The rest of PHP 8.6 is out of scope here. The partial application page covers one more 8.6 feature.
Further reading
- RFC: allow object property writes on objects referenced by constants is the specification. It lists every form that now compiles, and what stays refused.
- PHP 8.6 UPGRADING notes is where to check what actually shipped, rather than any summary of it.
- Cogway: assignment, references and copy-on-write
shows the copy that
$opts = LIMITSmakes, and the handle that$opts = DEFAULTSshares.
Spotted a problem, or have a way to make this clearer? Suggest an improvement.