Kodokon kodokon.com

Advanced PHP 8: attributes, readonly, callables

Explore the inner workings of attributes, readonly, first-class callables and PHP 8's strict typing.

10 min · 3 questions

Open this lesson in Kodokon

Attributes replace docblock annotations with real classes, validated by the engine. An attribute is nothing but dormant metadata: writing #[Route('/users')] creates no object. It only comes to life when some code queries it through reflection and calls newInstance() - which is exactly what frameworks do at startup to build their routing tables. The allowed target (TARGET_METHOD, TARGET_CLASS...) is itself declared by an attribute on the attribute class.

PHP
<?php
declare(strict_types=1);

#[Attribute(Attribute::TARGET_METHOD)]
final class Route
{
    public function __construct(
        public readonly string $path,
        public readonly string $method = 'GET'
    ) {}
}

final class UserController
{
    #[Route('/users', method: 'GET')]
    public function index(): array
    {
        return ['users' => []];
    }
}

$ref = new ReflectionMethod(
    UserController::class,
    'index'
);

foreach ($ref->getAttributes(Route::class) as $attr) {
    $route = $attr->newInstance();
    echo $route->method, ' ', $route->path, PHP_EOL;
}
The attribute is only instantiated at the moment of newInstance().

readonly - properties in 8.1, whole classes in 8.2 - guarantees that a property is written only once, from within the class's scope. It is the tool for value objects: any "modification" becomes the construction of a new object, which eliminates an entire family of shared-state bugs. A readonly class requires all of its properties to be typed and non-static.

PHP
<?php
declare(strict_types=1);

final readonly class Money
{
    public function __construct(
        public int $amount,
        public string $currency
    ) {}

    public function add(Money $other): self
    {
        if ($other->currency !== $this->currency) {
            throw new InvalidArgumentException(
                'Currency mismatch'
            );
        }
        return new self(
            $this->amount + $other->amount,
            $this->currency
        );
    }
}

$total = new Money(500, 'EUR');
$total = $total->add(new Money(250, 'EUR'));
echo $total->amount;
A value object: adding creates a new instance.

The first-class callable syntax strtoupper(...) (PHP 8.1) creates a Closure from any function or method. Unlike strings such as 'strtoupper' or arrays like [$obj, 'method'], the symbol is verified at creation time and static analysis tools follow the types end to end. For an instance method, the closure captures the object; for a static method, only the class.

PHP
<?php
declare(strict_types=1);

$words = ['php', 'rust', 'go'];

$upper = strtoupper(...);
var_dump($upper('php'));

print_r(array_map(strtoupper(...), $words));

final class Mailer
{
    public function send(string $to): bool
    {
        return $to !== '';
    }

    public static function ping(): string
    {
        return 'pong';
    }
}

$mailer = new Mailer();
$send = $mailer->send(...);
$ping = Mailer::ping(...);

var_dump($send('dev@example.org'), $ping());
Each f(...) expression produces a Closure verified at creation time.

Knowledge check

Make sure you remember the key points of this lesson.

  1. When is the class of a #[Route(...)] attribute instantiated?
    • When the file carrying it is compiled
    • As soon as the decorated method is called
    • Only when some code queries it through reflection and calls newInstance()
  2. What exactly does a readonly property guarantee?
    • The object it references becomes entirely immutable
    • It is written only once, from within the class's scope; the referenced object itself remains mutable
    • It can only be read from the class that declares it
  3. What is the scope of declare(strict_types=1)?
    • The whole project as soon as a single file declares it
    • The calls written in the file that contains the declaration
    • Only the functions defined in that file, wherever they are called