Laravel Excel: allowed memory size exhausted, or maximum execution time of 30 seconds exceeded
Two errors, one cause. Laravel Excel wraps PhpSpreadsheet, and PhpSpreadsheet
builds the entire workbook in memory before it writes a byte. Every documented
fix addresses one half of that sentence.
PHP Fatal error: Allowed memory size of 134217728 bytes exhausted
(tried to allocate 2621440 bytes) in .../PhpSpreadsheet/Collection/Cells.php
PHP Fatal error: Maximum execution time of 30 seconds exceeded
What everyone tells you first, and what it actually buys
The standard advice is to swap FromCollection for
FromQuery. It is real advice. FromCollection
hydrates every row into an Eloquent model and holds the lot, and
FromQuery chunks the read so that never happens.
But it chunks the query, not the sheet. Each chunk is still
appended to a PhpSpreadsheet worksheet that lives in memory until
save(). You remove the Eloquent collection and keep the workbook.
Measured on Laravel 13.24, PHP 8.4, Laravel Excel 3.1.69 (which resolves
PhpSpreadsheet 1.30), four columns of strings and numbers. Laravel itself
occupies 22 MB before any export runs, and that is included in every figure:
FromQuery is worth about 5,000 rows. If your
export is 12,000 rows it is the whole fix; if it is 60,000 it is a rounding
error, and the afternoon you spend converting your exports to it is an
afternoon you do not get back.
Fix 1: FromQuery, because it is free
Do it anyway. It is a small change and it lowers the floor for everything else.
use App\Models\Invoice;
use Maatwebsite\Excel\Concerns\FromQuery;
use Maatwebsite\Excel\Concerns\Exportable;
class InvoicesExport implements FromQuery
{
use Exportable;
public function query()
{
return Invoice::query()->where('issued_at', '>=', $this->from);
}
}
Return the builder, never ->get(). Returning
Invoice::all() from query() is the mistake that makes
this change measure as no change at all.
Fix 2: a cell cache, with two traps
PhpSpreadsheet can hold cells in a PSR-16 cache instead of the PHP heap. On the
50,000-row export this took the peak from 214 MB to 108 MB,
which fits under the default limit.
The .env variable does nothing. The published
config/excel.php hardcodes 'driver' => 'memory'
. There is not a single env() call in its cache block. Setting
EXCEL_CACHE_DRIVER=batch is read by nothing, and the export
behaves exactly as before. Verify with php artisan tinker:
>>> config('excel.cache.driver')
=> "memory" // still, after setting the env var
And on 3.1.69 the bundled drivers then crash. With
config/excel.php edited to batch, exports under the
60,000-cell batch threshold work; above it, every run died with
The script tried to call a method on an incomplete object from
Cells.php. The illuminate driver failed at every
size I tried, including 10,000 rows. Same area as
Laravel-Excel
issue 4075, which is the cache path serialising things it should not.
In other words: the driver does nothing until it is set in the config file,
and once set, it breaks precisely when it starts doing something.
Setting the cache on PhpSpreadsheet directly does work. Put it in a service
provider's boot(), and leave excel.cache.driver on
memory so Laravel Excel does not replace it:
composer require symfony/cache
use PhpOffice\PhpSpreadsheet\Settings;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Cache\Psr16Cache;
public function boot(): void
{
Settings::setCache(
new Psr16Cache(new FilesystemAdapter('phpspreadsheet', 0, sys_get_temp_dir()))
);
}
Then read the second column of that table again. 50,000 rows took
58 seconds, up from 4. Which brings us to the other error.
The timeout is the memory fix's bill
Maximum execution time of 30 seconds exceeded is usually not a
separate problem. It is what you get after fixing the memory problem with a
cache: every cell now makes a round trip to disk, and the export that used to
die at 4 seconds and 214 MB now survives at 58 seconds and 108 MB. The 58 is
over the limit.
Raising max_execution_time is not the end of it either. Behind
nginx there is fastcgi_read_timeout, and in front of that a load
balancer with its own idea, and each one returns a different unhelpful page.
Queue the export. It is the right move regardless, because a 58-second export
holds a PHP-FPM worker for 58 seconds and a pool of ten is a pool of ten:
class InvoicesExport implements FromQuery, ShouldQueue
{
use Exportable;
// ...
}
(new InvoicesExport)->queue('invoices.xlsx')->chain([
new NotifyUserOfCompletedExport($request->user()),
]);
Note what this does and does not do. It removes the request timeout and frees
the worker. It does not reduce memory. The queue worker hits
the same limit on the same row, just somewhere nobody is looking. Queueing a
broken export converts a visible 500 into a silent failure.
Fix 3: a writer that streams
PhpSpreadsheet has no streaming writer, so neither does Laravel Excel. If your
export is a data table rather than a formatted report, drop to
OpenSpout, which writes
each row to the file as it goes:
composer require openspout/openspout
use OpenSpout\Common\Entity\Row;
use OpenSpout\Writer\XLSX\Options;
use OpenSpout\Writer\XLSX\Writer;
$writer = new Writer(new Options());
$writer->openToFile(storage_path('app/invoices.xlsx'));
$writer->addRow(Row::fromValues(['Customer', 'Amount', 'Issued', 'Status']));
Invoice::query()->lazy()->each(function (Invoice $invoice) use ($writer) {
$writer->addRow(Row::fromValues([
$invoice->customer,
$invoice->amount,
$invoice->issued_at->format('Y-m-d'),
$invoice->status,
]));
});
$writer->close();
lazy() on the Eloquent side and OpenSpout on the writer side means
neither end accumulates. 24 MB at 20,000 rows, 24 MB at 200,000, and 22 MB of
that is Laravel. The writer itself is flat.
What you give up: formulas, charts, conditional formatting, and reading an
existing workbook's formatting. Styling, merged cells and multiple sheets are
all supported. Most exports called "the Excel export" are a table with a bold
header row, and lose nothing.
What we would do
Data table, and you can change the writer: OpenSpout with lazy(),
queued. That is the honest answer, it costs an afternoon, and it does not come
back.
Formatted report, or a writer you cannot change, or you would rather the memory
limit stop being your problem: Jarrah takes the same rows and returns a styled
.xlsx built outside your process.
Stream them. Collecting rows into an array and posting the lot costs 98 MB at
50,000 rows: better than PhpSpreadsheet's 214, but still your memory, and it
grows with the export exactly like the thing you are replacing. NDJSON is a
header line then one line per row, and stays at Laravel's 24 MB floor:
use GuzzleHttp\Psr7\Utils;
use Illuminate\Support\Facades\Http;
// First 2 MB in memory, the rest spilled to a temp file, so the body is never
// in the heap even when it is 10 MB of NDJSON.
$fh = fopen('php://temp/maxmemory:2097152', 'w+');
fwrite($fh, json_encode(['sheets' => [[
'name' => 'Invoices',
'columns' => [
['header' => 'Customer'],
['header' => 'Amount', 'style' => 'money'],
],
]]]) . "\n");
foreach (Invoice::query()->cursor() as $invoice) {
fwrite($fh, json_encode([$invoice->customer, (float) $invoice->amount]) . "\n");
}
rewind($fh);
$job = Http::withToken(config('services.jarrah.key'))
->withBody(Utils::streamFor($fh), 'application/x-ndjson')
->post('https://api.jarrah.sh/v1/jobs')
->json();
Measured against a local sink: 26 MB at 20,000 rows, 30 MB at 50,000,
37 MB at 200,000, 22 MB of which is Laravel sitting there. Not
perfectly flat, but it grows by 11 MB across a tenfold increase in rows, where
FromQuery grows by 11 MB every thousand.
The obvious streaming answer does not work here. Wrapping a
generator in GuzzleHttp\Psr7\PumpStream is what the Guzzle docs
point you at, and it would avoid the temp file entirely. Guzzle's curl handler
rejects it: every request returned 400, with and without an
explicit Transfer-Encoding: chunked, while the same body from a
seekable stream succeeded. A stream of unknown length is the case it does not
take.
If you want the truly flat version, drop to curl_setopt with
CURLOPT_UPLOAD and CURLOPT_INFILE. That holds 8 MB
at a million rows. It is just no longer idiomatic Laravel.
Then poll GET /v1/jobs/{id} or supply a webhook. Both fit the
queued job you were going to write anyway: the queue removes the request
timeout, and streaming removes the memory. Fixing one without the other is what
turns a visible 500 into a silent one.
For small exports there is a synchronous endpoint that hands back the file in
the response. See the quickstart. It has a
per-plan cell ceiling, so anything the size of this page's examples takes the
streaming path.
What each option costs you
50,000 rows x 4 columns, Laravel 13.24 on PHP 8.4. Laravel itself is 22 MB before anything runs, and that is inside every figure. Memory and time are what your own process
spends: the numbers you can reproduce on your machine.
Two Jarrah rows because the obvious way to call it is not the cheap way. Collecting rows into an array and posting the lot costs 98 MB at 50,000 rows: better than PhpSpreadsheet, but still your memory, and it still grows with the export. Streaming the same rows through php://temp is 30 MB, 22 MB of which is Laravel doing nothing. Neither figure includes the round trip.
Where this is the wrong choice. OpenSpout with lazy() matches the streamed figure at 24 MB, finishes in your own process in 2.5 seconds, and bills you nothing. If your export is a data table, take it. Jarrah is the better answer when the export is a formatted report OpenSpout cannot produce, when you cannot change the writer, or when you want the export to stop being a thing you maintain. Not because it is faster, because it is not.