Excel-Import & -Export
Datenverarbeitung

Excel-Import & -Export: Große Datenmengen verarbeiten

Carola Schulte
8. Dezember 2025
24 min

"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

LibraryStärkenSchwächenEmpfehlung
PhpSpreadsheetFeature-komplett, XLSX/XLS/ODSMemory-hungrigStandard für kleine/mittlere Dateien
Spout (box/spout)Streaming, memory-effizientWeniger FeaturesGroße Dateien (>10k Zeilen)
SimpleXLSXLeichtgewichtigNur LesenSchnelle Imports
Native CSVSchnellste OptionKein StylingDatenexport ohne Formatierung
OpenSpoutSpout-Nachfolger, aktiv maintainedAPI-ÄnderungenNeue 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

Carola Schulte

Ü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