Skip to content

Conversation

tegos
Copy link
Contributor

@tegos tegos commented Sep 22, 2025

Problem

By default, Laravel silently ignores ORDER BY and LIMIT on DELETE … JOIN queries when using the MySQL grammar.
This results in unexpected behavior: instead of deleting rows in small batches, the query deletes all matching rows at once.

Example:

$batchSize = 500;
$totalDeleted = 0;

do {
	$deleted = DB::table('cross_product_references as cpr')
		->leftJoin('products as p1', fn($join) => $join
			->on('cpr.article', '=', 'p1.article')
			->on('cpr.brand_id', '=', 'p1.brand_id'))
		->leftJoin('products as p2', fn($join) => $join
			->on('cpr.cross_article', '=', 'p2.article')
			->on('cpr.cross_brand_id', '=', 'p2.brand_id'))
		->where(fn($query) => $query
			->whereNull('p1.id')
			->orWhereNull('p2.id'))
		->limit($batchSize)
		->orderBy('cpr.id')
		->delete();
	$totalDeleted += $deleted;
} while ($deleted > 0);

$this->line("Deleted <info>$totalDeleted</info> rows.");

Expected: delete in batches of 500
Actual: deletes all 500k+ rows in one query


Fix

  • Added compileDeleteWithJoins override in MySqlGrammar.
  • Now compiles full SQL with JOIN, ORDER BY, and LIMIT instead of stripping them.
  • Leaves it to the underlying MySQL/MariaDB/Vitess implementation to decide whether the query is valid.

Why

  • Avoids silent ignoring of ORDER BY and LIMIT, which could lead to accidental mass deletions.
  • Provides consistent behavior: Laravel now generates the full query requested by the developer.
  • Compatible with systems like Vitess, which do support DELETE … JOIN … ORDER BY/LIMIT.
  • On vanilla MySQL, developers will now get QueryException.

@crynobone crynobone marked this pull request as draft September 22, 2025 13:15
@crynobone
Copy link
Member

Moved to draft as tests are failing

@crynobone crynobone added the needs work Not quite ready for primetime label Sep 22, 2025
@tegos
Copy link
Contributor Author

tegos commented Sep 23, 2025

update tests to cover mysql specific delete with join restrictions

@tegos tegos marked this pull request as ready for review September 23, 2025 20:57
@GrahamCampbell
Copy link
Member

Why not just generate the invalid statement always, so that you get the error back from MySQL?

@tegos
Copy link
Contributor Author

tegos commented Sep 24, 2025

@GrahamCampbell
Currently there is no compileDeleteWithJoins implementation in MySqlGrammar.
I chose to throw a RuntimeException instead of compiling invalid SQL because it fails fast and avoids silently stripping ORDER BY/LIMIT.
This provides a clear, framework-level error rather than a generic syntax error from MySQL.
Implementing full join/order/limit compilation just to produce invalid SQL would add unnecessary complexity and likely touch many more parts of the grammar.
This way the behavior stays predictable and safer across environments.

RuntimeException
MySQL does not support ORDER BY on DELETE statements with JOIN clauses.
at vendor/laravel/framework/src/Illuminate/Database/Query/Grammars/MySqlGrammar.php:352
Illuminate\Database\QueryException
SQLSTATE[42000]: Syntax error or access violation: 1064 You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'order by `cpr`.`id` asc' at line 1 (Connection: mysql, SQL: delete `cpr` from `cross_product_references` as `cpr` left join `products` as `p1` on `cpr`.`article` = `p1`.`article` and `cpr`.`brand_id` = `p1`.`brand_id` left join `products` as `p2` on `cpr`.`cross_article` = `p2`.`article` and `cpr`.`cross_brand_id` = `p2`.`brand_id` where (`p1`.`id` is null or `p2`.`id` is null or `p1`.`category_id` in (9) or `p2`.`category_id` in (9)) order by `cpr`.`id` asc)
at vendor/laravel/framework/src/Illuminate/Database/Connection.php:829

@GrahamCampbell
Copy link
Member

Some MySQL servers do support delete join with limit and order by though. For example Vitess and PlanetScale. vitessio/vitess#14959

@GrahamCampbell
Copy link
Member

It works by selecting the rows with a lock for update, and then performing the delete using a wherein.

@tegos
Copy link
Contributor Author

tegos commented Sep 24, 2025

@GrahamCampbell
True, Vitess/PlanetScale support this by rewriting the query internally,
but that's not native MySQL behavior. 😄
We're targeting the standard MySQL driver here, not clustering systems or proxies that add their own SQL semantics.
With the current MySqlGrammar, Laravel would silently drop LIMIT/ORDER BY on deletes with joins, potentially removing all rows.
By throwing an explicit exception, developers can immediately see the problem and apply the known workaround
(e.g. select IDs with FOR UPDATE then delete with WHERE IN). This keeps Laravel's behavior consistent and predictable.

@GrahamCampbell
Copy link
Member

I still think it's better to just allow the server to complain the query is invalid.

@tegos
Copy link
Contributor Author

tegos commented Sep 24, 2025

@GrahamCampbell
so what actually are you offering? could you tell what need to change?

@taylorotwell
Copy link
Member

I sort of agree that maybe we should just add the LIMIT and if the underlying MySQL system doesn't like it then it will error anyways? But, those using PlanetScale would still be able to use that feature?

@taylorotwell taylorotwell marked this pull request as draft September 24, 2025 16:31
@tegos tegos changed the title Prevent unsafe DELETE with JOIN in MySQL when using ORDER BY or LIMIT Compile full DELETE with JOIN including ORDER BY and LIMIT in MySQL grammar Sep 25, 2025
@tegos
Copy link
Contributor Author

tegos commented Sep 25, 2025

@taylorotwell agree, maybe it's better option and good compromise. And this still solve my initial problem. I've changed title and description. Enable ORDER BY and LIMIT compilation in MySQL DELETE … JOIN queries, updating tests to assert full SQL generation.

@tegos tegos marked this pull request as ready for review September 25, 2025 12:53
@taylorotwell
Copy link
Member

Can we send this to master branch? I'm a bit worried about this breaking existing applications if people build up a query builder instance with an orderBy then re-use that instance to perform a delete.

@taylorotwell taylorotwell marked this pull request as draft September 28, 2025 13:21
@tegos tegos changed the base branch from 12.x to master September 28, 2025 15:10
@tegos tegos changed the base branch from master to 12.x September 28, 2025 15:11
StyleCIBot and others added 25 commits September 28, 2025 15:44
Finding the middle of a list of values using the float div operator (/) is wrong, and goes wrong at some higher values where the int/2 is not representable as a float to single digit precision. 

That's why intdiv() exists.
* Fix PHP 8.5 null-key deprecations

* Fix getOrPut

* Update InteractsWithIO.php

* Update Collection.php

---------

Co-authored-by: Taylor Otwell <taylor@laravel.com>
* fix static analysis error

* fix typehint instead
…aravel#57157)

This adds null coalescing to maintain backward compatibility when
Htmlable::toHtml() returns non-string values.

**Breaking Change Context:**
The addition of explicit `: string` return type creates a BC break because
Htmlable::toHtml() only has PHPDoc type hints, not runtime enforcement.
 IMHO it should probably be reverted, this commit provides at least some backward compatibility.

**Example of previously valid code that now breaks:**
```php
class CustomHtmlable implements Htmlable
{
    public function toHtml()
    {
        // Valid before: PHPDoc says @return string but no runtime enforcement
        return $this->hasContent() ? $this->content : null;
    }
}

// This worked before but now causes TypeError
echo e(new CustomHtmlable());
* use copy instead of reference

* style fix
* Remove Request overview from Exceptions

* Fix HTTP status code
Co-authored-by: Sergey Danilchenko <s.danilchenko@ttbooking.ru>
Co-authored-by: Sergey Danilchenko <s.danilchenko@ttbooking.ru>
@tegos tegos force-pushed the fix/mysql-delete-joins-limit-order-check branch from bd43b53 to accc64a Compare September 28, 2025 15:48
@tegos tegos changed the base branch from 12.x to master September 28, 2025 15:49
@tegos
Copy link
Contributor Author

tegos commented Sep 28, 2025

I'll close this PR and open a new one against master with a clean branch based on the latest upstream.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
needs work Not quite ready for primetime
Projects
None yet
Development

Successfully merging this pull request may close these issues.