Skip to content

fix(reflection): resolve short docblock names in getIterableType - #2305

Open
radoslav-grencik wants to merge 9 commits into
tempestphp:3.xfrom
radoslav-grencik:fix/reflection-resolve-docblock-collection-types
Open

radoslav-grencik wants to merge 9 commits into
tempestphp:3.xfrom
radoslav-grencik:fix/reflection-resolve-docblock-collection-types

Conversation

@radoslav-grencik

Copy link
Copy Markdown
Contributor

Summary

PropertyReflector::getIterableType() regex-captured the bare short name from iterable docblocks (Filter[], Book[], Chapter[]) and returned a TypeReflector for a class literally named Filter / Book / Chapter — ignoring the declaring file's namespace and use statements. Everything downstream that consumes the iterable type resolved against a class that does not exist:

  • model relation hydration (/** @var Book[] */ on Author::$books)
  • request-body collection mapping (/** @var Chapter[] */ on BookRequest::$chapters)

getIterableType() now resolves docblock names through PHP namespace and use semantics.

Change

packages/reflection/src/PropertyReflector.php:

  • Builtin iterable types (int[], array, …) pass through unchanged.
  • Fully qualified names (\App\Foo[]) resolve as before — the leading \ is dropped, the FQCN is used verbatim.
  • Namespace fallback — an unqualified short name with no import resolves against the declaring class's own namespace (DocblockRelativeTarget).
  • Plain imports (use App\Foo;) and aliases (use App\Foo as Bar;) resolve both the exact class and alias-prefixed paths (use A\B as C; → C\D → A\B\D).
  • Grouped imports (use App\{Foo, Bar as Baz};) resolve first-class and aliased members, including a shared group prefix.
  • The list<Type> and array<Type> docblock forms go through the same resolution.

The parser is dependency-free (token_get_all) and deliberately skips things that are not imports:

  • closure bodies and trait use blocks (brace-depth tracking),
  • use function ... and use const ... statements,
  • inline comments inside the statement.

Parsed imports are cached per declaring file in a method-local static, so repeated property reflection per process re-parses once.

End-to-end coverage

Two existing FQCN fixture docblocks were intentionally converted to short names:

  • Author::$books → /** @var Book[] */ — relation hydration resolves and loads Book models.
  • BookRequest::$chapters → /** @var Chapter[] */ — request-body array mapping produces Chapter objects via ArrayToObjectCollectionCaster, which is selected automatically from the resolved iterable type.

New tests:

  • packages/reflection/tests/PropertyReflectorTest.php — unit coverage for imports, aliases, grouped imports (plain + aliased + prefixed), relative names, FQCN, list<…> / array<…> forms, and the no-docblock null case with a dedicated fixture model + bucket targets.
  • ArrayToObjectMapperTest::map_collection_of_objects_from_short_docblock_name — maps ['items' => [['name' => 'a'], …]] into LibraryItem instances from a short LibraryItem[] docblock (Library / Children\LibraryItem fixtures).

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Benchmark Results

Comparison of fix/reflection-resolve-docblock-collection-types against 3.x (86b06a9455206fef95386d07aa7dd2f9a1e1a6a0).

Open to see the benchmark results
Benchmark Set Mem. Peak Time Variability
KernelBootBench(benchBoot) - 31.264mb +0.16% 85.791ms +5.78% ±1.87% +49.24%
ViewRenderBench(benchExpressions) - 24.792mb 0.00% 607.943μs +5.95% ±3.95% +82.29%
ViewRenderBench(benchControlFlow) - 44.896mb 0.00% 699.794μs -7.95% ±2.11% +24.14%

Generated by phpbench against commit 59a7d5c

@brendt

brendt commented Oct 1, 2026

Copy link
Copy Markdown
Member

I'm on the fence about this. Yes, it's more convenient, but it's also a lot more parsing to do. One thing that's crucial is that these results are memoized — I don't think this method already is?

If memoization is in place, I feel ok-ish about merging it. @xHeaven @innocenzi how do you feel about it?

@radoslav-grencik

Copy link
Copy Markdown
Contributor Author

I'm on the fence about this. Yes, it's more convenient, but it's also a lot more parsing to do. One thing that's crucial is that these results are memoized — I don't think this method already is?

If memoization is in place, I feel ok-ish about merging it. @xHeaven @innocenzi how do you feel about it?

Actually it's memoized. The expensive part - the token_get_all pass over the declaring file - is cached in a static array keyed by file ($cache[$fileName] ??= $this->parseUseStatements(...)), so it runs once per file per process, no matter how many times the method is called.

But the common cases don't touch the parser at all: no docblock returns null early, and builtins / \FQCNs short-circuit before the name resolution kicks in. Only unqualified short names of iterable docblocks trigger the parse - once per file.

@innocenzi

Copy link
Copy Markdown
Member

I would be interested in a good benchmark here, that's the most important part. Code-wise the complexity looks fine to me

@radoslav-grencik

Copy link
Copy Markdown
Contributor Author

I would be interested in a good benchmark here, that's the most important part. Code-wise the complexity looks fine to me

I'll try to come up with something.

@radoslav-grencik

Copy link
Copy Markdown
Contributor Author

I would be interested in a good benchmark here, that's the most important part. Code-wise the complexity looks fine to me

I'll try to come up with something.

@innocenzi Benchmark added - tests/Benchmark/Reflection/PropertyReflectorBench.php.

It measures four scenarios, all through PropertyReflector::getIterableType():

subject what it measures mode (typical) relative cost
benchBuiltinTypeDocblocks baseline: int[] etc., regex only, no resolution ~1.5μs 1× (baseline)
benchFqcnDocblocks old path: FQCN, builtin check + ltrim ~2.4μs ~1.6× baseline
benchShortNameDocblocks new path after static cache warm-up: alias lookup ~3.1μs ~2× baseline
benchColdParse first call on a file: full token_get_all parse ~70μs ~45× baseline

Two ratios worth highlighting:

  1. Warm short-name resolution costs ~2× the builtin baseline, compared to ~1.6× for FQCN. In other words, short names add ~30% on top of FQCN (~0.7μs per property). Parsing happens once per file per process, so the per-request amortized overhead is negligible.

  2. Cold parse is ~45× the baseline (~70μs) - a one-time cost per file. The cold subject does no tmp-file IO: synthetic sources are served from memory via a parse-bench:// stream wrapper (ColdParseStreamWrapper), so the benchmark leaks no files and doesn't measure disk.

Fixtures intentionally have only 3 properties per class — variance is handled by iterations (20×), not property count, and rstdev stays ≤ ±2.5%.

@radoslav-grencik
radoslav-grencik marked this pull request as draft October 26, 2026 14:56
@brendt

brendt commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Can we try add a short-circuit trick where, before we call token_get_all, instead first do a regex match on the text in the file for /use [\w\\]+Baz;/? Where Baz would be the classname? 99% of cases would match without the need of parsing the file.

If that regex fails then it might be worth falling back to token_get_all. Please benchmark to verify whether it actually helps.

@radoslav-grencik

Copy link
Copy Markdown
Contributor Author

Can we try add a short-circuit trick where, before we call token_get_all, instead first do a regex match on the text in the file for /use [\w\\]+Baz;/? Where Baz would be the classname? 99% of cases would match without the need of parsing the file.

If that regex fails then it might be worth falling back to token_get_all. Please benchmark to verify whether it actually helps.

I’ll try this for the cold path and benchmark it. I have some local fixes for namespace/trait resolution and memoization of the resolved type names. I also corrected the benchmark to measure one property per call, with class/file preparation outside the timed section.

I’ll push those first as a baseline, then compare the regex fast path against it.

@radoslav-grencik

radoslav-grencik commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor Author

Can we try add a short-circuit trick where, before we call token_get_all, instead first do a regex match on the text in the file for /use [\w\\]+Baz;/? Where Baz would be the classname? 99% of cases would match without the need of parsing the file.
If that regex fails then it might be worth falling back to token_get_all. Please benchmark to verify whether it actually helps.

I’ll try this for the cold path and benchmark it. I have some local fixes for namespace/trait resolution and memoization of the resolved type names. I also corrected the benchmark to measure one property per call, with class/file preparation outside the timed section.

I’ll push those first as a baseline, then compare the regex fast path against it.

I've pushed the new baseline for the regex experiment. Compared with the current PR:

  • The original version cached parsed imports, but still matched the docblock and resolved the name on every call. Resolved type names are now memoized per declaring class/property, including null for non-collection docblocks.
  • Parsing moved out of PropertyReflector into an internal DocblockContext, using PhpToken. Imports stay local to each namespace block, and trait properties use the namespace/imports where their docblock was declared. Class overrides and promoted properties keep the class context, including readonly promotion.
  • Fixed case-insensitive aliases, uppercase AS, namespace\Foo names and function/const imports. Regression tests cover these cases; overlapping tests and fixtures were removed. The mapper integration test stays.

The benchmark needed correcting too. My earlier warm numbers measured three properties, and the builtin batch also included a property without a docblock. The cold benchmark timed preparation of the next class as well.

It now measures one lookup per call, with setup and correctness checks outside timing and the same method/parameter passing for compared scenarios. Cold sources are real temporary files, prepared and loaded before timing, then cleaned up afterwards.

Latest local results, PHP 8.5.12 CLI, OPcache disabled:

Scenario Per call
No docblock 0.038 µs
Warm int[] 0.581 µs
Warm list<string> 0.585 µs
Warm FQCN 0.600 µs
Warm short name 0.592 µs
First short-name lookup, source metadata cached 1.852 µs
Cold short-name lookup, small file 25.159 µs
Cold short-name lookup, 100 extra unrelated properties 171.644 µs

Warm short names, FQCNs and builtins are roughly on par. These replace the earlier figures, not a direct before/after speedup comparison: I haven't rerun the original implementation with this benchmark. Cold means uncached resolver metadata, not an empty filesystem cache; source reading and tokenization are included.

The regex fast path isn't implemented yet. That's the next comparison against this baseline.

@radoslav-grencik

Copy link
Copy Markdown
Contributor Author

Can we try add a short-circuit trick where, before we call token_get_all, instead first do a regex match on the text in the file for /use [\w\\]+Baz;/? Where Baz would be the classname? 99% of cases would match without the need of parsing the file.
If that regex fails then it might be worth falling back to token_get_all. Please benchmark to verify whether it actually helps.

I’ll try this for the cold path and benchmark it. I have some local fixes for namespace/trait resolution and memoization of the resolved type names. I also corrected the benchmark to measure one property per call, with class/file preparation outside the timed section.
I’ll push those first as a baseline, then compare the regex fast path against it.

I've pushed the new baseline for the regex experiment. Compared with the current PR:

  • The original version cached parsed imports, but still matched the docblock and resolved the name on every call. Resolved type names are now memoized per declaring class/property, including null for non-collection docblocks.
  • Parsing moved out of PropertyReflector into an internal DocblockContext, using PhpToken. Imports stay local to each namespace block, and trait properties use the namespace/imports where their docblock was declared. Class overrides and promoted properties keep the class context, including readonly promotion.
  • Fixed case-insensitive aliases, uppercase AS, namespace\Foo names and function/const imports. Regression tests cover these cases; overlapping tests and fixtures were removed. The mapper integration test stays.

The benchmark needed correcting too. My earlier warm numbers measured three properties, and the builtin batch also included a property without a docblock. The cold benchmark timed preparation of the next class as well.

It now measures one lookup per call, with setup and correctness checks outside timing and the same method/parameter passing for compared scenarios. Cold sources are real temporary files, prepared and loaded before timing, then cleaned up afterwards.

Latest local results, PHP 8.5.12 CLI, OPcache disabled:

Scenario Per call
No docblock 0.038 µs
Warm int[] 0.581 µs
Warm list<string> 0.585 µs
Warm FQCN 0.600 µs
Warm short name 0.592 µs
First short-name lookup, source metadata cached 1.852 µs
Cold short-name lookup, small file 25.159 µs
Cold short-name lookup, 100 extra unrelated properties 171.644 µs
Warm short names, FQCNs and builtins are roughly on par. These replace the earlier figures, not a direct before/after speedup comparison: I haven't rerun the original implementation with this benchmark. Cold means uncached resolver metadata, not an empty filesystem cache; source reading and tokenization are included.

The regex fast path isn't implemented yet. That's the next comparison against this baseline.

I've added the regex fast path and benchmarked it against the corrected baseline.

It matches a restricted header from the start of the file through the reflected class declaration, rather than searching for use ... anywhere in the source. Simple imports and aliases are supported. Comments in the header, grouped imports, traits and other unsupported cases fall back to the token parser, reusing the source already read. Warm lookups still use the memoized type name.

Tests cover fake imports in comments and strings, namespace isolation, and looking up another class after caching the first class's header.

I ran baseline → regex → baseline → regex with the same benchmark. Latest pair on PHP 8.5.12 CLI, OPcache disabled, in µs per lookup:

Scenario Baseline Regex
No docblock 0.039 0.039
Warm int[] 0.594 0.594
Warm list<string> 0.596 0.596
Warm FQCN 0.596 0.599
Warm short name 0.600 0.612
First short-name lookup, source metadata cached 1.800 1.801
Cold short-name lookup, small file 25.446 11.708
Cold short-name lookup, 100 extra unrelated properties 182.265 12.933
Cold short-name lookup, grouped import (fallback) 25.525 28.713
First lookups of 20 different properties in one file, average 6.364 2.939

The simple cold path is about 54% faster on the small file and 93% faster with 100 extra properties. The tradeoff is the grouped-import fallback: about 3.2 µs slower (~12.5%). Warm and cached-source lookups are roughly unchanged.

The 20-property case includes one cold lookup followed by 19 lookups with cached source metadata, not 20 cold parses. Cold here means uncached resolver metadata, not an empty filesystem cache. Setup and result checks are outside timing.

@radoslav-grencik
radoslav-grencik marked this pull request as ready for review November 11, 2026 11:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants