perf: precompile construction metadata and reuse dependency maps in D… - #569
Merged
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Team Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Removes repeated reflection work from
DataObjecthydration by precompilingper-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.
Purpose
DataObject::make()sits on the request hot path wherever DTOs are hydrated —the
AsDataObjectEloquent cast,HasCastsrequest casting, and the API clientcomponent 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 — parametername, type, nullability, default availability — even though these are fixed
once a class is loaded.
getDependenciesData()probed its cache with a falsy check. A class whosedependency map resolves to an empty array therefore re-ran
resolveDependenciesMap()— a recursive reflection walk over every publicproperty — on every
make($data, autoResolve: true)call. The cache waswritten but never read.
getSerializers()rebuilt its closure map on everytoArray()call.Correctness:
getPropertyMap()wrote into the cache slot from inside its loop. A class withno public non-static properties never initialised the slot, so the method
returned
nullthrough anarrayreturn type and raised aTypeError.getDependencyFromUnionType()threwRuntimeExceptionwhen a union containedno
DataObjectorDateTimeInterfacemember, so an ordinaryint|stringproperty broke dependency resolution for the whole class.
untyped properties — reached
->getName()and raised anError.DataObjecthandler was[$typeName, 'make'], so a payload alreadycarrying a hydrated instance raised a
TypeErrorinstead of passing through.is_subclass_of($className, DateTimeInterface::class)did not matchDateTimeInterfaceitself.Carbon::createFromFormat()may return a falsy value; the result was returnedunchecked.
Architecture and Implementation
Construction recipe.
compileConstructionRecipe()derives, once per class,the immutable facts
make()needs for each constructor parameter: the payloadkey, 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
$reversedPropertyMapCacheand$reflectionParametersCachevalues it was compiled from, and reused only whileboth still match.
make()carries a fast path that compares those cache slotsdirectly instead of routing through the accessors; the fast path is gated by a
directReadsflag that is false when a subclass overridesgetPropertyMap(),getReversedPropertyMap(), orgetReflectionParameters(), in which case theaccessor results are compared instead.
Hook dispatch. Scalar casting and missing-value handling are inlined only
when the class has not overridden
convertValueToType()orgetDefaultValueForType().overridesHook()resolves the declaring class onceper 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 maycapture 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 thatoverrides a hook feeding
resolveDependenciesMap(), preserving that behaviourwhere a subclass could have relied on it.
Defaults. Parameters with a declared default continue to read it through
ReflectionParameter::getDefaultValue()on each construction, which keepsevaluation semantics — including per-instance object defaults — identical to the
previous implementation.
Design Decisions and Trade-offs
$reflectionParametersCacheand$propertyMapCacheare public API and can becleared 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 andletting PHP evaluate the default. Both are equivalent in observed behaviour;
the explicit read was kept because it is structurally identical to the previous
implementation.
unconditionally, trading some reuse for exact preservation of the previous
re-resolution behaviour on customised subclasses.
asDateTime(),getSerializers(),getCustomizedDependencies(), andgetDependencyFromUnionType()keepsignatures compatible with existing overrides, and the date handling,
serializer keys, and dependency semantics are unchanged.
Changes
Construction path:
$constructionRecipeCacheplusgetConstructionRecipe()andcompileConstructionRecipe(); rewritemake()to consume the recipe, with adirect-cache-read fast path guarded by
directReads.overridesHook()andthrowMissingProperty(); addKIND_*andDEFAULT_DATE_FORMATconstants.their declared default instead of probing the payload.
Serialization:
$serializerCacheandresolveSerializers();toArray()consumes it anduses
$value::classfor the handler lookup.Dependency resolution:
$emptyDependencyCacheEligibilityandcanCacheEmptyDependencies(); allowgetDependenciesData()to reuse an empty map for non-overriding classes.getDependencyFromUnionType()returnsnullinstead of throwing, skipsnon-named union members, and matches
DateTimeInterfaceitself viais_a().resolveDependenciesMap()skips property types that are neither named norunion types, and skips unions with no usable member.
DataObjecthandler passes an already-hydrated instance through.Property maps:
getPropertyMap()builds a local map and assigns it once; both map accessorsprobe their caches with
isset().Dates:
asDateTime()falls back toCarbon::parse()whencreateFromFormat()returns a falsy value.
State:
flushState(), clearing every static cache and restoring the auto-castingflag and date format to their defaults.
Validation
Executed:
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.