PHP Custom Attributes in Practice — Beyond Laravel's Built-ins

Learn to design PHP custom attributes for DTO validation, role-based access control, and auto-registration with ReflectionAttribute. Production patterns inside.

Steven Richardson
Steven Richardson
· 10 min read

I started writing custom PHP attributes the day Laravel 13 dropped, because the new framework attributes finally pushed the question to the front of my brain: if I can replace a protected $table = 'invoices'; with #[Table('invoices')], what stops me from doing the same for my own validation rules, role checks, or event wiring? Nothing, it turns out. The PHP manual covers reading attributes well enough, but designing them — picking targets, structuring constructors, deciding when reflection is too expensive — is the part nobody writes about.

This article is the playbook I wish I'd had. We'll build a custom attribute end to end, then walk through three patterns I use in production: validation rules on DTOs, role-based access control on controller methods, and auto-registration of event listeners. If you've already read Laravel 13's first-party attribute syntax, this is the next step — building your own once the framework's set isn't enough.

How PHP Attributes Work#

Attributes are compile-time metadata. The PHP parser sees #[Cache(ttl: 60)] above a method, stores the attribute name and arguments in the engine's metadata table, and that's it. The Cache class is never instantiated, never validated, never even autoloaded until somebody calls reflection and asks for it.

That lazy evaluation is the most important fact about attributes. It explains why misspelling an attribute name doesn't throw at compile time, why constructor type errors only surface when you call newInstance(), and why attribute-heavy code adds zero startup cost — until you start reading them.

Three pieces make the system work:

  • The #[Attribute] declaration on the class, which marks something as a valid attribute.
  • The ReflectionAttribute object you get back from getAttributes(), which holds the name and raw arguments.
  • The newInstance() call, which actually constructs the attribute and runs your code.

If you've used PHP backed enums in Laravel models, the mental model is similar: cheap declarative metadata, with the heavy work deferred until you ask for it.

Your First Custom Attribute#

Here's the smallest useful attribute — a #[Cache] marker for service methods:

<?php

namespace App\Attributes;

use Attribute;

#[Attribute(Attribute::TARGET_METHOD)]
final class Cache
{
    public function __construct(
        public readonly int $ttl,
        public readonly ?string $tag = null,
    ) {}
}

Three things to notice:

The Attribute::TARGET_METHOD flag restricts where this attribute can appear. PHP enforces it inside newInstance() — try to use #[Cache] on a class and reflection throws Error: Attribute "Cache" cannot target class. Available targets are TARGET_CLASS, TARGET_FUNCTION, TARGET_METHOD, TARGET_PROPERTY, TARGET_CLASS_CONSTANT, TARGET_PARAMETER, and TARGET_ALL. Combine with the bitwise OR operator: Attribute::TARGET_CLASS | Attribute::TARGET_METHOD.

Constructor property promotion (PHP 8) plus readonly (PHP 8.1) gives you immutable attribute objects in three lines. This is the same pattern I use for immutable value objects with PHP readonly classes — attributes are basically value objects with metadata superpowers.

The constructor signature is your contract. Named arguments make declarations readable: #[Cache(ttl: 300, tag: 'reports')] is far better than positional #[Cache(300, 'reports')].

Apply it to a method:

<?php

namespace App\Services;

use App\Attributes\Cache;

final class ReportService
{
    #[Cache(ttl: 300, tag: 'reports')]
    public function quarterlyRevenue(int $year, int $quarter): array
    {
        // expensive query
    }
}

At this point nothing happens. The attribute is metadata only. To make it do something, we need reflection.

Reading Attributes with Reflection#

The reading API is simple but easy to mis-use. Here's a focused reader that finds methods decorated with a given attribute:

<?php

namespace App\Reflection;

use ReflectionAttribute;
use ReflectionClass;
use ReflectionMethod;

final class AttributeReader
{
    /**
     * @template T of object
     * @param class-string<T> $attributeClass
     * @return array<int, array{method: ReflectionMethod, attribute: T}>
     */
    public function methodsWithAttribute(string $class, string $attributeClass): array
    {
        $reflection = new ReflectionClass($class);
        $found = [];

        foreach ($reflection->getMethods() as $method) {
            // IS_INSTANCEOF lets you match subclasses too — useful for attribute hierarchies
            $attributes = $method->getAttributes($attributeClass, ReflectionAttribute::IS_INSTANCEOF);

            foreach ($attributes as $attribute) {
                $found[] = [
                    'method' => $method,
                    'attribute' => $attribute->newInstance(),
                ];
            }
        }

        return $found;
    }
}

Two details that bit me on my first pass:

getAttributes() accepts an optional class filter as the first argument. Pass it. Without the filter you get every attribute on the method, which means looping and string-comparing names yourself.

ReflectionAttribute::IS_INSTANCEOF is the only filter flag PHP currently exposes, and it changes the semantics: with it, the lookup matches the attribute class or any subclass or interface implementer. Without it, it's an exact match. For pattern-based attributes (a base Rule class with concrete Required, Email, Min subclasses) you almost always want IS_INSTANCEOF.

Now let's put this to work.

Pattern: Validation Attributes on DTOs#

Laravel's form requests are great for HTTP validation, but I increasingly use DTOs as the boundary type between controllers and services — and Form Request rules don't follow the data through that boundary. Attribute-driven validation does.

Define a base rule attribute and a couple of concrete ones:

<?php

namespace App\Validation\Rules;

use Attribute;

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
abstract class Rule
{
    abstract public function passes(mixed $value): bool;
    abstract public function message(string $property): string;
}

#[Attribute(Attribute::TARGET_PROPERTY)]
final class Required extends Rule
{
    public function passes(mixed $value): bool
    {
        return $value !== null && $value !== '';
    }

    public function message(string $property): string
    {
        return "The {$property} field is required.";
    }
}

#[Attribute(Attribute::TARGET_PROPERTY)]
final class Min extends Rule
{
    public function __construct(public readonly int $length) {}

    public function passes(mixed $value): bool
    {
        return is_string($value) && mb_strlen($value) >= $this->length;
    }

    public function message(string $property): string
    {
        return "The {$property} must be at least {$this->length} characters.";
    }
}

Attribute::IS_REPEATABLE on the base class allows multiple rules on one property — without it, PHP raises an error when reflection meets the second attribute of the same type.

The DTO becomes pure data:

<?php

namespace App\DTOs;

use App\Validation\Rules\Min;
use App\Validation\Rules\Required;

final readonly class CreateInvoiceData
{
    public function __construct(
        #[Required]
        #[Min(3)]
        public string $customerName,

        #[Required]
        public int $amountInCents,
    ) {}
}

And the validator reads attributes once and runs them:

<?php

namespace App\Validation;

use App\Validation\Rules\Rule;
use ReflectionAttribute;
use ReflectionObject;

final class AttributeValidator
{
    /**
     * @return array<string, list<string>>  Errors keyed by property name
     */
    public function validate(object $dto): array
    {
        $errors = [];
        $reflection = new ReflectionObject($dto);

        foreach ($reflection->getProperties() as $property) {
            $value = $property->getValue($dto);

            $rules = $property->getAttributes(Rule::class, ReflectionAttribute::IS_INSTANCEOF);

            foreach ($rules as $ruleAttribute) {
                $rule = $ruleAttribute->newInstance();

                if (! $rule->passes($value)) {
                    $errors[$property->getName()][] = $rule->message($property->getName());
                }
            }
        }

        return $errors;
    }
}

Trade-off: this is reflection at the DTO boundary, not on every property access. Fast enough for request-scoped use; cache the rule list per class if you call it inside a loop.

Pattern: Access Control Attributes#

Method-level attributes are perfect for declarative authorisation. Replace this:

public function destroy(Invoice $invoice): RedirectResponse
{
    Gate::authorize('admin');
    // ...
}

With this:

#[RequiresRole('admin')]
public function destroy(Invoice $invoice): RedirectResponse
{
    // ...
}

The attribute itself is trivial:

<?php

namespace App\Auth;

use Attribute;

#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class RequiresRole
{
    public function __construct(public readonly string $role) {}
}

The middleware does the work — it inspects the resolved controller and method, reads the attribute, and authorises:

<?php

namespace App\Http\Middleware;

use App\Auth\RequiresRole;
use Closure;
use Illuminate\Http\Request;
use ReflectionMethod;
use Symfony\Component\HttpFoundation\Response;

final class EnforceRoleAttributes
{
    public function handle(Request $request, Closure $next): Response
    {
        $route = $request->route();

        if ($route === null || ! is_string($route->getActionName())) {
            return $next($request);
        }

        [$controller, $method] = explode('@', $route->getActionName());

        if (! method_exists($controller, $method)) {
            return $next($request);
        }

        $reflection = new ReflectionMethod($controller, $method);
        $attributes = $reflection->getAttributes(RequiresRole::class);

        foreach ($attributes as $attribute) {
            $required = $attribute->newInstance()->role;

            if (! $request->user()?->hasRole($required)) {
                abort(403, "Requires {$required} role.");
            }
        }

        return $next($request);
    }
}

Register the middleware globally in bootstrap/app.php and you have declarative role gates that survive controller refactors — no policy boilerplate per action.

In production I cache the role lookup per route name, because reflecting in middleware on every request adds up. The pattern of caching reflection results matters more than I expected; on a high-traffic admin panel I measured ~0.4ms per request before caching, ~0.02ms after.

Pattern: Auto-Registration#

This is where attributes shine — wiring code together by convention instead of by config. Here's auto-registration of event listeners:

<?php

namespace App\Events;

use Attribute;

#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
final class ListensTo
{
    /**
     * @param class-string $event
     */
    public function __construct(public readonly string $event) {}
}

A listener declares what it cares about:

<?php

namespace App\Listeners;

use App\Events\InvoicePaid;
use App\Events\InvoiceRefunded;
use App\Events\ListensTo;

#[ListensTo(InvoicePaid::class)]
#[ListensTo(InvoiceRefunded::class)]
final class UpdateAccountingLedger
{
    public function handle(object $event): void
    {
        // ...
    }
}

The service provider walks app/Listeners, reads attributes, and registers them with Laravel's dispatcher:

<?php

namespace App\Providers;

use App\Events\ListensTo;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use ReflectionClass;
use Symfony\Component\Finder\Finder;

final class AttributeListenerProvider extends ServiceProvider
{
    public function boot(): void
    {
        $finder = (new Finder())->files()->in(app_path('Listeners'))->name('*.php');

        foreach ($finder as $file) {
            $class = $this->classFromFile($file->getRealPath());

            if (! class_exists($class)) {
                continue;
            }

            $reflection = new ReflectionClass($class);

            foreach ($reflection->getAttributes(ListensTo::class) as $attribute) {
                Event::listen($attribute->newInstance()->event, [$class, 'handle']);
            }
        }
    }

    private function classFromFile(string $path): string
    {
        // Translate file path to PSR-4 class name — your real implementation
        // probably uses composer's class map or a namespace prefix lookup.
        $relative = str_replace(app_path() . DIRECTORY_SEPARATOR, '', $path);
        $relative = str_replace(['.php', DIRECTORY_SEPARATOR], ['', '\\'], $relative);

        return 'App\\' . $relative;
    }
}

In production I cache the resolved listener map to a file at php artisan config:cache time, because scanning a directory and reflecting on every boot is wasteful. The Laravel framework does the same thing for its own attribute discovery — reflection only at build time, plain arrays at request time.

Testing Attribute Behaviour#

The mistake I made early on was reflecting over the real App\ codebase inside unit tests — every refactor broke unrelated tests. The fix is to define fixture classes inside the test file and reflect over those.

A Pest test for the validator:

<?php

use App\Validation\AttributeValidator;
use App\Validation\Rules\Min;
use App\Validation\Rules\Required;

// Fixture lives in the test file — never coupled to real DTOs
final readonly class FixtureCustomerData
{
    public function __construct(
        #[Required]
        #[Min(2)]
        public string $name = '',
    ) {}
}

it('returns errors for invalid properties', function (): void {
    $errors = (new AttributeValidator())->validate(new FixtureCustomerData(name: ''));

    expect($errors)->toHaveKey('name')
        ->and($errors['name'])->toContain('The name field is required.');
});

it('passes when all rules pass', function (): void {
    $errors = (new AttributeValidator())->validate(new FixtureCustomerData(name: 'Steven'));

    expect($errors)->toBeEmpty();
});

For wider behaviour — "every controller in App\Http\Controllers\Admin has a RequiresRole attribute" — Pest architecture testing for Laravel apps is a better fit than ad-hoc reflection.

Gotchas and Edge Cases#

A few real ones I've hit:

Attribute typos fail silently until you call newInstance(). #[RequiredField] on a property where you meant #[Required] will sit there forever. The attribute class doesn't have to exist for the parser to accept the syntax. Mitigate with an architecture test that asserts every attribute class referenced in App\ autoloads.

getAttributes() does not cache between calls. Each invocation walks the engine's metadata table. Inside a request handler this is fine; inside a tight loop, hoist the lookup to outside the loop or memoise it.

IS_REPEATABLE is opt-in, and forgetting it gives the worst error. Two attributes of the same non-repeatable type on one target raise Error: Attribute "X" must not be repeated. The error mentions the line of the second attribute, not the declaration that's missing the flag — easy to chase up the wrong tree.

Static analysis is improving but still misses things. Running PHPStan at level 10 in Laravel catches most attribute argument mismatches now, but custom validators that read attributes dynamically often need @phpstan-ignore or generic templates to satisfy the analyser.

Constructor side effects in attribute classes are a footgun. Don't read from the database, hit the cache, or call services in an attribute constructor. Attributes are values; do work in the reader, not the attribute.

Wrapping Up#

Custom attributes are the cleanest way I've found to keep configuration next to the code it configures. Start with one pattern — validation, auth, or auto-registration — and resist the urge to attribute-ify everything; once your reflection passes start showing up in profiles, you'll wish you'd been pickier.

If you're building DTOs to pair with these validation attributes, the PHP readonly classes value objects in Laravel walkthrough is the natural next read. And if you're stuck on the framework side, Laravel 13's built-in PHP attribute syntax is worth re-reading with this lens — many of the patterns above are how the framework wires its own.

FAQ#

How do I create a custom PHP attribute?

Create a regular PHP class and mark it with the #[Attribute] declaration. Add a constructor that accepts whatever parameters you want passed through the attribute syntax — promoted readonly properties keep it tidy. Restrict where the attribute can appear by passing target flags such as Attribute::TARGET_METHOD or Attribute::TARGET_PROPERTY to the #[Attribute(...)] declaration. The class is only instantiated when reflection calls newInstance(), so the constructor body runs lazily.

What are PHP 8 attributes used for?

PHP 8 attributes attach structured, machine-readable metadata to classes, methods, properties, parameters, and constants without using docblock comments. Frameworks use them for routing, dependency injection, validation rules, ORM mappings, and event handlers. They replace the old approach of parsing PHPDoc strings — attributes are typed, autoloaded, IDE-friendly, and validated by the engine when read. Common production uses are declarative authorisation, serialisation hints, and configuration of services that would otherwise live in arrays.

How do I read attributes with Reflection in PHP?

Get a reflection object for whatever you want to inspect — ReflectionClass, ReflectionMethod, ReflectionProperty, or ReflectionFunction — then call getAttributes(). Pass the attribute class name as a filter to avoid string comparisons, and add ReflectionAttribute::IS_INSTANCEOF if you also want subclasses and interface implementers to match. The result is a list of ReflectionAttribute objects. Call newInstance() on each one to instantiate your attribute class with the arguments captured at the declaration site.

Can I use PHP attributes for validation?

Yes — attributes are well suited to declaring validation rules on DTO or value object properties. Define one attribute per rule (or a base Rule attribute with concrete subclasses), mark them Attribute::IS_REPEATABLE so a property can carry several, and write a small validator that reflects over the object, instantiates each attribute with newInstance(), and runs its passes() check against the property value. This keeps validation rules co-located with the data they govern, which is hard to do with Laravel's request-scoped Form Request rules.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.