perf(compiler): optimize dynamic call argument handling and caching

- Replace ArgList with VarList for fixed argument arrays to avoid dynamic Args allocation
- Add call argument limit assertion to prevent unbounded call generation
- Implement direct static method calls using callStaticMethod when possible
- Add alternating dynamic static call benchmarks for class and method variations
- Update documentation on request-local call slot destruction requirements
- Fix dynamic static call target evaluation order to precede argument evaluation
- Add tests for call argument limits and dynamic class evaluation ordering
master
韩天峰 1 day ago
parent 30e2bb6121
commit b69eb1dee6
  1. 16
      benchmark/dynamic-call/README.md
  2. 52
      benchmark/dynamic-call/benchmark.php
  3. 3
      benchmark/dynamic-call/run.php
  4. 8
      docs/en/SCOPE_MANAGEMENT.md
  5. 8
      docs/zh-cn/SCOPE_MANAGEMENT.md
  6. 24
      phpunit/src/CallCacheCodegenTest.php
  7. 5
      phpunit/src/SymbolTest.php
  8. 31
      src/Generator/CallArgumentGenerator.php
  9. 5
      src/Generator/Symbol.php
  10. 50
      src/Parser/MethodCallTrait.php
  11. 4
      src/Parser/PropertyAccessTrait.php
  12. 12
      tests/compiler/dynamic_call/call-cache-arguments.phpt
  13. 55
      tests/compiler/static/static-call-dynamic-class-order.phpt

@ -20,14 +20,20 @@ The `scoped_*` cases exercise private/protected dynamic calls that must resolve
with the compiled method's lexical scope. They are kept separate because a
scoped cache must guard both the target callable and its calling scope.
The `static_*_dynamic` cases exercise direct `$class::method()`,
`Class::$method()`, and `$class::$method()` syntax. These sites still construct
their callable string dynamically, but now reuse the request-local resolution
slot instead of repeating `zend_is_callable_ex()` on every iteration.
The `static_*_dynamic` cases exercise direct `$class::fixedMethod()`,
`Class::$method()`, and `$class::$method()` syntax. PHPX resolves the class and
method independently through Zend's public class handlers, avoiding a
temporary `"Class::method"` callable string. Dynamic static dispatch is not
cached: only a source-level fixed class is lowered to a reusable class entry.
The corresponding `*_alternating` cases model route-like inputs where the
class or method changes at the same call site.
The monomorphic string-call cases cover zero, one, two, and four positional
arguments. This separates callable-cache lookup cost from argument
materialization cost and protects the small stack-argument fast path.
materialization cost. Fixed positional arguments are emitted as a contiguous
`std::array<php::Variant, N>` and passed through PHPX without constructing the
dynamic `php::Args` vector. Calls containing argument unpacking continue to
use `php::Args`/`php::Array` because their final size is only known at runtime.
Run it from the repository root against a release PHP/PHPX build:

@ -67,6 +67,11 @@ final class DynamicCallTarget
return $value + 1;
}
public static function addTwoStatic(int $value): int
{
return $value + 2;
}
public function addTwo(int $value): int
{
return $value + 2;
@ -95,6 +100,16 @@ final class DynamicCallTarget
final class DynamicCallAlternateTarget
{
public static function addOne(int $value): int
{
return $value + 1;
}
public static function addTwoStatic(int $value): int
{
return $value + 2;
}
public function hitOne(int $value): int
{
return $value + 1;
@ -290,6 +305,37 @@ function runDynamicStaticClassAndMethodCall(int $iterations): int
return $sum;
}
function runAlternatingDynamicStaticClassCall(int $iterations): int
{
$sum = 0;
for ($i = 0; $i < $iterations; $i++) {
$class = ($i & 1) === 0 ? DynamicCallTarget::class : DynamicCallAlternateTarget::class;
$sum += $class::addOne($i);
}
return $sum;
}
function runAlternatingDynamicStaticMethodCall(int $iterations): int
{
$sum = 0;
for ($i = 0; $i < $iterations; $i++) {
$method = ($i & 1) === 0 ? 'addOne' : 'addTwoStatic';
$sum += DynamicCallTarget::$method($i);
}
return $sum;
}
function runAlternatingDynamicStaticClassAndMethodCall(int $iterations): int
{
$sum = 0;
for ($i = 0; $i < $iterations; $i++) {
$class = ($i & 1) === 0 ? DynamicCallTarget::class : DynamicCallAlternateTarget::class;
$method = ($i & 1) === 0 ? 'addOne' : 'addTwoStatic';
$sum += $class::$method($i);
}
return $sum;
}
function runObjectMethodArrayCall(int $iterations): int
{
$target = new DynamicCallTarget();
@ -412,6 +458,9 @@ function runDynamicCallCase(string $case, int $iterations): int
'static_class_dynamic' => runDynamicStaticClassCall($iterations),
'static_method_dynamic' => runDynamicStaticMethodCall($iterations),
'static_class_method_dynamic' => runDynamicStaticClassAndMethodCall($iterations),
'static_class_alternating' => runAlternatingDynamicStaticClassCall($iterations),
'static_method_alternating' => runAlternatingDynamicStaticMethodCall($iterations),
'static_class_method_alternating' => runAlternatingDynamicStaticClassAndMethodCall($iterations),
'object_method_array' => runObjectMethodArrayCall($iterations),
'invokable_object' => runInvokableObjectCall($iterations),
'method_name_monomorphic' => runMonomorphicMethodNameCall($iterations),
@ -465,6 +514,9 @@ function main(): void
'static_class_dynamic',
'static_method_dynamic',
'static_class_method_dynamic',
'static_class_alternating',
'static_method_alternating',
'static_class_method_alternating',
'object_method_array',
'invokable_object',
'method_name_monomorphic',

@ -139,6 +139,9 @@ $cases = [
'static_class_dynamic',
'static_method_dynamic',
'static_class_method_dynamic',
'static_class_alternating',
'static_method_alternating',
'static_class_method_alternating',
'object_method_array',
'invokable_object',
'method_name_monomorphic',

@ -103,6 +103,10 @@ If a method never uses scoped dynamic calls, first-class callables, or scoped ca
### 3.5 Usage Entry Points
#### `typephp_call_cached()` / `typephp_call_method_cached()`
TypePHP assigns one request-local slot to each unresolved function or object-method call site. String callables cache their resolved `zend_function`; method calls additionally guard the receiver class. Non-string callables, transient magic trampolines, and relative `self::` / `parent::` / `static::` strings continue through Zend's full resolver.
#### `typephp_call_method_scoped_cached()`
Used for dynamic object method calls. It uses `CallableScope::resolve()` to obtain a `zend_fcall_info_cache`, retains cacheable results in a request-local call-site slot, then executes `zend_call_function()`.
@ -339,13 +343,17 @@ save EG(fake_scope)
7. When adding a PHP built-in function that synchronously invokes callbacks, update the callback argument description table, noting the position, argument name, and whether it is a callback map.
8. Functions that save a callback but do not invoke it immediately must not mark the scope fallback merely because they receive a callable, for example `spl_autoload_register()`.
9. When adding a `FakeScopeGuard` usage that crosses a Zend bailout, code review must check whether `zend_catch` explicitly restores it.
10. Request-local call slots must be destroyed before project request symbols are cleared. Do not retain Closure objects, receiver objects, or trampolines in those slots.
## 8. Performance Model
| Path | Main Cost | Optimization Strategy |
| --- | --- | --- |
| `CallableScope` | Initializing one synthetic frame | At most once per AOT method, reused across loops |
| `typephp_call_cached()` | Resolving a dynamic string callable | One request-local slot per call site; non-string and relative callables remain dynamic |
| `typephp_call_method_cached()` | Resolving a method name against a runtime object | Cache only a guarded monomorphic target; disable the slot after its class or name changes |
| `typephp_call_method_scoped_cached()` | Guarded cache lookup; `zend_is_callable_at_frame()` on a miss | One request-local slot per dynamic call site; resolvable Native Calls do not enter this path |
| Stable static property | Resolving class/property metadata and its Zend slot | Lazily cache only the final `zval*` in the generated C++ function; rebuild a lightweight `Variant` view on every access |
| `prepareScopedCallback()` | One callable resolution | Public absolute callbacks do not create a Closure |
| `makeScopedCallable()` | Callable resolution and Closure allocation | Used only for first-class callables |
| `UserCodeScopeGuard` | One pointer lookup and write at method entry, plus restoration at exit | Generated only for `call_user_func*`, callback maps, or unresolved unpack callbacks |

@ -103,6 +103,10 @@ php::CallableScope tmp_var_1 = php::getCallableScope(
### 3.5 使用入口
#### `typephp_call_cached()` / `typephp_call_method_cached()`
TypePHP 为每个未解析的函数或对象方法调用点分配一个 request 级 slot。字符串 callable 缓存解析后的 `zend_function`;方法调用还会校验 receiver class。非字符串 callable、临时 magic trampoline,以及相对的 `self::` / `parent::` / `static::` 字符串仍走 Zend 完整解析路径。
#### `typephp_call_method_scoped_cached()`
用于动态对象方法调用。它使用 `CallableScope::resolve()` 获取 `zend_fcall_info_cache`,将允许缓存的结果保存在 request 级调用点 slot 中,然后执行 `zend_call_function()`
@ -339,13 +343,17 @@ save EG(fake_scope)
7. 新增会同步调用 callback 的 PHP 内置函数时,需要更新 callback 参数描述表,注明位置、参数名以及是否为 callback map。
8. 保存 callback 但不立即调用的函数不能仅因接收 callable 就标记 scope fallback,例如 `spl_autoload_register()`
9. 新增跨 Zend bailout 的 `FakeScopeGuard` 用法时,代码审查必须检查 `zend_catch` 是否显式恢复。
10. request 级调用 slot 必须先于项目 request 符号清理而析构;slot 不得持有 Closure 对象、receiver 对象或 trampoline。
## 8. 性能模型
| 路径 | 主要成本 | 优化策略 |
| --- | --- | --- |
| `CallableScope` | 初始化一个 synthetic frame | 每个 AOT 方法最多一次,循环复用 |
| `typephp_call_cached()` | 解析动态字符串 callable | 每个调用点一个 request 级 slot;非字符串及相对 callable 仍保持动态解析 |
| `typephp_call_method_cached()` | 根据运行时对象解析方法名 | 只缓存带 class/name guard 的单态目标;调用点发生变化后禁用 slot |
| `typephp_call_method_scoped_cached()` | 带 guard 的缓存查找;未命中时执行 `zend_is_callable_at_frame()` | 每个动态调用点一个 request 级 slot;可解析的 Native Call 不进入此路径 |
| 稳定静态属性 | 解析类/属性元数据及 Zend slot | 仅在生成的 C++ 函数内惰性缓存最终 `zval*`;每次访问重新构造轻量 `Variant` view |
| `prepareScopedCallback()` | 一次 callable 解析 | public 绝对 callback 不创建 Closure |
| `makeScopedCallable()` | callable 解析及 Closure 分配 | 仅 first-class callable 使用 |
| `UserCodeScopeGuard` | 方法入口一次指针查找、写入和退出恢复 | 只为 `call_user_func*`、callback map 或未解析的 unpack callback 生成 |

@ -1,7 +1,10 @@
<?php
use PhpParser\Node\Arg;
use PhpParser\Node\Scalar\Int_;
use TypePhp\CompilerBase;
use TypePhp\CompilerTest;
use TypePhp\Exception\TestError;
final class CallCacheCodegenTest extends BaseTest
{
@ -22,15 +25,32 @@ final class CallCacheCodegenTest extends BaseTest
self::assertIsString($code);
self::assertIsString($extension);
self::assertSame(4, substr_count($code, 'typephp_call_cached('));
self::assertSame(1, substr_count($code, 'typephp_call_cached('));
self::assertSame(3, substr_count($code, 'php::callStaticMethod('));
self::assertStringNotContainsString('php::concat({', $code);
self::assertSame(7, substr_count($code, 'php::VarList{'));
self::assertStringNotContainsString('std::array<php::Variant', $code);
self::assertStringNotContainsString('php::ArgList{', $code);
self::assertSame(3, substr_count($code, 'typephp_call_method_cached('));
self::assertSame(1, substr_count($code, 'typephp_call_method_scoped_cached('));
self::assertStringNotContainsString('php::callScoped(', $code);
self::assertStringContainsString('.call(get_persistent_method(', $code);
self::assertStringContainsString('php::FunctionCallCacheSlot function_call_cache_map[4]', $extension);
self::assertStringContainsString('php::FunctionCallCacheSlot function_call_cache_map[1]', $extension);
self::assertStringContainsString('php::MethodCallCacheSlot method_call_cache_map[4]', $extension);
self::assertStringContainsString('typephp_get_function_call_cache(FunctionCallCacheId cache_id)', $extension);
self::assertStringContainsString('typephp_get_method_call_cache(MethodCallCacheId cache_id)', $extension);
}
public function testCallArgumentLimitRejectsBrokenUnboundedLowering(): void
{
$compiler = CompilerTest::create(TYPEPHP_ROOT_PATH);
$argument = new Arg(new Int_(0), false, false, ['startLine' => 1]);
$method = new ReflectionMethod($compiler, 'assertCallArgumentLimit');
(new ReflectionProperty($compiler, 'file'))->setValue($compiler, 'argument-limit.php');
$this->expectException(TestError::class);
$this->expectExceptionMessage('A function call cannot contain more than 65536 arguments');
$method->invoke($compiler, array_fill(0, 65_537, $argument));
}
}

@ -47,6 +47,11 @@ class SymbolTest extends TestCase
$this->assertEquals('php::ArgList', Symbol::argList());
}
public function testVarList(): void
{
$this->assertEquals('php::VarList', Symbol::varList());
}
public function testGetCalledCe(): void
{
$this->assertSame('typephp_get_called_ce(this_)', Symbol::getCalledCe());

@ -20,6 +20,9 @@ use TypePhp\Generator\Symbol;
trait CallArgumentGenerator
{
/** Guard against a broken lowering path producing an unbounded call. */
private const CALL_ARGUMENT_LIMIT = 65_536;
protected function parseNativeCallArgs(
array $callArgs,
string $nativeFunc,
@ -27,6 +30,7 @@ trait CallArgumentGenerator
bool $deferTrailingDefaults = false,
): string
{
$this->assertCallArgumentLimit($callArgs);
$functionDef = $this->getFunction($nativeFunc);
$providedArgs = [];
$defaultArgs = [];
@ -414,6 +418,7 @@ trait CallArgumentGenerator
bool $preserveExistingReferences = false
): string
{
$this->assertCallArgumentLimit($args);
$list_args = [];
$arrayArgsVar = null;
$argsVar = null;
@ -580,7 +585,12 @@ trait CallArgumentGenerator
if ($arrayArgsVar !== null) {
return $namedArgsVar !== null ? $arrayArgsVar . ', ' . $namedArgsVar . '.array()' : $arrayArgsVar;
}
$callArgs = Symbol::argList() . '{' . implode(', ', $list_args) . '}';
// VarList deduces the fixed argument count and owns contiguous
// Variant storage, which PHPX passes directly to Zend without a
// dynamic php::Args allocation. materializeCallArgValue() above
// ensures that ordinary values do not leave INDIRECT borrows in the
// list; explicit reference arguments remain references.
$callArgs = Symbol::varList() . '{' . implode(', ', $list_args) . '}';
return $namedArgsVar !== null ? $callArgs . ', ' . $namedArgsVar . '.array()' : $callArgs;
}
@ -667,7 +677,7 @@ trait CallArgumentGenerator
'Native objects cannot cross a dynamic PHP/ZendVM call boundary'
);
}
// C++17 evaluates php::ArgList{...} elements from left to right, but a
// C++17 evaluates fixed argument array elements from left to right, but a
// later argument may emit captured beforeStmtLines while being lowered.
// Those statements are placed before the whole outer call and would
// overtake an earlier Call left inside the initializer list. Complete
@ -692,9 +702,9 @@ trait CallArgumentGenerator
// A call that returns by reference yields a live php::Ref aliasing the
// callee's storage. When such a call feeds a by-value argument, PHP takes
// a value snapshot at evaluation time (left to right), so later mutations
// to the aliased storage must not be observable. The dynamic ArgList keeps
// references verbatim (Ctor::CopyRef), so we dereference into a temporary
// value at the point of the call.
// to the aliased storage must not be observable. PHPX argument container
// constructors preserve explicit references, so dereference into a
// temporary value at the point of an ordinary by-value call.
$expr = $this->materializeRefReturnAsValue($value, $expr);
if (!$this->shouldMaterializeCallArg($value)) {
return $expr;
@ -711,6 +721,17 @@ trait CallArgumentGenerator
return $value instanceof Expr\PropertyFetch;
}
protected function assertCallArgumentLimit(array $args): void
{
if (count($args) <= self::CALL_ARGUMENT_LIMIT) {
return;
}
$this->fatalError(
$args[self::CALL_ARGUMENT_LIMIT],
'A function call cannot contain more than 65536 arguments',
);
}
protected function parseReferenceCallArgValue(Node\Arg $arg): string
{
if ($this->isReferenceWrapperCall($arg->value)) {

@ -65,6 +65,11 @@ class Symbol
return 'php::ArgList';
}
public static function varList(): string
{
return 'php::VarList';
}
public static function safeIndex(string $index, int|string $size): string
{
return "php::safeIndex({$index}, {$size})";

@ -865,6 +865,9 @@ trait MethodCallTrait
return 'typephp_call_method_scoped_cached(' . $object . ', ' . $methodPtr . ', '
. $this->getCallableScopeExpr() . ', ' . $this->getMethodCallCache() . ')';
}
// The method is already a stable zend_function* from the
// project symbol cache. A second callable cache would only
// add guards before the same direct call.
return 'php::callScoped(' . $object . ', ' . $methodPtr . ', ' . $this->getCallableScopeExpr() . ')';
}
if (!$resolvedMethodPtr) {
@ -948,14 +951,15 @@ trait MethodCallTrait
}
/**
* Materialize a dynamic static-call target exactly once and normalize it
* to the runtime class name accepted by PHP callbacks.
* Materialize a dynamic static-call target exactly once before evaluating
* arguments. The snapshot is required even for a plain variable because
* an argument may mutate that variable by reference.
*
* PHP permits both an object and a class-name string before `::`. A
* declared object type is only an upper bound, so using it directly would
* lose late static binding when the runtime object is a subclass.
*/
private function materializeDynamicStaticCallClassName(Expr $target): string
private function materializeDynamicStaticCallTarget(Expr $target): string
{
[$value, $beforeStmts, $afterStmts] = $this->parseExprWithCapturedStmts($target);
$this->appendCapturedStmtLinesToContext($beforeStmts);
@ -963,7 +967,7 @@ trait MethodCallTrait
$this->context->beforeStmtLines[] = $classVar . ' = ' . $value . ';';
$this->appendCapturedStmtLinesToContext($afterStmts);
return '(' . $classVar . '.isObject() ? php::fn::get_class(' . $classVar . ') : php::toString(' . $classVar . '))';
return $classVar;
}
/**
@ -1033,6 +1037,9 @@ trait MethodCallTrait
$rtFunc = '';
$rtClass = '';
$cacheCallable = false;
$directStaticCall = false;
$staticCallTarget = '';
$staticCallMethod = '';
$canUseDirectCallScope = $this->isNameExpr($expr->class) && $this->isIdExpr($expr->name);
$class = ($this->isNameExpr($expr->class) || $this->isVarExpr($expr->class))
? $this->parseIdentifier($expr->class)
@ -1061,8 +1068,11 @@ trait MethodCallTrait
$class = $this->getObjectType($class);
goto _do_call;
}
$className = $this->materializeDynamicStaticCallClassName($expr->class);
$fn = 'php::concat({' . $className . ', "::", ' . $this->methodNameToStr($expr->name) . '})';
$classTarget = $this->materializeDynamicStaticCallTarget($expr->class);
$staticCallTarget = $classTarget;
$staticCallMethod = $this->methodNameToStr($expr->name, literal: true);
$fn = 'php::concat({(' . $classTarget . '.isObject() ? php::fn::get_class(' . $classTarget
. ') : php::toString(' . $classTarget . ')), "::", ' . $staticCallMethod . '})';
if ($this->isVarExpr($expr->class) && $this->isIdExpr($expr->name)) {
$declaredClass = $this->getDeclaredObjectType($class);
if ($declaredClass !== '') {
@ -1074,11 +1084,26 @@ trait MethodCallTrait
}
}
$placeHolder = $fn;
$cacheCallable = true;
$directStaticCall = true;
} elseif ($this->isVarExpr($expr->name)) {
$fn = 'php::concat({' . $this->identifierToStr($expr->class) . ', "::", ' . $this->methodNameToStr($expr->name) . '})';
$staticCallMethod = $this->methodNameToStr($expr->name, literal: true);
if ($class === 'static') {
$staticCallTarget = $this->getCalledCeExpr();
} elseif ($class !== 'self') {
$resolvedClass = $this->getNamespacedClassName($class);
$staticCallTarget = $this->getLocalClassEntryPtr($resolvedClass);
}
$fn = 'php::concat({' . $this->identifierToStr($expr->class) . ', "::", ' . $staticCallMethod . '})';
$placeHolder = $fn;
$cacheCallable = true;
if ($staticCallTarget !== '') {
$directStaticCall = true;
} else {
// `self::$method()` carries a lexical lookup class and a
// potentially different late-bound called scope. Keep the
// existing scoped callable resolution until the lookup class
// and called scope can both be represented explicitly.
$cacheCallable = true;
}
} elseif ($class === 'static') {
if ($this->classDef?->nativeObject) {
$this->fatalError(
@ -1170,12 +1195,19 @@ trait MethodCallTrait
}
if (empty($expr->args)) {
if ($directStaticCall) {
return 'php::callStaticMethod(' . $staticCallTarget . ', ' . $staticCallMethod . ')';
}
if ($cacheCallable) {
return 'typephp_call_cached(' . $fn . ', ' . $this->getFunctionCallCache() . ')';
}
return 'php::call(' . $fn . ')';
}
try {
if ($directStaticCall) {
return 'php::callStaticMethod(' . $staticCallTarget . ', ' . $staticCallMethod . ', '
. $this->parseCallArgs($expr->args, $rtFunc, $rtClass) . ')';
}
if ($cacheCallable) {
return 'typephp_call_cached(' . $fn . ', ' . $this->getFunctionCallCache() . ', '
. $this->parseCallArgs($expr->args, $rtFunc, $rtClass) . ')';

@ -413,6 +413,10 @@ trait PropertyAccessTrait
if ($resolution !== null) {
$property = $this->propertyNameToStr($expr->name, literal: true);
// Reference acquisition must run getStaticPropertyRef(): it
// converts the live slot to IS_REFERENCE and attaches a typed
// property's zend_property_info as a reference type source. The
// ordinary value-slot cache deliberately does neither operation.
if ($resolution->class !== null) {
$classPtr = $this->getClassEntryPtr($resolution->class);
return Symbol::getStaticPropertyRef() . '(' . $classPtr . ', ' . $property . ')';

@ -1,5 +1,5 @@
--TEST--
Dynamic call cache preserves small, large, named, unpacked, reference, and exception arguments
Dynamic call cache preserves fixed, named, unpacked, reference, and exception arguments
--FILE--
<?php
@ -18,6 +18,11 @@ function cached_throw(string $message): never
throw new RuntimeException($message);
}
class CachedArgumentHolder
{
public int $value = 6;
}
function main(): void
{
$sum = 'cached_sum';
@ -25,6 +30,10 @@ function main(): void
var_dump($sum(1, 2, 3, 4, 5));
var_dump($sum(d: 4, c: 3, b: 2, a: 1));
$holder = new CachedArgumentHolder();
$values = [7];
var_dump($sum($holder->value, $values[0], 3, 4, 5));
$arguments = [1, 2, 3, 4, 5];
var_dump($sum(...$arguments));
@ -45,6 +54,7 @@ function main(): void
int(10)
int(15)
int(10)
int(25)
int(15)
int(11)
int(11)

@ -0,0 +1,55 @@
--TEST--
dynamic static class target is evaluated before call arguments
--FILE--
<?php
class DynamicClassFirst
{
public static function render(string $value): string
{
return 'first:' . $value;
}
}
class DynamicClassSecond
{
public static function render(string $value): string
{
return 'second:' . $value;
}
}
function replace_dynamic_class(string &$class): string
{
echo "argument\n";
$class = DynamicClassSecond::class;
return 'value';
}
function choose_dynamic_class(): string
{
echo "class\n";
return DynamicClassFirst::class;
}
function make_dynamic_class_argument(): string
{
echo "argument\n";
return 'value';
}
function main(): void
{
$class = DynamicClassFirst::class;
var_dump($class::render(replace_dynamic_class($class)));
var_dump($class::render('next'));
var_dump(choose_dynamic_class()::render(make_dynamic_class_argument()));
}
?>
--EXPECT--
argument
string(11) "first:value"
string(11) "second:next"
class
argument
string(11) "first:value"
Loading…
Cancel
Save