Wenn der Export-Button stumm versagt
Im “Datenrecherche”-Backend-Modul von VINUM.eu können Redaktion und Datenpflege beliebige Treffermengen aus dem Wein- und Adressbestand als XLSX exportieren. Ein typischer Use-Case: alle Winzer aus der Schweiz, die in den letzten fünf Jahren mindestens einen 18-Punkte-Wein hatten – für einen redaktionellen Newsletter oder eine Eventeinladung.
Bisher lief das problemlos. Bis ein Redakteur eine Suche mit 19.565 Treffern abgesetzt hat, den Export-Button klickte – und nichts passierte.
Die Herausforderung
Aus Benutzersicht: kein Fehler, kein Download, kein Hinweis. Aus Sicht der Logs: ein PHP-Fatal, sechsmal hintereinander, weil der Redakteur den Button mehrfach gedrückt hatte:
PHP Fatal error: Allowed memory size of 1073741824 bytes exhausted
(tried to allocate 167772160 bytes)
in /var/www/html/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Collection/Cells.php
on line 395
Das memory_limit der Produktion liegt bei 1 GB – großzügig dimensioniert. PhpSpreadsheet hat es trotzdem gesprengt.
Die Ursache lag nicht in der Datenbankabfrage. Die war bereits gebatcht: BackendSearchHelper::exportXlsx() lädt die Treffer in Hunderter-Schritten über eine Pagination-Hilfsmethode. Das eigentliche Problem war die Spreadsheet-Erstellung:
$spreadsheet = new Spreadsheet();
$worksheet = $spreadsheet->getActiveSheet();
foreach ($paginatedResults as $row) {
$worksheet->fromArray($row, null, "A{$rowIndex}");
$rowIndex++;
}
$writer = new Xlsx($spreadsheet);
$writer->save($tmpFile);
PhpSpreadsheet hält intern die komplette Cells-Collection im Arbeitsspeicher, bis save() aufgerufen wird. Egal wie elegant man die Daten in 100er-Blöcken liest – die Mappe selbst wächst monoton mit. Bei rund 19.500 Zeilen × der vollständigen TCA-Spaltenliste war Schluss.
Im Frontend gab es zusätzlich ein UI-Problem: Der Export-Button wurde durch ein <f:if condition="{pagination.count} <= 35000"> ausgeblendet, sobald mehr als 35.000 Treffer in der Suche standen. Bei genau 19.565 war der Button sichtbar – und scheiterte trotzdem. Schlimmer noch: Beim Überschreiten der Grenze verschwand der Button kommentarlos, ohne dem Nutzer zu erklären, warum.
Unsere Lösung
1. Streaming-Writer statt In-Memory-Mappe
Wir haben den Export auf OpenSpout migriert – eine Library, die XLSX-Zeilen direkt auf die Festplatte streamt, statt sie im RAM zu sammeln. Der Speicherverbrauch bleibt damit konstant, unabhängig von der Zeilenzahl.
Die Migration war erstaunlich überschaubar, weil sich die API-Konzepte ähneln:
use OpenSpout\Common\Entity\Row;
use OpenSpout\Writer\XLSX\Writer;
$writer = new Writer();
$writer->openToFile($tmpFile);
$writer->addRow(Row::fromValues($headers));
foreach ($this->iterateResults($table, $repository, $search) as $row) {
$writer->addRow(Row::fromValues($row));
}
$writer->close();
Statt einer Spreadsheet-Instanz wird ein Writer geöffnet, der direkt in den Output-Stream schreibt. Jeder addRow()-Aufruf flusht die Zeile, der Speicher bleibt im einstelligen MB-Bereich – auch bei 100.000 Zeilen.
| PhpSpreadsheet bei 19.565 Treffern | 1 GB memory_limit gesprengt |
| OpenSpout, auch bei 100.000 Zeilen | einstelliger MB-Bereich |
Wichtig: openspout/openspout haben wir explizit in der composer.json der backendsearch-Extension deklariert. Vorher griff die Extension auf PhpSpreadsheet zu, das nur transitive Abhängigkeit der Haupt-Extension vinum war. Solche impliziten Vendor-Abhängigkeiten sind eine tickende Zeitbombe: Wenn die übergeordnete Extension irgendwann auf eine andere Library wechselt, bricht der Export ohne sichtbaren Anlass.
2. Ehrliches UI-Gate
Das <f:if> blendet den Button nicht mehr stumm aus, sondern ersetzt ihn durch eine erklärende Zeile:
<f:if condition="{pagination.count} <= {settings.maxExportRows}">
<f:then>
<f:render section="ExportButton" arguments="{_all}" />
</f:then>
<f:else>
<p class="text-muted">
<f:translate
key="export.tooManyResults"
arguments="{0: '{settings.maxExportRows}'}" />
</p>
</f:else>
</f:if>
Auf Deutsch: “Export nur bis 35.000 Treffer möglich. Bitte Suche eingrenzen.” Der Schwellwert ist konfigurierbar, das tatsächliche Limit wird im Text genannt. Mit OpenSpout könnten wir die Obergrenze theoretisch deutlich höher legen – pragmatisch belassen wir sie aber bei 35.000, weil ein Export dieser Größenordnung in Excel ohnehin nicht mehr sinnvoll zu bearbeiten ist.
3. Diagnose-Pfad dokumentieren
Was bei diesem Fix unterschätzt wird: Der größere Aufwand steckte nicht in der Code-Änderung, sondern in der Diagnose. Ohne den Blick in fpm-stderr.log wäre die Ursache nicht eindeutig zuzuordnen gewesen – der Browser meldet keinen Fehler, das Backend liefert eine leere Antwort, und der Button “tut einfach nichts”.
Wir haben den Diagnose-Pfad daher in der Projektdokumentation festgehalten: Welche Logs für welche Symptome relevant sind, welche memory_limit- und max_execution_time-Werte produktiv gelten, und wie man PHP-Fatals einer konkreten Action im Backend zuordnet. Das spart bei der nächsten ähnlichen Meldung schlicht Zeit.
Das Ergebnis
- Der ursprünglich gescheiterte Export mit 19.565 Treffern läuft jetzt durch – ohne PHP-Fatal, mit konstantem Speicherverbrauch.
- Suchen oberhalb des konfigurierten Limits zeigen einen verständlichen Hinweistext statt eines verschwindenden Buttons.
- Die
backendsearch-Extension hat ihre Abhängigkeit zu Excel-Libraries jetzt sauber in der eigenencomposer.jsondeklariert. - Die öffentliche API des Helpers (
exportXlsx(string $table, object $repository, array $search): string) ist unverändert – der Refactoring lief vollständig innerhalb der Implementierung.
Wann lohnt sich der Wechsel?
PhpSpreadsheet ist eine ausgezeichnete Library, wenn man Formatierungen, Formeln, Diagramme oder mehrere Sheets benötigt. Für reine Datentabellen-Exporte mit größeren Treffermengen ist OpenSpout aber fast immer die bessere Wahl: schneller, schlanker, und ohne harte Obergrenze beim Datenvolumen.