PHP Attributes and Metaprogramming

Muhamad HassanMuhamad Hassan
8 min read

Introduction

Attributes were introduced in PHP 8 around four years ago. As outlined in the RFC, attributes provide a structured and syntactic way to add metadata to declarations such as classes, properties, functions, methods, parameters, and constants. Previously, developers relied on DocBlocks' annotations for this purpose. The syntax of Attributes is as follows:

 <?php

#[Attribute]
class ExampleAttribute
{
    public function __construct(public string $message) {}
}

#[ExampleAttribute('This is a custom attribute for a class')]
class MyClass
{
    #[ExampleAttribute('This is a custom attribute for a method')]
    public function myMethod() { echo "Hello from myMethod!"; }
}

But first, how are PHP attributes different from decorators in Python? Or, Java’s Aspect-oriented Programming (AOP) annotations?

Metaprogramming

The concept that is fundamentally common across Aspect-Oriented Programming (AOP), attributes, and decorators is metaprogramming. Metaprogramming is the practice of writing code that generates, manipulates, or modifies other code. Essentially, it's code that writes code. This can be done at compile-time or runtime, depending on the language and use case.

Decorator

Decorator is a behavioral design pattern where a function or class is wrapped with additional behavior without modifying its structure. Achieved using higher-order functions (in Python, JavaScript) or annotations (in TypeScript).

💡
This is different from the stackable decorator classes implementation of the decorator pattern.

The following is a simple example of a logging decorator in Python.

def log_function_call(func):
    def wrapper(*args, **kwargs):
        print(f"Calling function: {func.__name__}")
        result = func(*args, **kwargs)
        print(f"Function {func.__name__} returned {result}")
        return result
    return wrapper

@log_function_call
def add(a, b):
    return a + b

# Usage
add(3, 4)
  • log_function_call is a decorator function.

  • @log_function_call applies that decorator to the add function.

  • wrapper is a nested function that wraps the original function, adding extra behavior before and after it runs.

The output of this code will be:

Calling function: add
Function add returned 7

Aspect-Oriented Programming

Aspect-Oriented Programming (AOP) is a paradigm that allows you to modularize concerns (like logging, security, or transaction management) that cut across the typical divisions of responsibility (such as methods or classes) by encapsulating them into reusable modules called aspects, improving separation of concerns and making the codebase easier to maintain and extend.

Let’s take another logging example but this time implemented in AOP to explain its core concept:

package com.example.demo.aspect;

import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class LoggingAspect {

    @Before("execution(* com.example.demo.service.*.*(..))")
    public void logBefore(JoinPoint joinPoint) {
        System.out.println("[LOG] Method called: " + joinPoint.getSignature().toShortString());
    }
}

The code defines a cross-cutting concern — specifically, logging — and separates it from the main business logic of the application (like the service classes).
Instead of adding System.out.println statements inside every service method, you modularized this behavior into a separate aspect: LoggingAspect.

ElementExplanation
@AspectDeclares this class as an aspect (a modular unit of a cross-cutting concern).
@ComponentTells Spring to manage this class as a bean.
@Before(...)A pointcut and advice: it says "Before any method in com.example.demo.service.* package is called, run logBefore()."
JoinPointGives details about the method being intercepted (like its name).
logBefore()This is the advice method where the logging action happens.

In essence, metaprogramming is the conceptual thread that ties AOP, attributes, and decorators together—they’re all ways to step outside the normal flow of code execution and define how code should behave beyond its immediate implementation.

Reflection API

Both the new first-class citizen attributes and the dockblocks implementation depend on the reflection API for inspecting the attributes at runtime. Reflection AP is another, much older, example of metaprogramming in PHP. It introduced the ability to introspect classes, interfaces, functions, methods and extensions. It can also retrieve the doc comments for functions, classes and methods.

If you are not familiar with it, here is a simple example where the properties of the User class can be accessed programmatically regardless of their access modifier:

<?php

class User {
    public function __construct(
        public string $name,
        private int $age,
        protected string $email
    ) {}
}

// Use ReflectionClass to inspect the User class
$reflection = new ReflectionClass('User');

// Get the public, protected, and private properties 
$properties = $reflection->getProperties();

Attributes

Syntax

Attributes are classes annotated with the base #[Attribute]:

#[Attribute(Attribute::TARGET_METHOD)]
class Log
{
    public function __construct(public string $message = 'Method called') {}
}

Attributes are configured by passing a bitmask as its first argument, It can be used to restrict the types that an attribute can be applied to. In the above example, the Log attribute can only be applied to methods. The available types are:

  • TARGET_CLASS

  • TARGET_FUNCTION

  • TARGET_METHOD

  • TARGET_PROPERTY

  • TARGET_CLASS_CONSTANT

  • TARGET_PARAMETER

  • TARGET_ALL

By default, an attribute can be used once per declaration. to change this behavior, specify it in the bitmask of the #[Attribute] declaration using the Attribute::IS_REPEATABLE flag:

#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class Log
{
    public function __construct(public string $message = 'Method called') {}
}

Usage

  1. Create a class that uses the Log attribute:

     class AccountService
     {
         #[Log("Creating a new account")]
         public function createAccount(string $user)
         {
             echo "Account created for $user" . PHP_EOL;
         }
    
         #[Log("Deleting an account")]
         public function deleteAccount(string $user)
         {
             echo "Account deleted for $user" . PHP_EOL;
         }
    
         public function helperFunction()
         {
             echo "This won't be logged." . PHP_EOL;
         }
     }
    
  2. Create a logger proxy function to call the methods with logging:

     function callWithLogging(object $object, string $method, array $args = [])
     {
         $refMethod = new ReflectionMethod($object, $method);
         $attributes = $refMethod->getAttributes(Log::class);
    
         if (!empty($attributes)) {
             /** @var Log $log */
             $log = $attributes[0]->newInstance();
             echo "[LOG] {$log->message}" . PHP_EOL;
         }
    
         return $refMethod->invokeArgs($object, $args);
     }
    

Client Code

$service = new AccountService();

callWithLogging($service, 'createAccount', ['alice']);
callWithLogging($service, 'deleteAccount', ['bob']);
callWithLogging($service, 'helperFunction'); // No log, no attribute

The output of this code will be:

[LOG] Creating a new account
Account created for alice
[LOG] Deleting an account
Account deleted for bob
This won't be logged.

Real-Life Example

Let’s take a real-life example of attributes. Laravel 11 introduced contextual attributes and for our example we will be using the Config attribute. The #[Config(...)] attribute allows you to annotate a constructor or method parameter, so that Laravel’s service container can resolve and inject the corresponding config value at runtime:

<?php

namespace App\Http\Controllers;

use Illuminate\Container\Attributes\Config;

class PhotoController extends Controller
{
    public function __construct(#[Config('app.timezone')] protected string $timezone)
    {
        // ...
    }
}

Config Attribute Definition

The Config attribute definition goes as follows:

<?php

namespace Illuminate\Container\Attributes;

use Attribute;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Container\ContextualAttribute;

#[Attribute(Attribute::TARGET_PARAMETER)]
class Config implements ContextualAttribute
{
    /**
     * Create a new class instance.
     */
    public function __construct(public string $key, public mixed $default = null) {}

    /**
     * Resolve the configuration value.
     *
     * @param  self  $attribute
     * @param  \Illuminate\Contracts\Container\Container  $container
     * @return mixed
     */
    public static function resolve(self $attribute, Container $container)
    {
        return $container->make('config')->get($attribute->key, $attribute->default);
    }
}
The ContextualAttribute is an empty interface in Laravel 12.x.

Resolving Config Attribute

Contextual attributes resolving start in Illuminate\Container\Container::build($concrete).

Some of the code in Illuminate\Container\Container will be omitted if it’s not related to the flow of binding a constructor’s config parameter. The full class can be found here

First, we create a ReflectionClass to inspect the $concrete class:

try {
    $reflector = new ReflectionClass($concrete);
} catch (ReflectionException $e) {
    throw new BindingResolutionException("Target class [$concrete] does not exist.", 0, $e);
}

The next step is to get the $concrete constructor method:

// returns a ReflectionMethod instance
$constructor = $reflector->getConstructor();

Then, if the class does not have a constructor, the service container returns a new instance of $concrete. But, that is not the case in our PhotoController, so that, it gets a list of the $constructor method parameters:

// returns an array of ReflectionParameter[]
$dependencies = $constructor->getParameters();

try {
    $instances = $this->resolveDependencies($dependencies);
} catch (BindingResolutionException $e) {
    array_pop($this->buildStack);

    throw $e;
}

The method Container::resolveDependencies($dependencies) loops over the $dependencies array, and returns an array of the constructor parameters. One execution flow is when the $dependency has a contextual parameter:

foreach ($dependencies as $dependency) {
    $result = null;

    if (! is_null($attribute = Util::getContextualAttributeFromDependency($dependency))) {
        $result = $this->resolveFromAttribute($attribute);
    }
}

Finally, in Container::resolveFromAttribute(ReflectionAttribute $attribute), There are two scenarios for resolving the value of the attribute:

public function resolveFromAttribute(ReflectionAttribute $attribute)
{
    $handler = $this->contextualAttributes[$attribute->getName()] ?? null;

    $instance = $attribute->newInstance();

    if (is_null($handler) && method_exists($instance, 'resolve')) {
        $handler = $instance->resolve(...);
    }

    if (is_null($handler)) {
        throw new BindingResolutionException("Contextual binding attribute [{$attribute->getName()}] has no registered handler.");
    }

    return $handler($instance, $this);
}
  1. If there is a custom handler function (or callable) for the $attribute name defined in the App\Providers\AppServiceProvider::boot():

     use Illuminate\Container\Attributes\Config;
    
     /**
      * Bootstrap any application services.
      */
     public function boot(): void
     {
         $this->app->contextualAttributes[Config::class] = fn() => 'Africa/Cairo';
     }
    

    The callable fn() => 'Africa/Cairo' is invoked.

  2. If the $handler is null, and the Illuminate\Container\Attributes\Config::resolve() method exists in the attribute definition, it will be invoked.

Attributes Misuse

Metaprogramming allows code to generate or modify other code dynamically, offering flexibility and reduced boilerplate, but it comes with significant downsides. It can reduce readability and maintainability, make debugging harder, and introduce performance overhead.

To minimize the negative side effects of attributes, they should not be used beyond their described purpose as metadata providers. Misuse examples includes:

  • Embedding logic or side effects within attributes

    • For example, having an attribute that modifies global state or performs operations at runtime just by being present.
  • Replacing business logic with attribute logic

    • E.g., putting application logic or decisions into attribute classes, rather than keeping that logic in controllers or services.
  • Overloading attribute meaning

    • When attributes are overloaded with too many responsibilities or become a place to "hide" logic, it becomes harder to understand or maintain code.
  • Using attributes for code that should remain in configuration files

    • E.g., trying to implement environment configuration through attributes.

In conclusion, PHP attributes provide a powerful and expressive way to add metadata to your code, making it easier to build more declarative and maintainable applications. From core language features like #[Deprecated], #[Override], and #[Attribute], to PHPUnit-specific attributes such as #[Test], #[Before], #[After], and #[Depends], attributes help streamline testing and application behavior without relying on verbose annotations or docblocks. As attributes continue to evolve in PHP, they open up cleaner integration points for frameworks and tooling. Whether you're writing application logic or unit tests, leveraging built-in and custom attributes can greatly improve the readability and structure of your codebase.

0
Subscribe to my newsletter

Read articles from Muhamad Hassan directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Muhamad Hassan
Muhamad Hassan