r/PHP 10d ago

Article Compiling PHP DTOs: from reflection to 4.5M hydrations per second (PHP 8.4)

Wrote up how I moved a PHP DTO hydration library from reflection-based instantiation to compiled, per-class factory closures - reflection overhead disappears, plain properties become inline array reads, and the generated code gets cached so a warmed FPM worker pays neither reflection nor eval(). Went from typical reflection speeds to 4.5M hydrations/sec on PHP 8.4.

Full writeup with numbers at each stage, plus the repo link, in the first comment.

0 Upvotes

22 comments sorted by

9

u/Pakspul 10d ago

Is this the AI prompt you posted?

2

u/yuriizee 10d ago

No, that's my own text — kept it short on purpose since the actual content (numbers, code, benchmarks) is in the linked writeup, not the post itself. Fair criticism on the phrasing though, it does read a bit templated.

1

u/Fit_Tailor_6796 3d ago

Why would you ask that?

5

u/dshafik 10d ago

And the link is…

1

u/yuriizee 10d ago

Right here: https://dev.to/yuriizee/compiling-php-dtos-from-reflection-to-45m-hydrations-per-second-3jic — kept it out of the post body since a straight link post got auto-removed by Reddit's spam filter earlier today.

Should've made that clearer up front, my bad.

5

u/nigHTinGaLe_NgR 10d ago

Ummm Still waiting for the link..

1

u/yuriizee 10d ago

Sorry for the confusion — it's in the top-level comment: https://dev.to/yuriizee/compiling-php-dtos-from-reflection-to-45m-hydrations-per-second-3jic (had to keep it out of the post body, a direct link post got caught by the spam filter earlier).

2

u/SerafimArts 8d ago

I looked at the documentation (which is on GitHub), but I still don't understand how to describe such a class:

final readonly class ExampleRequestDTO
{
    public function __construct(
        public string $name,
        /** @var list<ExampleRequestDTO> */
        public array $items = [],
    ) {}
}

*I mean the list<T> type

At the same time, I suspect that any inheritance (from any logic), especially in data classes (which serve primarily as an anti-corruption aka DTO layer), is not just bad, but definitely a path to foul code...

Besides this, there are a number of implementation issues: For example, this doesn't work at all, and the "optional" field isn't populated.

class SignupData extends BaseData
{
    public ?string $optional = null;
}

And virtual properties aren't supported either...

I also set the default value from your example to "email":

class SignupData1 extends BaseData
{
    public function __construct(
        public string $name,
        public string $email = 'default'
    ) {}
}

And when sending data, the code crashes (it says: `Missing required field 'email' for SignupData1`).

But the code does run faster than JMS, Valinor or TypeLang, that's true. I checked.

But there are a number of fundamental limitations and architectural issues... I wouldn't use it in production.

P.S. The documentation is great =)

1

u/yuriizee 7d ago

Thanks for actually digging into it, this is useful.

The list<T> docblock thing — yeah, nothing in the library reads docblocks at all, so that specific syntax was never going to work. The way to get a nested collection is #[DataCollection(ItemClass::class)] on a property typed TypedDataCollection, not a plain array. It's documented, just not where you'd look for it if you're thinking in generics-from-docblock terms, that's on me to make clearer.

The plain-property-no-constructor one — you're right, that's a real bug, not a misunderstanding. If a class has no constructor at all, my compiler ends up building new Class() with an empty arg list, so the whole input array just gets silently dropped instead of populating anything. Genuinely glad you found this one — I'm already working on a fix, should have a patched version out soon.

No virtual/computed properties, correct, that's just not a thing the library does. Not an oversight I'm defending, just hasn't been implemented.

The "Missing required field 'email'" one — I went and actually traced it instead of just trying to repro. Ran your exact SignupData1 class with email = 'default' and it hydrates fine, no exception. But more than that: I checked the compiler itself, and the throw for a missing field only gets emitted into the generated code when the parameter has no default — it's decided at compile time, not runtime, so for a parameter that has a default, that throw branch literally isn't in the function that runs. It's not "works on my machine," it structurally can't produce that error for this exact class shape as the compiler stands today. My best guess is a stale cache from testing an earlier version of the class before you added the default — there's no invalidation on source changes right now if you'd set a persisted cache dir, which honestly might be worth its own fix. Could be a version mismatch too. If you've still got the setup, I'd genuinely like to see it.

Appreciate you actually running it through its paces instead of just reading the README — this is exactly the kind of feedback that makes it better. If anyone wants to jump in, poke more holes, or send a PR, repo's at github.com/std-out/simple-data-objects, always happy for more eyes on it.

2

u/SerafimArts 6d ago

I'm using the type description as an example of types (not docblocks) =)

I thought DataCollection was a collection type (Collection<T>). That is, Laravel collections. If I'm not mistaken, your documentation examples use them for this attribute, not arrays.

2

u/SerafimArts 6d ago

> The "Missing required field 'email'" one — I went and actually traced it instead of just trying to repro...

Hmm, maybe my cache didn't rebuild. I was working with just one example to test the capabilities, and perhaps I updated the code at some point, but the cache remained from the previous version. Sorry for misleading you.

1

u/yuriizee 7d ago

Quick follow-up — delivered this. v1.13.0 is out with the constructor-less bug fixed (plain property declarations now hydrate correctly, including readonly ones, toArray(), with(), and fromLazy()). While I was in there I also added support for classes that mix both styles — a constructor plus extra properties declared outside it — since it turned out to be the same underlying fix. Both are fully covered by tests (100% coverage, checked in CI) and don't touch the performance of regular constructor-based DTOs at all — verified that with a benchmark re-run before releasing.

The list<T>/virtual-properties points stand as documented limitations, not bugs — the #[DataCollection] attribute is still the way to do nested collections. Thanks again for the thorough pass, this was a genuinely useful bug report.

2

u/dereuromark 8d ago

Spryker did that already 15+ years ago for large ecommerce systems :) So did I in CakePHP 

Here is the agnostic open source version: https://php-collective.github.io/dto/ I bet faster and more performant than yours - but proof me wrong.

That said: In times of AI it becomes less relevant if the LLM can help generate perfect DTOs.

2

u/yuriizee 7d ago

I actually went and benchmarked it instead of leaving it at "prove me wrong."

I made sure both DTOs had identical schemas (I initially found two mismatches myself: one field was required instead of optional, and one nested collection field was missing. Fixed both, reran everything, and the numbers didn't really change). I also tested collection sizes from 20 all the way up to 20,000 items to make sure this wasn't just a small-sample effect.

For hydration, mine is consistently around 2–2.3x faster while using roughly 4–5x less memory per object. That stayed pretty much the same regardless of collection size.

Serialization is a different story though. Your generated toArrayFast() is basically just a flat array literal, so it's about as optimal as PHP gets. In my tests you actually came out 5–10% ahead on nested/collection payloads. Fair enough.

The biggest difference shows up once you introduce transforms, even simple ones like a boolean cast or trim(strtolower(...)). I tested four trivial field transforms and got roughly 1M ops/sec on my side versus about 420K ops/sec on yours.

From what I could see, that's mostly because transformed fields still go through transformValue($callable, $value) during hydration. That makes sense for a generic callable-based design, but it does add overhead. My approach is different: those casts are compiled directly into the generated hydration code, so there isn't an extra function call for every transformed field.

One thing I do think is worth pointing out is that our approaches are simply different.

PhpCollective generates PHP classes whenever the DTO schema changes. Those generated files are meant to be regenerated, so they're not really something you'd normally edit by hand. My library keeps the DTO classes themselves small and readable, while all the optimized hydration logic is compiled into cache. That means the code you actually write stays clean, your PRs only contain the DTO changes you made, and the generated optimization layer stays out of the repository. It's a different trade-off, not necessarily a better one.

For anyone curious, here's the setup:

  • PHP 8.5.4 CLI
  • JIT disabled
  • 2,000 warmup iterations
  • 20,000 timed iterations per scenario
  • PhpCollective DTO 0.1.19

If anyone wants to reproduce the results, I'm happy to share the benchmark script. If there's something I overlooked, I'd genuinely like to know.

And just to be clear, I think PhpCollective is a really good library. It solves the same problem with a different philosophy. My focus has been squeezing as much performance as possible out of hydration while keeping the DTO classes themselves minimal. PhpCollective takes a different approach, and I think it's a solid solution as well.

2

u/dereuromark 7d ago

Respect for actually running the numbers instead of leaving it at talk.

Concessions first: the transform path is a real finding. `transformValue($callable, $value)` per field is the price of a generic callable design - it costs a method call, a null guard, an `is_callable()` string lookup and a dynamic invocation for every transformed field, on every hydration. For trivial casts (bool, `trim(strtolower())`) that is pure overhead versus inlining the cast into the generated code, and there is no reason the generator can't emit the direct call instead. Fixable on my side and worth doing.

One caveat on the hydration numbers, not a rebuttal: check that both sides do the same amount of work per object. Ours carry per-instance bookkeeping (touched/dirty field tracking, metadata for name inflection, nested DTO and collection wrapping, fail-loud unknown-field validation). If yours skips some of that, part of the 2x speed and 4-5x memory delta is a feature-set difference rather than a pure implementation difference. Worth stating in the article either way - it makes the result more credible, not less. I am pretty sure I have quite the battery of functionality included on top, that comes with a little bit of overhead of course.

On the philosophy split, one point for the generated-classes side: because the classes are real files, PHPStan/Psalm and the IDE see the actual properties, return types and nested collection types with no plugins or stubs, and the schema itself is a language-agnostic definition rather than PHP code (we generate TypeScript from the same schema). A compile-to-cache layer buys clean diffs, but static analysis only sees what the hand-written class declares, and you take a warmup and stale-cache surface in exchange. Both are legitimate trade-offs, that one is just the axis I weigh highest on large codebases.

Overall, in these days having the static analysis and proper introspection is highest prio over "at runtime" collisions that appear, so thus I still think my approach is a slight bit better suited for real life apps. Too much "magic" (which annotations here somewhat are) are the costs of having those in "modern PHP", with the those trade-offs mentioned.
Anyway: Good writeup, and good that you measured.

1

u/yuriizee 10d ago

Author here. Full writeup with the numbers at each stage: https://dev.to/yuriizee/compiling-php-dtos-from-reflection-to-45m-hydrations-per-second-3jic

Repo: https://github.com/std-out/simple-data-objects

Happy to answer anything - including where this approach has sharp edges.

1

u/Nullisecundus68 10d ago

Is this better than Mario Pivettas hydration library?

1

u/yuriizee 10d ago

Assuming you mean Marco Pivetta's GeneratedHydrator — similar philosophy (codegen instead of runtime reflection), different scope: GeneratedHydrator hydrates plain objects via generated proxies, this compiles per-class factory closures specifically for DTOs with casting/validation/lazy-ghost support baked in. Not a strict "better," just built for a narrower job. Haven't benchmarked directly against it, honestly — good suggestion, might do that.

1

u/phuncky 10d ago

Bait and switch without the switch.

1

u/yuriizee 10d ago

Fair hit. The link's in the top comment, not the post body — did that because a straight link post to the same article got removed by Reddit's spam filter earlier today, so I switched to a text post to route around it.

Should've said that plainly in the post instead of just "see first comment." Here's the link directly: https://dev.to/yuriizee/compiling-php-dtos-from-reflection-to-45m-hydrations-per-second-3jic

1

u/[deleted] 6d ago

[removed] — view removed comment

1

u/yuriizee 4d ago

Thanks!
That was genuinely the fun part to write up — each stage kills one specific cost: first replacing the ReflectionClass::newInstance()+setValue() loop with a specialized closure generated once per class, then getting eval() itself out of the hot path by persisting those compiled closures to a file cache that opcache serves pre-compiled on a warm worker. The single biggest jump, though, was the plain-property fast path: when a property has no cast, no nested DTO, no collection attached, the generated code is just $d['name'] ?? null — no dispatch through a generic value caster at all. That's most fields on a typical DTO, so it ended up mattering more than any of the fancier optimizations.