Skip to content

perf: precompile construction metadata and reuse dependency maps in D… - #569

Merged
albertcht merged 2 commits into
0.3from
feature/improve-data-object
Sep 7, 2026
Merged

perf: precompile construction metadata and reuse dependency maps in D…#569
albertcht merged 2 commits into
0.3from
feature/improve-data-object

Conversation

@albertcht

Copy link
Copy Markdown
Member

Summary

Removes repeated reflection work from DataObject hydration by precompiling
per-class construction metadata and by repairing three cache probes that could
never hit, and fixes several reflection-handling defects that made ordinary
property type declarations fatal.

refer to: #566

Purpose

DataObject::make() sits on the request hot path wherever DTOs are hydrated —
the AsDataObject Eloquent cast, HasCasts request casting, and the API client
component all funnel through it. Every construction re-derived declaration facts
that are immutable for the lifetime of the class, and one cache never served a
hit at all, so the cost scaled with request volume rather than with class count.
Applications hydrating DTOs per request benefit directly; no call sites change.

Problem

Construction cost:

  • make() re-derived per-parameter reflection facts on every call — parameter
    name, type, nullability, default availability — even though these are fixed
    once a class is loaded.
  • getDependenciesData() probed its cache with a falsy check. A class whose
    dependency map resolves to an empty array therefore re-ran
    resolveDependenciesMap() — a recursive reflection walk over every public
    property — on every make($data, autoResolve: true) call. The cache was
    written but never read.
  • getSerializers() rebuilt its closure map on every toArray() call.

Correctness:

  • getPropertyMap() wrote into the cache slot from inside its loop. A class with
    no public non-static properties never initialised the slot, so the method
    returned null through an array return type and raised a TypeError.
  • getDependencyFromUnionType() threw RuntimeException when a union contained
    no DataObject or DateTimeInterface member, so an ordinary int|string
    property broke dependency resolution for the whole class.
  • Property types that are neither named nor union — intersection types, and
    untyped properties — reached ->getName() and raised an Error.
  • The nested DataObject handler was [$typeName, 'make'], so a payload already
    carrying a hydrated instance raised a TypeError instead of passing through.
  • is_subclass_of($className, DateTimeInterface::class) did not match
    DateTimeInterface itself.
  • Carbon::createFromFormat() may return a falsy value; the result was returned
    unchecked.

Architecture and Implementation

Construction recipe. compileConstructionRecipe() derives, once per class,
the immutable facts make() needs for each constructor parameter: the payload
key, an integer kind describing the scalar cast, nullability, and whether a
default is declared. make() iterates that recipe instead of re-reflecting.
The recipe deliberately stores no evaluated default values.

Cache validity. The two reflection caches the recipe is built from are public
and mutable, so the recipe is validated rather than memoised outright. It is
stored alongside the exact $reversedPropertyMapCache and
$reflectionParametersCache values it was compiled from, and reused only while
both still match. make() carries a fast path that compares those cache slots
directly instead of routing through the accessors; the fast path is gated by a
directReads flag that is false when a subclass overrides getPropertyMap(),
getReversedPropertyMap(), or getReflectionParameters(), in which case the
accessor results are compared instead.

Hook dispatch. Scalar casting and missing-value handling are inlined only
when the class has not overridden convertValueToType() or
getDefaultValueForType(). overridesHook() resolves the declaring class once
per class and the answer is stored in the recipe, so an overriding subclass keeps
its dispatch and a plain subclass pays no call.

Serializers. resolveSerializers() memoises the serializer map per class,
but only when getSerializers() is not overridden. An overriding hook may
capture runtime configuration when it is built, so it continues to be invoked on
every call.

Dependency maps. An empty dependency map is now reusable. Because the
previous falsy probe had the side effect of re-consulting overridden hooks on
every call, canCacheEmptyDependencies() withholds reuse from any class that
overrides a hook feeding resolveDependenciesMap(), preserving that behaviour
where a subclass could have relied on it.

Defaults. Parameters with a declared default continue to read it through
ReflectionParameter::getDefaultValue() on each construction, which keeps
evaluation semantics — including per-instance object defaults — identical to the
previous implementation.

Design Decisions and Trade-offs

  • The recipe is revalidated per call rather than memoised outright.
    $reflectionParametersCache and $propertyMapCache are public API and can be
    cleared or rewritten by application code; an unconditional cache would then
    serve stale metadata. The fast path reduces the check to two array reads and
    two identity comparisons.
  • getDefaultValue() is retained rather than omitting the named argument and
    letting PHP evaluate the default. Both are equivalent in observed behaviour;
    the explicit read was kept because it is structurally identical to the previous
    implementation.
  • Empty dependency maps are gated by hook inspection rather than cached
    unconditionally, trading some reuse for exact preservation of the previous
    re-resolution behaviour on customised subclasses.
  • No public contract changes. asDateTime(), getSerializers(),
    getCustomizedDependencies(), and getDependencyFromUnionType() keep
    signatures compatible with existing overrides, and the date handling,
    serializer keys, and dependency semantics are unchanged.

Changes

Construction path:

  • Add $constructionRecipeCache plus getConstructionRecipe() and
    compileConstructionRecipe(); rewrite make() to consume the recipe, with a
    direct-cache-read fast path guarded by directReads.
  • Add overridesHook() and throwMissingProperty(); add KIND_* and
    DEFAULT_DATE_FORMAT constants.
  • Constructor parameters with no matching public property now fall through to
    their declared default instead of probing the payload.

Serialization:

  • Add $serializerCache and resolveSerializers(); toArray() consumes it and
    uses $value::class for the handler lookup.

Dependency resolution:

  • Add $emptyDependencyCacheEligibility and canCacheEmptyDependencies(); allow
    getDependenciesData() to reuse an empty map for non-overriding classes.
  • getDependencyFromUnionType() returns null instead of throwing, skips
    non-named union members, and matches DateTimeInterface itself via is_a().
  • resolveDependenciesMap() skips property types that are neither named nor
    union types, and skips unions with no usable member.
  • The nested DataObject handler passes an already-hydrated instance through.

Property maps:

  • getPropertyMap() builds a local map and assigns it once; both map accessors
    probe their caches with isset().

Dates:

  • asDateTime() falls back to Carbon::parse() when createFromFormat()
    returns a falsy value.

State:

  • Add flushState(), clearing every static cache and restoring the auto-casting
    flag and date format to their defaults.

Validation

Executed:

  • Microbenchmarks, p50 of 7 samples on PHP 8.4.18, measured pairwise against the
    committed version:
    • make($data), 8-field flat object, 200k ops: 2268 ns/op to 1618 ns/op.
    • make($data, autoResolve: true) with no dependencies, 50k ops:
      10090 ns/op to 1928 ns/op.
    • make($data, autoResolve: true) with nested and date dependencies, 30k ops:
      8526 ns/op to 8059 ns/op. This path is dominated by Carbon parsing, which
      this change does not touch.

@coderabbitai

coderabbitai Bot commented Sep 7, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: aadb40c7-1730-4e89-a00b-aabc5e291f6d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@albertcht albertcht added the enhancement Improved feature or adjustments. label Sep 7, 2026
@albertcht
albertcht merged commit f35080d into 0.3 Sep 7, 2026
17 checks passed
@albertcht
albertcht deleted the feature/improve-data-object branch September 7, 2026 13:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement Improved feature or adjustments.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant