Description
Virtual property hook returns adjacent property value instead of calling getter (JIT + asymmetric visibility)
Description
When a class has a public protected(set) property (with asymmetric visibility) immediately followed by a virtual property hook (get-only, no backing store), accessing the virtual property returns the value of the preceding property instead of invoking the getter. The preceding property is a LoggerInterface object, but the virtual property is typed string.
This only happens when OPcache + JIT is enabled. Disabling JIT (opcache.jit=disable) or replacing the virtual property hook with a regular method resolves the issue.
This appears to be a variant of GH-17376 that was fixed in PHP 8.4.6, but the fix does not cover this specific combination of virtual property hooks + asymmetric visibility.
PHP Version
PHP 8.4.23 (cli) (built: Jul 3 2026 15:02:10) (NTS)
Copyright (c) The PHP Group
Built by Alpine Linux aports
Zend Engine v4.4.23, Copyright (c) Zend Technologies
with Zend OPcache v8.4.23, Copyright (c), by Zend Technologies
Operating System
Alpine Linux (Docker container)
OPcache/JIT Configuration
opcache.enable=1
opcache.enable_cli=1
opcache.jit_buffer_size=100M
opcache.jit=1205
Minimal Reproduction
<?php
// file: repro.php
// Run with: php -d opcache.enable=1 -d opcache.enable_cli=1 -d opcache.jit_buffer_size=100M -d opcache.jit=1205 repro.php
interface LoggerInterface {
public function info(string $msg): void;
}
class StdoutLogger implements LoggerInterface {
public function info(string $msg): void {
echo "[LOG] $msg\n";
}
}
class ActConfig
{
// Property with asymmetric visibility (no default value)
public protected(set) LoggerInterface $logger;
// Virtual property hook (get-only, no backing store)
public string $filename {
get => self::buildFilename($this->serviceType, $this->actId);
}
protected mixed $prevCfg = null;
public function __construct(
public protected(set) string $serviceType,
public protected(set) string $actId
) {
$this->logger = new StdoutLogger();
}
public static function buildFilename(string $serviceType, string $actId): string
{
return "/tmp/act_{$serviceType}_{$actId}.yaml";
}
public function test(): void
{
// This should print a string like "/tmp/act_test_1112233.yaml"
// But with JIT enabled, it returns the StdoutLogger object from $this->logger
$filename = $this->filename;
echo "Type: " . gettype($filename) . "\n";
echo "Value: " . (is_string($filename) ? $filename : get_class($filename)) . "\n";
}
}
$config = new ActConfig('qqqq', '1112233');
$config->test();
Expected Output
Type: string
Value: /tmp/act_qqqq_1112233.yaml
Actual Output (with JIT enabled)
PHP Fatal error: Uncaught TypeError: dumpToYaml(): Argument #2 ($filename) must be of type ?string, StdoutLogger given
Or in our real-world scenario, passing $this->filename to a function expecting ?string receives the StdoutLogger object that lives in $this->logger (the immediately preceding property slot).
Analysis
The issue seems to be that the JIT-compiled code for virtual property access incorrectly resolves the property slot. Instead of calling the get hook, it reads the memory offset of the adjacent declared property ($logger).
Key conditions to trigger:
public protected(set) asymmetric visibility on the property before the virtual property
- The virtual property has only a
get hook (no backing store)
- Constructor uses promoted properties (
public protected(set) string $serviceType)
- JIT is enabled
Workaround
Replace the virtual property hook with a regular method:
// Before (broken with JIT):
public string $filename {
get => self::buildFilename($this->serviceType, $this->actId);
}
// After (works correctly):
public function getFilename(): string
{
return self::buildFilename($this->serviceType, $this->actId);
}
Alternatively, disabling JIT resolves the issue:
Related Issues
PHP Version
PHP 8.4.23 (cli) (built: Jul 3 2026 10:03:08) (NTS)
Copyright (c) The PHP Group
Built by Ubuntu
Zend Engine v4.4.23, Copyright (c) Zend Technologies
with Zend OPcache v8.4.23, Copyright (c), by Zend Technologies
Operating System
No response
Description
Virtual property hook returns adjacent property value instead of calling getter (JIT + asymmetric visibility)
Description
When a class has a
public protected(set)property (with asymmetric visibility) immediately followed by a virtual property hook (get-only, no backing store), accessing the virtual property returns the value of the preceding property instead of invoking the getter. The preceding property is aLoggerInterfaceobject, but the virtual property is typedstring.This only happens when OPcache + JIT is enabled. Disabling JIT (
opcache.jit=disable) or replacing the virtual property hook with a regular method resolves the issue.This appears to be a variant of GH-17376 that was fixed in PHP 8.4.6, but the fix does not cover this specific combination of virtual property hooks + asymmetric visibility.
PHP Version
Operating System
Alpine Linux (Docker container)
OPcache/JIT Configuration
Minimal Reproduction
Expected Output
Actual Output (with JIT enabled)
Or in our real-world scenario, passing
$this->filenameto a function expecting?stringreceives theStdoutLoggerobject that lives in$this->logger(the immediately preceding property slot).Analysis
The issue seems to be that the JIT-compiled code for virtual property access incorrectly resolves the property slot. Instead of calling the
gethook, it reads the memory offset of the adjacent declared property ($logger).Key conditions to trigger:
public protected(set)asymmetric visibility on the property before the virtual propertygethook (no backing store)public protected(set) string $serviceType)Workaround
Replace the virtual property hook with a regular method:
Alternatively, disabling JIT resolves the issue:
opcache.jit=disableRelated Issues
PHP Version
Operating System
No response