PHP 8.0+ PHP 8.4 PHP 8.5

๐Ÿท๏ธ 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
New in PHP 8.5 โ€” 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

  1. Deprecate old code. Add #[\Deprecated(message: 'Use list() instead', since: '2.0')] directly above the getAll() method. Run the file โ€” PHP will now print a deprecation notice alongside the output. (Requires PHP 8.4.)
  2. Protect a sensitive parameter. Add #[SensitiveParameter] directly in front of $password in the login() signature. Run the file again โ€” the password hunter2 in the stack trace should now read Object(SensitiveParameterValue). (PHP 8.2+)
  3. Document the override. Add #[Override] to Dog::speak(). Then temporarily rename Animal::speak() to Animal::snore() and run the file โ€” PHP will refuse to compile because the override is now invalid. Rename it back when done. (PHP 8.3+)
  4. Create a custom Route attribute. Declare a class with #[Attribute], two constructor properties (string $path and string $method = 'GET'), then replace the /** @Route */ docblocks on ApiController with real #[Route(...)] attributes. Finally, uncomment the Reflection block to print the routing table.
  5. Guard a return value with #[NoDiscard]. PHP 8.5 Add #[NoDiscard('return value should not be ignored')] above getUserById(). Run the file โ€” the call on the first line (result discarded) will emit a notice; the second call (result assigned to $user) stays silent.
  6. Delay target validation. PHP 8.5 Update your Route attribute declaration to use Attribute::TARGET_METHOD | Attribute::DELAYED_TARGET_VALIDATION. Then add #[Route('/bad')] on a property of ApiController and verify that the file still loads without error โ€” the violation is only caught when you call newInstance() on that attribute.
  7. ๐ŸŽ Bonus โ€” restrict targets without delay. Remove DELAYED_TARGET_VALIDATION again so only Attribute::TARGET_METHOD remains. 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?

๐Ÿงช Think about it: Attributes are read lazily โ€” 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?
Copied to clipboard!