diff --git a/phpunit/code/magic-call-codegen.php b/phpunit/code/magic-call-codegen.php
new file mode 100644
index 00000000..959c5a93
--- /dev/null
+++ b/phpunit/code/magic-call-codegen.php
@@ -0,0 +1,29 @@
+missing(1, named: 2);
+}
+
+function runtimeMagicCall(ExactMagicHandler $handler): mixed
+{
+ return $handler->missing();
+}
+
diff --git a/phpunit/src/MagicCallCodegenTest.php b/phpunit/src/MagicCallCodegenTest.php
new file mode 100644
index 00000000..c68b52ef
--- /dev/null
+++ b/phpunit/src/MagicCallCodegenTest.php
@@ -0,0 +1,42 @@
+compileFixture();
+
+ $exactBody = $this->functionBody($code, 'php_exactmagiccall');
+ self::assertStringContainsString('php_exactmagichandler____call(', $exactBody);
+ self::assertStringNotContainsString('.call(', $exactBody);
+
+ $runtimeBody = $this->functionBody($code, 'php_runtimemagiccall');
+ self::assertStringContainsString('.call(', $runtimeBody);
+ self::assertStringNotContainsString('php_exactmagichandler____call(', $runtimeBody);
+ }
+
+ private function compileFixture(): string
+ {
+ global $translator;
+
+ $compiler = CompilerTest::create(TYPEPHP_ROOT_PATH);
+ $translator = $compiler;
+ $source = TYPEPHP_ROOT_PATH . '/phpunit/code/magic-call-codegen.php';
+ $compiler->addFiles([$source]);
+ $compiler->prepareFile($source);
+ $generated = $compiler->convertFile($source);
+ $code = file_get_contents($generated);
+
+ self::assertIsString($code);
+ return $code;
+ }
+
+ private function functionBody(string $code, string $function): string
+ {
+ $matched = preg_match('/php::Var ' . preg_quote($function, '/') . '\\([^)]*\\) \\{(?
.*?)\\n\\}/s', $code, $match);
+ self::assertSame(1, $matched, "generated body of {$function}() not found");
+ return $match['body'];
+ }
+}
diff --git a/src/Context/FunctionContext.php b/src/Context/FunctionContext.php
index d07df058..8d13ba29 100644
--- a/src/Context/FunctionContext.php
+++ b/src/Context/FunctionContext.php
@@ -18,6 +18,15 @@ class FunctionContext
/** Map of SSA-stable object variable name => class name (SsaPropOptimizer). */
public array $stableObjects = [];
+ /**
+ * SSA-stable variables whose sole definition is a concrete `new ClassName()`.
+ * Unlike a declared/returned object type, these entries prove the exact
+ * runtime class and may be used for conservative method devirtualization.
+ *
+ * @var array
+ */
+ public array $exactObjects = [];
+
/** Map of hoisted property refs: objName => [propName => true] (SsaPropOptimizer). */
public array $hoistedProps = [];
@@ -126,6 +135,7 @@ class FunctionContext
$this->objectProps = [];
$this->ssaBuilder = null;
$this->stableObjects = [];
+ $this->exactObjects = [];
$this->hoistedProps = [];
$this->unsafeObjectProps = [];
$this->staticPropRefs = [];
diff --git a/src/Optimizer/SsaPropOptimizer.php b/src/Optimizer/SsaPropOptimizer.php
index 285bad5d..6f09aa58 100644
--- a/src/Optimizer/SsaPropOptimizer.php
+++ b/src/Optimizer/SsaPropOptimizer.php
@@ -61,7 +61,35 @@ trait SsaPropOptimizer
}
$this->context->stableObjects[$objName] = $className;
+ if ($this->isExactNewObjectDefinition($ssa, $objName)) {
+ $this->context->exactObjects[$objName] = $className;
+ }
+ }
+ }
+
+ /**
+ * A declared return type is only an upper bound and may contain a subclass.
+ * Only a sole, concrete `new` definition proves the runtime class exactly.
+ */
+ protected function isExactNewObjectDefinition(SsaBuilder $ssa, string $objName): bool
+ {
+ foreach ($ssa->ssaVars as $ssaVar) {
+ if ($ssaVar->origName !== $objName || $ssaVar->flags & SsaFlags::PHI) {
+ continue;
+ }
+
+ $definition = $ssaVar->definition;
+ if (!$definition instanceof Node\Stmt\Expression
+ || !$definition->expr instanceof Expr\Assign
+ || !$definition->expr->expr instanceof Expr\New_
+ || !$definition->expr->expr->class instanceof Node\Name) {
+ return false;
+ }
+
+ return strtolower($definition->expr->expr->class->toString()) !== 'static';
}
+
+ return false;
}
/**
diff --git a/src/Parser/MethodCallTrait.php b/src/Parser/MethodCallTrait.php
index 6697f5c5..525211de 100644
--- a/src/Parser/MethodCallTrait.php
+++ b/src/Parser/MethodCallTrait.php
@@ -283,6 +283,59 @@ trait MethodCallTrait
return false;
}
+ /**
+ * Directly invoke a TypePHP-compiled __call() only when the receiver's
+ * exact runtime class is statically proven. A declared class is not enough:
+ * a subclass may provide the requested real method instead of invoking the
+ * parent's __call().
+ */
+ protected function parseDirectNativeMagicCall(
+ Expr\MethodCall $expr,
+ string $object,
+ string $class,
+ string $method,
+ ): ?string {
+ if ($class === '' || !$this->hasClass($class)) {
+ return null;
+ }
+
+ $exactClass = null;
+ if ($object === 'this_' && $this->isCurrentClassFinal()) {
+ $exactClass = $this->getFullClassName();
+ } elseif (isset($this->context->exactObjects[$object])) {
+ $exactClass = $this->context->exactObjects[$object];
+ } elseif ($this->isFinalClass($class)) {
+ $exactClass = $class;
+ }
+
+ if ($exactClass === null || strcasecmp(ltrim($exactClass, '\\'), ltrim($class, '\\')) !== 0) {
+ return null;
+ }
+
+ $nativeFunc = $this->getNativeMethod($expr, $exactClass, '__call', false);
+ if ($nativeFunc === false || !$this->hasFunction($nativeFunc)) {
+ return null;
+ }
+ $this->checkFunction($nativeFunc);
+
+ if ($this->getVarType($object) !== Type::OBJECT) {
+ $tmpObject = $this->genTmpVarName();
+ $this->context->beforeStmtLines[] = Type::OBJECT . ' ' . $tmpObject . ' = ' . $object . ';';
+ $object = $tmpObject;
+ }
+
+ // __call receives one PHP array containing positional and named
+ // arguments. Reuse the dynamic call argument builder so evaluation
+ // order, unpacking and named keys remain identical to the Zend path.
+ $arguments = $this->parseCallArgs(
+ $expr->args,
+ separateNamedArgs: false,
+ forceArrayArgs: true,
+ );
+
+ return self::PREFIX . $nativeFunc . '(' . $object . ', ' . $method . ', ' . $arguments . ')';
+ }
+
protected function parseNativeMethodCall(string $object, string $nativeFunc, array $args): string
{
if ($this->getVarType($object) != Type::OBJECT) {
@@ -695,6 +748,14 @@ trait MethodCallTrait
if ($extension !== null) {
return $this->parseUniversalMethodCall($expr, $object, $methodName, $extension);
}
+ try {
+ $directMagicCall = $this->parseDirectNativeMagicCall($expr, $object, $class, $method);
+ if ($directMagicCall !== null) {
+ return $directMagicCall;
+ }
+ } catch (PlaceHolder) {
+ return $this->genPlaceHolder($this->genArray([$object, $method]));
+ }
$magicMethod = true;
}
if (!$nativeFunc) {
diff --git a/tests/compiler/dynamic_call/call-magic-direct-safe.phpt b/tests/compiler/dynamic_call/call-magic-direct-safe.phpt
new file mode 100644
index 00000000..cc26502c
--- /dev/null
+++ b/tests/compiler/dynamic_call/call-magic-direct-safe.phpt
@@ -0,0 +1,69 @@
+--TEST--
+compiled __call fast path preserves arguments and dynamic subclass dispatch
+--FILE--
+existing();
+}
+
+function main(): void
+{
+ $direct = new DirectMagic();
+ var_dump($direct->missing(1, second: 2));
+
+ $inherited = new InheritedMagic();
+ $values = [3, 4];
+ var_dump($inherited->packed(...$values, tail: 5));
+
+ // A declared base type is not an exact runtime type. The compiler must
+ // retain Zend dispatch so a subclass's real method wins over __call().
+ var_dump(callFromDeclaredBase(new RuntimeMethod()));
+}
+?>
+--EXPECT--
+array(2) {
+ [0]=>
+ string(7) "missing"
+ [1]=>
+ array(2) {
+ [0]=>
+ int(1)
+ ["second"]=>
+ int(2)
+ }
+}
+array(2) {
+ [0]=>
+ string(6) "packed"
+ [1]=>
+ array(3) {
+ [0]=>
+ int(3)
+ [1]=>
+ int(4)
+ ["tail"]=>
+ int(5)
+ }
+}
+string(14) "runtime-method"