The Death of the 'DocBlock Lie': Introducing TypePHP and Runtime Reified Generics in Pure PHP
For the past several years, the PHP ecosystem has undergone a massive renaissance. With tools like PHPStan, Psalm, and Pest, PHP developers now write some of the most strictly-typed, expresive codebases in the world.
We routinely write rich, descriptive DocBlocks:
/**
* @param positive-int $id
* @param non-empty-string $email
* @param Collection<User> $team
* @param array{role: 'admin'|'editor', active: bool} $options
*/
function registerUser(int $id, string $email, Collection $team, array $options): voidAnd yet, there is a fundamental elephant in the room.
The "DocBlock Lie" Problem
Because PHP's C-engine (the Zend Engine) ignores DocBlocks at runtime, those rich types are passive comments:
- An incoming HTTP JSON payload or database row can pass
$id = -50or$email = ''. - A third-party webhook can inject an unexpectted key into what you assumed was a sealed array shape.
- A caller can pass
$team->add(new Product())and pollute what was supposed to be aCollection<User>.
Native PHP type hints allow all of this through without a single warning. To defend against this, developers were historically forced to litter their clean domain logic with repetitive assertion boilerplate:
// The old, cluttered way:
public function registerUser(int $id, string $email, Collection $team, array $options): void
{
Assert::positiveInteger($id);
Assert::nonEmptyString($email);
Assert::allIsInstanceOf($team->toArray(), User::class);
Assert::keyExists($options, 'role');
// ... actual business logic is buried!
}We asked ourselves: Why should we write our types twice? What if our existing DocBlocks were living, executable laws at runtime?
Enter TypePHP
TypePHP is a transparent, pure-PHP runtime type checker that turns your existing DocBlocks (@param, @return, @var, @template, @property, @method, array shapes, integer ranges, and scalar refinements) into active runtime guard rails during execution.
- Zero New Syntax: Write standard, clean PHP and standard PHPDocs.
- Zero Build Steps & Zero Node.js: No Babel, Webpack, or transpilation toolchains.
- Zero C-Extensions: Runs in 100% pure PHP userland across PHP 8.1, 8.2, 8.3, 8.4, and 8.5 on Linux, macOS, and Windows.
- Zero Line-Drift: Injected guard rails are squashed onto opening braces so that eror stack traces and Xdebug breakpoints match your source files 100% accurately.
How It Works: Load-Time Weaving via StreamWrapper
TypePHP utilizes a technique known in Aspect-Oriented Programming (AOP) as Load-Time Weaving (LTW):
HOW TYPEPHP WEAVES RUNTIME TYPE CHECKS
require 'file.php' ──► [ TypePHP StreamWrapper ] ──► [ Injects AST Checks ] ──► [ OPcache RAM ]
│
▼
Native C-Speed!- Stream Interception: When PHP loads a file, TypePHP's custom
file://stream wrapper intercepts the file inclusion. - Selective Path Filtering: Un-included vendor packages pass through 100% raw and untouched, while application files are parsed into an Abstract Syntax Tree using
nikic/php-parserandphpstan/phpdoc-parser. - AST Guard-Rail Injection:
ContractVisitortraverses the AST and attaches single-line runtime type guards at method boundaries. - OPcache Compilation: Transformed files are saved to disk once. PHP's OPcache compiles them directly into shared memory bytecode, meaning AST parsing runs 0 times on subsequent web requests.
True Riefied Generics in Pure PHP (\WeakMap)
Unlike languages that erase generic types at compile time (like Java or TypeScript), TypePHP delivers Reified Generics in memory using PHP's native \WeakMap:
use App\Collections\Collection;
use App\Models\User;
use App\Models\Product;
use TypePHP\TypePHP;
/** @var Collection<User> $users */
$users = new Collection();
$users->add(new User('Alice')); // Valid
$users->add(new Product('SKU-100'));
// Throws: TypeError: Collection::add(): Argument $item (template T = User) must be of type User, Product givenBecause \WeakMap uses weak references, the exact microsecond $users is unset or goes out of scope, its generic bindings are automatically deleted from RAM with zero memory leaks.
You can even inspect generic parameters at runtime via the library's reified generics api:
TypePHP::getGenericType($users); // Returns 'App\Models\User'Tooling Tag Priority Hierarchy (@phpstan-* > @psalm-* > @*)
Enterprise libraries (like Doctrine Collections, Symfony, and Shopware) often declare broad fallback annotations for IDEs and stricter annotations for static analyzers:
/**
* @param mixed $element // Broad fallback for IDEs
* @phpstan-param T $element // Stricter generic contract
*/
public function add(mixed $element): void;TypePHP automatically resolves tag priority: @phpstan-* > @psalm-* > @*. It prioritizes the stricter @phpstan-param contract automatically, allowing third-party collections (like Doctrine's ArrayCollection<int, Animal>) to enforce generic types at runtime with zero wrapper boilerplate.
Call-Site Stack Trace Attribution (Zero Confusion)
When a type contract fails, TypePHP automatically rewrites the exception's file and line attributes via Reflection to blame the exact line of code in the caller file where the invalid argument was passed:
TypePHP\Exception\TypeError: registerUser(): Argument $id must be of type positive-int, negative int (-50) given
in /app/Http/Controllers/UserController.php on line 24Exception handlers (Laravel Ignition, Symfony ErrorHandler, Whoops) and test runners (Pest, PHPUnit) point directly to the line of code responsible for the violation.
Real-World Enterprise Battle-Testing
TypePHP was recently tested on Shopware 6 (one of the largest enterprise Symfony-based e-commerce platforms in the world), where it immediately surfaced real-world "DocBlock Lies" in legacy methods—such as database migration queues promising list<string> while SQL timestamps physically produced int (1536232600).
Catching these discrepancies at runtime ensures that API schemas, database writes, and OpenAPI documentation stay 100% honest and bug-free.
Getting Started in 30 Seconds
Install TypePHP as a development dependency for your local testing and CI/CD pipelines:
composer require --dev typephp/typephpGenerate your default configuration:
vendor/bin/typephp config:initNow, run your exiting test suite or application:
php artisan servesymfony serve./vendor/bin/phpunit./vendor/bin/pestEvery function contract, array shape, integer range, and generic container in your application is now actively defended by runtime type safety!