DEV Community

Cover image for Working with PHP Attributes: Do’s & Don’ts
Nasrul Hazim Bin Mohamad
Nasrul Hazim Bin Mohamad

Posted on

Working with PHP Attributes: Do’s & Don’ts

Attributes in PHP simplify code configuration by allowing you to annotate code elements with metadata directly, potentially reducing boilerplate in frameworks like Laravel. However, as with any feature, attributes can be overused or misapplied, leading to messy controllers and harder-to-maintain code.

In this post, we’ll explore best practices for using attributes in a way that enhances code clarity. I’ll also provide a table of “do’s and don’ts” with examples for each comparison, highlighting scenarios where attributes work well—and where they may not.

1. Understanding Attributes in PHP

Here’s a quick example of defining and using an attribute to provide some context:

#[Attribute]
class MyCustomAttribute {
    public function __construct(public string $description) {}
}

#[MyCustomAttribute("This is a test class")]
class MyClass {
    #[MyCustomAttribute("This is a test method")]
    public function myMethod() {}
}
Enter fullscreen mode Exit fullscreen mode

2. Do’s and Don’ts: Quick Overview

Here’s a table to summarize best practices and common pitfalls:

Do’s Don’ts
Use attributes for standard, repetitive configurations (e.g., HTTP methods, caching). Don’t overload attributes with complex configurations or conditional logic.
Leverage attributes for metadata rather than core application logic. Avoid embedding business logic or intricate rules within attributes.
Apply attributes for simple, reusable annotations (e.g., #[Throttle], #[Cache]). Don’t try to replace Laravel’s route files entirely with attribute-based routing.
Cache attribute-based reflections when possible to improve performance. Don’t rely solely on attributes for configurations that need flexibility or change often.
Document your attributes, so team members understand their purpose and usage. Avoid using attributes for configurations where traditional methods work better (e.g., middleware settings).

3. Detailed Comparisons with Examples

Let’s dive into each comparison with specific examples.

1. Use Attributes for Standard, Repetitive Configurations (Do)

Attributes are ideal for standard configurations that don’t require complex logic. Here are three good examples:

  • Defining Routes: Use attributes to define straightforward routes with HTTP methods and paths.
  #[Attribute]
  class Route {
      public function __construct(public string $method, public string $path) {}
  }

  class ProductController {
      #[Route('GET', '/products')]
      public function index() {}
  }
Enter fullscreen mode Exit fullscreen mode
  • Cache Control: Use an attribute to specify cache duration for methods.
  #[Attribute]
  class Cache {
      public function __construct(public int $duration) {}
  }

  class ProductController {
      #[Cache(3600)]
      public function show($id) {}
  }
Enter fullscreen mode Exit fullscreen mode
  • Rate Limiting: A Throttle attribute could be used to limit the number of requests per user.
  #[Attribute]
  class Throttle {
      public function __construct(public int $maxAttempts) {}
  }

  class UserController {
      #[Throttle(5)]
      public function store() {}
  }
Enter fullscreen mode Exit fullscreen mode

Don’t Overload Attributes with Complex Configurations (Don’t)

Avoid using attributes for configurations that require multiple parameters or conditions. Here’s what not to do:

  • Overloading with Multiple Configurations: Avoid adding multiple parameters to an attribute.
  #[Attribute]
  class Route {
      public function __construct(
          public string $method,
          public string $path,
          public ?string $middleware = null,
          public ?string $prefix = null
      ) {}
  }

  #[Route('POST', '/users', middleware: 'auth', prefix: '/admin')]
Enter fullscreen mode Exit fullscreen mode
  • Conditional Logic in Attributes: Avoid conditional settings within attributes.
  #[Attribute]
  class Condition {
      public function __construct(public string $condition) {}
  }

  class Controller {
      #[Condition("isAdmin() ? 'AdminRoute' : 'UserRoute'")]
      public function index() {}
  }
Enter fullscreen mode Exit fullscreen mode
  • Chained Configurations in a Single Attribute: Avoid chaining multiple configuration behaviors in one attribute.
  #[Attribute]
  class Combined {
      public function __construct(
          public int $cacheDuration,
          public int $rateLimit
      ) {}
  }

  #[Combined(cacheDuration: 300, rateLimit: 5)]
Enter fullscreen mode Exit fullscreen mode

2. Leverage Attributes for Metadata (Do)

Use attributes as markers or metadata, rather than embedding application logic within them. Here’s how:

  • Annotations for Validation: Mark a field as required with an attribute.
  #[Attribute]
  class Required {}

  class User {
      #[Required]
      public string $name;
  }
Enter fullscreen mode Exit fullscreen mode
  • Specify HTTP Method as Metadata: Use attributes to mark the HTTP method type.
  #[Attribute]
  class Get {}

  class BlogController {
      #[Get]
      public function list() {}
  }
Enter fullscreen mode Exit fullscreen mode
  • Indicate Access Levels: Use attributes to indicate access level requirements.
  #[Attribute]
  class RequiresAdmin {}

  class SettingsController {
      #[RequiresAdmin]
      public function update() {}
  }
Enter fullscreen mode Exit fullscreen mode

Don’t Embed Business Logic in Attributes (Don’t)

Avoid using attributes to determine application behavior directly. Here’s what not to do:

  • Avoid Direct Conditions in Attributes: Don’t place conditional checks in attributes.
  #[Attribute]
  class AccessControl {
      public function __construct(public string $role) {}
  }

  #[AccessControl(role: isAdmin() ? 'admin' : 'user')]
Enter fullscreen mode Exit fullscreen mode
  • Avoid Method Calls in Attributes: Don’t place function calls or business logic in attributes.
  #[Attribute]
  class ConditionalCache {
      public function __construct(public int $duration) {}
  }

  #[ConditionalCache(duration: userHasPremium() ? 3600 : 300)]
Enter fullscreen mode Exit fullscreen mode
  • Avoid Calculated Values in Attributes: Attributes should be static metadata, not calculated values.
  #[Attribute]
  class Cache {
      public function __construct(public int $duration) {}
  }

  #[Cache(duration: (int)env('CACHE_DURATION'))]
Enter fullscreen mode Exit fullscreen mode

3. Apply Attributes for Simple, Reusable Annotations (Do)

Attributes work well for lightweight annotations that can be reused. Here are some reusable annotation examples:

  • Simple Throttle: A straightforward throttle attribute to limit request rates.
  #[Attribute]
  class Throttle {
      public function __construct(public int $limit) {}
  }

  #[Throttle(5)]
Enter fullscreen mode Exit fullscreen mode
  • Cache Control: Add cache control attributes with a single duration parameter.
  #[Attribute]
  class Cache {
      public function __construct(public int $duration) {}
  }

  #[Cache(120)]
Enter fullscreen mode Exit fullscreen mode
  • Deprecation Warning: Mark methods as deprecated to alert developers.
  #[Attribute]
  class Deprecated {
      public function __construct(public string $message) {}
  }

  #[Deprecated("This method will be removed in v2.0")]
Enter fullscreen mode Exit fullscreen mode

Don’t Overuse Attributes for Configurations That Are Easier in Other Formats (Don’t)

Some configurations are better managed outside of attributes. Here’s what not to do:

  • Middleware Configuration: Avoid configuring middleware directly in attributes.
  #[Attribute]
  class Middleware {
      public function __construct(public string $name) {}
  }

  #[Middleware('auth')]
Enter fullscreen mode Exit fullscreen mode
  • Authorization Rules: Complex authorization configurations are better in policy files.
  #[Attribute]
  class Permission {
      public function __construct(public string $requiredPermission) {}
  }

  #[Permission("edit_post")]
Enter fullscreen mode Exit fullscreen mode
  • Complex Validation Rules: Keep validation logic out of attributes.
  #[Attribute]
  class Validate {
      public function __construct(public array $rules) {}
  }

  #[Validate(['name' => 'required|min:3'])]
Enter fullscreen mode Exit fullscreen mode

Conclusion

Attributes offer an elegant way to handle repetitive configurations, especially in PHP frameworks like Laravel.

However, they work best as simple metadata, and it’s essential to avoid overloading them with complex configurations or logic.

By following the best practices and using attributes as lightweight, reusable annotations, you can harness their full potential without adding unnecessary complexity to your codebase.


Sponsor

Support my open-source work by sponsoring me on GitHub Sponsors! Your sponsorship helps me keep creating useful Laravel packages, tools, and educational content that benefit the developer community. Thank you for helping make open-source better!


Photo by Milad Fakurian on Unsplash

Top comments (1)

Collapse
 
xwero profile image
david duymelinck

Maybe you should clarify attributes are used mostly used as a part of a bigger system, like a framework where you can manipulate the bootstrapping or request lifetime to do custom things.

Before attributes where a part of php most frameworks parsed documentation strings to get the same result.
Separating documentation from executable code is the main goal of attributes

Attributes can be as simple or complex as you want them to be. It depends on what problem they solve. A route can get very complicated, think adding middleware, a name, default values and so on.
I think the biggest reason to keep the attribute function as small as possible, is for better reusability. When we go back to the router example, not every framework is going to have the same config so that is going to be framework dependent. But input validation can be reusable as it is most of the times get/post data.

My rule of thumb is does it make sense to replace the current thing with an attribute, then go for it.