From Sluggish to Blazing Fast: How I Made TypePHP 90x Faster and Uncovered 1000+ "DocBlock Lies" on Shopware 6
When building a userland runtime contract engine for PHP, synthetic benchmarks on small scripts only tell 5% of the story. Toy benchmarks running 100 iterations of positive-int will always look blazing fast.
To discover where an engine truly breaks, and where PHP's static type system breaks down, you have to test it against an enterprise open-source monolith.
For TypePHP, that testing ground was Shopware 6, one of the largest, most sophisticated Symfony-based e-commerce architectures in the PHP ecosystem.
(And to clear up any naming confusion: no, this is not that other project that transpiles PHP into C++ and forbids top-level code unless you write a main() entrypoint. Is PHP really so busted that someone felt the need to pretend it is C++ instead of just improving the language itself? This TypePHP is 100% pure PHP, built solely to elevate type safety and enforce contracts dynamically without changing the way you write PHP).
The results? I took the CI test excecution times across the entire matrix from hours down to just 4 to 6 minutes each (a ~90x speedup). Along the way, TypePHP caught 1,000+ runtime contract violations in code that passed static analysis tools at maximum strictness with zero complaints and sometimes suppressed warnings.
The Proof: GitHub Actions CI Comparison
Shopware's continuous integration workflow is divided across dozens of parallel matrix batches (Core/Content, Core/Checkout, core-framework, migration, devops).
While these optimizations improved every batch across the entire workflow, I will focus specifically on Core/Content (1,680 integration tests on PHP 8.2 with MySQL 8.0) as the primary benchmark.
Before: 3 Hours+ (PHPUnit Running for 2h 57m)
Figure 1: Initial test run choking the CI runner, with PHPUnit taking 2 hours 57 minutes before timing out.
After: 4 Minutes+ Total (PHPUnit Running for 2m 36s)
Figure 2: The exact same test suite executing in just 2 minutes 36 seconds after static blueprint caching, zero-allocation hot paths, and trait call frame synchronization.
Part 1: The Math Behind the 3-Hour Freeze
In an enterprise test suite with 1,680 integration tests, methods and entity contructors are not called 10 times. They are called hundreds of thousands of times.
A micro-inefficiency of just 5 milliseconds repeated 1,000,000 times equals:
To make TypePHP viable for massive real-world projects, I had to systematically eliminate four major architectural bottlenecks:
Pillar 1: Static Class Blueprint Caching (Saved ~2.5 Hours)
The Bottleneck
Every time Shopware created or hydrated an entity (such as new ProductEntity(), new Criteria(), or new EntityCollection()):
TemplateManagerinspected the object to discover generic template contracts.- It traversed the entire reflection hierarchy across all parents, interfaces, and traits.
- It ran the full PHPStan Lexer and AST Parser in RAM on every single object instantiation to extract
@extends,@implements, and@usetags.
In Shopware, about 1,000,000 entity and collection objects are created during a full test run:
The Solution: Static Blueprinting
A class's generic inheritance tree is immutable during a single PHP request lifecycle. ProductEntity does not change its parent class or interface contracts between test #1 and test #1,000.
I moved from per-instance parsing to static class blueprinting:
[ Object Instantiation #1 ] ──► Compute Inheritance Blueprint ──► Cache in Static RAM
│
[ Objects #2 through #1,000,000 ] ──────────────────────────────────────────┘
(0 Reflection, 0 Lexing, 0 AST Parsing: instant 1ns array copy into \WeakMap)- Invocation #1: Computes template mappings once and caches them in
$classInheritedBindingsCacheand$classHierarchyTemplatesCache. - Invocations #2 to #1,000,000: Fetch the pre-computed blueprint in static RAM and perform an instant copy into
\WeakMap. - Impact: Reduced ~2.5 hours of continuous AST re-parsing down to ~15 seconds total.
Pillar 2: Stopping the "Exception Serialization Cascade" (Saved ~30 Minutes)
When a test crashes with an unhandled exception, PHPUnit halts excecution, serializes the global state, copies stack traces, formats JUnit XML logs, and runs teardown hooks.
When thousands of false-positive exceptions throw inside tight database hydration loops, this serialization cascade completely freezes GitHub Actions runners. By fixing these resolution edge cases, tests executed on the clean happy path without trigering PHPUnit's heavy serialization subsystem.
Pillar 3: Fixing the Asymmetric Generic Trait Memory Leak
When an application class used a generic Trait method (such as ProductRepository using LoggerTrait):
ParamCheckerpushed a generic call frame under the calling class name (ProductRepository::log).- When the method exited,
ScopeCleanerattempted to pop the frame under the declaring trait name (LoggerTrait::log).
Because the array keys did not match:
The call frame was never popped. Over 1,680 tests, hundreds of thousands of abandoned array frames leaked into $callStackBindings. This bloated process memory and forced Zend Engine's Garbage Collector into constant, slow full-heap sweep cycles.
I unified caller vs. trait resolution using a centralized resolveEffectiveFunction() helper across setupScope(), ParamChecker, and ScopeCleaner. Every generic call frame is now popped cleanly on method exit, keeping process memory completely flat.
Pillar 4: Zero-Allocation Hot Path & Direct Dispatch
On high-frequency methods executed hundreds of thousands of times:
- Deferred String Construction: Error messages and context paths (
"$function(): Argument $id") are formatted only if a validation failure actually occurs, eliminating over 2,000,000 throwaway string allocations on the happy path. - Direct Dispatch for Identifiers: Inlined direct routing for
IdentifierTypeNode(which representsof all types), bypassing dynamic get_class()lookups.
Part 2: The Many Docblock lies Errors: When Reality Hits the "DocBlock Lie"
Once TypePHP was running at full native speed, PHPUnit began executing the actual test assertions, and immediately surfaced 1000+ runtime contract failures.
Here are real snippets directly from the test run:
Exhibit A: The Dynamic DI Container Generic Mismatch
1) Shopware\Tests\Integration\Core\Content\ImportExport\Api\ImportExportFileApiTest::testImportExportFileSearch
TypePHP\Exception\TypeError: SalesChannelTrackingListener::__construct():
Argument $salesChannelRepository expects
EntityRepository<covariant SalesChannelCollection>,
but
EntityRepository<EntityCollection<PartialEntity>> was given
in /var/cache/test/Container/Shopware_Core_KernelTestDebugContainer.php on line 7215Look at what happened here:
SalesChannelTrackingListenerdeclared in its DocBlock:/** @param EntityRepository<SalesChannelCollection> $salesChannelRepository */- At compile time, static analysis tools read this annotation, nod approvingly, and mark the code as 100% clean.
- At runtime, Symfony's compiled Dependency Injection container dynamically instantiates an
EntityRepositoryconfigured withEntityCollection<PartialEntity>.
The DocBlock claimed one thing. The compiled DI container physicaly injected another. The DocBlock was a lie, and static analysis was completely blind to it.
Exhibit B: The Array Key Assumption
3) Shopware\Tests\Integration\Core\Content\ImportExport\Api\ImportExportFileApiTest::testImportExportFileList
TypePHP\Exception\TypeError: SystemConfigLoader::getSubArray():
Argument $value key must be of type string, zero int (0) given
in /src/Core/System/SystemConfig/SystemConfigLoader.php on line 72Here, SystemConfigLoader::getSubArray() documented its parameter as an associative string-keyed map (array<string, mixed>).
At runtime, cached configuration structures and nested arrays physicaly contained integer keys (0 => ...). In production, this can lead to silent data truncation when functions like array_merge() or key-sensitive array iterators treat string keys and integer keys with completely different semantics.
Why Static Analysis Tools Alone Are Not Enough
There is a popular dogma in the PHP community:
"Why would anyone need runtime type checks in PHP? Just run static analysis tools at maximum strictness, write clean DocBlocks, and you have 100% type safety!"
This is a dangerous illusion.
Static analysis tools (PHPStan, Psalm, Mago, Phan) are exceptional at linting syntax, catching typos, and verifying static call graphs. But static analyzers operate in an idealized compile-time world that does not exist during live execution:
| What Static Analysis Tools Cannot See | Real-World Failure Scenario |
|---|---|
| Dynamic Dependency Injection | Compiled DI containers, synthetic services, and decorated factories |
| Un-sanitized External Payloads | JSON payloads, Stripe webhooks, and third-party HTTP API responses |
| Dynamic Database & Cache Records | Redis arrays, PDO associative rows, and serialized session state |
| Dynamic Runtime Configuration State | Runtime system config loaders, feature flags, and environment vars |
| Dynamic Test Mocks & Doubles | Anonymous mock objects, and dynamic runtime proxy classes |
| Runtime Generic Mutations | Objects pushed into collections downstream after type erasure |
A DocBlock is a wish. Static analysis checks if your code is consistent with your wishes. TypePHP verifies whether reality actually matches what you wished for.
A Direct Challenge
I dare you to run TypePHP in your codebase, even if it already pases static analysis tools at maximum strictness. You will be surprised by how many hidden type errors and DocBlock lies surface the moment your code actually executes in real time.
PHP is Not Slow
There is another common misconception that running AST inspection and runtime type validation in pure PHP userland is inherently doomed to be slow.
"PHP is not slow. PHP is remarkably fast when you write and architect your code with respect to the Zend Engine, memory allocations, and static memoization."
TypePHP does not require custom C-extensions, Rust or C FFI bindings, or modified PHP binaries. It runs 100% in pure PHP userland, yet it easily sustains over 460,000 type checks per second in single-threaded execution when memory access patterns and static blueprints are respected.
When paired with OPcache and PHP 8's Tracing JIT, performance scales even further. OPcache compiles transformed code directly into shared memory bytecode so AST transformations execute zero times on subsequent requests, while the Tracing JIT compiles hot type-checking loops into native CPU machine code. You get uncompromising runtime safety without sacrificing execution speed.
A Special Thank You to Michael Telgmann & Shopware 6
I want to give a massive, heartfelt thank you to Michael Telgmann from the Shopware core team for trying and testing TypePHP against Shopware 6's continuous integration test suite.
Setting up TypePHP on Shopware's GitHub Actions pipeline provided the exact real-world crucible this engine needed. Shopware is an incredible, modern engineering platform, and runing against its full test suite forced me to build an engine capable of handling genuine architectural complexity:
- Multi-tier generic class hierarchies (
EntitySearchResult<TEntityCollection>) - Deep Symfony DI container compilation and runtime service wiring
- PHP 8.1+ attribute constructors with shifted parameter lists
- Massive runtime entity collections and hydration loops
Without Michael running TypePHP on Shopware 6, these performance bottlenecks, memory leaks, and subtle DocBlock edge cases would have taken me months to uncover.
The Takeaway: Static Analysis + Runtime Verification
Static analysis and runtime contract enforcement are not rivals; they are complementary partners:
- Use static analysis tools in your IDE and CI to catch static logic flaws before your code ever runs.
- Use TypePHP during Pest / PHPUnit test runs and CI pipelines to guarantee that your dynamic data, compiled containers, and generic collections withstand reality.
Get Started in 30 Seconds
composer require --dev typephp/typephp
vendor/bin/typephp config:initRun your test suite:
./vendor/bin/pest
./vendor/bin/phpunitEvery parameter contract, array shape, and generic container in your application is now actively defended by runtime type safety!
Have an edge case or massive codebase you'd like to benchmark? Open an issue or join the discussion on GitHub!