๐ท๏ธ Exercise 4 โ PHP Attributes
๐ The setup
What is an attribute?
An attribute is a label you stick on a piece of code to say something about it โ without changing what the code does. Think of it like a sticky note on a file folder: the note doesn't change what's in the folder, but anyone who reads it knows something extra about it.
In PHP, attributes use the #[โฆ] syntax and live directly above the thing they describe:
#[Route('/users')] // โ the attribute (a label)
public function list(): string // โ the thing being labelled
{
return json_encode(['alice', 'bob', 'charlie']);
}
That's it. The attribute doesn't do anything by itself. It just stores information. Something else โ your framework, a library, or your own code โ can read that label later and decide what to do with it.
Why not just use a comment?
Before PHP 8.0, the common trick was a docblock annotation:
/** @Route("/users") */ // โ old style: just a comment
public function list(): string { ... }
This works, but PHP has no idea what @Route means. It's just a string inside a comment โ
the engine ignores it completely. A library like Doctrine or Symfony had to parse those comment strings
itself with regex, which is slow, fragile, and gives you zero editor support.
Attributes are real PHP syntax. The engine knows about them, validates them, and makes them available through the Reflection API with zero string parsing. Your IDE understands them too โ you get autocomplete, type checking, and rename refactoring for free.
How do you create your own attribute?
You write a normal PHP class and put #[Attribute] on it.
That's the signal to PHP: "this class can be used as an attribute".
#[Attribute] // 1. mark it as an attribute
class Route
{
public function __construct(
public readonly string $path,
public readonly string $method = 'GET',
) {}
}
// 2. use it anywhere
#[Route('/users', method: 'POST')]
public function create(): string { ... }
// 3. read it with Reflection
$ref = new ReflectionClass(UserController::class);
$attrs = $ref->getMethod('create')->getAttributes(Route::class);
$route = $attrs[0]->newInstance(); // returns a Route object
echo $route->path; // /users
echo $route->method; // POST
newInstance() is where the attribute class is actually constructed.
Before that call nothing happens โ attributes are lazy.
Scanning an entire file full of attributes costs almost nothing unless you ask for them.
Built-in attributes PHP ships with
You don't need to create all attributes yourself. PHP comes with several ready-made ones:
| Attribute | Since | What it does |
|---|---|---|
#[Attribute] |
8.0 | Marks a class so it can be used as a custom attribute |
#[SensitiveParameter] |
8.2 | Hides a parameter's value in stack traces โ great for passwords |
#[Override] |
8.3 | Documents that a method intentionally overrides a parent โ PHP throws a compile error if the parent method ever disappears |
#[\Deprecated] |
8.4 | Marks a function or method as deprecated โ PHP itself will emit a deprecation notice when it's called |
#[NoDiscard] |
8.5 | Warns when a function's return value is silently ignored โ useful for functions like getUserById() where ignoring the result is almost certainly a bug |
DELAYED_TARGET_VALIDATION:
Normally PHP checks at compile time whether an attribute is placed on a valid target
(e.g. a method-only attribute on a property causes an immediate error).
With Attribute::DELAYED_TARGET_VALIDATION that check is postponed until
newInstance() is called. This is mainly useful for library authors who want
to load files without crashing and only validate attributes when they're actually used.
๐งฑ Starter code
Run this file right now โ it already produces output on every section. Your job is to replace each docblock or workaround with the proper PHP attribute and run again to see what changes.
attributes.php โ starter
<?php
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Run this file as-is first: php attributes.php
// Then replace each docblock / workaround with the proper PHP attribute.
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// โโ Challenge 1: Mark deprecated code โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
class UserController
{
public function list(): string
{
return json_encode(['alice', 'bob', 'charlie']);
}
/**
* @deprecated since 2.0, use list() instead.
*/
// TODO: replace the docblock above with the built-in #[\Deprecated] attribute,
// set the message to 'Use list() instead' and the since version to '2.0'
public function getAll(): string
{
return $this->list();
}
}
$ctrl = new UserController();
echo '1. Users: ' . $ctrl->getAll() . PHP_EOL;
echo PHP_EOL;
// โโ Challenge 2: Protect a sensitive parameter โโโโโโโโโโโโโโโโโโโโโโโโ
function login(string $username, string $password): bool
{
// TODO: add the built-in #[SensitiveParameter] attribute directly before $password above โ
if ($username !== 'admin' || $password !== 'secret') {
throw new \InvalidArgumentException("Login failed for: {$username}");
}
return true;
}
try {
login('alice', 'hunter2');
} catch (\Throwable $e) {
echo '2. Exception thrown. Stack trace:' . PHP_EOL;
echo $e . PHP_EOL;
}
echo PHP_EOL;
// โโ Challenge 3: Document an override โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
class Animal
{
public function speak(): string { return '(silence)'; }
}
class Dog extends Animal
{
/**
* @override
*/
// TODO: replace the docblock above with the built-in #[Override] attribute
// Then rename Animal::speak() to Animal::snore() and see what happens.
public function speak(): string { return 'Woof!'; }
}
echo '3. Dog says: ' . (new Dog())->speak() . PHP_EOL;
echo PHP_EOL;
// โโ Challenge 4: Create a custom attribute + read it โโโโโโโโโโโโโโโโโ
class ApiController
{
/**
* @Route("/api/users", method="GET")
*/
// TODO: replace with a #[Route] attribute โ path '/api/users', default method
public function index(): string { return '[]'; }
/**
* @Route("/api/users/{id}", method="GET")
*/
// TODO: replace with a #[Route] attribute โ path '/api/users/{id}', default method
public function show(int $id): string { return '{"id":' . $id . '}'; }
/**
* @Route("/api/users", method="POST")
*/
// TODO: replace with a #[Route] attribute โ same path as index, but method POST
public function store(): string { return '{"created":true}'; }
}
// TODO: declare a Route attribute class โ it needs a $path and an optional $method (default 'GET')
// TODO: uncomment these lines once you've added the #[Route] attributes:
// $ref = new ReflectionClass(ApiController::class);
// foreach ($ref->getMethods(ReflectionMethod::IS_PUBLIC) as $m) {
// foreach ($m->getAttributes(Route::class) as $attr) {
// $r = $attr->newInstance();
// printf(" %-6s %-25s โ %s\n", $r->method, $r->path, $m->name);
// }
// }
echo '4. Routing table:' . PHP_EOL;
echo ' (no routing table yet โ complete challenge 4 to unlock this)' . PHP_EOL;
echo PHP_EOL;
// โโ Challenge 5: Guard a return value (PHP 8.5) โโโโโโโโโโโโโโโโโโโโโโ
/**
* @return array The user data. Do not ignore this return value!
*/
// TODO: replace the docblock above with the built-in #[NoDiscard] attribute,
// include a short message explaining why the return value matters
function getUserById(int $id): array
{
return ['id' => $id, 'name' => 'Alice'];
}
getUserById(1); // result silently thrown away
$user = getUserById(2); // result is used โ this one should stay silent
echo '5. Fetched user: ' . $user['name'] . PHP_EOL;
echo PHP_EOL;
๐ฏ Your challenges
-
Deprecate old code.
Add
#[\Deprecated(message: 'Use list() instead', since: '2.0')]directly above thegetAll()method. Run the file โ PHP will now print a deprecation notice alongside the output. (Requires PHP 8.4.) -
Protect a sensitive parameter.
Add
#[SensitiveParameter]directly in front of$passwordin thelogin()signature. Run the file again โ the passwordhunter2in the stack trace should now readObject(SensitiveParameterValue). (PHP 8.2+) -
Document the override.
Add
#[Override]toDog::speak(). Then temporarily renameAnimal::speak()toAnimal::snore()and run the file โ PHP will refuse to compile because the override is now invalid. Rename it back when done. (PHP 8.3+) -
Create a custom
Routeattribute. Declare a class with#[Attribute], two constructor properties (string $pathandstring $method = 'GET'), then replace the/** @Route */docblocks onApiControllerwith real#[Route(...)]attributes. Finally, uncomment the Reflection block to print the routing table. -
Guard a return value with
#[NoDiscard]. PHP 8.5 Add#[NoDiscard('return value should not be ignored')]abovegetUserById(). Run the file โ the call on the first line (result discarded) will emit a notice; the second call (result assigned to$user) stays silent. -
Delay target validation. PHP 8.5
Update your
Routeattribute declaration to useAttribute::TARGET_METHOD | Attribute::DELAYED_TARGET_VALIDATION. Then add#[Route('/bad')]on a property ofApiControllerand verify that the file still loads without error โ the violation is only caught when you callnewInstance()on that attribute. -
๐ Bonus โ restrict targets without delay.
Remove
DELAYED_TARGET_VALIDATIONagain so onlyAttribute::TARGET_METHODremains. Put#[Route('/bad')]on a property โ PHP should now refuse to compile immediately. Spot the difference in when the error fires.
๐ก Hint โ attribute syntax & Reflection
// Declaring a custom attribute
#[Attribute] // marks this class AS an attribute
class Route
{
public function __construct(
public readonly string $path,
public readonly string $method = 'GET',
) {}
}
// Using an attribute (named arguments work great here)
#[Route('/users', method: 'POST')]
public function create(): string { ... }
// Multiple attributes on one target โ stack them or use a comma
#[Route('/'), Route('/home')]
public function home(): string { ... }
// Built-in attributes need no import
#[SensitiveParameter] // PHP 8.2 โ hides value in stack traces
#[Override] // PHP 8.3 โ fatal error if parent method doesn't exist
#[\Deprecated(message: '...', since: '1.0')] // PHP 8.4 โ emits deprecation notice
#[NoDiscard('reason')] // PHP 8.5 โ notice when return value is discarded
// DELAYED_TARGET_VALIDATION (PHP 8.5) โ defer where-can-this-be-used check
#[Attribute(Attribute::TARGET_METHOD | Attribute::DELAYED_TARGET_VALIDATION)]
class Route { ... }
// Target errors only fire when you call $attr->newInstance(), not on file load.
// Reading attributes at runtime
$ref = new ReflectionClass(UserController::class);
foreach ($ref->getMethods() as $method) {
$attrs = $method->getAttributes(Route::class);
foreach ($attrs as $attr) {
$route = $attr->newInstance(); // returns a Route object
echo "{$route->method} {$route->path} โ {$method->name}" . PHP_EOL;
}
}
โ Show one possible solution
Answer both questions correctly to unlock the solution.
1. What is the purpose of placing #[Attribute] on a class?
2. You add #[SensitiveParameter] to a $password parameter. What changes?
<?php
// โโ Route attribute (challenge 4) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
#[Attribute(Attribute::TARGET_METHOD | Attribute::DELAYED_TARGET_VALIDATION)]
class Route
{
public function __construct(
public readonly string $path,
public readonly string $method = 'GET',
) {}
}
// โโ Challenge 1: Deprecated โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
class UserController
{
public function list(): string
{
return json_encode(['alice', 'bob', 'charlie']);
}
#[\Deprecated(message: 'Use list() instead', since: '2.0')]
public function getAll(): string { return $this->list(); }
}
$ctrl = new UserController();
echo '1. Users: ' . $ctrl->getAll() . PHP_EOL;
// Output: "1. Users: ["alice","bob","charlie"]"
// + Deprecated notice: Since 2.0: Use list() instead
echo PHP_EOL;
// โโ Challenge 2: SensitiveParameter โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function login(string $username, #[SensitiveParameter] string $password): bool
{
if ($username !== 'admin' || $password !== 'secret') {
throw new \InvalidArgumentException("Login failed for: {$username}");
}
return true;
}
try {
login('alice', 'hunter2');
} catch (\Throwable $e) {
echo '2. Exception thrown. Stack trace:' . PHP_EOL;
echo $e . PHP_EOL; // "hunter2" is now Object(SensitiveParameterValue)
}
echo PHP_EOL;
// โโ Challenge 3: Override โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
class Animal
{
public function speak(): string { return '(silence)'; }
}
class Dog extends Animal
{
#[Override] // PHP won't compile if Animal::speak() ever disappears
public function speak(): string { return 'Woof!'; }
}
echo '3. Dog says: ' . (new Dog())->speak() . PHP_EOL;
echo PHP_EOL;
// โโ Challenge 4: Custom Route attribute + Reflection โโโโโโโโโโโโโโโโโโ
class ApiController
{
#[Route('/api/users')]
public function index(): string { return '[]'; }
#[Route('/api/users/{id}')]
public function show(int $id): string { return '{"id":' . $id . '}'; }
#[Route('/api/users', method: 'POST')]
public function store(): string { return '{"created":true}'; }
}
echo '4. Routing table:' . PHP_EOL;
$ref = new ReflectionClass(ApiController::class);
foreach ($ref->getMethods(ReflectionMethod::IS_PUBLIC) as $m) {
foreach ($m->getAttributes(Route::class) as $attr) {
$r = $attr->newInstance();
printf(" %-6s %-25s โ %s\n", $r->method, $r->path, $m->name);
}
}
echo PHP_EOL;
// โโ Challenge 5: NoDiscard (PHP 8.5) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
#[NoDiscard('return value should not be ignored')]
function getUserById(int $id): array
{
return ['id' => $id, 'name' => 'Alice'];
}
getUserById(1); // โ notice: return value discarded
$user = getUserById(2); // โ
fine โ value is used
echo '5. Fetched user: ' . $user['name'] . PHP_EOL;
getAttributes() returns
ReflectionAttribute objects, and the attribute class is only instantiated when
you call newInstance(). What does that mean for performance in a large app?
How does this compare to parsing docblocks at runtime?