Updated June 2026. Tested on Laravel 13 and PHP 8.4. Part of the Techalyst queue series.

You open your logs or failed_jobs table and find a job marked failed with MaxAttemptsExceededException and the message "Job has been attempted too many times or run too long". The confusing part is that you cannot see any exception from your own code. So what happened? It is almost always one of two things.

Cause 1: the job timed out

Your job ran longer than its allowed timeout. The worker forcibly killed it, then checked whether the job was allowed another attempt. If the job had already used its attempts, or had passed its retryUntil expiry, there is nowhere left to go, so the worker marks it failed with MaxAttemptsExceededException.

The message is misleading: it was not your code throwing, it was the worker stopping a job that ran too long and then finding no attempts left.

Fixes:

  • Raise the job's timeout if the work legitimately needs longer, but remember to keep it below the connection's retry_after (see why your job ran twice).
  • If the job is slow because it does too much, split it into smaller jobs or a batch.
  • Give it enough attempts with $tries if a retry is actually reasonable.

Cause 2: the job was released past its limit

The other path is release(). Every time a job releases itself back to the queue, that counts as an attempt. A job that releases itself repeatedly, to retry an API call, to wait out a rate limit, to poll for something, will burn through its attempts. When it is picked up again, the worker first checks whether the maximum attempts has been exceeded or the job has expired, and if so, throws MaxAttemptsExceededException before even running your handle() method.

So a job designed to release itself several times needs enough attempts to cover every release.

class MonitorPendingOrder implements ShouldQueue
{
    public $tries = 4; // releases itself 3 times, runs a 4th, so needs 4
}

The better fix for self-releasing jobs: expire by time

When a job releases itself an unpredictable number of times, a fixed $tries count is fragile. It is cleaner to drop the attempt limit and expire the job by wall clock time instead.

public $tries = 0; // no attempt limit

public function retryUntil(): \DateTime
{
    return now()->addHours(12);
}

Now the job can release itself as often as it needs, and only stops being retried once twelve hours have passed, regardless of how many releases that took.

Guard against real failures while using retryUntil

With $tries = 0, a genuine bug could keep throwing for the whole twelve hour window. Cap that with maxExceptions, which fails the job fast if it keeps hitting real errors, while still allowing unlimited time based releases.

public $tries = 0;
public $maxExceptions = 3; // give up after 3 actual exceptions

This is the combination you usually want for self-releasing jobs: expire by time, but bail out quickly if the work is genuinely broken rather than just waiting.

Wrapping up

MaxAttemptsExceededException with no visible error means one of two things: the job ran past its timeout and had no attempts left, or it released itself more times than $tries allowed. Raise the timeout or split the work for the first, and give self-releasing jobs enough attempts (or better, swap $tries for retryUntil plus maxExceptions) for the second. Once you know which one it is, the fix is small.

More in the series: running the same queued job multiple times and handling API rate limits in queued jobs. Questions welcome below.