Deprecate Your Own Code with PHP 8.4's #[\Deprecated] Attribute

Use the PHP deprecated attribute in 8.4 to mark methods, functions, constants and enum cases as deprecated with a runtime warning your tooling can read.

Steven Richardson
Steven Richardson
· 6 min read

I maintain a couple of internal Laravel packages, and the hardest part isn't shipping new methods — it's retiring old ones. You drop a /** @deprecated */ docblock on a method, tell yourself the team will notice, and six months later it's still being called in three services because a docblock does precisely nothing at runtime.

PHP 8.4 fixes this with a first-class #[\Deprecated] attribute. It's the cleanest way to use the php deprecated attribute pattern: callers get a real E_USER_DEPRECATED warning, and your tooling can finally see the deprecation through reflection. Here's how I roll it out across a package.

Add the Deprecated attribute to a method you are retiring#

Start with the method you want to kill. Drop the #[\Deprecated] attribute directly above the declaration — no docblock, no trigger_error call inside the body. The attribute lives in the global namespace, so reference it as #[\Deprecated] to avoid import collisions with anything called Deprecated in your own code.

<?php

namespace App\Services\Billing;

class InvoiceFormatter
{
    #[\Deprecated]
    public function formatLegacy(int $amountInPence): string
    {
        return '£' . number_format($amountInPence / 100, 2);
    }
}

Call formatLegacy() now and PHP emits Deprecated: Method App\Services\Billing\InvoiceFormatter::formatLegacy() is deprecated. That's the whole point — the warning fires at the call site, every single time the method runs, not buried in a doc comment nobody reads. This is the same attribute syntax that Laravel 13 leans on for cleaner models, jobs and commands, so if you've used attributes there it'll feel familiar.

Provide a message and since version for callers#

A bare warning tells people something is deprecated but not what to do about it. The attribute takes two optional named arguments — message and since — and you should always supply both. The message is where you point callers at the replacement; since records the version the clock started ticking on.

#[\Deprecated(
    message: 'Use format() with a Money instance instead.',
    since: '2.4.0',
)]
public function formatLegacy(int $amountInPence): string
{
    return '£' . number_format($amountInPence / 100, 2);
}

That produces: Deprecated: Method ...::formatLegacy() is deprecated since 2.4.0, Use format() with a Money instance instead. I treat since as the contract for a removal version — deprecate in 2.4, remove in 3.0. Writing the replacement into the message itself is the difference between a warning people act on and one they mute.

Trigger and observe the E_USER_DEPRECATED warning#

The attribute raises an E_USER_DEPRECATED error, not an exception. That distinction matters: nothing breaks, the method still runs and returns its value, and the warning routes through your normal error handling. In a Laravel app that means it lands wherever your log channel sends deprecation-level messages.

$formatter = new InvoiceFormatter();

// Still works — returns "£19.99" — but logs an E_USER_DEPRECATED warning.
echo $formatter->formatLegacy(1999);

Laravel routes PHP deprecations through a dedicated channel. Set LOG_DEPRECATIONS_CHANNEL in your .env so they don't drown your main log, and you get a running tally of exactly which deprecated calls are still happening in production. If you're already on top of PHP's own deprecation churn between releases, this is the same warning stream — you're just adding your own entries to it.

Surface deprecations with reflection and static analysis#

The real upgrade over docblocks is that #[\Deprecated] is reflectable. Before 8.4, ReflectionMethod::isDeprecated() ignored the @deprecated docblock entirely and only ever returned true for internal PHP functions. Now it reports the attribute on your own code.

$method = new \ReflectionMethod(InvoiceFormatter::class, 'formatLegacy');

$method->isDeprecated(); // true

// Read the message and since values back off the attribute:
$attribute = $method->getAttributes(\Deprecated::class)[0] ?? null;
$instance  = $attribute?->newInstance();

$instance?->message; // "Use format() with a Money instance instead."
$instance?->since;   // "2.4.0"

This is what makes the attribute worth adopting: PHPStan, Psalm and IDEs read it and flag call sites with a strikethrough or a warning before you ever run the code. Wiring PHPStan into a Laravel project at a high level turns every deprecated call into a static-analysis hit in CI. If you're building richer reflection-driven behaviour, the same getAttributes() pattern shows up when you write your own attributes beyond Laravel's built-ins.

Handle deprecation warnings cleanly in your test suite#

Deprecations you emit on purpose shouldn't make your suite noisy — or worse, fail it if you've set failOnDeprecation. PHPUnit 11, which Pest 3 runs on, gives you expectUserDeprecationMessage() to assert that a specific deprecation fired. That converts the warning from background noise into a tested guarantee.

test('formatLegacy is deprecated', function () {
    $this->expectUserDeprecationMessage(
        'Method App\Services\Billing\InvoiceFormatter::formatLegacy() is deprecated since 2.4.0, Use format() with a Money instance instead.'
    );

    (new InvoiceFormatter())->formatLegacy(1999);
});

If you only want to confirm a deprecation happened without pinning the exact string, expectUserDeprecationMessageMatches('/is deprecated/') takes a regex. Either way, asserting the deprecation means a future refactor that silently drops the attribute will fail the test — exactly the safety net you want around a public API.

Gotchas and Edge Cases#

The attribute's targets are narrower than people expect. In 8.4 it applies to functions, methods, class constants and enum cases — but not to class declarations, properties, or function parameters. If you want to deprecate a whole class, deprecate its constructor and public methods, or lean on a docblock for the class itself.

The bigger trap is older PHP versions. The attribute is inert below 8.4 — code carrying it runs fine but emits no warning at all. If your package still supports 8.3 or lower and you need consistent signalling everywhere, keep a guarded trigger_error inside the body alongside the attribute:

#[\Deprecated(message: 'Use format() instead.', since: '2.4.0')]
public function formatLegacy(int $amountInPence): string
{
    if (\PHP_VERSION_ID < 80400) {
        trigger_error(
            'Method formatLegacy() is deprecated since 2.4.0, use format() instead.',
            E_USER_DEPRECATED,
        );
    }

    return '£' . number_format($amountInPence / 100, 2);
}

One more: the constant-deprecation target landed in 8.4, but standalone (non-class) const declarations only became taggable in 8.5. Check the PHP version you're actually targeting before you tag a global constant.

Wrapping Up#

Replace your @deprecated docblocks with #[\Deprecated(message:, since:)], point LOG_DEPRECATIONS_CHANNEL at its own log, and add an expectUserDeprecationMessage() test for each retired method. You'll have runtime warnings, reflectable metadata, and static-analysis coverage where you previously had a comment.

From here, the #[\NoDiscard] attribute for return values is the natural next stop in the same first-class-attributes family. And when it's finally time to delete the deprecated methods and migrate every caller, automating the call-site rewrites with Rector beats doing it by hand.

FAQ#

How do I mark a method as deprecated in PHP 8.4?

Add the #[\Deprecated] attribute directly above the method declaration. PHP then emits an E_USER_DEPRECATED warning every time the method is called. Pass the optional message and since named arguments to tell callers what to use instead and from which version it was deprecated.

What is the difference between the @deprecated docblock and the #[\Deprecated] attribute?

The @deprecated docblock is a comment — it produces no runtime warning and ReflectionMethod::isDeprecated() ignores it for user-land code. The #[\Deprecated] attribute is a real language construct: it emits a runtime deprecation warning and is fully reflectable, so static analysers and IDEs can detect it. The attribute is strictly the better mechanism on PHP 8.4 and up.

Does #[\Deprecated] throw an error or just a warning?

It only emits a warning, specifically an E_USER_DEPRECATED notice. It does not throw an exception and it does not halt execution — the deprecated function or method still runs and returns its value normally. The warning routes through your application's standard error and logging handlers.

Can I deprecate a class constant or enum case in PHP 8.4?

Yes. PHP 8.4 supports the attribute on functions, class methods, class constants and enum cases. Using a deprecated constant or enum case emits the same E_USER_DEPRECATED warning. Note that the attribute cannot be applied to class declarations, properties or parameters, and standalone non-class constants only became taggable in PHP 8.5.

How do I suppress deprecation warnings in tests?

The cleanest approach is to assert the deprecation rather than suppress it: PHPUnit 11 (which Pest 3 uses) provides expectUserDeprecationMessage() and expectUserDeprecationMessageMatches() to expect a specific warning inside a test. By default a deprecation is reported but does not fail the suite unless you've enabled failOnDeprecation in your PHPUnit configuration, so leaving that off keeps intentional deprecations from breaking the build.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.