Excel-Import & -Export: Große Datenmengen verarbeiten
"Der Excel-Import braucht 5 Minuten und frisst 2GB RAM." "Bei 50.000 Zeilen bricht der Export ab." "Warum ist meine XLSX-Datei 80MB groß?" – Klassische Excel-Probleme in PHP. Mit den richtigen Techniken verarbeiten wir Millionen Zeilen ohne Memory-Explosion.
TL;DR – Die Kurzfassung
- PhpSpreadsheet: Standard-Library, aber memory-hungrig bei großen Dateien
- Streaming/Chunking: Für große Dateien unverzichtbar
- CSV statt XLSX: 10x schneller, 5x kleiner – wenn Format egal
- Background-Jobs: Imports >10.000 Zeilen nie im Request
- Validierung: Header + erste N Zeilen prüfen, dann streaming mit Fehler-Sammlung (max X)
Das Problem mit Excel in PHP
┌─────────────────────────────────────────────────────────────────┐
│ EXCEL MEMORY-PROBLEM │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Naive Ansatz (PhpSpreadsheet Standard): │
│ ──────────────────────────────────────── │
│ 50.000 Zeilen × 20 Spalten = 1.000.000 Zellen │
│ → Jede Zelle ist ein PHP-Objekt │
│ → ~500 Bytes pro Zelle (konservativ) │
│ → 500MB RAM nur für die Daten! │
│ → Plus Overhead: 800MB-1.5GB gesamt │
│ │
│ Mit Streaming/Chunking: │
│ ─────────────────────── │
│ 1.000 Zeilen pro Chunk × 20 Spalten = 20.000 Zellen │
│ → ~10MB RAM konstant │
│ → Beliebig große Dateien möglich │
│ │
└─────────────────────────────────────────────────────────────────┘
Library-Übersicht
| Library | Stärken | Schwächen | Empfehlung |
|---|---|---|---|
| PhpSpreadsheet | Feature-komplett, XLSX/XLS/ODS | Memory-hungrig | Standard für kleine/mittlere Dateien |
| Spout (box/spout) | Streaming, memory-effizient | Weniger Features | Große Dateien (>10k Zeilen) |
| SimpleXLSX | Leichtgewichtig | Nur Lesen | Schnelle Imports |
| Native CSV | Schnellste Option | Kein Styling | Datenexport ohne Formatierung |
| OpenSpout | Spout-Nachfolger, aktiv maintained | API-Änderungen | Neue Projekte mit großen Dateien |
# Installation
composer require phpoffice/phpspreadsheet # Standard
composer require openspout/openspout # Für große Dateien
Import: Grundlagen
Einfacher Import (kleine Dateien)
<?php
declare(strict_types=1);
use PhpOffice\PhpSpreadsheet\IOFactory;
final class SimpleExcelImporter
{
/**
* Für kleine Dateien (<5.000 Zeilen)
* ⚠️ NICHT für große Dateien verwenden!
*/
public function import(string $filePath): array
{
// Automatische Format-Erkennung (xlsx, xls, csv, ods)
$spreadsheet = IOFactory::load($filePath);
$worksheet = $spreadsheet->getActiveSheet();
$rows = [];
$headers = [];
foreach ($worksheet->getRowIterator() as $rowIndex => $row) {
$cellIterator = $row->getCellIterator();
$cellIterator->setIterateOnlyExistingCells(false);
$rowData = [];
foreach ($cellIterator as $cell) {
$rowData[] = $cell->getValue();
}
if ($rowIndex === 1) {
// Erste Zeile = Header
$headers = $rowData;
continue;
}
// Zeile als assoziatives Array
$rows[] = array_combine($headers, $rowData);
}
// Speicher freigeben!
$spreadsheet->disconnectWorksheets();
unset($spreadsheet);
return $rows;
}
}
Streaming-Import für große Dateien
Hinweis: Spout/OpenSpout haben über Versionen API-Änderungen. Wenn dein Code nicht 1:1 passt, checke die README deiner installierten Version (Factory/StyleBuilder vs. direkte Klassen).
<?php
declare(strict_types=1);
use OpenSpout\Reader\XLSX\Reader;
use OpenSpout\Reader\XLSX\Options;
use Psr\Log\LoggerInterface;
final class StreamingExcelImporter
{
public function __construct(
private ?LoggerInterface $logger = null, // Optional, PSR-3 Logger
) {}
/**
* Für große Dateien (10.000+ Zeilen)
* Memory-Verbrauch: konstant ~10-20MB
*/
public function importWithCallback(string $filePath, callable $rowHandler): ImportResult
{
$options = new Options();
$options->SHOULD_FORMAT_DATES = true;
$reader = new Reader($options);
$reader->open($filePath);
$result = new ImportResult();
$headers = [];
foreach ($reader->getSheetIterator() as $sheet) {
foreach ($sheet->getRowIterator() as $rowIndex => $row) {
$cells = $row->getCells();
$rowData = array_map(fn($cell) => $cell->getValue(), $cells);
if ($rowIndex === 1) {
$headers = $rowData;
continue;
}
try {
// Callback verarbeitet jede Zeile einzeln
$rowHandler(array_combine($headers, $rowData), $rowIndex);
$result->incrementProcessed();
} catch (ValidationException $e) {
$result->addError($rowIndex, $e->getMessage());
} catch (\Throwable $e) {
$result->addError($rowIndex, 'Unbekannter Fehler: ' . $e->getMessage());
}
// Fortschritt loggen alle 1000 Zeilen
if ($rowIndex % 1000 === 0) {
$this->logger?->info("Processed {$rowIndex} rows");
}
}
// Nur erstes Sheet verarbeiten
break;
}
$reader->close();
return $result;
}
}
// Nutzung (Logger optional)
$importer = new StreamingExcelImporter($logger); // oder: new StreamingExcelImporter()
$result = $importer->importWithCallback($filePath, function (array $row, int $index) {
// Validierung
$validated = $this->validator->validate($row);
// In DB speichern
$this->repository->upsert($validated);
});
echo "Verarbeitet: {$result->processed}, Fehler: {$result->errorCount}";
Chunk-Import mit Background-Jobs
Für wirklich große Imports (100.000+ Zeilen): In Chunks aufteilen und als Background-Jobs verarbeiten.
⚠️ Performance-Warnung: Direkte Chunk-Verarbeitung auf XLSX ist O(n²) – jeder Chunk muss die Datei von vorn bis zum Offset durchlaufen. Bei 100 Chunks = 100× Datei-Scan.
Bessere Alternativen:
- Single-Worker: Ein Worker liest einmal durch, committet alle 1.000 Zeilen
- CSV-Intermediate: Beim Upload XLSX→CSV konvertieren (Streaming), dann Chunk-Jobs auf CSV (kann Zeilen überspringen)
- Row-Range im ersten Pass: Beim Zählen die Byte-Offsets speichern, dann direkt anspringen
Die sauberste Lösung für Queue + große Dateien: CSV als Zwischenformat.
<?php
declare(strict_types=1);
/**
* ⚠️ Hinweis: Dieser Ansatz ist bei sehr großen Dateien langsam (O(n²)).
* Für Produktion: XLSX beim Upload zu CSV konvertieren, dann Chunk-Jobs auf CSV.
*/
final class ChunkedExcelImporter
{
private const CHUNK_SIZE = 1000;
public function __construct(
private QueueInterface $queue,
private string $uploadDir,
) {}
/**
* Schritt 1: Datei hochladen und Import-Job erstellen
*/
public function startImport(UploadedFile $file, string $userId): string
{
// Datei speichern
$importId = Uuid::v4();
$filePath = "{$this->uploadDir}/{$importId}.xlsx";
$file->moveTo($filePath);
// Zeilen zählen (schnell, ohne alles zu laden)
$totalRows = $this->countRows($filePath);
// Import-Record erstellen
$import = new Import(
id: $importId,
userId: $userId,
filePath: $filePath,
totalRows: $totalRows,
status: ImportStatus::PENDING,
);
$this->importRepository->save($import);
// Chunks als Jobs in Queue
$chunks = (int) ceil($totalRows / self::CHUNK_SIZE);
for ($chunk = 0; $chunk < $chunks; $chunk++) {
$this->queue->push('imports', [
'type' => 'process_chunk',
'import_id' => $importId,
'chunk' => $chunk,
'offset' => $chunk * self::CHUNK_SIZE,
'limit' => self::CHUNK_SIZE,
]);
}
return $importId;
}
/**
* Schritt 2: Einzelnen Chunk verarbeiten (im Worker)
*/
public function processChunk(string $importId, int $offset, int $limit): void
{
$import = $this->importRepository->find($importId);
if ($import->status === ImportStatus::CANCELLED) {
return; // Abgebrochen
}
$import->status = ImportStatus::PROCESSING;
$this->importRepository->save($import);
$options = new Options();
$reader = new Reader($options);
$reader->open($import->filePath);
$currentRow = 0;
$headers = [];
$processedInChunk = 0;
$errorsInChunk = [];
foreach ($reader->getSheetIterator() as $sheet) {
foreach ($sheet->getRowIterator() as $rowIndex => $row) {
$cells = $row->getCells();
$rowData = array_map(fn($cell) => $cell->getValue(), $cells);
if ($rowIndex === 1) {
$headers = $rowData;
continue;
}
$currentRow++;
// Nur Zeilen in diesem Chunk verarbeiten
if ($currentRow <= $offset) {
continue;
}
if ($currentRow > $offset + $limit) {
break 2; // Chunk fertig
}
try {
$data = array_combine($headers, $rowData);
$this->processRow($data, $rowIndex, $import);
$processedInChunk++;
} catch (\Throwable $e) {
$errorsInChunk[] = [
'row' => $rowIndex,
'error' => $e->getMessage(),
];
}
}
}
$reader->close();
// Fortschritt aktualisieren
$this->updateProgress($import, $processedInChunk, $errorsInChunk);
}
private function countRows(string $filePath): int
{
// Schnelles Zählen ohne alle Daten zu laden
$reader = new Reader();
$reader->open($filePath);
$count = 0;
foreach ($reader->getSheetIterator() as $sheet) {
foreach ($sheet->getRowIterator() as $row) {
$count++;
}
break;
}
$reader->close();
return $count - 1; // Minus Header
}
private function updateProgress(Import $import, int $processed, array $errors): void
{
$import->processedRows += $processed;
$import->errors = array_merge($import->errors, $errors);
if ($import->processedRows >= $import->totalRows) {
$import->status = empty($import->errors)
? ImportStatus::COMPLETED
: ImportStatus::COMPLETED_WITH_ERRORS;
}
$this->importRepository->save($import);
// WebSocket/SSE für Live-Updates
$this->notifier->notifyProgress($import);
}
}
Validierung: Header + Streaming mit Fehler-Limit
Realität bei 1M+ Zeilen: "Vorher komplett validieren" bedeutet auch einen Voll-Scan. Pragmatischer Ansatz: Header + erste N Zeilen prüfen, dann streaming importieren und Fehler sammeln bis max X. Danach abbrechen und dem User die Fehler-Liste zeigen.
<?php
declare(strict_types=1);
final class ExcelValidator
{
/**
* Validiert Datei mit Streaming (Header + Zeilen bis max 100 Fehler)
* Gibt alle gefundenen Fehler zurück
*/
public function validate(string $filePath, array $rules): ValidationResult
{
$result = new ValidationResult();
$reader = new Reader();
$reader->open($filePath);
$headers = [];
$requiredHeaders = array_keys($rules);
foreach ($reader->getSheetIterator() as $sheet) {
foreach ($sheet->getRowIterator() as $rowIndex => $row) {
$cells = $row->getCells();
$rowData = array_map(fn($cell) => $cell->getValue(), $cells);
if ($rowIndex === 1) {
$headers = $rowData;
// Header-Validierung
$missing = array_diff($requiredHeaders, $headers);
if (!empty($missing)) {
$result->addError(1, 'Fehlende Spalten: ' . implode(', ', $missing));
break 2; // Abbruch
}
continue;
}
$data = array_combine($headers, $rowData);
// Zeilen-Validierung
foreach ($rules as $field => $fieldRules) {
$value = $data[$field] ?? null;
foreach ($fieldRules as $rule) {
$error = $this->validateField($value, $rule, $field);
if ($error !== null) {
$result->addError($rowIndex, $error);
}
}
}
// Max 100 Fehler sammeln, dann abbrechen
if ($result->errorCount() > 100) {
$result->addError(0, 'Zu viele Fehler, Validierung abgebrochen');
break 2;
}
}
break;
}
$reader->close();
return $result;
}
private function validateField(mixed $value, array $rule, string $field): ?string
{
return match ($rule['type']) {
'required' => empty($value) && $value !== 0
? "Zeile: Feld '{$field}' ist erforderlich"
: null,
'email' => $value && !filter_var($value, FILTER_VALIDATE_EMAIL)
? "Zeile: '{$field}' ist keine gültige E-Mail"
: null,
'numeric' => $value && !is_numeric($value)
? "Zeile: '{$field}' muss eine Zahl sein"
: null,
'date' => $value && !$this->isValidDate($value)
? "Zeile: '{$field}' ist kein gültiges Datum"
: null,
'in' => $value && !in_array($value, $rule['values'], true)
? "Zeile: '{$field}' muss einer der Werte sein: " . implode(', ', $rule['values'])
: null,
'unique' => null, // Wird separat geprüft (DB-Lookup)
default => null,
};
}
private function isValidDate(mixed $value): bool
{
if ($value instanceof \DateTimeInterface) {
return true;
}
if (is_numeric($value)) {
// Excel-Seriennummer
return $value > 0 && $value < 2958466; // Bis Jahr 9999
}
return strtotime((string) $value) !== false;
}
}
// Nutzung
$validator = new ExcelValidator();
$result = $validator->validate($filePath, [
'email' => [
['type' => 'required'],
['type' => 'email'],
],
'name' => [
['type' => 'required'],
],
'status' => [
['type' => 'in', 'values' => ['active', 'inactive', 'pending']],
],
'amount' => [
['type' => 'numeric'],
],
]);
if (!$result->isValid()) {
return response()->json([
'errors' => $result->getErrors(),
], 422);
}
Export: Grundlagen
Einfacher Export (kleine Datenmengen)
<?php
declare(strict_types=1);
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
final class SimpleExcelExporter
{
/**
* Für kleine Exports (<10.000 Zeilen)
*
* @param array $data Zeilen-Daten (jede Zeile als assoc Array)
* @param array $headers MUSS assoziativ sein: ['db_key' => 'Spaltenname', ...]
*/
public function export(array $data, array $headers): string
{
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
// Header schreiben (nur die Werte = sichtbare Spaltennamen)
$col = 1;
foreach (array_values($headers) as $header) {
$sheet->setCellValueByColumnAndRow($col, 1, $header);
$col++;
}
// Header-Styling
$headerRange = 'A1:' . $sheet->getHighestColumn() . '1';
$sheet->getStyle($headerRange)->applyFromArray([
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
'fill' => [
'fillType' => Fill::FILL_SOLID,
'startColor' => ['rgb' => '1a355e'],
],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER],
]);
// Daten schreiben (Keys aus $headers für Zugriff auf $rowData)
$row = 2;
foreach ($data as $rowData) {
$col = 1;
foreach (array_keys($headers) as $key) {
$value = $rowData[$key] ?? '';
// Datum-Formatierung
if ($value instanceof \DateTimeInterface) {
$sheet->setCellValueByColumnAndRow($col, $row, $value->format('d.m.Y'));
} else {
$sheet->setCellValueByColumnAndRow($col, $row, $value);
}
$col++;
}
$row++;
}
// Auto-Breite für alle Spalten (funktioniert auch bei > 26 Spalten)
// ⚠️ range('A', 'AA') ist kaputt, daher Coordinate-Klasse nutzen
$maxCol = Coordinate::columnIndexFromString($sheet->getHighestColumn());
for ($i = 1; $i <= $maxCol; $i++) {
$colLetter = Coordinate::stringFromColumnIndex($i);
$sheet->getColumnDimension($colLetter)->setAutoSize(true);
}
// Temporäre Datei
$tempFile = tempnam(sys_get_temp_dir(), 'excel_') . '.xlsx';
$writer = new Xlsx($spreadsheet);
$writer->save($tempFile);
// Speicher freigeben
$spreadsheet->disconnectWorksheets();
unset($spreadsheet);
return $tempFile;
}
}
Streaming-Export für große Datenmengen
<?php
declare(strict_types=1);
use OpenSpout\Writer\XLSX\Writer;
use OpenSpout\Writer\XLSX\Options;
use OpenSpout\Common\Entity\Row;
use OpenSpout\Common\Entity\Cell;
use OpenSpout\Common\Entity\Style\Style;
use OpenSpout\Common\Entity\Style\Color;
use Psr\Log\LoggerInterface;
final class StreamingExcelExporter
{
public function __construct(
private ?LoggerInterface $logger = null, // Optional, PSR-3 Logger
) {}
/**
* Für große Exports (100.000+ Zeilen)
* Schreibt direkt in Datei, kein Memory-Problem
*/
public function export(iterable $dataGenerator, array $headers, string $outputPath): void
{
$options = new Options();
$options->DEFAULT_ROW_STYLE = (new Style())->setFontSize(11);
$writer = new Writer($options);
$writer->openToFile($outputPath);
// Header mit Styling
$headerStyle = (new Style())
->setFontBold()
->setFontColor(Color::WHITE)
->setBackgroundColor('1a355e');
$headerCells = array_map(
fn($h) => Cell::fromValue($h, $headerStyle),
array_values($headers)
);
$writer->addRow(new Row($headerCells));
// Daten streamen
$rowCount = 0;
foreach ($dataGenerator as $rowData) {
$cells = [];
foreach (array_keys($headers) as $key) {
$value = $rowData[$key] ?? '';
// Typ-Handling
if ($value instanceof \DateTimeInterface) {
$cells[] = Cell::fromValue($value->format('Y-m-d H:i:s'));
} elseif (is_bool($value)) {
$cells[] = Cell::fromValue($value ? 'Ja' : 'Nein');
} else {
$cells[] = Cell::fromValue($value);
}
}
$writer->addRow(new Row($cells));
$rowCount++;
// Logging alle 10.000 Zeilen
if ($rowCount % 10000 === 0) {
$this->logger?->info("Exported {$rowCount} rows");
}
}
$writer->close();
$this->logger?->info("Export completed: {$rowCount} rows");
}
/**
* Streaming direkt zum Browser (Download)
*/
public function exportToResponse(iterable $dataGenerator, array $headers, string $filename): void
{
$options = new Options();
$writer = new Writer($options);
// Headers für Download
header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Cache-Control: max-age=0');
$writer->openToBrowser($filename);
// Header-Zeile
$headerCells = array_map(fn($h) => Cell::fromValue($h), array_values($headers));
$writer->addRow(new Row($headerCells));
// Daten streamen
foreach ($dataGenerator as $rowData) {
$cells = [];
foreach (array_keys($headers) as $key) {
$cells[] = Cell::fromValue($rowData[$key] ?? '');
}
$writer->addRow(new Row($cells));
}
$writer->close();
exit;
}
}
// Nutzung mit Generator für Memory-Effizienz
function getOrdersGenerator(PDO $db): \Generator
{
$stmt = $db->query("SELECT * FROM orders ORDER BY created_at DESC");
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
yield $row;
}
}
$exporter = new StreamingExcelExporter($logger); // oder: new StreamingExcelExporter()
$exporter->export(
getOrdersGenerator($db),
[
'id' => 'Bestellnummer',
'customer_name' => 'Kunde',
'total' => 'Gesamtbetrag',
'created_at' => 'Datum',
],
'/tmp/orders.xlsx'
);
CSV: Die schnelle Alternative
Wenn Formatierung nicht wichtig ist: CSV ist 10x schneller und 5x kleiner.
<?php
declare(strict_types=1);
final class CsvExporter
{
/**
* Schnellster Export, minimaler Memory
*/
public function export(iterable $data, array $headers, string $outputPath): void
{
$handle = fopen($outputPath, 'w');
// BOM für Excel-Kompatibilität (UTF-8)
fwrite($handle, "\xEF\xBB\xBF");
// Header
fputcsv($handle, array_values($headers), ';');
// Daten
foreach ($data as $row) {
$rowData = [];
foreach (array_keys($headers) as $key) {
$value = $row[$key] ?? '';
// Datum formatieren
if ($value instanceof \DateTimeInterface) {
$value = $value->format('d.m.Y H:i');
}
$rowData[] = $value;
}
fputcsv($handle, $rowData, ';');
}
fclose($handle);
}
/**
* Streaming zum Browser
*/
public function exportToResponse(iterable $data, array $headers, string $filename): void
{
header('Content-Type: text/csv; charset=UTF-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
$output = fopen('php://output', 'w');
// BOM für Excel
fwrite($output, "\xEF\xBB\xBF");
// Header
fputcsv($output, array_values($headers), ';');
// Daten streamen
foreach ($data as $row) {
$rowData = [];
foreach (array_keys($headers) as $key) {
$rowData[] = $row[$key] ?? '';
}
fputcsv($output, $rowData, ';');
// Flush für echtes Streaming
flush();
}
fclose($output);
exit;
}
}
final class CsvImporter
{
/**
* Schneller CSV-Import mit Generator
*/
public function import(string $filePath, string $delimiter = ';'): \Generator
{
$handle = fopen($filePath, 'r');
// BOM überspringen
$bom = fread($handle, 3);
if ($bom !== "\xEF\xBB\xBF") {
rewind($handle);
}
// Header lesen
$headers = fgetcsv($handle, 0, $delimiter);
$headerCount = count($headers);
// Zeilen als Generator
while (($row = fgetcsv($handle, 0, $delimiter)) !== false) {
// Normalisieren: Excel-CSVs haben oft trailing empty columns
$rowCount = count($row);
if ($rowCount === $headerCount) {
yield array_combine($headers, $row);
} elseif ($rowCount > $headerCount) {
// Zu viele Spalten: abschneiden
yield array_combine($headers, array_slice($row, 0, $headerCount));
} elseif ($rowCount > 0) {
// Zu wenige Spalten: mit leeren Strings auffüllen
$padded = array_pad($row, $headerCount, '');
yield array_combine($headers, $padded);
}
// Komplett leere Zeilen überspringen
}
fclose($handle);
}
}
// Performance-Vergleich (100.000 Zeilen, 20 Spalten)
// ┌────────────┬──────────┬────────────┬───────────┐
// │ Format │ Zeit │ Memory │ Dateigröße│
// ├────────────┼──────────┼────────────┼───────────┤
// │ XLSX │ 45s │ 512MB │ 12MB │
// │ XLSX Stream│ 25s │ 20MB │ 12MB │
// │ CSV │ 3s │ 5MB │ 8MB │
// └────────────┴──────────┴────────────┴───────────┘
Background-Export mit Download-Link
<?php
declare(strict_types=1);
final class AsyncExportService
{
public function __construct(
private QueueInterface $queue,
private ExportRepository $exports,
private string $exportDir,
) {}
/**
* Export starten (gibt sofort zurück)
*/
public function startExport(ExportRequest $request, string $userId): string
{
$exportId = Uuid::v4();
$export = new Export(
id: $exportId,
userId: $userId,
type: $request->type,
filters: $request->filters,
status: ExportStatus::PENDING,
);
$this->exports->save($export);
// Job in Queue
$this->queue->push('exports', [
'type' => 'generate_export',
'export_id' => $exportId,
]);
return $exportId;
}
/**
* Export generieren (im Worker)
*/
public function processExport(string $exportId): void
{
$export = $this->exports->find($exportId);
$export->status = ExportStatus::PROCESSING;
$export->startedAt = new \DateTimeImmutable();
$this->exports->save($export);
try {
$filename = "{$exportId}.xlsx";
$filePath = "{$this->exportDir}/{$filename}";
// Daten holen (als Generator)
$data = $this->getExportData($export);
// Export erstellen
$exporter = new StreamingExcelExporter();
$exporter->export($data, $this->getHeaders($export->type), $filePath);
// Erfolgreich
$export->status = ExportStatus::COMPLETED;
$export->filePath = $filePath;
$export->completedAt = new \DateTimeImmutable();
// Download-Link generieren (zeitlich begrenzt)
$export->downloadUrl = $this->generateSignedUrl($filePath, hours: 24);
// User benachrichtigen
$this->notifyUser($export);
} catch (\Throwable $e) {
$export->status = ExportStatus::FAILED;
$export->error = $e->getMessage();
$this->logger->error('Export failed', [
'export_id' => $exportId,
'error' => $e->getMessage(),
]);
}
$this->exports->save($export);
}
/**
* Signierte Download-URL (nur für berechtigten User)
*
* ⚠️ Signiert nur basename, nie den vollen Pfad (Path-Traversal-Schutz)
*/
private function generateSignedUrl(string $filePath, int $hours): string
{
$filename = basename($filePath);
$expires = time() + ($hours * 3600);
// WICHTIG: Signatur-Input = genau das, was später geprüft wird
$signature = hash_hmac('sha256', "{$filename}:{$expires}", $this->secret);
return "/exports/download?" . http_build_query([
'file' => $filename,
'expires' => $expires,
'signature' => $signature,
]);
}
/**
* Download-Endpoint
*/
public function download(string $file, int $expires, string $signature): void
{
// ⚠️ IMMER basename() nutzen (Path-Traversal-Schutz)
$filename = basename($file);
// Signatur prüfen (muss mit generateSignedUrl() übereinstimmen)
$expected = hash_hmac('sha256', "{$filename}:{$expires}", $this->secret);
if (!hash_equals($expected, $signature)) {
throw new ForbiddenException('Invalid signature');
}
// Abgelaufen?
if (time() > $expires) {
throw new GoneException('Download link expired');
}
// Pfad aus exportDir + basename (nie User-Input direkt!)
$filePath = "{$this->exportDir}/{$filename}";
if (!file_exists($filePath)) {
throw new NotFoundException('File not found');
}
// Download senden
header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . filesize($filePath));
readfile($filePath);
exit;
}
}
// API-Endpoints
// POST /api/exports → startExport()
// GET /api/exports/{id} → Status abfragen
// GET /exports/download → download()
Excel-Templates verwenden
<?php
declare(strict_types=1);
use PhpOffice\PhpSpreadsheet\IOFactory;
final class TemplateExporter
{
/**
* Befüllt ein vorbereitetes Excel-Template
* Gut für: Rechnungen, Reports mit Formeln, Formatierungen
*/
public function fillTemplate(string $templatePath, array $data): string
{
$spreadsheet = IOFactory::load($templatePath);
$sheet = $spreadsheet->getActiveSheet();
// Einfache Platzhalter ersetzen
foreach ($data['placeholders'] ?? [] as $placeholder => $value) {
$this->replacePlaceholder($sheet, $placeholder, $value);
}
// Tabellen-Daten einfügen
if (isset($data['table'])) {
$this->fillTable(
$sheet,
$data['table']['startRow'],
$data['table']['data'],
$data['table']['columns']
);
}
// Temporäre Datei
$outputPath = tempnam(sys_get_temp_dir(), 'excel_') . '.xlsx';
$writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet);
$writer->save($outputPath);
$spreadsheet->disconnectWorksheets();
return $outputPath;
}
private function replacePlaceholder($sheet, string $placeholder, mixed $value): void
{
foreach ($sheet->getRowIterator() as $row) {
foreach ($row->getCellIterator() as $cell) {
$cellValue = $cell->getValue();
if (is_string($cellValue) && str_contains($cellValue, "{{$placeholder}}")) {
$cell->setValue(str_replace("{{$placeholder}}", $value, $cellValue));
}
}
}
}
private function fillTable($sheet, int $startRow, array $data, array $columns): void
{
// Zeilen einfügen (Formatierung kopieren)
if (count($data) > 1) {
$sheet->insertNewRowBefore($startRow + 1, count($data) - 1);
}
$row = $startRow;
foreach ($data as $rowData) {
$col = 1;
foreach ($columns as $key) {
$sheet->setCellValueByColumnAndRow($col, $row, $rowData[$key] ?? '');
$col++;
}
$row++;
}
}
}
// Nutzung für Rechnung
$exporter = new TemplateExporter();
$filePath = $exporter->fillTemplate('/templates/invoice.xlsx', [
'placeholders' => [
'invoice_number' => 'RE-2025-001234',
'invoice_date' => '08.12.2025',
'customer_name' => 'Musterfirma GmbH',
'customer_address' => 'Musterstraße 123, 12345 Musterstadt',
'total_net' => '1.234,56 €',
'vat' => '234,57 €',
'total_gross' => '1.469,13 €',
],
'table' => [
'startRow' => 15,
'columns' => ['position', 'description', 'quantity', 'unit_price', 'total'],
'data' => [
['position' => 1, 'description' => 'Beratung', 'quantity' => 10, 'unit_price' => '100,00 €', 'total' => '1.000,00 €'],
['position' => 2, 'description' => 'Entwicklung', 'quantity' => 2, 'unit_price' => '117,28 €', 'total' => '234,56 €'],
],
],
]);
Fehlerbehandlung & Logging
<?php
declare(strict_types=1);
final class RobustExcelImporter
{
private array $errors = [];
private array $warnings = [];
public function import(string $filePath): ImportResult
{
// 1. Datei-Validierung
if (!$this->validateFile($filePath)) {
return ImportResult::failed($this->errors);
}
// 2. Transaktionaler Import
$this->db->beginTransaction();
try {
$processed = $this->processFile($filePath);
if (!empty($this->errors)) {
$this->db->rollBack();
return ImportResult::failed($this->errors);
}
$this->db->commit();
return ImportResult::success($processed, $this->warnings);
} catch (\Throwable $e) {
$this->db->rollBack();
$this->logger->error('Import failed', [
'file' => $filePath,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return ImportResult::failed(['Import fehlgeschlagen: ' . $e->getMessage()]);
}
}
private function validateFile(string $filePath): bool
{
// Datei existiert?
if (!file_exists($filePath)) {
$this->errors[] = 'Datei nicht gefunden';
return false;
}
// Dateigröße prüfen
$maxSize = 50 * 1024 * 1024; // 50MB
if (filesize($filePath) > $maxSize) {
$this->errors[] = 'Datei zu groß (max. 50MB)';
return false;
}
// MIME-Type prüfen
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($filePath);
$allowedMimes = [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel',
'text/csv',
'text/plain',
];
if (!in_array($mimeType, $allowedMimes)) {
$this->errors[] = "Ungültiger Dateityp: {$mimeType}";
return false;
}
// Datei lesbar?
try {
$reader = new Reader();
$reader->open($filePath);
$reader->close();
} catch (\Throwable $e) {
$this->errors[] = 'Datei konnte nicht gelesen werden: ' . $e->getMessage();
return false;
}
return true;
}
private function processFile(string $filePath): int
{
$reader = new Reader();
$reader->open($filePath);
$processed = 0;
$headers = [];
foreach ($reader->getSheetIterator() as $sheet) {
foreach ($sheet->getRowIterator() as $rowIndex => $row) {
try {
$cells = $row->getCells();
$rowData = array_map(fn($c) => $c->getValue(), $cells);
if ($rowIndex === 1) {
$headers = $rowData;
continue;
}
// Leere Zeilen überspringen
if ($this->isEmptyRow($rowData)) {
continue;
}
$data = array_combine($headers, $rowData);
$this->processRow($data, $rowIndex);
$processed++;
} catch (ValidationException $e) {
// Validierungsfehler sammeln, nicht abbrechen
$this->errors[] = "Zeile {$rowIndex}: {$e->getMessage()}";
// Bei zu vielen Fehlern abbrechen
if (count($this->errors) > 100) {
throw new TooManyErrorsException('Zu viele Fehler');
}
}
}
break;
}
$reader->close();
return $processed;
}
private function isEmptyRow(array $row): bool
{
foreach ($row as $cell) {
if ($cell !== null && $cell !== '') {
return false;
}
}
return true;
}
}
Checkliste
┌─────────────────────────────────────────────────────────────────┐
│ EXCEL-VERARBEITUNG CHECKLISTE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ IMPORT │
│ □ Dateityp validieren (MIME-Type, Extension) │
│ □ Dateigröße begrenzen │
│ □ Header-Zeile prüfen (erwartete Spalten) │
│ □ Header + erste N Zeilen validieren, streaming + Fehlerlimit │
│ □ Transaktionaler Import (Rollback bei Fehler) │
│ □ >10.000 Zeilen: Background-Job + Chunking │
│ □ Streaming/Generator für große Dateien │
│ □ Fortschritts-Tracking für User │
│ │
│ EXPORT │
│ □ >10.000 Zeilen: Streaming statt In-Memory │
│ □ Generator für Datenbank-Queries │
│ □ Große Exports als Background-Job │
│ □ Signierte Download-Links (zeitbegrenzt) │
│ □ CSV-Option anbieten (schneller, kleiner) │
│ │
│ MEMORY │
│ □ Spreadsheet-Objekte nach Verwendung freigeben │
│ □ memory_limit für CLI-Scripts erhöhen │
│ □ gc_collect_cycles() nach großen Operationen │
│ □ Chunk-Größe an verfügbaren RAM anpassen │
│ │
│ SECURITY │
│ □ Uploads in nicht-öffentlichen Ordner │
│ □ MIME-Type serverseitig prüfen (nicht Extension) │
│ □ Dateinamen sanitizen │
│ □ Download-Berechtigung prüfen │
│ │
└─────────────────────────────────────────────────────────────────┘
Fazit
Excel-Verarbeitung in PHP ist kein Hexenwerk, aber Memory-Management ist kritisch:
- Kleine Dateien (<5.000 Zeilen): PhpSpreadsheet, alles in Memory
- Mittlere Dateien (5-50.000 Zeilen): OpenSpout/Spout mit Streaming
- Große Dateien (>50.000 Zeilen): Background-Jobs + Chunking
- Wenn Formatierung egal: CSV ist 10x schneller
Mein Standard-Stack: OpenSpout für Streaming, PhpSpreadsheet nur wenn Features wie Formeln/Styling nötig. Imports immer validieren, große Exports immer als Background-Job mit Download-Link.
Weiterführende Ressourcen
Über Carola Schulte
Software-Architektin mit 25+ Jahren Erfahrung. Spezialisiert auf robuste Business-Apps mit PHP/PostgreSQL, Security-by-Design und DSGVO-konforme Systeme. 1,8M+ Lines of Code in Produktion.
Excel-Verarbeitung für Ihre App?
Lassen Sie uns über Import/Export-Funktionen sprechen – kostenlos und unverbindlich.
Kostenloses Erstgespräch