# Laravel Deadlocks: Queue Jobs That Already Ran End Up in failed_jobs

**Author:** Mozex | **Published:** 2026-10-05 | **Tags:** Laravel, PHP, DevOps, Database | **URL:** https://mozex.dev/blog/31-laravel-deadlocks-queue-jobs-that-already-ran-end-up-in-failed-jobs

---


I queued 2,000 jobs in a scratch Laravel 13.33.0 app and ran them through eight `php artisan queue:work` processes against MySQL 5.7. All 2,000 ran. When the workers stopped, 76 of them were still in the `jobs` table, reserved, and the log had lines that start like this one:

```text
[2026-09-23 20:05:01] local.ERROR: SQLSTATE[40001]: Serialization failure: 1213 Deadlock found when trying to get lock; try restarting transaction (Connection: mysql, Host: 127.0.0.1, Port: 41357, Database: dl, SQL: delete from `jobs` where `id` = 1664)
```

About 90 seconds later a worker picked the 76 up and moved every one of them to `failed_jobs` with "App\Jobs\Probe has been attempted too many times." None of them had failed. Each had finished its work before its row deadlocked.

<!--more-->

A 1213 means MySQL found two transactions waiting on each other's locks and rolled one of them back. Laravel has two answers to it: the `attempts` argument of `DB::transaction()` for your own code, and `FOR UPDATE SKIP LOCKED` for its database queue. I measured both, on Laravel 13.33.0, PHP 8.5, and MySQL 8.4.11 and 5.7.44 in Docker.

If you only need the answer: on the database queue, these deadlocks come from workers that reserve jobs with a plain `FOR UPDATE`, which Laravel uses on MySQL 5.7 and on any server it doesn't recognise as able to skip locked rows. On a server it does recognise, they went away in my runs. A one-line check further down tells you which kind of server you have. In your own transactions, `attempts` retries at once, runs your whole closure again, and treats a lock wait timeout like a deadlock.

## The DELETE deadlocks after the job has run

`SHOW ENGINE INNODB STATUS` after one of these runs names the two sides. One worker is reserving its next job with `select * from jobs where queue = ? ... order by id asc limit 1 for update`: it holds locks on entries of the `queue` index and waits for a row in the primary key. The other worker is deleting the job it just finished: it holds that primary-key row and waits for its `queue` index entry. Two locks, taken in opposite order. In the InnoDB reports I read, on 5.7 and on 8.4, MySQL rolled back the DELETE, and the error counts lean the same way: on 8.4 with the plain `FOR UPDATE` forced (how, below), 548 to 810 DELETEs lost per run against 2 to 4 SELECTs. In every run I logged per worker, the rows left behind matched the DELETE errors one for one.

After `handle()` returns, `DatabaseJob::delete()` marks the job as deleted in memory, then runs the DELETE in its own transaction with no retry. When the DELETE throws, the worker won't release a job that's marked deleted, and at `--tries=1` its attempt to fail the job stops in `Job::fail()` for the same reason. So nothing touches the row. It stays reserved with `attempts` at 1, and the worker's console shows `RUNNING` for it with no `DONE` after.

Then `retry_after` runs out (90 seconds in the stock `config/queue.php`), and the next worker reserves the row again as attempt 2:

- With `queue:work`'s default `--tries=1`, the worker fails it without running it. That's how the 76 rows in the opening got their `MaxAttemptsExceededException`. The job's `failed()` method runs too, for a job that succeeded: in a later run on 8.4 forced to the plain clause, it ran for all 784 of that run's leftover rows.
- With `--tries=3`, it runs a second time. One run on 8.4 forced to the plain clause left 749 such rows; I started a worker with `--tries=3` once `retry_after` had passed, and all 749 ran a second time.

The second case is why [every job should survive running twice](https://mozex.dev/blog/12-5-laravel-queue-failures-that-only-show-up-in-production#3-deployments-killing-jobs-mid-execution), deadlocks or not.

Rows like the 76 also need care with `queue:retry`: each one already ran once, so a retry runs it a second time. Not every `MaxAttemptsExceededException` row is one of these, though: [a job whose worker was killed mid-run](https://mozex.dev/blog/25-shouldbeunique-vs-withoutoverlapping-neither-sets-a-lock-expiry-for-you#a-job-that-times-out-leaves-the-queue-lock-behind) can land there without ever finishing.

The deadlocks on the reserving SELECT are cheaper. They happen before a job is picked, so the worker logs the error, sleeps for a second plus its `--sleep` value and asks again. In every run, every job still ran exactly once before the workers stopped.

## SKIP LOCKED removed the DELETE deadlocks

Laravel reserves with `FOR UPDATE SKIP LOCKED` only when it recognises the server: MySQL 8.0.1 or later, MariaDB 10.6 or later, PostgreSQL 9.5 or later, or Vitess 19 or later ([`DatabaseQueue::getLockForPopping()`](https://github.com/laravel/framework/blob/v13.33.0/src/Illuminate/Queue/DatabaseQueue.php)). SQL Server gets a `readpast` hint, and older MySQL and MariaDB versions get a plain `FOR UPDATE`, which waits for a locked row instead of skipping it. The [database docs](https://laravel.com/docs/database) still list MySQL 5.7+ and MariaDB 10.3+ as supported.

To separate the lock clause from the server version, I also ran MySQL 8.4 with two keys added to its connection in `config/database.php`: `version` set to `8.0.0`, which makes Laravel fall back to the plain clause on the same server, and `modes` set to the list Laravel uses for MySQL 8. Every run queued 2,000 jobs whose `handle()` inserts one row, on one queue, and started eight workers with `queue:work database --stop-when-empty-for=5 --sleep=1`:

| Server | Lock clause | Runs | DELETE deadlocks per run | First to last job |
|---|---|---|---|---|
| MySQL 8.4.11 | `FOR UPDATE SKIP LOCKED` | 3 | 0, 0, 0 | 9.2 to 10.8 s |
| MySQL 8.4.11 | `FOR UPDATE` | 6 | 548 to 810 | 21.2 to 22.8 s |
| MySQL 5.7.44 | `FOR UPDATE` | 9 | 0 to 622 | 8.4 to 20.3 s |

On the same server, changing only the clause took the DELETE deadlocks down to none, from 548 to 810 per run, and the runs took about half as long. MySQL 5.7 swung from run to run: four of its nine runs had no DELETE deadlock, and the worst had 622. With jobs that sleep for 50 ms, two more 5.7 runs still left 80 and 81 rows behind.

## Check which lock your workers use

This prints the clause your workers use:

```bash
php artisan tinker --execute='dump((fn () => $this->getLockForPopping())->call(Queue::connection("database")));'
```

It printed `"FOR UPDATE SKIP LOCKED"` against 8.4 and `true` against 5.7. `true` means the plain `FOR UPDATE`. The closure reads a protected method. `database` is the connection name from `config/queue.php`.

If it prints `true` on a server that supports SKIP LOCKED, Laravel read the wrong version. Add a `version` key with the server's real version to the database connection in `config/database.php`; neither the database page nor the queues page of the docs mentions it. Use the real version: unless `modes` is set, Laravel picks the `sql_mode` it sets on connect from that value, and when I set `5.7.0` on the 8.4 server, the first query failed with error 1231 because MySQL 8.4 rejects one of the modes Laravel sends for 5.7. The key exists because, in 2020, Azure's managed MySQL reported 5.6.42 for 8.0 servers: [#32708](https://github.com/laravel/framework/pull/32708) added it for the `sql_mode`, and [#35263](https://github.com/laravel/framework/pull/35263) made the queue read it too.

On a server without SKIP LOCKED, the old workaround from [#31660](https://github.com/laravel/framework/issues/31660) is to drop the `queue` index. With the plain clause forced on 8.4, two runs without it had no deadlocks at all, against 548 to 810 per run with it. Three runs on 5.7 without the index had none either, but that proves less: four of the nine 5.7 runs with the index had no DELETE deadlock. I only tested one queue with 2,000 rows. The maintainer who closed that issue objected that "the query uses the queue column to filter jobs".

## What `attempts` in `DB::transaction()` does with a deadlock

The [docs cover deadlocks](https://laravel.com/docs/database#handling-deadlocks) in two sentences: the second argument "defines the number of times a transaction should be retried when a deadlock occurs." Here's my reproduction, for `routes/console.php`, with an `accounts` table holding rows 1 and 2 and an integer `balance`:

```php
Artisan::command('transfer {from} {to}', function (int $from, int $to) {
    DB::transaction(function () use ($from, $to) {
        DB::table('accounts')->where('id', $from)->decrement('balance', 10);
        usleep(1_000_000);
        DB::table('accounts')->where('id', $to)->increment('balance', 10);
    }, attempts: 3);
});
```

Two copies started together in bash (Git Bash on Windows) lock the same two rows in opposite order:

```bash
php artisan transfer 1 2 & php artisan transfer 2 1 & wait
```

With `attempts: 1`, one of them throws the 1213. With `attempts: 3`, both commit. In a copy of the command that timed each attempt, the losing side started its second attempt 1.9 to 2.5 ms after the deadlock, in three runs. [`ManagesTransactions::transaction()`](https://github.com/laravel/framework/blob/v13.33.0/src/Illuminate/Database/Concerns/ManagesTransactions.php) is a `for` loop with no pause in it. A `backoff` parameter was proposed in January 2026 in [#58253](https://github.com/laravel/framework/pull/58253), and its author closed it after another contributor suggested you "wrap the transaction into a `retry` function."

Three more things the docs don't say:

**The whole closure runs again.** With a `Mail::raw()` inside it, the losing process sent two emails to the log mailer. With the mail registered through `DB::afterCommit()` instead, it sent one, because the rolled-back attempt's callbacks are dropped. I covered [holding queued jobs, listeners and observers until the commit](https://mozex.dev/blog/16-the-laravel-bug-your-tests-will-never-catch#every-way-to-fix-it) in an earlier post.

**A nested call doesn't retry.** Inside another transaction, `DB::transaction($work, 5)` ran its closure once and threw `DeadlockException`, in three runs out of three. Only the outermost transaction retries, since MySQL has already rolled back the whole transaction, outer part included. With `attempts: 3` on the outer call, the outer one retried and committed.

**Lock wait timeouts count too.** Laravel's [concurrency error detector](https://github.com/laravel/framework/blob/v13.33.0/src/Illuminate/Database/ConcurrencyErrorDetector.php) treats "Lock wait timeout exceeded" (error 1205) like a deadlock. With the row held by another open transaction, `attempts: 3` waited out MySQL's default 50-second `innodb_lock_wait_timeout` three times and threw after 150.05 seconds.

## A retry that pauses and gives up on lock waits

This is the wrapper I tested, for MySQL:

```php
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;

retry(3, fn () => DB::transaction(function () {
    // your queries
}), 100, fn (\Throwable $e) => $e instanceof QueryException && $e->getCode() === '40001');
```

MySQL reports a deadlock as SQLSTATE `40001` and a lock wait timeout as `HY000`, so the filter retries the first and rethrows the second. In the two-process test, the losing side paused for 115 to 118 ms and committed on its second attempt. Against a held row, with the timeout set to 2 seconds, it gave up after one wait instead of three. It still runs the closure again, so side effects belong after the commit here too.

If your app runs the database queue on a server where that tinker line prints `true`, look in `failed_jobs` for `MaxAttemptsExceededException` rows whose work happened anyway. And if you've seen the DELETE deadlock on MySQL 8 with SKIP LOCKED in use, I'd like to see the InnoDB report: it didn't happen once in my runs.