I read Paul Redmond’s write-up of the new DB::olap() method on Laravel News this morning, and my first reaction was a mix of “nice, small, useful” and “I want to be suspicious of this before I trust it”. I haven’t run it against a PlanetScale branch yet. So everything below is a read of the article, the pull request and PlanetScale’s docs, plus some opinions about where I’d get burned. Not a benchmark.
Short version for the impatient: DB::olap() is a tidy way to lift PlanetScale’s per-query row cap for one callback. It is good for occasional exports and backfills. It is a bad default for anything a user is waiting on, and it has one trap that code review will miss.
What the limit actually is
PlanetScale runs MySQL on Vitess, and by default every query runs in an OLTP workload with guard rails. According to the PlanetScale system limits page, a query there can return, update or delete at most 100,000 rows, and a result set tops out at 64 MiB. Those numbers exist so one lazy report can’t starve everyone else on the cluster.
The part that bites Laravel developers is not the limit itself. It’s that nothing on your laptop enforces it. A local MySQL container happily returns two million rows, your export job passes its test with a seeded table of fifty rows, and the first time it meets a real customer with three years of orders it falls over in production. The code is identical. The database is not.
That’s an old category of bug, and I’ve never found a cure other than running against something shaped like production. I wrote about a different flavour of the same problem, indexes your tests never notice are missing, and the lesson there applies here: a green test suite on a small database proves very little about a big one.
Vitess has a second workload, OLAP, that drops those limits. Until now, using it from Laravel meant reaching for a raw statement.
The old way and the new way
Before Laravel 13.36, running one query in OLAP mode looked like this:
try {
DB::statement("set workload = 'olap'");
$rows = Order::query()
->where('created_at', '>=', now()->subYears(3))
->get();
} finally {
DB::statement("set workload = 'oltp'");
}
Two things are wrong with that, and the article spells both out. The raw statement runs on the write connection, so if you have read replicas the select goes to a read connection that never left OLTP mode. You set the flag and the query ignored it. And because the workload is a session setting, a missing finally leaves the connection in OLAP for whatever runs next, which in a queue worker or an Octane process is somebody else’s job.
The new method, added in this framework pull request, wraps all of that:
$revenue = DB::olap(fn () => Order::query()
->selectRaw("customer_id, date_format(created_at, '%Y-%m') as month, sum(total) as revenue")
->where('created_at', '>=', now()->subYears(3))
->groupBy('customer_id', 'month')
->get()
);
It sets the workload on both the write and the read connection, runs your callback, and flips back to OLTP in a finally block. Whatever the callback returns, you get back. If the database isn’t Vitess, it just calls the callback and does nothing, which means the same code runs on local MySQL and in CI without a branch.
I like that last decision a lot. A helper that only works against one vendor would force an if at every call site, and people would forget it. A no-op elsewhere is the right default.
One catch: the method lives on the MySQL connection class. If your default connection is PostgreSQL or SQLite, DB::olap() throws a BadMethodCallException. That matters if you run SQLite in tests and MySQL in production. You’d get a failure in the one place you didn’t want one.
The trap: lazy results leave the callback
This is the part I’d flag in every pull request that uses the method.
The workload flips back the moment the callback returns. So this looks right and isn’t:
$orders = DB::olap(fn () => Order::query()->cursor());
foreach ($orders as $order) {
// the query actually runs here, back in OLTP mode
}
cursor(), lazy() and lazyById() return a LazyCollection, and a lazy collection doesn’t touch the database until something iterates it. The arrow function returns the collection, olap() resets the workload, and then your foreach triggers the real query under the old limits. The code reads as correct. It fails, or worse, silently runs under different rules than you think.
The fix is boring: iterate inside the callback, or call get() there. The article’s queued export does the first. But “boring fix” and “easy to notice in review” are different things. An arrow function returning a query builder chain looks identical whether the last call is get() or cursor().
There’s a second, quieter limit. OLAP removes PlanetScale’s 64 MiB result cap, but it doesn’t remove PHP’s memory_limit. A get() over three years of orders moves the ceiling from the database to your worker’s memory. I’d pair olap() with lazyById() and a loop that writes rows out as it goes, and I’d treat a bare get() over a big table as a smell even when the database allows it.
Where I would still chunk
PlanetScale’s own docs recommend against OLAP for regular application queries. The article summarises that guidance: the OLTP limits stop long-running queries from using up the database’s resources, and large reads are better paginated or copied to a separate analytics database. It also says OLAP queries can fail if the connection to PlanetScale is interrupted.
That last point changes how I’d write an export. Suppose a queued job streams a million rows inside one olap() call and the connection drops at row 900,000. The job retries, and it starts over from row one. For a nightly report nobody is waiting on, that may be fine. For anything with a deadline, I’d rather have a job that remembers where it stopped:
public function handle(): void
{
$lastId = Cache::get("export:{$this->path}:last_id", 0);
Order::query()
->where('id', '>', $lastId)
->chunkById(5000, function ($orders) {
$this->append($orders);
Cache::put("export:{$this->path}:last_id", $orders->last()->id, now()->addDay());
});
}
That runs entirely in OLTP mode, each chunk is a small query well under every limit, and a retry picks up near where it died. It’s more code than DB::olap(). It’s also the version I’d bet on at 2am.
My rough rule: reach for olap() when the job is one-off or rare and a restart is cheap. A backfill, an admin report, a data fix. Reach for chunking when the work is recurring, user-facing, or expensive to repeat. And for reporting that keeps growing, stop reading the production database at all and copy the data somewhere built for it, which is exactly what PlanetScale suggests.
The article frames olap() around reads, and I’d keep it there. I haven’t seen anything that tells me the OLAP workload is a good idea for the update and delete side of that 100,000-row limit, so for bulk writes I’d still chunk and not assume.
A guard I’d put around it
Because the method is so easy to call, I’d wrap it once and make the dangerous uses harder. Something like:
final class HeavyRead
{
public static function run(Closure $callback): mixed
{
if (! app()->runningInConsole()) {
throw new LogicException('Heavy reads belong in queued jobs or commands.');
}
$start = microtime(true);
try {
return DB::olap($callback);
} finally {
Log::info('heavy read finished', [
'seconds' => round(microtime(true) - $start, 1),
]);
}
}
}
It refuses to run inside a web request, so nobody wires a “download all orders” button straight to an unlimited query. It logs a duration, so you notice when a one-off report quietly becomes a nightly habit. And with the one call site in one file, reviewing for the lazy-collection mistake is a single grep.
Two smaller details from the article are worth knowing. The same pull request adds isVitess() to the MySQL connection, which returns true when the server’s version string mentions Vitess or PlanetScale. Like isMaria(), it connects to the database to read that version, so the first call costs a round trip. And if your read connection hasn’t connected yet when you call olap(), the method connects it first. Neither is a problem. Both are the kind of thing I’d want to know before I saw an unexpected connection in a profiler.
What I would do this week
Search your codebase for exports, reports and backfills that call get() or all() on a table that has grown. Those are your candidates, and they’re also the jobs most likely to have never been run against production-sized data.
Then pick one and test it against a PlanetScale development branch with a realistic row count, not a seeded fifty. If it fails on the limit, you have two honest options: wrap it in DB::olap() with iteration inside the callback, or rewrite it with chunkById() so a retry resumes. Decide per job using the restart-cost rule above.
Last, grep for set workload in your repository. Any raw statement you find is a replica-routing bug or a leaked session waiting to happen, and swapping it for DB::olap() is a five-minute change.
If you’re running Laravel on PlanetScale and want a second pair of eyes on an export that keeps timing out, that’s the kind of backend work I do. You can see more of it on my portfolio.