For my Apie project I use a lot of the Reflection API in PHP. And I found many, many quirks in how it was implemented.
Why use reflection in the first place?
Imagine we have some simple PHP object:
class MyData {
public function __construct(
private ?int $x = null,
private ?int $y = null,
private ?string $name = null
) {
}
}
And you want to convert an array into your object, you would come up with code like this:
public static function fromArray(array $input): MyData
{
return new MyData(
$input['x'] ?? null,
$input['y'] ?? null,
$input['name'] ?? null
);
}
What if we add an other property? These fromArray() methods are notorious for easy to break when being updated. Working with large arrays also lead to terrible code. With Reflection, we can ask PHP all the properties and make the code only once (or even work with different objects).
public static function fromArray(array $input): MyData
{
$refl = new ReflectionClass(MyData::class);
$result = $refl->newInstanceWithoutConstructor();
foreach ($input as $key => $value) {
$property = $refl->getProperty($key);
$property->setValue($result, $value);
}
return $result;
}
As a bonus you even generalize the serialization that array keys should map the property names exactly! In case you do want to add additional information for serialization, you can use PHP8 attributes and read them with Reflection as well. I have made an object mapper package for this use case and it seems Symfony also decided to come up with an object mapper package.
Inconsistent naming and inheritance
PHP has quite a lot of Reflection classes, but most of them seems to be variations of what you want to reflect on. For objects you have:
- ReflectionClass
- ReflectionObject
- ReflectionInterface
- ReflectionTrait
- ReflectionEnum
- ReflectionType
- ReflectionNamedType
First of all the naming is inconsistent. So ReflectionClass represents everything, while you would expect it reflects classes only. You can get abstract classes, interfaces and anonymous classes with ease. ReflectionObject only accept classes. ReflectionEnum only accept enums and ReflectionTrait will only accept traits. Still all the classes have no code reuse as all of them are standalone. The worst are ReflectionNamedType, ReflectionUnionType, ReflectionIntersectionType and ReflectionType, because none of the specific classes extend ReflectionType meaning you will have to do lots of ducktyping or class instance checking.
For example you would expect ReflectionObject to extend ReflectionClass, but you need to convert it. And you have to be careful not do this wrong:
function (ReflectionObject $reflClass): ReflectionClass {
return new ReflectionClass($reflClass);
}
This function will return a ReflectionClass instance with information about the class ReflectionObject. The real one should be this:
function (ReflectionObject $reflClass): ReflectionClass {
return new ReflectionClass($reflClass->name);
}
this gets even more annoying with ReflectionNamedType, ReflectionType, ReflectionUnionType and ReflectionIntersectionType as __toString() is deprecated so you have to do manual type juggling a lot.
Promoted properties
The first time I saw promoted properties was in Typescript and I loved it there, so I was happy to see them in PHP. Before promoted properties this was very common in PHP especially if you used dependency injection:
class AncientPhpClass
{
private string $argument;
private string $secondArgument;
public function __construct(
string $argument,
string $secondArgument
) {
$this->argument = $argument;
$this->secondArgument = $secondArgument;
}
}
When promoted properties were added the code became a lot smaller and easier to maintain:
class AncientPhpClass
{
public function __construct(
private string $argument,
private string $secondArgument
) {
}
}
But there are differences that most people do not know of!
First of all there is with PHP8 attributes. If you make a custom attribute in PHP8 you need to specify the target of the Attribute. It's possible to restrict to put an attribute only on a property or a function argument. And you might guess it: if a property is a promoted property the attribute require both targets. Adding a promoted property who is not both will throw an exception. It gets even crazier if you evaluate the attribute name first and then define the class in realtime without target, but autoloading quirks will be skipped for now as most people just use Composer. Still the error is being thrown only when you call getAttributes.
Default values
But the biggest "I did not realise this" was when you ask if the promoted property has a default value. They do not! Basically this code below:
class ExampleClass
{
public function __construct(
private string $example = 'example'
) {
}
}
If we would write it to older PHP code it would be this instead:
class ExampleClass
{
private string $example;
public function __construct(
string $example = 'example'
) {
$this->example = $example;
}
}
The reason this is done like this, because you could extend the class and override the constructor and never call parent::__construct() in which case you can skip setting the default value.
The last example if the property with no typehint. There is one difference between having a typehint and no typehint: if you never set the value you will get an error if you try to read the property, but you will get null on any property without a typehint. In reality typeless properties have a default value of null, when typehints were added (before that ReflectionProperty::hasDefaultValue() would return false if there was no default value.) So the only reason not setting a typeless property is not only Backwards Compatibility, but also as a workaround to not throw an error on typeless properties by defining a default value. In the example code below for default values both properties are the same:
class ExampleClass
{
public mixed $withTypehint = null;
public $withoutTypehint;
}
Union types
Union types can be created with ReflectionType and ReflectionUnionType.
ReflectionUnionType is also the type you get back for all methods related to types:
- ReflectionProperty::getType(),
- ReflectionMethod::getReturnType()
- ReflectionParameter::getType()
- ReflectionUnionType::getTypes()
- ReflectionIntersectionType::getTypes()
Now the biggest issue is the order of declaring types. For example the output of this PHP program will always be 'string', 'int'.
class Test {
public int|string $property1;
public string|int $property2;
}
function dump(string $property) {
$refl = new ReflectionProperty(Test::class, $property);
var_dump(array_map(
function ($type) {
return $type->getName();
},
$refl->getType()->getTypes()
));
var_dump((string) $refl->getType());
}
dump('property1');
dump('property2');
Open sandbox
The only way to get the correct order is by reading the source code and parse the string yourself. Even __toString() will always return 'string|int'. When you add typehints for classes or interfaces it will define the classes in order of declaration, but any primitive like string or int will come after that (see sandbox).
This was a big issue when writing the Apie serializer as I wanted to automatically convert "12" to 12 if the typehint is integer and convert 12 to "12" if the typehint is string. So for union types I need to see first if the type already matches and then do it again to see if I can cast it to the type or the typehint int|string will always cast it to string.
Inheritance
Inheritance is annoying. You have to get through the entire inheritance tree of interfaces and classes to get all attributes and the only function you can use ReflectionClass::getParentClass().
$class = new ReflectionClass('classname');
$parents = [];
while ($parent = $class->getParentClass()) {
$parents[] = $parent->getName();
$class = $parent;
}
echo "Parents: " . implode(", ", $parents);
Also if a base class has an attribute you still need to check the attribute if the parent class as it will not be returned in the base class at all!
If you want to go over all properties you also need to look at the parent class to get private properties. Public and protected properties will always be returned, but it could give some issues if you redefine it with PHP8 attributes.
PHPStan generics
I've written an article about making your own PHPStan rules in the past. While PHPStan is a nice tool for being stricter than PHP is on your code, PHPStan itself can be quite confusing. It's getting better, but sometimes the errors are confusing.
PHPStan also has definitions for pre-built PHP classes, including the Reflection API. The creators of PHPStan decided it would be wise to make ReflectionClass use a generic, so that if I use newInstance() or newInstanceWithoutConstructor() I know what type of object is being created. The downside is that everywhere I use a return value or argument for ReflectionClass I would need to specify the generic for PHPStan. In some cases you also have to use template generics to indicate argument and return type are of the same generic:
/**
* @template T of EntityInterface
* @param ReflectionClass<T>> $class
* @return T
*/
public function create(ReflectionClass $class): EntityInterface
{
// do something
return $class->newInstanceWithoutConstructor();
}
Now you could disable that you have to specify the generics, but if you use lots of Reflection, these docblocks are a en eyesore to have in your codebase and you are forced to use them all the time. Also it could result in errors from PHPStan not being able to understand the generic. Weirdly enough, the generics are not defined on ReflectionMethod, ReflectionProperty, etc. so you have to write an other docblock to specify the generic of the class:
/**
* @return ReflectionClass<EntityInterface>
*/
public function create(ReflectionMethod $method): ReflectionClass
{
/** @var ReflectionClass<EntityInterface> $class **/
$class = $method->getDeclaringClass();
return $class;
}
Conclusion
The Reflection API is an API that you should learn, especially if you need to learn to program 'generic'. The only thing is that there are quite a lot of quirks and gotcha's you have to be careful. Of course if you keep your classes simple without complicated inheritance trees, large collections of traits etc, you will be fine.
Comments
Post a Comment