Skip to content

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

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

radoslav-grencik wants to merge 4 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 (0f478fb487e12a5891738a15a1663fa44c47295b).

Open to see the benchmark results
Benchmark Set Mem. Peak Time Variability
ViewRenderBench(benchPlainHtml) - 22.038mb 0.00% 530.564μs -7.36% ±1.92% -3.47%
ViewRenderBench(benchExpressions) - 24.595mb 0.00% 573.231μs -7.77% ±1.19% -35.06%
ViewRenderBench(benchControlFlow) - 44.504mb 0.00% 724.100μs -8.49% ±0.90% +8.97%
ViewRenderBench(benchViewComponent) - 69.745mb 0.00% 1.452ms -7.61% ±1.54% +14.46%
ContainerBench(benchRegisterSingletonInstance) - 6.109mb 0.00% 1.368μs -17.67% ±3.27% +73.48%
ContainerBench(benchRegisterDefinition) - 6.779mb 0.00% 1.255μs -20.17% ±1.74% +105.91%
ContainerBench(benchRegisterInitializer) - 6.424mb 0.00% 3.573μs -6.50% ±1.90% +76.21%
ContainerBench(benchRegisterClosureSingleton) - 6.456mb 0.00% 1.300μs -13.89% ±1.12% -31.04%

Generated by phpbench against commit 3382b7a

@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%.

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