A package upgrade renamed a protected hook on a base class we extended. Our subclass kept its override, the method just stopped being called, and nothing failed — not a test, not a log line, not static analysis. We found out three weeks later when a customer noticed the behaviour was gone.
PHP has had a one-line fix for this since 8.3, and hardly anyone in the Laravel world uses it. The php override attribute — #[\Override] — tells the engine to check your intent. This is what it actually validates, where it pays off in a Laravel codebase, and the four framework methods that will blow up the moment you add it.
What the PHP Override Attribute Actually Checks#
#[\Override] is a marker. It carries no arguments and changes no behaviour. All it does is ask the engine, at compile time, to confirm that a method with the same name exists in a parent class or an implemented interface.
<?php
class Base
{
protected function foo(): void {}
}
final class Extended extends Base
{
#[\Override]
protected function boo(): void {}
}
Fatal error: Extended::boo() has #[\Override] attribute, but no matching parent method exists
That's the whole feature. Unlike a custom attribute, it is never instantiated and never reflected over — if you've read about designing your own PHP attributes, #[\Override] is the opposite case: the engine handles it natively and your code never touches it.
The RFC's rule of thumb is precise: if changing the method signature would trigger Fatal error: Declaration of X must be compatible with Y, then #[\Override] is satisfied. Concretely:
- Public and protected methods on a parent class or implemented interface satisfy it.
- Abstract methods satisfy it, including abstract methods in a
used trait. - Static methods behave exactly like instance methods.
- Interfaces extending interfaces work —
#[\Override]onSubInterface::method()is valid ifParentInterfacedeclares it. - Enums and anonymous classes work as you'd expect.
And the exclusions, which matter more:
- Private parent methods do not satisfy it. They aren't part of the externally visible API, so the engine treats them as invisible.
__construct()never satisfies it. Constructors are exempt from signature checks entirely.
One thing that has changed since the brief version of this feature: as of PHP 8.5, #[\Override] also applies to properties, producing Fatal error: Extended::$boo has #[\Override] attribute, but no matching parent property exists. On PHP 8.4 it remains method-only, so don't reach for it alongside property hooks until you're on 8.5.
The Two Bugs It Catches in Laravel Apps#
The first is the typo that survives your test suite. Laravel's FormRequest has a cluster of confusingly similar hooks — passedValidation(), passesAuthorization(), prepareForValidation(), failedValidation(). Getting one wrong is easy:
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
final class StoreInvoiceRequest extends FormRequest
{
// Meant to be passedValidation(). Never called. No error.
protected function passesValidation(): void
{
$this->merge(['reference' => Str::upper($this->reference)]);
}
}
The request validates. The test asserts a 201. The reference is never uppercased. Add the attribute and the file refuses to compile:
#[\Override]
protected function passesValidation(): void
Fatal error: App\Http\Requests\StoreInvoiceRequest::passesValidation() has #[\Override] attribute, but no matching parent method exists
The second is the upgrade case, which is the one that bit us. A vendor base class renames or removes a protected hook in a major version. Without the attribute your override becomes dead code silently. With it, the first request that autoloads the class throws a fatal error naming the exact method — you find out during composer update, not in production. That makes it a genuinely useful companion to automating Laravel upgrades with Rector, which handles the mechanical renames but can't tell you about a hook that quietly stopped existing.
Where the Override Attribute Pays Off in a Laravel Codebase#
Here's the part that trips people up. Laravel resolves a lot of "overridable" methods through method_exists() and the container rather than through inheritance — so those methods have no parent, and #[\Override] fatals. I checked each of these against Laravel 13.6.0.
Safe — a real parent method exists:
| Class | Methods |
|---|---|
FormRequest |
prepareForValidation(), passedValidation(), failedValidation(), passesAuthorization(), failedAuthorization(), messages(), attributes(), validationData() |
ServiceProvider |
register() |
Eloquent Model |
boot(), booting(), booted(), newQuery(), newEloquentBuilder(), toArray() |
| A custom cast | get(), set() (from the CastsAttributes interface) |
Middleware extending TransformsRequest |
transform() |
Fatal — no parent method exists:
| What you'd write | Why it fails |
|---|---|
ServiceProvider::boot() |
Not defined on the base class. Application::bootProvider() calls it via method_exists($provider, 'boot'). |
FormRequest::rules() |
Not defined on the base class. Resolved via method_exists($this, 'rules'). |
FormRequest::authorize() |
Same — passesAuthorization() guards it with method_exists($this, 'authorize'). |
Model::newFactory() |
Lives in the HasFactory trait, which the model itself uses. See the next section. |
Custom middleware handle() |
A plain middleware extends nothing; the pipeline duck-types handle(). |
register() being safe while boot() is not is the single most surprising line in that table, and it's the kind of thing you only learn by trying it.
Traits, Private Methods and Other Gotchas#
Trait semantics are where most people get burned, because the rule is not "the method exists somewhere in this class".
Trait methods are copied into the using class as if you'd typed them there. So if a class both uses a trait and declares the same method, the class's own declaration shadows the trait method — and there is no parent method left to satisfy the check:
<?php
trait HasFactory
{
protected static function newFactory() {}
}
final class Invoice extends Model
{
use HasFactory;
#[\Override] // Fatal error — the trait method is shadowed, not inherited
protected static function newFactory(): Factory
{
return InvoiceFactory::new();
}
}
But the same trait method reached through a parent class is fine. Illuminate\Foundation\Http\FormRequest uses ValidatesWhenResolvedTrait, so prepareForValidation() is flattened into FormRequest itself. Your request class extends FormRequest, sees a genuine parent method, and #[\Override] passes. Shadowing only bites when the use and the declaration are in the same class.
Two more:
#[\Override]on a trait method is checked against the class that uses the trait, not the trait. That's the useful inversion — you can now force consumers to override a default implementation without declaring itabstract.- Private parent methods fail even when the names match exactly. If you're refactoring a base class and a subclass suddenly fatals, check the visibility before you check the spelling.
Rolling It Out with Rector and PHPStan#
Don't hand-annotate an existing codebase. Sweep it with Rector, then lock it with PHPStan.
<?php
// rector.php
use Rector\Config\RectorConfig;
use Rector\Php83\Rector\ClassMethod\AddOverrideAttributeToOverriddenMethodsRector;
return RectorConfig::configure()
->withPaths([__DIR__.'/app', __DIR__.'/tests'])
->withConfiguredRule(AddOverrideAttributeToOverriddenMethodsRector::class, [
// Skip no-op overrides that only call parent::
'allow_override_empty_method' => false,
// Off by default — turn on to match PHPStan's behaviour
'add_to_interface_methods' => true,
]);
Then make new code fail CI without the attribute:
# phpstan.neon
parameters:
phpVersion: 80400
checkMissingOverrideMethodAttribute: true
checkMissingOverrideMethodAttribute requires phpVersion at 80300 or higher. Two things to expect when you turn it on:
- Rector and PHPStan disagree about interfaces. Rector skips interface implementations unless you set
add_to_interface_methods: true, while PHPStan flags them regardless. Set the Rector option or you'll be fixing the same files by hand. - Traits produce unfixable errors. Whether a trait method satisfies the check depends on the consuming class, so PHPStan sometimes reports a trait method you cannot legally annotate. Baseline those rather than fighting them — the same triage approach as burning down a PHPStan baseline on a legacy Laravel app.
If you're already running Pint, PHPStan and Rector behind one command, this is one config line in each and no new tooling.
Wrapping Up#
Turn on checkMissingOverrideMethodAttribute today and let it apply to new code only — that costs nothing and stops the next typo shipping. Run the Rector rule over app/ when you've got an afternoon, skip the four Laravel methods in the table above, and commit the sweep separately so the diff stays reviewable.
For the parts of your inheritance that #[\Override] can't reach — a middleware handle(), a duck-typed boot() — architecture tests in Pest cover the gap. And if you're inventorying the attributes worth adopting, #[\Deprecated] and #[\NoDiscard] are the other two that earn their keep.
FAQ#
What does the #[Override] attribute do in PHP?
It tells the PHP engine to verify, at compile time, that the method it's attached to actually overrides a method in a parent class or implements one declared in an interface. If no such method exists, PHP emits a fatal error instead of silently accepting a method that will never be called. It changes nothing about how your code runs otherwise.
Which PHP version added #[\Override]?
PHP 8.3, via the "Marking overridden methods" RFC by Tim Düsterhus, which passed 22 votes to 1. PHP 8.5 extended it to properties as well as methods. Because attributes are parsed as comments by older versions, adding #[\Override] to code that still runs on PHP 8.2 or earlier is harmless — it simply does nothing.
Does #[\Override] work with interfaces and traits?
Yes to both, with a catch on traits. Interface methods satisfy the check, including an interface extending another interface. A trait method carrying #[\Override] is validated against the class that uses the trait, which lets you require consumers to override a default implementation. But a method declared in a class that also uses a trait declaring the same name shadows it, and that does not satisfy the check.
Is #[\Override] the same as @inheritdoc?
No. @inheritdoc is a docblock convention that tells IDEs and documentation generators to pull the parent's description down into the child. It is inert — nothing verifies that a parent method exists. #[\Override] is enforced by the PHP engine itself and produces a fatal error when the relationship is broken.
Does #[\Override] slow down my application?
No. The check happens once when the class is compiled, alongside the signature compatibility checks PHP already performs on every inheritance relationship. There is no runtime instantiation of the attribute, no reflection, and no measurable effect on request time. With OPcache enabled the class is compiled once per deploy.
Can PHPStan enforce #[\Override] automatically?
Yes. Set checkMissingOverrideMethodAttribute: true in phpstan.neon with phpVersion at 80300 or above, and PHPStan will report every method that overrides a parent without the attribute. Pair it with Rector's AddOverrideAttributeToOverriddenMethodsRector to fix the existing violations in bulk, and set that rule's add_to_interface_methods option to true so the two tools agree about interface implementations.