Allowed memory size of 134217728 bytes exhausted (PHPSpreadsheet)
PHPSpreadsheet keeps the entire workbook in memory as an object graph. The
error is not a leak or a misconfiguration. It is the library working as
designed, on more rows than the design allows for.
Why it happens
Every cell you write becomes a Cell object holding its value, data
type, a style index and a reference back to its parent worksheet. Rows and
columns carry their own dimension objects. None of it is released until the
workbook is saved, because the writer needs the whole thing: the shared string
table, the style table and the dimensions are all computed across every cell
before a single byte of XML is produced.
That works out at roughly 700–800 bytes per cell in practice. Measured on PHP
8.4 with PHPSpreadsheet 5.9, writing four simple columns of strings and
numbers:
10,000 rows (40,000 cells) 53 MB peak
20,000 rows (80,000 cells) 80 MB peak
50,000 rows (200,000 cells) 160 MB peak ← fails on the 128 MB default
So the default memory_limit = 128M gets you to about 20,000 rows
of plain data. Add styling, a formula per row, or twenty columns instead of
four, and it is considerably fewer. The traceback usually points at
StringTable.php or a writer class, which is misleading: that is
simply where the last allocation happened, not where the memory went.
Fix 1: move cells out of PHP's heap
PHPSpreadsheet can store cells in a PSR-16 cache instead of memory. This is the
fix that keeps your existing code: the only change is one line at startup.
composer require symfony/cache
use PhpOffice\PhpSpreadsheet\Settings;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Cache\Psr16Cache;
Settings::setCache(
new Psr16Cache(new FilesystemAdapter('phpspreadsheet', 0, sys_get_temp_dir()))
);
// ... the rest of your export is unchanged
On the same 50,000-row export this takes peak memory from 160 MB to
66 MB, which fits comfortably inside the default limit.
It costs time, and a lot of it. The same export went from
5 seconds to 50 seconds. Every cell now makes a round trip
through the filesystem. At 100,000 rows it did not finish inside five
minutes.
Use a memory-backed cache (Redis, APCu) instead of the filesystem and it is
faster, but then you are paying for the memory somewhere else. This fix buys
headroom, not scale.
Fix 2: use a writer that streams
PHPSpreadsheet has no streaming writer, and cannot easily have one: the shared
string table and style table are computed across the whole workbook. If your
export is genuinely large, the right answer in PHP is
OpenSpout, which writes
each row to the file as you add it and never holds more than one row.
composer require openspout/openspout
use OpenSpout\Common\Entity\Row;
use OpenSpout\Writer\XLSX\Writer;
use OpenSpout\Writer\XLSX\Options;
$writer = new Writer(new Options());
$writer->openToFile($path); // or openToBrowser($filename)
foreach ($invoices as $invoice) {
$writer->addRow(Row::fromValues([
$invoice->customer,
$invoice->amount,
$invoice->issued_at->format('Y-m-d'),
$invoice->status,
]));
}
$writer->close();
Same data, same machine, memory_limit=128M:
PHPSpreadsheet 50,000 rows 160 MB 5s (fails at 128 MB)
PHPSpreadsheet + cache 50,000 rows 66 MB 50s
OpenSpout 50,000 rows 2 MB 1s
OpenSpout 1,000,000 rows 2 MB ~20s
Memory is flat because nothing accumulates. A million rows costs the same 2 MB
as fifty thousand.
The trade is features. OpenSpout does styling, merged cells and multiple
sheets, but not formulas, charts, conditional formatting, or reading the
formatting of an existing file. If your export is a table of data, which most
exports are, you lose nothing. If it is a formatted report with computed
totals, you do.
Fix 3: raise the limit, or move off the web request
ini_set('memory_limit', '512M') is the fix everybody tries first,
and it works right up until it doesn't. Memory use grows linearly with cells,
so every fixed limit has a row count that exceeds it, and the limit you pick
becomes the size of export a single user can trigger on a shared web server.
It is listed third because it is a stopgap rather than a fix. If you take it,
take the other half too: move the export off the request and into a queued job,
so a large one cannot occupy a PHP-FPM worker for a minute or exhaust the pool
when three people click at once.
What we would do
If the export is a data table and you can change the writer, use OpenSpout.
That is the honest answer and it costs an afternoon.
If you cannot change the writer, or the export is a formatted report, or you
would rather not own the memory problem at all, that is what Jarrah is for. Post
the rows and get back a styled .xlsx built outside your process.
Write them as NDJSON: a header line describing the workbook, then one line per
row, so your side never holds more than one row either. Handing every row to
json_encode at once would just move the same 160 MB out of
PHPSpreadsheet and into the request body:
$fh = fopen('php://temp/maxmemory:2097152', 'w+');
fwrite($fh, json_encode(['sheets' => [[
'name' => 'Invoices',
'columns' => [
['header' => 'Customer'],
['header' => 'Amount', 'style' => 'money'],
],
]]]) . "\n");
foreach ($invoices as $invoice) { // a generator, or an unbuffered query
fwrite($fh, json_encode([$invoice['customer'], $invoice['amount']]) . "\n");
}
rewind($fh);
$ch = curl_init('https://api.jarrah.sh/v1/jobs');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_UPLOAD => true, // read the body from INFILE, chunked
CURLOPT_INFILE => $fh,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('JARRAH_KEY'),
'Content-Type: application/x-ndjson',
],
]);
$job = json_decode(curl_exec($ch), true); // poll /v1/jobs/{id}, or take a webhook
php://temp holds the first 2 MB in memory and spills the rest to a
temporary file; CURLOPT_UPLOAD makes curl read it back in chunks
rather than loading it. Measured against a local sink: 4 MB at 50,000
rows, 8 MB at 200,000, and still 8 MB at a million.
For small exports there is a synchronous endpoint that hands the file straight
back. See the quickstart. It has a per-plan cell
ceiling, so a 50,000-row sheet takes the streaming path above.
What each option costs you
50,000 rows x 4 columns, PHP 8.4, standalone script with no framework, same data every run. Memory and time are what your own process
spends: the numbers you can reproduce on your machine.
Jarrah's figures are what your script spends writing and sending the rows, measured against a local sink: 4 MB at 50,000 rows and 8 MB at a million. The workbook is then built on our side, which is a network round trip you are paying for and is not in that column.
Where this is the wrong choice. If your export is a plain data table and you can change the writer, OpenSpout wins and it is not close. It finishes the file inside your process in about a second, holds 2 MB doing it, costs nothing, and adds no network dependency to a request that currently has none. Jarrah earns its place when you need the formatting OpenSpout drops (formulas, charts, conditional formatting) and flat memory, or when you would rather not own the export path at all.