Skip to content

Virtual property hook returns adjacent property value instead of calling getter (JIT + asymmetric visibility) #22855

Description

@zhaohao19941221

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:

  1. public protected(set) asymmetric visibility on the property before the virtual property
  2. The virtual property has only a get hook (no backing store)
  3. Constructor uses promoted properties (public protected(set) string $serviceType)
  4. 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:

opcache.jit=disable

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions