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

Simulator · Const object write
php
constant
write
serve

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.

PHP 8.4 refuses to compile the file: "Cannot use temporary expression in write context". Nothing in the file ran, not even the lines above the write. The rule is about writing to a temporary value, not about constants.

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. $opts changes, and LIMITS['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:php on 8.4 and by docs/verify-php86.sh on 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:work runs jobs the same way. Octane itself was not run.
  • A class constant cannot hold new at all. Both versions refuse class Config { const DEFAULTS = new Options(); } with New expressions are not supported in this context. That is why the class constant above points at the global one.
  • static properties 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

Spotted a problem, or have a way to make this clearer? Suggest an improvement.