DEV Community

Cover image for PHP 8.5 #[\DelayedTargetValidation]: Attributes Across a Version Gap
Gabriel Anhaia
Gabriel Anhaia

Posted on

PHP 8.5 #[\DelayedTargetValidation]: Attributes Across a Version Gap


You maintain a library that runs on four PHP versions. A newer PHP release lets you put a built-in attribute somewhere it couldn't go before. You want that placement. It reads better, and the engine now understands it.

Then CI turns red on the older versions. Not a test failure. A fatal at compile time. The file with the attribute won't even load, because the older engine looks at where you put the attribute and refuses.

That is the wall PHP 8.5's #[\DelayedTargetValidation] is built to get you over. It is a small attribute with one job: move a built-in attribute's target check from compile time to reflection time, so code that spans a version window stops blowing up on load.

Two kinds of attribute validation

Attributes have a declared list of legal targets. A user-defined attribute declares its own with the #[Attribute] flags:

use Attribute;

#[Attribute(Attribute::TARGET_METHOD)]
final class Audited {}

final class Report
{
    #[Audited]
    public string $title = '';
}
Enter fullscreen mode Exit fullscreen mode

That #[Audited] sits on a property, which is not a method. PHP does not complain when the class is compiled. The check for a user attribute happens only when reflection instantiates it:

$prop = new ReflectionProperty(Report::class, 'title');
$prop->getAttributes(Audited::class)[0]->newInstance();
// Error: Attribute "Audited" cannot target
// property (allowed targets: method)
Enter fullscreen mode Exit fullscreen mode

So user attributes are lazy. Wrong placement is silent until something reflects them and calls newInstance(). If nothing ever does, the misplacement never surfaces.

Built-in attributes work the other way. #[\Override], #[\Deprecated], #[\NoDiscard] and the rest are validated by the engine at compile time. The moment the file is parsed, the target is checked. Get it wrong and the file does not load at all.

That eager check is usually what you want. It catches typos early. It becomes a problem in exactly one situation: when different PHP versions disagree about where a built-in attribute is allowed to sit.

Why the disagreement happens

Built-in attributes gain targets over releases. #[\Override] shipped in 8.3 for methods. PHP 8.5 extended it to properties. Future releases can widen the allowed set again.

That drift is the trap for anyone supporting more than one version. Picture a built-in attribute that a newer PHP accepts on some target, while 8.5 and 8.6 still reject it there. You want to write it on that target today. On the newer engine the file compiles. On 8.5 and 8.6 the compile-time check fires and the file is dead on arrival.

Normally your only move is to wait. Keep the attribute off that target until you drop support for every version that rejects it. For a library with a slow-moving user base, that wait can run for years, even while most of your users are already on the newer engine.

What #[\DelayedTargetValidation] changes

Add #[\DelayedTargetValidation] next to the built-in attribute and the engine stops checking its target at compile time. The check moves to reflection time, the same place user attributes are already checked.

class Base
{
    public function run(): void {}
}

class Worker extends Base
{
    #[\DelayedTargetValidation]
    #[\Override]
    public const TAG = 'worker';
}
Enter fullscreen mode Exit fullscreen mode

#[\Override] on a class constant is not a legal target on 8.5. Without the marker, that class is a compile-time fatal. With it, the file loads. On an engine where the target is valid, the override behaves normally. On an engine where it is not, the attribute is inert until you go looking for it with reflection.

The name is literal. Validation of the target is delayed, not removed. You are trading an eager compile-time guarantee for a lazy runtime one, on purpose, for the span of the migration.

The error still exists, on your terms

Delayed does not mean gone. On a version where the target is genuinely invalid, reflecting the attribute and calling newInstance() still throws:

$ref = new ReflectionClassConstant(Worker::class, 'TAG');

foreach ($ref->getAttributes() as $attr) {
    try {
        $attr->newInstance();
    } catch (\Error $e) {
        // Engine rejects this target here; skip it
        // instead of crashing the whole request.
    }
}
Enter fullscreen mode Exit fullscreen mode

This is the part that makes the pattern safe rather than reckless. Framework code that scans attributes with reflection can catch the \Error, skip the placement the current engine does not understand, and keep running. Your own code stays in control of what an unsupported target does, instead of the engine deciding for you at parse time.

It is built-in attributes only

One boundary matters. #[\DelayedTargetValidation] affects built-in attributes and nothing else. It does not apply to your own attribute classes, and you cannot mark a userland attribute so its consumers get the deferred behavior.

That is not a gap. User attributes are already deferred by default. Their target check has always lived at newInstance(), as the Audited example showed. There is nothing to delay, because nothing happens at compile time to begin with. The class is provided by PHP and reserved for internal use, so trying to reach for it on a user attribute buys you nothing.

The mental model:

  • User attribute on a wrong target — silent until reflected, then throws. No marker needed.
  • Built-in attribute on a wrong target — compile-time fatal by default.
  • Built-in attribute plus #[\DelayedTargetValidation] — silent until reflected, then throws. Same shape as a user attribute.

When to reach for it

This is a tool for code that runs across a version boundary. Concretely:

  • Libraries and framework packages with a composer.json that spans several PHP versions. You want to adopt a widened attribute target early without abandoning users on older engines.
  • Migrations in flight where part of the fleet is on the newer PHP and part is not, and you would rather ship one branch than gate the attribute behind a version check.

And when to leave it alone:

  • Application code pinned to one PHP version. If every environment runs the same engine and that engine accepts the target, the marker adds noise and hides a real mistake. Let the compile-time check do its job.
  • A genuine misplacement. If the attribute is on the wrong target because of a bug, delaying the error just moves the crash from load time to some later reflection call. The compile-time fatal was the feature there. Fix the placement, do not mute it.
  • User-defined attributes. Nothing to do. They already validate at runtime.

The honest read is that most application developers will never type this attribute. It is plumbing for the people who write the libraries you install, so those libraries can track new language targets without a hard version cutoff. If you maintain such a package, it turns a multi-year wait into a two-line opt-in. If you do not, knowing it exists is enough to recognize it when it shows up in a vendor directory.

#[\DelayedTargetValidation] is a narrow feature that solves a narrow, real problem: the seam between PHP versions that disagree about a built-in attribute. Small blast radius, precise intent, no runtime cost when the target is valid. That is the kind of addition that ages well.

Which built-in attribute would you widen the targets on first if the version window stopped being a blocker?


Attributes are metadata that framework and library layers read by reflection, which makes them a boundary concern by nature. Keeping the decision about how to react to an unsupported target in your own code, behind a caught \Error, rather than letting the engine abort a request at parse time, is the same instinct that keeps a domain independent of the framework scanning it. That seam between your code and the machinery that inspects it is what Decoupled PHP spends its chapters on.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)