Update: the fix described in this post has since been merged into Laravel and ships in the next
13.xrelease. From that release on, a child class’s plain$timeoutproperty correctly overrides an attribute inherited from a parent, so the specific footgun below no longer bites. This post stays up as a historical write-up: how the bug behaved, the subtle PHP reflection edge case behind it, and the fix that closed it.
I recently lost an embarrassing amount of time to a queued job that kept timing out far sooner than it should have. The job clearly declared public int $timeout = 1700, the worker was configured to allow it, and yet it got killed after a handful of seconds. No error pointing at the cause, no warning. The property was right there in the class, set to the value I wanted, but the queued payload still used a different timeout.
The cause turned out to be a base class. It declared the timeout as a PHP attribute, and that changed how Laravel resolved the child’s plain property. The reason is subtle and easy to walk into without noticing.
The setup that breaks#
Modern Laravel lets you configure queued jobs two ways: the long-standing property style (public int $timeout, public int $tries) and class-level attributes (#[Timeout], #[Tries]). Both are supported. The trouble starts when you mix them across an inheritance boundary.
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\Attributes\Timeout;
#[Timeout(40)]
abstract class BaseJob implements ShouldQueue
{
use Dispatchable;
}
class GenerateReport extends BaseJob
{
public int $timeout = 1700;
public function handle(): void
{
// takes a few minutes on a big account
}
}
The base class sets a sensible 40 second default for the small, fast jobs that make up most of the queue. GenerateReport is the exception: it crunches data and needs minutes, so it bumps $timeout to 1700. Reasonable. Reads fine in review.
Now dispatch it and look at what actually gets pushed onto the queue:
GenerateReport::dispatch();
// The payload's timeout is 40, not 1700.
The job is dispatched with a 40 second timeout. The $timeout = 1700 property is still present on the job object, but it does not become the payload timeout. Your big job gets killed by SIGKILL at 40 seconds, every single time.
Why the property loses#
The resolution lives in Illuminate\Support\Traits\ReadsClassAttributes::getAttributeValue()
, which the queue uses to build a job’s payload at dispatch time. The relevant method, lightly trimmed:
protected function getAttributeValue($target, string $attributeClass, ?string $property = null, $default = null)
{
$reflection = new ReflectionClass($target);
$defaultProperties = $reflection->getDefaultProperties();
if (isset($target->{$property}) && $target->{$property} !== ($defaultProperties[$property] ?? null)) {
return $target->{$property};
}
if ($instance = $this->getAttributeInstance($target, $attributeClass)) {
return $this->extractAttributeValue($instance);
}
return $target->{$property} ?? $default;
}
That first if is the interesting part.
The property is only used when the live value differs from getDefaultProperties()[$property]. And getDefaultProperties() returns the value written in the property declaration. So for public int $timeout = 1700, the declared default is 1700, and the live value is also 1700, because nothing reassigned it. 1700 !== 1700 is false. The property is skipped.
Next, getAttributeInstance() walks up the class hierarchy looking for the attribute. It finds #[Timeout(40)] on the parent, and that’s what you get. The 1700 never had a chance.
A statically declared property can never win that first check. Its live value equals its own declaration, so the !== is false. The only way the property branch fires is if something assigns the property a value different from its declaration at runtime, for example in a constructor:
class GenerateReport extends BaseJob
{
public int $timeout = 40; // declared default matches the base
public function __construct()
{
$this->timeout = 1700; // now live (1700) !== default (40), so the property wins
}
}
That works, and it’s not an accident. Laravel is trying to answer a question PHP can’t answer cleanly: did the developer deliberately set this, or is it just sitting at its default? Reflection gives you the declared default and the current value, but it does not expose a flag for “this property was explicitly assigned.”
So Laravel treats “different from the declared default” as “set by the user” and otherwise gives the attribute a chance to win. For a class with no competing inherited attribute, that’s invisible, because the final line returns the property value anyway. It only bites when an ancestor declares the same thing as an attribute.
How you actually hit this#
You don’t write both on the same class. Nobody does that. You hit it across inheritance, which is exactly where it’s hard to see:
- A base job class adds
#[Timeout]or#[Tries]to set a default for a family of jobs. - Existing child classes already set
public $timeoutthe old way. - Everything keeps reading correctly.
$job->timeoutstill returns 1700. Tests that assert on the property stay green. - But the dispatched payload now carries the parent’s attribute value, and the worker arms its timeout from the payload.
The resolution happens at dispatch and gets baked into the serialized payload, so even jobs already sitting in the queue carry the wrong value. Fixing the class doesn’t fix them.
The consequences depend on what got clobbered, and they’re rarely a clean failure:
- A short timeout kills long jobs mid-flight. With
failOnTimeoutleft at its default offalseand retries still available, the killed job isn’t failed yet, it’s left reserved, then retried only after the connection’sretry_afterelapses. On a long-running queue connection that can be half an hour later, so the symptom is “the job seems to hang for ages and then eventually fails,” which sends you looking in entirely the wrong place. - An inherited
#[Tries]quietly changes your retry behavior. - An inherited
#[UniqueFor]changes how long the unique lock is held.
And the whole time, the property in the class says otherwise.
Is this a Laravel bug?#
Sort of, but I don’t think this is a case of careless framework code.
Laravel has supported public $timeout for years, and current Laravel still supports it. Having an inherited attribute silently override a child’s declared property, with no error, violates the principle of least astonishment. That part is a real footgun.
But the underlying cause is a PHP runtime limitation. You cannot distinguish “left at the declared default” from “explicitly assigned a value that happens to equal the default.” Both are identical at runtime. Given that, the “if it differs from the default, the developer meant it” heuristic is defensible, and it’s correct in the common case. The breakage is specific: a parent attribute plus a child property override whose value equals its own declaration.
PHP reflection can tell Laravel where a property and an attribute are declared, so a narrower fix is possible: prefer a property that’s redeclared on a subclass below the class that declares the attribute. That solves this inheritance case cleanly, even if it doesn’t solve the general “was this value assigned or is it just the default?” question.
So that’s exactly what I did: I sent the fix upstream as laravel/framework#60369
, and it’s now merged into 13.x. It’s a narrow change: a public queue property declared on a child class now overrides an attribute inherited from a parent, while same-class resolution stays exactly as it was. Everything above describes how Laravel behaved up to and including v13.13.0; from the next 13.x release onward, this particular footgun is gone. That’s the nice part of an open-source framework: you don’t have to live with a sharp edge once you understand it, you contribute the fix back and everyone downstream benefits. What I would not do is patch the framework in vendor/ and call it a day. That’s a maintenance trap of its own, and it helps nobody else.
How to avoid it#
Once you’re on a release with the fix, the child property wins and you don’t have to think about any of this. But if you’re still on v13.13.0 or earlier, or you just want the cleaner habit either way, the rule is simple: pick one mechanism per class hierarchy and stick to it.
If a base job class configures the queue with attributes, its children must override with attributes too, not properties:
#[Timeout(1700)]
#[Tries(1)]
class GenerateReport extends BaseJob
{
public function handle(): void { /* ... */ }
}
Because getAttributeInstance() walks from the child upward and returns the first match, the child’s #[Timeout(1700)] is found before the parent’s, and it wins cleanly. No reflection guessing involved.
Conversely, if your base class uses properties, don’t sprinkle a #[Timeout] attribute onto it later and expect every existing child’s property to keep working. That single attribute silently changes the resolution for the whole subtree.
Two more things worth doing:
- Assert the dispatched payload, not the property. A test that checks
(new GenerateReport)->timeout === 1700passes right through this bug, because the property genuinely is 1700. Assert on what gets queued instead, for example by checking the stored queue payload or by building the payload in a focused test. That’s the value the worker actually uses. - Remember it’s not just timeout. The same
getAttributeValueresolution drives#[Timeout],#[Tries],#[Backoff],#[MaxExceptions],#[FailOnTimeout], and#[UniqueFor]. For normal object jobs,timeout,tries,backoff,maxExceptions, andfailOnTimeoutare written into the queue payload at dispatch time.uniqueForis used when Laravel acquires the unique job lock. If you mix attributes and properties for any of those across a hierarchy, the same kind of silent override can apply.
The lesson I took from it: attributes and properties are two ways of saying the same thing, and Laravel resolves the conflict with a heuristic that can’t read your mind. The moment you split them across a parent and a child, you’ve handed it a guess to make. Don’t.