PHP Attributes

Let's get meta

Larry Garfield

@Crell@phpc.social

Cover of Exploring PHP 8.0 Cover of Thinking Functionally in PHP

What is Metadata?

We don't mean Meta the company

Metadata

data that provides information
about other data
Wikipedia

Information about code that is not executable

Historical metadata


          /**
           * @Command(name="product:create")
           */
          class CreateProductCommand implements Command {
              // Externally mutable, which is not good.
              public static string $name = 'product:create';

              // More verbose, but more flexible if some logic is needed.
              public static function name(): string {
                  return "product:create";
              }

              public function run(): void {
                // ...
              }
          }

          // Could be far from the actual code
          // Possibly YAML/XML file that translates to this.
          function register_commands(CommandList $commands) {
              $commands->add('product:create', CreateProductCommand::class);
          }
        

Doctrine Annotations

  • Split off from Doctrine ORM, Jan 2009
  • Split off to its own package Jan 2013
  • Parser for docblocks
  • Maps @-tags to class properties
  • Works on class, property, method, or function

cf: https://www.doctrine-project.org/2022/11/04/annotations-to-attributes.html

Annotations example


          /**
           * @Annotation
           */
          final class Command {
              public $name;
          }

          /**
           * @Command(name="product:create")
           */
          class CreateProductCommand implements Command {
              public function run(): void {
                // ...
              }
          }
        

          // Load through reflection
          $reflectionClass = new ReflectionClass(CreateProductCommand::class);

          $reader = new AnnotationReader();
          $annotation = $reader->getClassAnnotation(
              $reflectionClass,
              Command::class
          );
          print $annotation->name; // "product:create"
        

Downsides

  • Eeew, code in docblocks
  • Requires extra library
  • Very hard to lint
  • Mediocre IDE autocompletion
  • Confusing with docblock tags generally

Enter Attributes

  • 2020: Covid is ravaging the world...
  • Basically port Annotations to core
  • Different name to avoid confusion
  • Coincided with named args and constructor promotion

Attributes example


          #[\Attribute]
          final class Command {
              public function __construct(public string $name) {}
          }

          #[Command(name: 'product:create')]
          class CreateProductCommand implements Command {
              public function run(): void {
                // ...
              }
          }
        

          // Load through reflection
          $rClass = new ReflectionClass(CreateProductCommand::class);

          $attribs = $rClass
              ->getAttributes(Command::class, \ReflectionAttribute::IS_INSTANCEOF);
          $cmd = $attribs[0]->getInstance();

          print $cmd->name; // "product:create"
        

Basic Reflection API


          $rClass = new ReflectionClass(CreateProductCommand::class);

          // Get all attributes as raw data.
          /** @var \ReflectionAttribute[] */
          $attribs = $rClass->getAttributes(Command::class);

          var_dump($attribs[0]->getName());
          var_dump($attribs[0]->getArguments());
          var_dump($attribs[0]->getTarget());
          var_dump($attribs[0]->isRepeated());

          var_dump($attribs[0]->getInstance());
        

          string(7) "Command"

          array(1) {
            ["name"]=>
            string(14) "product:create"
          }

          int(1)

          bool(false)

          object(Command)#3 (1) {
            ["name"]=>
            string(14) "product:create"
          }
        

Declaring attributes


          #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)]
          class OnClass {}

          #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)]
          class OnClassOrMethod {}
        

          #[Foo]
          #[Bar('baz')]
          class Demo {
              #[Beep]
              private Foo $foo;

              public function __construct(
                  #[Load(context: 'foo', bar: true)]
                  private readonly FooService $fooService,

                  #[LoadProxy(context: 'bar')]
                  private readonly BarService $barService,
              ) {}

              /**
               * Sets the foo.
               */
              #[Poink('narf'), Narf('poink')]
              public function setFoo(#[Beep] Foo $new): void
              {
                // ...
              }
          }
        

Even core uses it

  • #[SensitiveParameter] - Hides parameter from backtrace (Eg, passwords.)
  • #[ReturnTypeWillChange] - Ignore return type mismatch. (Mostly for internal.)
  • #[AllowDynamicProperties] - What it says. (8.2+)
  • #[Override] - Error if there is no parent method. (8.3+)

Limitations

  • Reflection API is very limited
  • No access to the structure the attribute was on
  • No inheritance
  • No way to group related attributes
  • No built-in default handling
  • Attributes cannot be contextual
I wanted more power

Story time

TYPO3

Don't solve a problem, build a tool to solve the problem, then use it.

Three libraries

AttributeUtils

  • Driven entirely by interfaces
  • All enhancements are opt-in
  • Only analyze function or class, but class can scan-down
  • Accepts class, anon class, object, function, or closure
  • Cache friendly!

FromReflection*


          #[\Attribute(\Attribute::TARGET_CLASS)]
          class Widget implements FromReflectionClass {
              public readonly string $name;

              public function fromReflection(\ReflectionClass $subject): void {
                  $this->name ??= $subject->getShortName();
              }
          }

          #[Widget]
          class Dohicky { ... }
        

          $analyzer = new \Crell\AttributeUtils\ClassAnalyzer();
          // Analyze Dohicky with respect to Widget
          $def = $analyzer->analyze(Dohicky::class, Widget::class);

          $def->name; // "Dohicky"
        

Also FromReflectionMethod, FromReflectionFunction, FromReflectionProperty, FromReflectionParameter, FromReflectionClassConstant

ParseProperties


          #[\Attribute(\Attribute::TARGET_CLASS)]
          class Data implements ParseProperties {
              public readonly array $props;

              public function propertyAttribute(): string { return MyProperty::class; }
              public function includePropertiesByDefault(): bool { return true; }
              public function setProperties(array $ps): void { $this->props = $ps; }
          }

          #[\Attribute(\Attribute::TARGET_PROPERTY)]
          class MyProperty {
              public function __construct(
                  public readonly string $column = '',
              ) {}
          }
        

          #[Data]
          class Record {
              #[MyProperty(column: 'beep')]
              protected property $foo;

              private property $bar;
          }
          $dataAttrib = $analyzer->analyze(Record::class, Data::class);

          // $dataAttrib instanceof Data
          // $dataAttrib->props is ['foo' => MyProperty('beep'), 'bar' => MyProperty('')]
        

Parse*


          #[\Attribute(\Attribute::TARGET_CLASS)]
          class MyClass implements ParseProperties, ParseStaticProperites,
              ParseMethods, ParseStaticMethods, ParseClassConstants {}

          #[\Attribute(\Attribute::TARGET_CLASS_CONSTANT)]
          class MyConstant {}

          #[\Attribute(\Attribute::TARGET_PROPERTY)]
          class MyProperty {}

          #[\Attribute(\Attribute::TARGET_METHOD)]
          class MyMethod implements ParseParameters {}

          #[\Attribute(\Attribute::TARGET_PARAMETER)]
          class MyParam {}

          #[MyClass]
          class Record {
              #[MyConstant]
              public const AConstant = 'value';
              #[MyProperty]
              protected string $foo;
              #[MyMethod]
              public function run(#[MyParam] string $key): string { ... }
          }
        

ReadsClass

For handling defaults


          #[\Attribute(\Attribute::TARGET_CLASS)]
          class MyClass implements ParseProperties {
              public function __construct(public readonly ?string $color = 'red') {}
              // ...
          }

          #[\Attribute(\Attribute::TARGET_PROPERTY)]
          class MyProperty implements ReadsClass {
              public readonly string $color;

              public function __construct(?string $color = null) {
                if ($color) $this->color = $color;
              }
              public function fromClassAttribute(MyClass $classDef): void {
                  $this->color ??= $classDef->color;
              }
          }

          #[MyClass(color: 'green')]
          class Record {
              #[MyProperty]                // Will be green.
              protected string $first;
              #[MyProperty(color: 'blue')] // Will be blue.
              protected string $first;
          }
        

Inheritance is opt-in


          #[\Attribute(\Attribute::TARGET_CLASS)]
          class Person implements Inheritable {
              public function __construct(public string $name = '') {}
          }

          #[Person(name: 'Jorge')]
          class A {}
          class B extends A {}

          $attrib = $analyzer->analyze(B::class, Person::class);
          print $attrib->name . PHP_EOL; // prints Jorge
        

            #[\Attribute(\Attribute::TARGET_CLASS)]
            class Manager extends Person {
                public function __construct(string $name = '', public string $dept) {
                  parent::_construct($name);
                }
            }

            #[Manager(name: 'Jorge', dept: 'Accounting')]
            class A {}

            $attrib = $analyzer->analyze(A::class, Person::class);
            print $attrib->name . PHP_EOL; // prints Jorge
            print $attrib->dept . PHP_EOL; // prints Accounting
        

Analyzer does instanceof, so child attributes work, too

Sub-attributes


          #[\Attribute(\Attribute::TARGET_CLASS)]
          readonly class Person implements HasSubAttributes {
              public int $age;

              public function __construct(public string name = 'none') {}

              public function subAttributes(): array {
                  return [Age::class => 'fromAge'];
              }

              public function fromAge(?Age $sub): void {
                  // Can also store the sub-attribute if you prefer.
                  $this->age = $sub?->age ?? 0;
              }
          }

          #[\Attribute(\Attribute::TARGET_CLASS)]
          readonly class Age {
              public function __construct(public int $age = 0) {}
          }
        

Sub-attributes


          #[Person(name: 'Larry'), Age(21)]
          class A {}

          $attribA = $analyzer->analyze(A::class, Person::class);
          print "$attribA->name, $attribA->age\n"; // prints "Larry, 21"

          class B {}

          $attribB = $analyzer->analyze(B::class, Person::class);
          print "$attribB->name, $attribB->age\n"; // prints "none, 0"

          #[Person(name: 'Larry')]
          class C {}

          $attribC = $analyzer->analyze(C::class, Person::class);
          print "$attribC->name, $attribC->age\n"; // prints "Larry, 0"
        

Exclusions


          #[\Attribute(\Attribute::TARGET_PROPERTY)]
          class Field implements Excludable {
              public function __construct(
                  public readonly bool $exclude = false,
              ) {}

              public function exclude(): bool {
                  return $this->exclude;
              }
          }

          #[ClassSettings(includeByDefault: true)]
          class User {
              public function __construct(
                  private string $loginName,
                  private string $displayName,
                  #[Field(exclude: true), SensitiveParameter]
                  private string $password,
              ) {}
          }
        

Multi-value sub-attributes


          #[\Attribute(\Attribute::TARGET_CLASS)]
          readonly class Person implements HasSubAttributes {
              public array $knows;

              public function __construct(public string name = 'none') {}

              public function subAttributes(): array {
                  return [Knows::class => 'fromKnows'];
              }

              public function fromKnows(array $knows): void { $this->knows = $knows; }
          }

          #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)]
          readonly class Knows implements Multivalue {
              public function __construct(public string $name) {}
          }

          #[Person(name: 'Larry')]
          #[Knows('Kai')]
          #[Knows('Molly')]
          class A {}

          class B {}
          

Scopes


            #[\Attribute(\Attribute::TARGET_CLASS)]
            readonly class Labeled implements ParseProperties {
                public array $properties;

                public function setProperties(array $ps): void { $this->properties ??= $ps; }
                public function includePropertiesByDefault(): bool { return true; }
                public function propertyAttribute(): string { return Label::class; }
            }

            #[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
            readonly class Label implements SupportsScopes, Excludable {
                public function __construct(
                    public string $name = 'None',
                    public ?string $language = null,
                    public bool $exclude = false,
                ) {}

                public function scopes(): array {
                    return [$this->language];
                }

                public function exclude(): bool { return $this->exclude; }
            }
          

Scopes


            class App {
                #[Label(name: 'Installation')]
                #[Label(name: 'InstalaciĆ³n', language: 'es')]
                public string $install;

                #[Label(name: 'Setup')]
                #[Label(name: 'Configurar', language: 'es')]
                #[Label(name: 'Einrichten', language: 'de')]
                public string $setup;

                #[Label(name: 'Einloggen', language: 'de')]
                #[Label(language: 'fr', exclude: true)]
                public string $login;

                public string $custom;
            }

            $labels = $analyzer->analyze(App::class, Labeled::class);
            // install = "Installation", setup = "Setup", login = "None", custom = "None"

            $labels = $analyzer->analyze(App::class, Labeled::class, scopes: ['es']);
            // install = "InstalaciĆ³n", setup = "Configurar", login = "None", custom = "None"

            $labels = $analyzer->analyze(App::class, Labeled::class, scopes: ['fr']);
            // install = "Installation", setup = "Setup", custom = "None"
          

Transitivity


            #[\Attribute(\Attribute::TARGET_CLASS)]
            readonly class Names implements ParseProperties {
                // ...
                public function propertyAttribute(): string { return FancyName::class; }
            }
            #[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_CLASS)]
            readonly class FancyName implements TransitiveProperty {
                public function __construct(public string $name = '') {}
            }

            class Stuff {
                protected string $bare;
                #[FancyName('A happy little integer')]
                protected int $namedNumber;
                #[FancyName('Her Majesty Queen Elizabeth II')]
                protected Person $namedPerson;
                protected Person $anonymousPerson;
            }

            #[FancyName('I am not an object, I am a free man!')]
            class Person {}

          

            $p = $analyzer->analyze(Stuff::class, Names::class)->properties;

            print $p['bare']->name; // ""
            print $p['namedNumber']->name; // "A happy little integer"
            print $p['namedPerson']->name; // "Her Majesty Queen Elizabeth II"
            print $p['anonymousPerson']->name; // "I am not an object, I am a free man!"
            

Finalizable


          class Info implements Finalizable, FromReflectionClass {
              public readonly string $name;

              public function __construct(?string $name = null) {
                  if ($name) $this->name = $name;
              }

              public function fromReflection(\ReflectionClass $r) {
                  if ($r->getNamespace() == '\\My\\Space') $this->name ??= $r->getShortName();
              }

              public function finalize() {
                  $this->name ??= 'Untitled';
              }
          }
        

Functions


          use Crell\AttributeUtils\FuncAnalyzer;

          #[MyFunc]
          function beep(int $a) {}

          $closure = #[MyClosure] fn(int $a) => $a + 1;

          // For functions...
          $analyzer = new FuncAnalyzer();
          $funcDef = $analyzer->analyze('beep', MyFunc::class);

          // For closures
          $analyzer = new FuncAnalyzer();
          $funcDef = $analyzer->analyze($closure, MyFunc::class);
        
  • ParseParameters, Finalizable, FromReflectionFunction

ClassAnalyzer overview

            sequenceDiagram
                actor user as User code
                participant Analyzer
                participant RDB as ReflectionDefinitionBuilder
                participant AttributeParser
                user->>Analyzer: analyze()
                activate Analyzer
                Analyzer->>AttributeParser: construct(scopes)
                activate AttributeParser
                Analyzer->>RDB: construct(parser, self)
                activate RDB
                Analyzer->>AttributeParser: Get attribute
                Analyzer->>RDB: Get Properties
                Analyzer->>RDB: Get Static Properties
                Analyzer->>RDB: Get Methods
                Analyzer->>RDB: Get Methods
                Analyzer->>RDB: Get Static Methods
                Analyzer->>RDB: Get Enum cases
                Analyzer->>RDB: Get Constants
                deactivate AttributeParser
                deactivate RDB
                Analyzer-->>user: return
                deactivate Analyzer

          

Cool PHP trick

Internal execution objects


          readonly class PublicFacing {
              public function __construct(private DepA $depA, private DepB $debB) {}

              public function run(A $paramA, B $paramB) {
                  $runner = new Executor($this->depA, $this->depB, $paramA, $paramB);
                  return $runner->run();
              }
          }

          readonly class Executor {
              public function __construct(
                public DepA $depA, public DepB $debB, private,
                public A $a, public B $b) {}

            public function run(): Result {
              $this->foo(); // Can use all the passed dependencies
              $this->a->bar($this); // Pass all the deps to A, so it can use them.
            }
          }
        
  • Avoid passing values in each method every time
  • Fully immutable, so public properties OK!
  • Safe to pass $this around dependencies
  • No need to DI every separate piece separately!

AttributeUtils in action

Example: Serde


          #[ClassSettings(renameWith: new Prefix('mail_')]
          class MailConfig {
              protected string $host = 'smtp.example.com';

              protected int $port = 25;

              protected string $user = 'me';

              #[Field(renameWith: Case::CAPITALIZE)]
              protected string $password = 'sssh';

              #[PostLoad]
              private function validate(): void {
                  if ($port > 1024) throw new \InvalidArgumentException();
              }
          }
        

          #[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
          class Field implements
              FromReflectionProperty,
              HasSubAttributes,
              Excludable,
              SupportsScopes,
              ReadsClass,
              Finalizable
        

Example: Serde


          use Crell\Serde\Attributes\StaticTypeMap;

          #[StaticTypeMap(key: 'type', map: [
              'paper' => PaperBook::class,
              'ebook' => DigitalBook::class,
          ])]
          interface Book {}

          class PaperBook implements Book {}
          class DigitalBook implements Book {}

          class Sale {
              protected Book $book;
              protected float $discountRate;
          }
        

Example: Tukio


          class ListenerOne {
              public function __construct(
                  private readonly DependencyA $depA,
              ) {}

              public function __invoke(MyEvent $event): void { ... }
          }

          #[ListenerBefore(ListenerOne::class)]
          class ListenerTwo {
              public function __invoke(MyEvent $event): void { ... }
          }

          $provider->listenerService(ListenerOne::class);
          $provider->listenerService(ListenerTwo::class);
        

Example: Tukio


          class MyListeners {
              #[Listener(id: 'a')]
              public function onA(CollectingEvent $event): void {
                  $event->add('A');
              }

              #[ListenerPriority(priority: 5)]
              public function onB(CollectingEvent $event): void {
                  $event->add('B');
              }

              #[ListenerAfter(after: 'a')]
              public function onD(CollectingEvent $event): void {
                  $event->add('D');
              }

              #[ListenerPriority(priority: -5)]
              public function notNormalName(CollectingEvent $event): void {
                  $event->add('F');
              }

              public function ignoredMethodThatDoesNothing(): void {
                  throw new \Exception('What are you doing here?');
              }
          }
        

Example: Routing (fictional)


          #[Routes(path: '/app/v1', perm: 'auth user')]
          class ProductController {
              #[Route]
              public function index() { ... }

              #[Route(path: '/{$id}')]
              public function get(string $id) { ... }

              #[Route(method: Http::Post, perm: 'create post')]
              public function post(Product $p) { ... }

              #[Route(path: '/{$id}', method: Http::Delete, perm: 'delete')]
              public function delete(string $id) { ... }
          }
        

Example: TYPO3 DI


          class SomeUserService {
              public function __construct(
                  private readonly ADependency $dep,
                  #[Channel('user')]
                  private readonly LoggerInterface $logger,
              ) {}
          }
        

In summation

  • #[Attributes] are cool
  • #[AttributeUtils] is cooler
  • Declarative logic is powerful... with the right support
  • AttributeUtils is that support

Larry Garfield

@Crell@phpc.social

All about PHP 8!

https://bit.ly/php80

Cover of Exploring PHP 8.0

Buy my book!

https://bit.ly/fn-php

Cover of Thinking Functionally in PHP

https://www.garfieldtech.com/