jarrah

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:

approach20,000 rows50,000 rowssurvives 128 MB to…
FromCollection139 MB · 1.6s286 MB · 4.1s~15,000 rows
FromQuery111 MB · 1.6s214 MB · 4.1s~20,000 rows
FromQuery + cell cache70 MB · 22s108 MB · 58s50,000 rows, in 58s
OpenSpout, streamed24 MB · 1.0s24 MB · 2.5s200,000+ rows

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.

approachpeak memorytime before it breaksformulas & charts
FromCollection286 MB4.1s~15,000 rowsyes
FromQuery214 MB4.1s~20,000 rowsyes
FromQuery + cell cache108 MB58s~50,000 rowsyes
OpenSpout + lazy()24 MB2.5s200,000+no
Jarrah, whole body in memory98 MB0.6s + the requestno limit you tuneyes
Jarrah, NDJSON via php://temp30 MB1.3s + the requestno limit you tuneyes

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.