Your PHP code is full of docblocks like @param and @return that nobody really reads. Or you have a class full of annotations in comments that your framework has to parse with fragile string manipulation. If a refactoring changes a property name, the comment stays there lying. With PHP 8 Attributes, metadata becomes real code, checked by the compiler and queryable via reflection. No more magic strings, no more silent failures. Let's see how they work and how to use them right away in your projects.
What are PHP 8 Attributes and why do they replace docblocks?
Attributes are a native syntax for adding structured metadata to classes, methods, properties, functions, parameters, and constants. Unlike docblocks, which are just comments, Attributes are executable code: you can instantiate them, pass arguments, and read them with the Reflection API. This means the data is typed, validated, and always in sync with the code it annotates.
We, at Meteora Web, have been using them since they landed in PHP 8.0. In a Laravel or Symfony project, Attributes replace old mappings in config files or parsed docblocks. The concrete benefit? Fewer bugs in production. If an attribute expects an enum and you pass a string, the error surfaces immediately, not months later in a log nobody reads.
Basic Attribute syntax
An attribute is declared with #[...] and can be applied to any reflectable element. Here's a minimal example:
<?php
#[\Attribute]
class Route {
public function __construct(
public string $path,
public string $method = 'GET'
) {}
}
#[Route('/users', method: 'POST')]
class CreateUser {
// ...
}
The Route class is an attribute because it has the #[\Attribute] marker. Then you apply it to a class with the #[Route(...)] syntax. The constructor receives the parameters. Simple, clean, typed.
Common mistake to avoid: forgetting the #[\Attribute] marker. Without it, the class is not an attribute and PHP throws an error when you try to use it. We see this often in code reviews: developers define the class but don't mark it.
Sponsored Protocol
Attribute parameters and flags
Attributes accept positional and named parameters, like regular functions. Additionally, you can specify the target with flags: Attribute::TARGET_CLASS, TARGET_METHOD, TARGET_PROPERTY, TARGET_FUNCTION, TARGET_PARAMETER, TARGET_CONSTANT, TARGET_ALL. And you can decide if they are repeatable with IS_REPEATABLE.
<?php
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY)]
class Validator {
public function __construct(
public string $rule,
public ?string $message = null
) {}
}
This restricts the attribute to methods and properties only. If someone applies it to a class, PHP throws an error. This is the control docblocks can't give you.
How do you read Attributes with the Reflection API?
An attribute without reading is useless. The Reflection API in PHP 8 offers getAttributes() on any reflectable object. Here's how to read the Route attribute we defined earlier:
<?php
$reflection = new ReflectionClass(CreateUser::class);
$attributes = $reflection->getAttributes(Route::class);
foreach ($attributes as $attribute) {
$route = $attribute->newInstance();
echo $route->path . ' ' . $route->method;
}
With newInstance() you get the attribute instance, with all parameters validated. If the constructor throws an exception, you see it right away. No string parsing, no regex on docblocks.
Real example from our work: in a proprietary platform for managing social presence for multiple clients, we use Attributes to map API actions. Every controller method has #[Route('/api/social/post', method: 'POST')]. A central router reads all attributes and builds the routing table. Adding a new route means adding a method and an attribute: zero config files to sync.
Sponsored Protocol
Filtering Attributes by type
If you have multiple attributes on the same element, you can filter them with the parameter of getAttributes(). Here's how:
<?php
$attributes = $reflection->getAttributes(Validator::class);
// Only Validator attributes, ignore others
This is useful when you have different attributes for different purposes: routing, validation, authorization. Each is read separately, without confusion.
How to create custom attributes for data validation?
Validation is one of the most common use cases. Instead of writing rules in external files or associative arrays, define attributes that express rules directly on properties. Here's a complete example:
<?php
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class NotBlank {
public function __construct(
public string $message = 'The field cannot be blank'
) {}
}
class User {
#[NotBlank]
public string $name;
#[NotBlank(message: 'Email is required')]
public string $email;
}
function validate(object $object): array {
$errors = [];
$reflection = new ReflectionClass($object);
foreach ($reflection->getProperties() as $property) {
$attributes = $property->getAttributes(NotBlank::class);
if (empty($attributes)) continue;
$value = $property->getValue($object);
if (empty($value)) {
$rule = $attributes[0]->newInstance();
$errors[] = $rule->message;
}
}
return $errors;
}
$user = new User();
$user->name = 'Mario';
print_r(validate($user)); // ['Email is required']
This is a minimal but functional validator. You can extend it with more complex rules: min length, email format, regex. The point is that the rule lives next to the data, not in a distant file. If you rename the property, the attribute moves with it.
Sponsored Protocol
Why it pays off: fewer files to maintain, fewer sync errors, and validation logic is readable directly in the class. In a project with dozens of models, this reduces bugs by an order of magnitude.
What advanced use cases do PHP 8 Attributes support?
Beyond validation, Attributes shine in more complex scenarios. Here are the three we use most in client projects.
Automatic routing in custom frameworks
Instead of a routes.php file with dozens of entries, each controller declares its routes with attributes. A central router reads them at startup and builds the table. Adding a route is a local change, not a jump between different files.
<?php
#[\Attribute(\Attribute::TARGET_METHOD)]
class Route {
public function __construct(
public string $path,
public string $method = 'GET'
) {}
}
class UserController {
#[Route('/users', method: 'GET')]
public function index() { /* ... */ }
#[Route('/users/{id}', method: 'GET')]
public function show(int $id) { /* ... */ }
}
The router uses ReflectionMethod::getAttributes() on every public method and builds the map. Zero external config, zero files to forget.
ORM mapping without XML or YAML files
In a lightweight custom ORM, you can map properties to database columns with attributes. No more separate mapping files that desync from the model.
<?php
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class Column {
public function __construct(
public string $name,
public string $type = 'string'
) {}
}
class Product {
#[Column('id', 'int')]
public int $id;
#[Column('name')]
public string $name;
}
The persistence layer reads the attributes and generates queries. Simple, direct, and the model is self-documenting.
Permission and authorization system
Attributes can mark methods with required roles. A middleware reads them and decides whether to allow access.
Sponsored Protocol
<?php
#[\Attribute(\Attribute::TARGET_METHOD)]
class RequiresRole {
public function __construct(
public string $role
) {}
}
class AdminController {
#[RequiresRole('admin')]
public function deleteUser(int $id) { /* ... */ }
}
The middleware checks getAttributes(RequiresRole::class) and compares with the logged-in user's roles. Security becomes declarative, not scattered across the codebase.
How to avoid the most common Attribute mistakes?
After years of projects, we've seen the same mistakes repeat. Here are the three most frequent and how to avoid them.
Forgetting the #[Attribute] marker
Without the marker, the class is not an attribute. PHP throws a fatal error when you try to use it. The solution is simple: always mark it, even for simple attributes. A habit that saves you from useless debugging.
Using attributes on unsupported targets
If you apply an attribute to a class but the flag says TARGET_METHOD, PHP throws an error. Check the flags in the Attribute constructor. We recommend being explicit: TARGET_CLASS | TARGET_METHOD if needed, but never TARGET_ALL by default. Better to restrict than to widen.
Forgetting that newInstance() can throw exceptions
If the attribute constructor validates parameters, newInstance() can throw exceptions. Handle them with try-catch or let the framework propagate them. Never ignore them: they are your safety net.
<?php
try {
$route = $attribute->newInstance();
} catch (\Exception $e) {
// Log and handle the error
}
Why do Attributes beat docblocks for maintainability?
Docblocks are comments: PHP completely ignores them. If you write @param int $id but the parameter is a string, nobody tells you. With Attributes, the data is typed and validated. If you pass a string to an attribute that expects an int, PHP throws an error. The compiler becomes your first reviewer.
Sponsored Protocol
Moreover, Attributes are readable via reflection in a structured way. You can iterate, filter, and serialize them. Docblocks require string parsing with regex, which is fragile and slow. We, at Meteora Web, have migrated several projects from docblocks to attributes: the code became cleaner and configuration bugs disappeared. Maintainability isn't an opinion: it's a design matter.
A concrete example: in an e-commerce project, we had a discount system based on parsed docblocks. After a refactoring, a property name changed and the parser didn't find it. The discount wasn't applied for a week, losing margin. With Attributes, the refactoring would have updated the attribute along with the property, and the type would have been checked. Errors like this cost dearly, and Attributes prevent them at the root.
What to do next
Here are immediate actions to integrate Attributes into your workflow:
- Install PHP 8 or higher — if you haven't already, it's the prerequisite. Check with
php -v. - Convert a docblock into an attribute — take a class with
@routeor@validateannotations and turn it into attributes. Use reflection to read them. - Define a custom attribute for validation — start with a simple case like
NotBlank, and integrate it into your data model. - Write a test — create a test that verifies attributes are read correctly. This protects you from future regressions.
- Review your existing code — look for docblocks that describe structured metadata and evaluate whether to convert them. Not everything needs conversion, but routing and validation cases do.
If you want to dive deeper into the PHP 8 ecosystem, we have a complete guide on advanced PHP 8 covering typing, performance, and asynchrony. And if you work with TypeScript, our article on TypeScript with Node.js shows how static typing applies there too.