Skip to content

Set properties without throwing for unknown names on dynamic classes - #37

Merged
veewee merged 5 commits into
mainfrom
feature/throwless-properties-set
Oct 15, 2026
Merged

veewee merged 5 commits into
mainfrom
feature/throwless-properties-set

Conversation

@veewee

@veewee veewee commented Oct 14, 2026

Copy link
Copy Markdown
Owner

Problem

properties_set looks up every passed name with ReflectedClass::property(), which throws UnreflectableException when the class and its parents do not declare it. For a dynamic class that exception is caught and the value is assigned directly, so hydrating a stdClass costs one thrown and caught exception per property. php-soap/encoding decodes every untyped complex type into a stdClass this way, which came up in the review of php-soap/encoding#70.

Approach

ReflectedClass::findProperty() walks the same parent chain as property() and returns null instead of throwing. property() is now built on it, and properties_set uses it directly: a known property is set through reflection, an unknown one is assigned on a dynamic class and still throws UnreflectableException on any other class. findProperty() is public so php-soap/encoding can use it for a non-throwing read later. property_get is unchanged.

object_data(stdClass::class) now casts the values with (object) $values instead of hydrating through properties_set. It only does that for exactly stdClass with the default accessor, because from always builds a fresh object there. A custom accessor and other #[AllowDynamicProperties] classes keep the normal path.

For a class with parents, the unknownProperty message now names the class you asked for. It used to name the topmost ancestor, because the exception came from the last recursive call.

I added phpbench as a dev dependency with benchmarks for both functions (composer bench). They run with xdebug.mode=off.

Benchmarks

Mode per call in microseconds, 500 revs, 10 iterations, PHP 8.4.13. All variants ran in the same phpbench run, because separate runs on this machine drifted by more than 50%.

  • A is the current code.
  • B loads $class->properties() once and does a key lookup per value.
  • C keeps the lookup per name through findProperty(). I measured it returning a Psl\Option and returning a nullable.
Scenario Size A (current) B (eager) C with Option C with nullable
typed, all set 5 1.388 2.008 2.051 1.332
typed, all set 20 5.088 6.310 7.082 5.105
typed, all set 100 25.421 26.403 34.107 24.183
typed, one set 5 0.472 1.375 0.548 0.480
typed, one set 20 0.478 3.361 0.605 0.505
typed, one set 100 0.689 13.455 0.812 0.716
stdClass, all new 5 2.684 0.669 0.851 0.516
stdClass, all new 20 10.505 1.046 2.566 1.380
stdClass, all new 100 51.632 2.980 11.963 5.771
private props in parent 5 2.552 2.855 2.318 1.791
private props in parent 20 9.386 8.041 8.687 6.214
private props in parent 100 47.171 33.928 39.974 30.771

I went with C returning a nullable. B is fastest for a stdClass but up to 20x slower than today when one property of many is set, because it reflects every property up front. Returning an Option added 35 to 50% on typed classes with all properties set, which is the php-soap/encoding case. The nullable version stays within a few percent of the current code on typed classes and is faster on dynamic classes and on private properties in a parent.

object_data(stdClass::class)->from($values), through the new properties_set and with the cast:

Size Through properties_set With the cast
5 0.743 0.060
20 1.591 0.065
100 6.147 0.102

On main the same call took 3.8, 14.0 and 66.6 microseconds in a separate run.

Throws

Counted with Xdebug xdebug.show_exception_trace in develop mode, 20 properties per scenario:

Scenario main this branch
typed class, all set 0 0
typed class, one set 0 0
private props in parent 0 0
stdClass, all new 20 0
dynamic class, 1 declared and 2 unknown 2 0
object_data(stdClass)->from 20 0
unknown property on a non-dynamic class (error path) 2 1

The error path counted 2 before because the exception was caught and rethrown.

Release

This is meant for a 1.x minor release (1.1.0). The public API only gains findProperty(), and the exception message above is the only other visible difference.

properties_set threw and caught an UnreflectableException for every name a dynamic class does not declare, so hydrating a stdClass cost one exception per property. ReflectedClass::findProperty() walks the same parent chain as property() and returns null instead, and property() is built on top of it.
object_data always builds a new object, so for a plain stdClass with the default accessor the cast gives the same result without reflection.
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.

1 participant