diff --git a/CHANGELOG.md b/CHANGELOG.md
index 163e79a5d9..e56172b95e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,7 @@ Some earlier branches remain supported and security fixes are applied to them; i
- Support for Excel sparklines (line, column, and win/loss) in Xlsx reader and writer. [Issue #4941](https://github.com/PHPOffice/PhpSpreadsheet/issues/4941)
- Read-only object model for Pivot Tables. Existing pivot tables in an Xlsx file are now parsed into `Worksheet\PivotTable\PivotTable` objects (name, location, source cache definition, and row/column/page/data field layout), accessible via `Worksheet::getPivotTableCollection()` / `getPivotTableByName()`. Pivot tables (their tables, caches and records) are now also preserved through an Xlsx load/save round-trip instead of being silently dropped. [Issue #4534](https://github.com/PHPOffice/PhpSpreadsheet/issues/4534)
+- Streaming Xlsx writer for large, append-only exports with memory use independent of row count. [PR #4966](https://github.com/PHPOffice/PhpSpreadsheet/pull/4966)
### Removed
diff --git a/docs/topics/streaming-writer.md b/docs/topics/streaming-writer.md
new file mode 100644
index 0000000000..3040a3bedc
--- /dev/null
+++ b/docs/topics/streaming-writer.md
@@ -0,0 +1,236 @@
+# Streaming Xlsx writer
+
+`PhpOffice\PhpSpreadsheet\Writer\Xlsx\Streaming\StreamingWriter` writes Xlsx
+files directly to a file handle, one row at a time. It does not build a
+`Spreadsheet` object in memory. Use it for large exports where the standard
+writer would use too much memory.
+
+## When to use it
+
+Use the streaming writer for large, append-only exports, for example a
+report with hundreds of thousands of rows. Peak memory does not depend on
+the number of rows written.
+
+Measurements on this feature: writing 100,000 rows and writing 200,000 rows
+both peak at about 34MB of total memory. Most of that 34MB is a fixed 16MB
+read block used internally by the Zip stream, not row data. The standard
+`Xlsx` writer holds every cell in memory, at roughly 1KB or more per cell,
+so its memory use grows with the row count.
+
+Do not use the streaming writer when you need to read the file back, edit
+existing cells, or use features it does not support (see below). Use the
+standard `Xlsx` writer, with [cell caching](memory_saving.md) if needed,
+for those cases.
+
+## Basic usage
+
+```php
+use PhpOffice\PhpSpreadsheet\Writer\Xlsx\Streaming\StreamingWriter;
+
+$writer = new StreamingWriter('large-export.xlsx');
+$sheet = $writer->startSheet('Data');
+$sheet->appendRow(['Name', 'Amount']);
+$sheet->appendRow(['Widget', 12.5]);
+$writer->close();
+```
+
+Rows are plain arrays of values, written left to right starting at column A.
+A `null` value leaves that cell empty. Once `close()` has run, the writer
+and every sheet it produced are no longer usable.
+
+## Full worked example
+
+This example shows styles, dates, formulas, column widths, a freeze pane,
+an autofilter, and a second sheet:
+
+```php
+use PhpOffice\PhpSpreadsheet\Writer\Xlsx\Streaming\StreamedCell;
+use PhpOffice\PhpSpreadsheet\Writer\Xlsx\Streaming\StreamingWriter;
+
+$writer = new StreamingWriter('large-export.xlsx');
+
+// Styles are registered once on the writer and referenced by id.
+$headerStyle = $writer->registerStyle(['font' => ['bold' => true]]);
+$moneyStyle = $writer->registerStyle(['numberFormat' => ['formatCode' => '#,##0.00']]);
+
+$sheet = $writer->startSheet('Orders');
+
+// Column widths and the freeze pane must be set before the first
+// appendRow() call.
+$sheet->setColumnWidths([1 => 30.0, 2 => 15.0, 3 => 12.0]);
+$sheet->freezePane('A2');
+
+// The autofilter can be requested at any time before the sheet is
+// finished; the actual range is only known once the sheet finishes.
+$sheet->setAutoFilterToWrittenRange();
+
+// A style id applies to every cell in the row unless overridden per cell.
+$sheet->appendRow(['Customer', 'Order Date', 'Total'], $headerStyle);
+
+$sheet->appendRow([
+ 'Acme Corp',
+ new \DateTimeImmutable('2026-01-15'),
+ new StreamedCell(1234.5, $moneyStyle),
+]);
+
+// A leading "=" writes a formula; it is stored without a cached value.
+// A string that is only "=" (length 1) is not long enough to be treated
+// as a formula and is written as a plain string instead.
+$sheet->appendRow(['Total', null, '=SUM(C2:C2)']);
+
+// Start a second sheet; the first sheet is finished and can no longer
+// be appended to once startSheet() runs again.
+$sheet2 = $writer->startSheet('Notes');
+$sheet2->appendRow(['Generated by the streaming writer.']);
+
+$writer->close();
+```
+
+Because the workbook contains a formula, `close()` marks the workbook for
+full calculation on load, so Excel (or another spreadsheet application)
+computes `=SUM(C2:C2)` when it opens the file. The written cell itself has
+no cached value. If no sheet in the workbook contains a formula, `close()`
+does not force a recalculation on load.
+
+Any `DateTimeInterface` value that has no explicit style gets a default
+date number format automatically.
+
+`freezePane('A1')` is accepted and writes no frozen pane. A pane frozen
+at the top-left cell freezes zero rows and columns, so the writer skips
+it. This makes computed freeze cells safe: code that derives the freeze
+cell from a header-row count does not need a special case when that cell
+turns out to be A1.
+
+Each finished sheet keeps its buffered XML in memory (a `php://temp`
+stream) until `close()` runs, up to about 2MB per sheet before it spills
+to a real temporary file on disk. Memory use at `close()` therefore scales
+with the number of sheets, not with the number of rows in any one sheet.
+
+## API
+
+### `StreamingWriter`
+
+- `__construct(string $filename)` — opens `$filename` for writing.
+- `startSheet(string $name): StreamingSheet` — finishes the current sheet,
+ if any, and starts a new one. The name must not be empty, must be at most
+ 31 characters, and must not contain `* : / \ ? [ ]`; an invalid name
+ throws a `Writer\Exception` and leaves the writer usable. A name that is
+ already in use is silently given a numeric suffix (a second `Data` sheet
+ becomes `Data 1`), the same behavior as `Worksheet::setTitle()`.
+- `registerStyle(array $styleArray): int` — registers a style, in the same
+ array format used by `Style::applyFromArray()`, and returns its style id.
+- `close(): void` — finishes the last sheet and writes the Xlsx file. The
+ writer must have at least one sheet.
+
+### `StreamingSheet` (returned by `startSheet()`)
+
+- `appendRow(array $cells, ?int $styleId = null): void` — writes one row.
+ `$styleId`, if given, applies to every cell in the row that does not
+ carry its own style through `StreamedCell`.
+- `setColumnWidths(array $widths): void` — `$widths` is a 1-based column
+ number mapped to a width, for example `[1 => 30.0, 3 => 12.0]`. Must be
+ called before the first `appendRow()`.
+- `freezePane(string $cell): void` — freezes rows and columns above and to
+ the left of `$cell`, for example `'A2'` to freeze the header row. Must be
+ called before the first `appendRow()`. The cell reference is validated
+ immediately; an invalid reference throws a `Writer\Exception` and leaves
+ the sheet usable.
+- `setAutoFilterToWrittenRange(): void` — adds an autofilter over the full
+ range written to the sheet. Can be called at any time before the sheet is
+ finished; the range is only known once the sheet is finished.
+
+### `StreamedCell`
+
+Wrap a single cell value in `new StreamedCell($value, $styleId, $dataType)`
+to give that cell its own style, overriding the row style, or to force its
+data type. `$styleId` and `$dataType` both default to `null`. The only
+supported `$dataType` values are `DataType::TYPE_STRING` and
+`DataType::TYPE_STRING2`, which force the value to be written as a string
+even if it looks like a formula (starts with `=`). Any other `$dataType`
+value throws a `Writer\Exception`. Forcing a non-scalar value, such as a
+`DateTimeInterface` date, to a string also throws; only scalar values can
+be forced to string.
+
+### Supported cell values
+
+- Strings
+- Integers and floats
+- Booleans
+- `DateTimeInterface` instances
+- Formula strings (any string starting with `=`)
+- `null` (leaves the cell empty)
+
+Values are written by their PHP type; there is no value binder. A numeric
+string such as `'123'` is written as text, not as a number. Pass an `int`
+or a `float` to write a number.
+
+Any other value type throws a `Writer\Exception`. Invalid values of a
+supported type also throw instead of producing a broken file:
+
+- `NAN` and `INF` floats (Excel cannot store non-finite numbers)
+- Strings that are not valid UTF-8 (they would corrupt the sheet XML)
+- Strings longer than 32,767 characters (the Excel cell limit)
+
+## Strings are written as inline strings
+
+The streaming writer has no shared string table; every string cell is
+written as an inline string. Excel and other spreadsheet applications read
+inline strings as plain strings, with no special handling needed.
+
+PhpSpreadsheet's own `Xlsx` reader is the exception: it reads inline string
+cells back as `RichText` objects, not plain strings. If you read a
+streaming-writer file back with PhpSpreadsheet, cast the cell value to
+`(string)` before comparing it to the original string:
+
+```php
+$value = (string) $worksheet->getCell('A1')->getValue();
+```
+
+## Supported features
+
+- Values, formulas, and dates
+- Per-row styles (the `$styleId` argument of `appendRow()`)
+- Per-cell styles and forced data types (`StreamedCell`)
+- Column widths (`setColumnWidths()`)
+- A single freeze pane (`freezePane()`)
+- An autofilter over the written range (`setAutoFilterToWrittenRange()`)
+- Multiple sheets, written one after another (`startSheet()`)
+
+## Not supported
+
+The streaming writer is append-only and forward-only. It does not support:
+
+- Random access: you cannot go back and change a cell or row already
+ written.
+- Reading the file back through this writer; use the `Xlsx` reader instead,
+ and remember the inline-string caveat above.
+- Merged cells
+- Charts
+- Drawings and images
+- Comments
+- Conditional formatting
+- Hyperlinks
+- Rich text cell values
+
+## Lifecycle rules
+
+- `setColumnWidths()` and `freezePane()` must be called before the first
+ `appendRow()` on that sheet. Calling either after the first row throws a
+ `Writer\Exception`.
+- Sheets are sequential. Calling `startSheet()` finishes the current sheet;
+ the `StreamingSheet` object returned by the previous `startSheet()` call
+ becomes unusable, and any further method call on it throws a
+ `Writer\Exception`.
+- Nothing on the writer or on any sheet is usable after `close()` has run.
+- Arguments are checked before any XML is written where possible: an
+ invalid sheet name, an unregistered row-level `$styleId`, an invalid
+ freeze pane cell, and invalid column widths all throw without harming
+ the writer, so those exceptions are safe to catch and correct.
+- A failed `appendRow()` call that throws while a row is being built, for
+ example because of an unsupported value or an unregistered
+ `StreamedCell` style id, invalidates the sheet. Every later call on that
+ sheet, including `close()`, throws. Discard the writer and start again.
+- If the writer is destroyed without a `close()` call, for example when an
+ exception ends the request, the destructor deletes the partial file. No
+ file exists on disk until `close()` returns; a forgotten `close()` means
+ no output and no error.
diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamedCell.php b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamedCell.php
new file mode 100644
index 0000000000..c632a5a82a
--- /dev/null
+++ b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamedCell.php
@@ -0,0 +1,19 @@
+ */
+ private array $columnWidths = [];
+
+ private ?string $freezeCell = null;
+
+ private bool $autoFilter = false;
+
+ public function __construct(private StreamingWriter $writer)
+ {
+ $stream = fopen(self::TEMP_STREAM, 'wb+');
+ if ($stream === false) {
+ // @codeCoverageIgnoreStart
+ throw new WriterException('Could not open temporary stream for sheet data.');
+ // @codeCoverageIgnoreEnd
+ }
+ $this->stream = $stream;
+ $this->xmlWriter = new XMLWriter();
+ $this->xmlWriter->openMemory();
+ }
+
+ /**
+ * Close sheetData and worksheet, and hand the temp stream to the writer.
+ *
+ * @return resource
+ *
+ * @internal called by StreamingWriter only
+ */
+ public function finish()
+ {
+ $this->assertUsable();
+ if (!$this->headerWritten) {
+ $this->writeHeader();
+ }
+ $this->finished = true;
+ fwrite($this->stream, '');
+ if ($this->autoFilter && $this->rowNumber > 0 && $this->maxColumn > 0) {
+ $range = 'A1:' . Coordinate::stringFromColumnIndex($this->maxColumn) . $this->rowNumber;
+ fwrite($this->stream, '');
+ }
+ fwrite($this->stream, '');
+
+ return $this->stream;
+ }
+
+ private function writeHeader(): void
+ {
+ $this->headerWritten = true;
+ fwrite($this->stream, '' . "\n");
+ fwrite($this->stream, '');
+ $paneXml = '';
+ if ($this->freezeCell !== null) {
+ [$paneColumn, $paneRow] = Coordinate::indexesFromString($this->freezeCell);
+ $xSplit = $paneColumn - 1;
+ $ySplit = $paneRow - 1;
+ $activePane = ($xSplit > 0 && $ySplit > 0) ? 'bottomRight' : ($ySplit > 0 ? 'bottomLeft' : 'topRight');
+ $paneXml = ' 0 ? ' xSplit="' . $xSplit . '"' : '')
+ . ($ySplit > 0 ? ' ySplit="' . $ySplit . '"' : '')
+ . ' topLeftCell="' . $this->freezeCell . '" activePane="' . $activePane . '" state="frozen"/>';
+ }
+ fwrite($this->stream, '' . $paneXml . '');
+ if ($this->columnWidths !== []) {
+ $cols = '';
+ ksort($this->columnWidths);
+ foreach ($this->columnWidths as $columnNumber => $width) {
+ $cols .= '';
+ }
+ $cols .= '';
+ fwrite($this->stream, $cols);
+ }
+ fwrite($this->stream, '');
+ }
+
+ private function assertUsable(): void
+ {
+ if ($this->broken) {
+ throw new WriterException('A failed appendRow() left this sheet in an undefined state; discard this writer.');
+ }
+ if ($this->finished) {
+ throw new WriterException('This sheet has been finished; use the sheet returned by the most recent startSheet().');
+ }
+ }
+
+ /** @param mixed[] $cells */
+ public function appendRow(array $cells, ?int $styleId = null): void
+ {
+ $this->assertUsable();
+ if ($styleId !== null) {
+ $this->assertStyleId($styleId);
+ }
+
+ try {
+ if (!$this->headerWritten) {
+ $this->writeHeader();
+ }
+ ++$this->rowNumber;
+ $xmlWriter = $this->xmlWriter;
+ $xmlWriter->startElement('row');
+ $xmlWriter->writeAttribute('r', (string) $this->rowNumber);
+ $column = 0;
+ foreach ($cells as $value) {
+ ++$column;
+ if ($value === null) {
+ continue;
+ }
+ $this->writeCell($column, $value, $styleId);
+ }
+ $this->maxColumn = max($this->maxColumn, $column);
+ $xmlWriter->endElement(); // row
+ $flushed = $xmlWriter->flush();
+ if (!is_string($flushed)) {
+ // @codeCoverageIgnoreStart
+ throw new WriterException('Unexpected non-string result from XMLWriter::flush().');
+ // @codeCoverageIgnoreEnd
+ }
+ fwrite($this->stream, $flushed);
+ } catch (Throwable $e) {
+ $this->broken = true;
+ $this->xmlWriter->flush(); // discard the unclosed left behind by the failure
+ if ($e instanceof WriterException) {
+ throw $e;
+ }
+
+ throw new WriterException('Failed to write the row: ' . $e->getMessage(), 0, $e);
+ }
+ }
+
+ private function writeCell(int $column, mixed $value, ?int $rowStyleId): void
+ {
+ $cellStyleId = $rowStyleId;
+ $forcedType = null;
+ if ($value instanceof StreamedCell) {
+ if ($value->styleId !== null) {
+ $this->assertStyleId($value->styleId);
+ $cellStyleId = $value->styleId;
+ }
+ $forcedType = $value->dataType;
+ if ($forcedType !== null && $forcedType !== DataType::TYPE_STRING && $forcedType !== DataType::TYPE_STRING2) {
+ throw new WriterException("Unsupported StreamedCell data type '$forcedType'; only DataType::TYPE_STRING and DataType::TYPE_STRING2 are supported.");
+ }
+ $value = $value->value;
+ if ($value === null) {
+ return;
+ }
+ }
+
+ $isDate = $value instanceof DateTimeInterface;
+ if ($isDate && $cellStyleId === null) {
+ $cellStyleId = $this->writer->getDefaultDateStyleId();
+ }
+
+ $xmlWriter = $this->xmlWriter;
+ $xmlWriter->startElement('c');
+ $xmlWriter->writeAttribute('r', Coordinate::stringFromColumnIndex($column) . $this->rowNumber);
+ if ($cellStyleId !== null && $cellStyleId !== 0) {
+ $xmlWriter->writeAttribute('s', (string) $cellStyleId);
+ }
+
+ if ($forcedType === DataType::TYPE_STRING || $forcedType === DataType::TYPE_STRING2) {
+ $this->writeInlineString(is_scalar($value) ? (string) $value : $this->rejectValue($value));
+ } elseif ($isDate) {
+ $excelDate = Date::PHPToExcel($value);
+ if ($excelDate === false) {
+ $this->rejectValue($value); // @codeCoverageIgnore
+ }
+ $xmlWriter->writeElement('v', self::formatNumber($excelDate));
+ } elseif (is_bool($value)) {
+ $xmlWriter->writeAttribute('t', 'b');
+ $xmlWriter->writeElement('v', $value ? '1' : '0');
+ } elseif (is_int($value) || is_float($value)) {
+ if (is_float($value) && !is_finite($value)) {
+ throw new WriterException('Cell value is not a finite number; NAN and INF cannot be stored in an Xlsx file.');
+ }
+ $xmlWriter->writeElement('v', self::formatNumber($value));
+ } elseif (is_string($value)) {
+ if (strlen($value) > 1 && $value[0] === '=') {
+ if (!StringHelper::isUTF8($value)) {
+ throw new WriterException('Cell value is not valid UTF-8; writing it would corrupt the sheet XML.');
+ }
+ $this->writer->noteFormulaWritten();
+ $xmlWriter->startElement('f');
+ $xmlWriter->text(FunctionPrefix::addFunctionPrefixStripEquals($value));
+ $xmlWriter->endElement(); // f
+ } else {
+ $this->writeInlineString($value);
+ }
+ } else {
+ $this->rejectValue($value);
+ }
+ $xmlWriter->endElement(); // c
+ }
+
+ private function writeInlineString(string $value): void
+ {
+ if (!StringHelper::isUTF8($value)) {
+ throw new WriterException('Cell value is not valid UTF-8; writing it would corrupt the sheet XML.');
+ }
+ if (mb_strlen($value, 'UTF-8') > DataType::MAX_STRING_LENGTH) {
+ throw new WriterException('Cell string value exceeds the Excel limit of ' . DataType::MAX_STRING_LENGTH . ' characters.');
+ }
+ $xmlWriter = $this->xmlWriter;
+ $xmlWriter->writeAttribute('t', 'inlineStr');
+ $xmlWriter->startElement('is');
+ $xmlWriter->startElement('t');
+ if (trim($value) !== $value) {
+ $xmlWriter->writeAttribute('xml:space', 'preserve');
+ }
+ $xmlWriter->text(StringHelper::controlCharacterPHP2OOXML($value));
+ $xmlWriter->endElement(); // t
+ $xmlWriter->endElement(); // is
+ }
+
+ /**
+ * Same serialization as Writer\Xlsx\Worksheet::writeCellNumeric(): full
+ * float precision, and a ".0" marker so integral floats read back as float.
+ */
+ private static function formatNumber(float|int $value): string
+ {
+ $result = StringHelper::convertToString($value);
+ if (is_float($value) && !str_contains($result, '.')) {
+ $result .= '.0';
+ }
+
+ return $result;
+ }
+
+ private function rejectValue(mixed $value): never
+ {
+ throw new WriterException('Unsupported cell value of type ' . get_debug_type($value) . '.');
+ }
+
+ private function assertStyleId(int $styleId): void
+ {
+ if (!$this->writer->isStyleIdRegistered($styleId)) {
+ throw new WriterException("Style id $styleId has not been registered with registerStyle().");
+ }
+ }
+
+ /** @param array $widths 1-based column number => width */
+ public function setColumnWidths(array $widths): void
+ {
+ $this->assertBeforeFirstRow('setColumnWidths');
+ foreach ($widths as $columnNumber => $width) {
+ if ($columnNumber < 1) {
+ throw new WriterException("Column number $columnNumber is invalid; column numbers are 1-based.");
+ }
+ if ($width <= 0) {
+ throw new WriterException("Column width $width is invalid; width must be positive.");
+ }
+ $this->columnWidths[$columnNumber] = $width;
+ }
+ }
+
+ public function freezePane(string $cell): void
+ {
+ $this->assertBeforeFirstRow('freezePane');
+
+ try {
+ [$column, $row] = Coordinate::indexesFromString($cell);
+ } catch (Throwable $e) {
+ throw new WriterException("Invalid freeze pane cell '$cell': " . $e->getMessage(), 0, $e);
+ }
+ if ($row < 1) {
+ throw new WriterException("Invalid freeze pane cell '$cell': row numbers are 1-based.");
+ }
+ $normalized = Coordinate::stringFromColumnIndex($column) . $row;
+ if ($normalized === 'A1') {
+ return;
+ }
+ $this->freezeCell = $normalized;
+ }
+
+ public function setAutoFilterToWrittenRange(): void
+ {
+ $this->assertUsable();
+ $this->autoFilter = true;
+ }
+
+ private function assertBeforeFirstRow(string $method): void
+ {
+ $this->assertUsable();
+ if ($this->headerWritten) {
+ throw new WriterException("$method() must be called before the first appendRow().");
+ }
+ }
+}
diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php
new file mode 100644
index 0000000000..15f5e6eb01
--- /dev/null
+++ b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php
@@ -0,0 +1,207 @@
+ */
+ private array $finishedSheets = [];
+
+ private int $sheetCount = 0;
+
+ private bool $closed = false;
+
+ private bool $hasFormulas = false;
+
+ private ?int $defaultDateStyleId = null;
+
+ public function __construct(string $filename)
+ {
+ try {
+ $fileHandle = fopen($filename, 'wb');
+ } catch (Exception) {
+ $fileHandle = false;
+ }
+ if ($fileHandle === false) {
+ throw new WriterException("Could not open file $filename for writing.");
+ }
+ $this->filename = $filename;
+ $this->fileHandle = $fileHandle;
+ $this->shell = new Spreadsheet();
+ $this->partWriter = new XlsxWriter($this->shell);
+ }
+
+ public function __destruct()
+ {
+ if (!$this->closed) {
+ $this->closeSheetStreams();
+ $this->closeFileHandleAndUnlink();
+ }
+ }
+
+ public function startSheet(string $name): StreamingSheet
+ {
+ $this->assertNotClosed();
+ if ($name === '') {
+ throw new WriterException('Sheet name cannot be empty.');
+ }
+ $this->finishActiveSheet();
+ $shellSheet = ($this->sheetCount === 0)
+ ? $this->shell->getSheet(0)
+ : $this->shell->createSheet();
+
+ try {
+ $shellSheet->setTitle($name);
+ } catch (Throwable $e) {
+ if ($this->sheetCount > 0) {
+ // roll back createSheet() so the shell workbook only lists sheets that have a stream
+ $this->shell->removeSheetByIndex($this->shell->getIndex($shellSheet));
+ }
+
+ throw new WriterException("Invalid sheet name '$name': " . $e->getMessage(), 0, $e);
+ }
+ ++$this->sheetCount;
+ $this->activeSheet = new StreamingSheet($this);
+
+ return $this->activeSheet;
+ }
+
+ /** @param mixed[] $styleArray */
+ public function registerStyle(array $styleArray): int
+ {
+ $this->assertNotClosed();
+ $style = new Style();
+ $style->applyFromArray($styleArray);
+ $this->shell->addCellXf($style);
+
+ return $style->getIndex();
+ }
+
+ public function close(): void
+ {
+ $this->assertNotClosed();
+ if ($this->sheetCount === 0) {
+ $this->closed = true;
+ $this->closeFileHandleAndUnlink();
+
+ throw new WriterException('Cannot close a streaming writer with no sheets; call startSheet() first.');
+ }
+ $this->closed = true;
+
+ try {
+ if (!is_resource($this->fileHandle)) {
+ // ZipStream silently falls back to php://output for a non-resource
+ throw new WriterException('The output file handle is no longer valid.');
+ }
+ $this->finishActiveSheet();
+ $zip = ZipStream0::newZipStream($this->fileHandle);
+ $partWriter = $this->partWriter;
+ $partWriter->createStyleDictionaries();
+ $zip->addFile('[Content_Types].xml', $partWriter->getWriterPartContentTypes()->writeContentTypes($this->shell, false));
+ $zip->addFile('_rels/.rels', $partWriter->getWriterPartRels()->writeRelationships($this->shell));
+ $zip->addFile('xl/_rels/workbook.xml.rels', $partWriter->getWriterPartRels()->writeWorkbookRelationships($this->shell));
+ $zip->addFile('docProps/app.xml', $partWriter->getWriterPartDocProps()->writeDocPropsApp($this->shell));
+ $zip->addFile('docProps/core.xml', $partWriter->getWriterPartDocProps()->writeDocPropsCore($this->shell));
+ $zip->addFile('xl/theme/theme1.xml', $partWriter->getWriterPartTheme()->writeTheme($this->shell));
+ $zip->addFile('xl/sharedStrings.xml', $partWriter->getWriterPartStringTable()->writeStringTable([]));
+ $zip->addFile('xl/styles.xml', $partWriter->getWriterPartStyle()->writeStyles($this->shell));
+ // when a formula was streamed, $forceFullCalc makes writeCalcPr() emit
+ // fullCalcOnLoad="1" forceFullCalc="1" so the opening application computes it
+ $zip->addFile('xl/workbook.xml', $partWriter->getWriterPartWorkbook()->writeWorkbook($this->shell, !$this->hasFormulas, $this->hasFormulas));
+ foreach ($this->finishedSheets as $index => $finishedSheet) {
+ rewind($finishedSheet['stream']);
+ $zip->addFileFromStream('xl/worksheets/sheet' . ($index + 1) . '.xml', $finishedSheet['stream']);
+ }
+ $zip->finish();
+ } catch (Throwable $e) {
+ $this->closeSheetStreams();
+ $this->closeFileHandleAndUnlink();
+ if ($e instanceof WriterException) {
+ throw $e;
+ }
+
+ throw new WriterException('Failed to write the Xlsx file: ' . $e->getMessage(), 0, $e);
+ }
+
+ $this->closeSheetStreams();
+ fclose($this->fileHandle);
+ }
+
+ public function isStyleIdRegistered(int $styleId): bool
+ {
+ return $styleId >= 0 && $styleId < count($this->shell->getCellXfCollection());
+ }
+
+ public function getDefaultDateStyleId(): int
+ {
+ if ($this->defaultDateStyleId === null) {
+ $this->defaultDateStyleId = $this->registerStyle([
+ 'numberFormat' => ['formatCode' => NumberFormat::FORMAT_DATE_DATETIME_BETTER],
+ ]);
+ }
+
+ return $this->defaultDateStyleId;
+ }
+
+ public function noteFormulaWritten(): void
+ {
+ $this->hasFormulas = true;
+ }
+
+ private function finishActiveSheet(): void
+ {
+ if ($this->activeSheet !== null) {
+ $this->finishedSheets[] = ['stream' => $this->activeSheet->finish()];
+ $this->activeSheet = null;
+ }
+ }
+
+ private function assertNotClosed(): void
+ {
+ if ($this->closed) {
+ throw new WriterException('This streaming writer has already been closed.');
+ }
+ }
+
+ private function closeSheetStreams(): void
+ {
+ foreach ($this->finishedSheets as $finishedSheet) {
+ if (is_resource($finishedSheet['stream'])) {
+ fclose($finishedSheet['stream']);
+ }
+ }
+ $this->finishedSheets = [];
+ }
+
+ private function closeFileHandleAndUnlink(): void
+ {
+ if (is_resource($this->fileHandle)) {
+ fclose($this->fileHandle);
+ }
+ if (file_exists($this->filename)) {
+ unlink($this->filename);
+ }
+ }
+}
diff --git a/tests/Benchmark/README.md b/tests/Benchmark/README.md
new file mode 100644
index 0000000000..f424b221b5
--- /dev/null
+++ b/tests/Benchmark/README.md
@@ -0,0 +1,53 @@
+# Streaming writer benchmark
+
+`bench.php` compares the standard `Writer\Xlsx` engine against
+`Writer\Xlsx\Streaming\StreamingWriter`. It writes rows of 8 mixed-type
+cells (string, int, float, bool, `DateTimeImmutable`, string, float, bool)
+and prints one JSON line with wall time, peak memory, and output file
+size.
+
+These scripts are not PHPUnit tests. `phpunit.xml.dist` only scans
+`tests/PhpSpreadsheetTests`, so `tests/Benchmark` is never picked up by
+`phpunit` runs.
+
+## Usage
+
+```
+php tests/Benchmark/bench.php [rows]
+```
+
+`rows` defaults to 200000.
+
+Run each engine in its own process, so peak memory and wall time are never
+shared between engines or between runs of the same engine. For a stable
+median, run each engine 3 times:
+
+```
+php -d memory_limit=4G tests/Benchmark/bench.php standard 200000
+php -d memory_limit=4G tests/Benchmark/bench.php standard 200000
+php -d memory_limit=4G tests/Benchmark/bench.php standard 200000
+php -d memory_limit=4G tests/Benchmark/bench.php streaming 200000
+php -d memory_limit=4G tests/Benchmark/bench.php streaming 200000
+php -d memory_limit=4G tests/Benchmark/bench.php streaming 200000
+```
+
+Take the median `elapsed_ms` and median `peak_memory_bytes` of each set of
+3 runs.
+
+## Recorded results
+
+Environment: PHP 8.5.2 (cli, NTS, Opcache), Darwin 24.5.0 arm64
+(macOS, Apple Silicon), 2026-08-18. 200,000 rows x 8 columns, 3 runs per
+engine in separate `php -d memory_limit=4G` processes, medians reported.
+
+| Engine | Median wall time | Median peak memory | Output file size |
+| --- | --- | --- | --- |
+| Standard (`Spreadsheet` + `Writer\Xlsx`) | 123.35 s | 1113.36 MB | 10,547,334 bytes |
+| Streaming (`StreamingWriter`) | 6.73 s | 46.42 MB | 9,398,884 bytes |
+| **Ratio (standard / streaming)** | **~18.3x slower** | **~24.0x more memory** | ~1.12x larger |
+
+The streaming writer is about 18 times faster and uses about 24 times less
+peak memory for this workload, while producing a slightly smaller file
+(inline strings avoid `sharedStrings.xml` overhead for this
+mostly-unique-string dataset). Peak memory and file size are stable across
+runs; wall time varies by less than 4%.
diff --git a/tests/Benchmark/bench.php b/tests/Benchmark/bench.php
new file mode 100644
index 0000000000..4842c9aaf1
--- /dev/null
+++ b/tests/Benchmark/bench.php
@@ -0,0 +1,109 @@
+ [rows]
+ *
+ * Run each engine in its own process (this script does not fork) so wall
+ * time and memory are never shared between runs. See README.md in this
+ * directory for the full recipe and recorded results.
+ */
+
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
+use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
+use PhpOffice\PhpSpreadsheet\Writer\Xlsx\Streaming\StreamingWriter;
+
+require __DIR__ . '/../../vendor/autoload.php';
+
+/** @return array */
+function benchBuildRow(int $row): array
+{
+ return [
+ 'Name ' . $row,
+ $row,
+ $row * 1.5,
+ $row % 2 === 0,
+ new DateTimeImmutable('2026-01-01 00:00:00'),
+ 'Description for row ' . $row,
+ $row * 3.25,
+ $row % 3 === 0,
+ ];
+}
+
+function benchRunStandard(int $rows, string $file): void
+{
+ $spreadsheet = new Spreadsheet();
+ $sheet = $spreadsheet->getActiveSheet();
+ for ($row = 1; $row <= $rows; ++$row) {
+ $sheet->fromArray(benchBuildRow($row), null, 'A' . $row);
+ }
+ $writer = new Xlsx($spreadsheet);
+ $writer->save($file);
+ $spreadsheet->disconnectWorksheets();
+}
+
+function benchRunStreaming(int $rows, string $file): void
+{
+ $writer = new StreamingWriter($file);
+ $sheet = $writer->startSheet('Data');
+ for ($row = 1; $row <= $rows; ++$row) {
+ $sheet->appendRow(benchBuildRow($row));
+ }
+ $writer->close();
+}
+
+/** @param array $argv */
+function benchMain(array $argv): int
+{
+ $engine = $argv[1] ?? null;
+ if ($engine !== 'standard' && $engine !== 'streaming') {
+ fwrite(STDERR, "Usage: php bench.php [rows]\n");
+
+ return 1;
+ }
+ $rows = isset($argv[2]) ? (int) $argv[2] : 200000;
+
+ $file = tempnam(sys_get_temp_dir(), 'phpspreadsheet_bench_');
+ if ($file === false) {
+ fwrite(STDERR, "Could not create a temporary file.\n");
+
+ return 1;
+ }
+
+ $start = hrtime(true);
+ if ($engine === 'standard') {
+ benchRunStandard($rows, $file);
+ } else {
+ benchRunStreaming($rows, $file);
+ }
+ $elapsedNs = hrtime(true) - $start;
+
+ $peakMemoryBytes = memory_get_peak_usage(true);
+ $fileSizeBytes = filesize($file);
+ unlink($file);
+
+ $result = [
+ 'engine' => $engine,
+ 'rows' => $rows,
+ 'elapsed_ms' => $elapsedNs / 1_000_000,
+ 'peak_memory_bytes' => $peakMemoryBytes,
+ 'file_size_bytes' => $fileSizeBytes,
+ ];
+
+ $encoded = json_encode($result);
+ echo ($encoded === false ? '{}' : $encoded) . "\n";
+
+ return 0;
+}
+
+/** @var array $arguments */
+$arguments = $_SERVER['argv'] ?? [];
+exit(benchMain($arguments));
diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamedCellTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamedCellTest.php
new file mode 100644
index 0000000000..71be1b763b
--- /dev/null
+++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamedCellTest.php
@@ -0,0 +1,28 @@
+value);
+ self::assertSame(3, $cell->styleId);
+ self::assertSame(DataType::TYPE_STRING, $cell->dataType);
+ }
+
+ public function testDefaults(): void
+ {
+ $cell = new StreamedCell(1.5);
+ self::assertSame(1.5, $cell->value);
+ self::assertNull($cell->styleId);
+ self::assertNull($cell->dataType);
+ }
+}
diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingErrorsTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingErrorsTest.php
new file mode 100644
index 0000000000..850947416b
--- /dev/null
+++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingErrorsTest.php
@@ -0,0 +1,306 @@
+tempFiles as $file) {
+ if (file_exists($file)) {
+ unlink($file);
+ }
+ }
+ $this->tempFiles = [];
+ }
+
+ private function tempFile(): string
+ {
+ $file = File::temporaryFilename();
+ $this->tempFiles[] = $file;
+
+ return $file;
+ }
+
+ public function testUnwritableFileThrows(): void
+ {
+ $this->expectException(WriterException::class);
+ new StreamingWriter('/nonexistent-dir-zzz/out.xlsx');
+ }
+
+ public function testCloseWithoutSheetsThrows(): void
+ {
+ $writer = new StreamingWriter($this->tempFile());
+ $this->expectException(WriterException::class);
+ $this->expectExceptionMessage('no sheets');
+ $writer->close();
+ }
+
+ public function testDoubleCloseThrows(): void
+ {
+ $writer = new StreamingWriter($this->tempFile());
+ $writer->startSheet('Data');
+ $writer->close();
+ $this->expectException(WriterException::class);
+ $this->expectExceptionMessage('already been closed');
+ $writer->close();
+ }
+
+ public function testStartSheetAfterCloseThrows(): void
+ {
+ $writer = new StreamingWriter($this->tempFile());
+ $writer->startSheet('Data');
+ $writer->close();
+ $this->expectException(WriterException::class);
+ $writer->startSheet('More');
+ }
+
+ public function testStaleSheetThrows(): void
+ {
+ $writer = new StreamingWriter($this->tempFile());
+ $first = $writer->startSheet('First');
+ $writer->startSheet('Second');
+ $this->expectException(WriterException::class);
+ $this->expectExceptionMessage('finished');
+ $first->appendRow(['x']);
+ }
+
+ public function testAppendAfterCloseThrows(): void
+ {
+ $writer = new StreamingWriter($this->tempFile());
+ $sheet = $writer->startSheet('Data');
+ $writer->close();
+ $this->expectException(WriterException::class);
+ $sheet->appendRow(['x']);
+ }
+
+ public function testInvalidSheetNameThrows(): void
+ {
+ $writer = new StreamingWriter($this->tempFile());
+ $this->expectException(WriterException::class);
+ $this->expectExceptionMessage("Invalid sheet name 'Bad[Name]'");
+ $writer->startSheet('Bad[Name]');
+ }
+
+ public function testEmptySheetNameThrows(): void
+ {
+ $writer = new StreamingWriter($this->tempFile());
+ $this->expectException(WriterException::class);
+ $this->expectExceptionMessage('cannot be empty');
+ $writer->startSheet('');
+ }
+
+ public function testWriterStaysConsistentAfterInvalidSheetName(): void
+ {
+ $file = $this->tempFile();
+ $writer = new StreamingWriter($file);
+ $writer->startSheet('Good')->appendRow(['x']);
+
+ try {
+ $writer->startSheet('Bad[Name]');
+ self::fail('Expected a WriterException.');
+ } catch (WriterException) {
+ // expected; the failed sheet must leave no trace in the workbook
+ }
+
+ $writer->startSheet('Recovered')->appendRow(['y']);
+ $writer->close();
+
+ $spreadsheet = (new XlsxReader())->load($file);
+ self::assertSame(['Good', 'Recovered'], $spreadsheet->getSheetNames());
+ $spreadsheet->disconnectWorksheets();
+
+ $zip = new ZipArchive();
+ $zip->open($file);
+ self::assertIsString($zip->getFromName('xl/worksheets/sheet1.xml'));
+ self::assertIsString($zip->getFromName('xl/worksheets/sheet2.xml'));
+ self::assertFalse($zip->getFromName('xl/worksheets/sheet3.xml'));
+ $zip->close();
+ }
+
+ public function testInvalidFirstSheetNameLeavesWriterUsable(): void
+ {
+ $file = $this->tempFile();
+ $writer = new StreamingWriter($file);
+
+ try {
+ $writer->startSheet('Bad[Name]');
+ self::fail('Expected a WriterException.');
+ } catch (WriterException) {
+ // expected; the initial shell sheet is reused on retry
+ }
+
+ $writer->startSheet('Good')->appendRow(['x']);
+ $writer->close();
+
+ $spreadsheet = (new XlsxReader())->load($file);
+ self::assertSame(['Good'], $spreadsheet->getSheetNames());
+ $spreadsheet->disconnectWorksheets();
+ }
+
+ public function testRegisterStyleAfterCloseThrows(): void
+ {
+ $writer = new StreamingWriter($this->tempFile());
+ $writer->startSheet('Data');
+ $writer->close();
+ $this->expectException(WriterException::class);
+ $writer->registerStyle(['font' => ['bold' => true]]);
+ }
+
+ public function testAppendRowAfterFailureThrowsBrokenState(): void
+ {
+ $writer = new StreamingWriter($this->tempFile());
+ $sheet = $writer->startSheet('Data');
+
+ try {
+ $sheet->appendRow([new stdClass()]);
+ self::fail('Expected a WriterException from the failed appendRow().');
+ } catch (WriterException) {
+ // expected; the sheet is now broken
+ }
+
+ $this->expectException(WriterException::class);
+ $this->expectExceptionMessage('undefined state');
+ $sheet->appendRow(['x']);
+ }
+
+ public function testCloseAfterFailedAppendRowThrows(): void
+ {
+ $writer = new StreamingWriter($this->tempFile());
+ $sheet = $writer->startSheet('Data');
+
+ try {
+ $sheet->appendRow([new stdClass()]);
+ self::fail('Expected a WriterException from the failed appendRow().');
+ } catch (WriterException) {
+ // expected; the sheet is now broken
+ }
+
+ $this->expectException(WriterException::class);
+ $this->expectExceptionMessage('undefined state');
+ $writer->close();
+ }
+
+ public function testDestructWithoutCloseRemovesFile(): void
+ {
+ $file = $this->tempFile();
+ $writer = new StreamingWriter($file);
+ self::assertFileExists($file);
+ unset($writer);
+ self::assertFileDoesNotExist($file);
+ }
+
+ public function testCloseWithoutSheetsRemovesFile(): void
+ {
+ $file = $this->tempFile();
+ $writer = new StreamingWriter($file);
+ self::assertFileExists($file);
+
+ try {
+ $writer->close();
+ self::fail('Expected a WriterException.');
+ } catch (WriterException) {
+ // expected
+ }
+ self::assertFileDoesNotExist($file);
+ }
+
+ public function testCloseWithInvalidFileHandleThrows(): void
+ {
+ $file = $this->tempFile();
+ $writer = new StreamingWriter($file);
+ $writer->startSheet('Data')->appendRow(['x']);
+ $handleProperty = new ReflectionProperty(StreamingWriter::class, 'fileHandle');
+ $handle = $handleProperty->getValue($writer);
+ self::assertIsResource($handle);
+ fclose($handle);
+
+ try {
+ $writer->close();
+ self::fail('Expected a WriterException.');
+ } catch (WriterException $e) {
+ self::assertStringContainsString('no longer valid', $e->getMessage());
+ }
+ self::assertFileDoesNotExist($file);
+ }
+
+ public function testInvalidFreezePaneCellThrowsImmediately(): void
+ {
+ $writer = new StreamingWriter($this->tempFile());
+ $sheet = $writer->startSheet('Data');
+
+ try {
+ $sheet->freezePane('banana');
+ self::fail('Expected a WriterException.');
+ } catch (WriterException $e) {
+ self::assertStringContainsString("Invalid freeze pane cell 'banana'", $e->getMessage());
+ }
+
+ // the eager check must not poison the sheet
+ $sheet->appendRow(['x']);
+ }
+
+ public function testZeroRowFreezePaneCellThrows(): void
+ {
+ $writer = new StreamingWriter($this->tempFile());
+ $sheet = $writer->startSheet('Data');
+ $this->expectException(WriterException::class);
+ $this->expectExceptionMessage('row numbers are 1-based');
+ $sheet->freezePane('B0');
+ }
+
+ public function testAppendRowWrapsUnderlyingWriteFailure(): void
+ {
+ $writer = new StreamingWriter($this->tempFile());
+ $sheet = $writer->startSheet('Data');
+ $sheet->appendRow(['x']);
+ $streamProperty = new ReflectionProperty(StreamingSheet::class, 'stream');
+ $stream = $streamProperty->getValue($sheet);
+ self::assertIsResource($stream);
+ fclose($stream);
+
+ try {
+ $sheet->appendRow(['y']);
+ self::fail('Expected a WriterException.');
+ } catch (WriterException $e) {
+ self::assertStringContainsString('Failed to write the row', $e->getMessage());
+ self::assertNotNull($e->getPrevious());
+ }
+ }
+
+ public function testCloseWrapsUnderlyingWriteFailure(): void
+ {
+ $file = $this->tempFile();
+ $writer = new StreamingWriter($file);
+ $sheet = $writer->startSheet('Data');
+ $sheet->appendRow(['x']);
+ $streamProperty = new ReflectionProperty(StreamingSheet::class, 'stream');
+ $stream = $streamProperty->getValue($sheet);
+ self::assertIsResource($stream);
+ fclose($stream);
+
+ try {
+ $writer->close();
+ self::fail('Expected a WriterException.');
+ } catch (WriterException $e) {
+ self::assertStringContainsString('Failed to write the Xlsx file', $e->getMessage());
+ self::assertNotNull($e->getPrevious());
+ }
+ self::assertFileDoesNotExist($file);
+ }
+}
diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingMemoryTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingMemoryTest.php
new file mode 100644
index 0000000000..193df7fceb
--- /dev/null
+++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingMemoryTest.php
@@ -0,0 +1,51 @@
+measurePeak(100000);
+ $doubled = $this->measurePeak(200000);
+ self::assertLessThan($atSaturation + 4 * 1024 * 1024, $doubled);
+ self::assertLessThan(64 * 1024 * 1024, $doubled);
+ }
+
+ private function measurePeak(int $rows): int
+ {
+ $file = File::temporaryFilename();
+
+ try {
+ $writer = new StreamingWriter($file);
+ $sheet = $writer->startSheet('Big');
+ if (function_exists('memory_reset_peak_usage')) { // PHP 8.2+, the caller skips otherwise
+ memory_reset_peak_usage();
+ }
+ $before = memory_get_peak_usage(true);
+ for ($row = 1; $row <= $rows; ++$row) {
+ $sheet->appendRow(['row ' . $row, $row, $row * 1.5, $row % 2 === 0]);
+ }
+ $writer->close();
+ self::assertGreaterThan(0, filesize($file));
+
+ return memory_get_peak_usage(true) - $before;
+ } finally {
+ if (file_exists($file)) {
+ unlink($file);
+ }
+ }
+ }
+}
diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php
new file mode 100644
index 0000000000..92080bb6e9
--- /dev/null
+++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php
@@ -0,0 +1,430 @@
+tempFiles as $file) {
+ if (file_exists($file)) {
+ unlink($file);
+ }
+ }
+ $this->tempFiles = [];
+ }
+
+ private function tempFile(): string
+ {
+ $file = File::temporaryFilename();
+ $this->tempFiles[] = $file;
+
+ return $file;
+ }
+
+ /** Cell values read back as RichText or scalar; normalize to string for comparison. */
+ private static function stringValue(mixed $value): string
+ {
+ if (is_scalar($value) || $value instanceof Stringable) {
+ return (string) $value;
+ }
+
+ throw new RuntimeException('Expected a stringable cell value, got ' . get_debug_type($value) . '.');
+ }
+
+ public function testEmptySheetsRoundTrip(): void
+ {
+ $file = $this->tempFile();
+ $writer = new StreamingWriter($file);
+ $writer->startSheet('First');
+ $writer->startSheet('Second Sheet');
+ $writer->close();
+
+ $spreadsheet = (new XlsxReader())->load($file);
+ self::assertSame(['First', 'Second Sheet'], $spreadsheet->getSheetNames());
+ $spreadsheet->disconnectWorksheets();
+ }
+
+ public function testScalarRowsRoundTrip(): void
+ {
+ $file = $this->tempFile();
+ $writer = new StreamingWriter($file);
+ $sheet = $writer->startSheet('Data');
+ $sheet->appendRow(['Name', 'Count', 'Ratio', 'Flag']);
+ $sheet->appendRow(['Ärger & ', 42, 1.25, true]);
+ $sheet->appendRow([null, null, ' padded ', false]);
+ $writer->close();
+
+ $worksheet = (new XlsxReader())->load($file)->getSheetByNameOrThrow('Data');
+ // Note: inline strings are read as RichText by PhpSpreadsheet's reader; cast to string for comparison
+ self::assertSame('Name', self::stringValue($worksheet->getCell('A1')->getValue()));
+ self::assertSame('Ärger & ', self::stringValue($worksheet->getCell('A2')->getValue()));
+ self::assertSame(42, $worksheet->getCell('B2')->getValue());
+ self::assertSame(1.25, $worksheet->getCell('C2')->getValue());
+ self::assertTrue($worksheet->getCell('D2')->getValue());
+ self::assertFalse($worksheet->getCell('D3')->getValue());
+ self::assertNull($worksheet->getCell('A3')->getValue());
+ self::assertSame(' padded ', self::stringValue($worksheet->getCell('C3')->getValue()));
+ }
+
+ public function testNumericFidelityMatchesStandardWriter(): void
+ {
+ $file = $this->tempFile();
+ $writer = new StreamingWriter($file);
+ $sheet = $writer->startSheet('Data');
+ $sheet->appendRow([2.0, 123456.78901234567, 1.0E+20, PHP_INT_MAX]);
+ $writer->close();
+
+ $worksheet = (new XlsxReader())->load($file)->getSheetByNameOrThrow('Data');
+ // an integral float must read back as float, not int, like the standard writer
+ self::assertSame(2.0, $worksheet->getCell('A1')->getValue());
+ // full double precision must survive; a plain (string) cast would truncate at 14 digits
+ self::assertSame(123456.78901234567, $worksheet->getCell('B1')->getValue());
+ self::assertSame(1.0E+20, $worksheet->getCell('C1')->getValue());
+ self::assertSame(PHP_INT_MAX, $worksheet->getCell('D1')->getValue());
+ }
+
+ public function testUnsupportedValueThrows(): void
+ {
+ $file = $this->tempFile();
+ $writer = new StreamingWriter($file);
+ $sheet = $writer->startSheet('Data');
+ $this->expectException(WriterException::class);
+ $sheet->appendRow([new stdClass()]);
+ }
+
+ public function testFormulaRoundTrip(): void
+ {
+ $file = $this->tempFile();
+ $writer = new StreamingWriter($file);
+ $sheet = $writer->startSheet('Data');
+ $sheet->appendRow([2, 3]);
+ $sheet->appendRow(['=SUM(A1:B1)']);
+ $sheet->appendRow([new StreamedCell('=not a formula', null, DataType::TYPE_STRING)]);
+ $writer->close();
+
+ $worksheet = (new XlsxReader())->load($file)->getSheetByNameOrThrow('Data');
+ self::assertSame('=SUM(A1:B1)', $worksheet->getCell('A2')->getValue());
+ self::assertSame(5, $worksheet->getCell('A2')->getCalculatedValue());
+ self::assertSame('=not a formula', self::stringValue($worksheet->getCell('A3')->getValue()));
+ }
+
+ public function testDateTimeRoundTrip(): void
+ {
+ $file = $this->tempFile();
+ $writer = new StreamingWriter($file);
+ $sheet = $writer->startSheet('Data');
+ $sheet->appendRow([new DateTimeImmutable('2026-01-02 03:04:05')]);
+ $writer->close();
+
+ $worksheet = (new XlsxReader())->load($file)->getSheetByNameOrThrow('Data');
+ $cell = $worksheet->getCell('A1');
+ self::assertEqualsWithDelta(46024.12783564815, $cell->getValue(), 1E-8);
+ self::assertSame(
+ NumberFormat::FORMAT_DATE_DATETIME_BETTER,
+ $worksheet->getStyle('A1')->getNumberFormat()->getFormatCode()
+ );
+ }
+
+ public function testStylesRoundTrip(): void
+ {
+ $file = $this->tempFile();
+ $writer = new StreamingWriter($file);
+ $bold = $writer->registerStyle(['font' => ['bold' => true]]);
+ $money = $writer->registerStyle(['numberFormat' => ['formatCode' => '#,##0.00']]);
+ $sheet = $writer->startSheet('Data');
+ $sheet->appendRow(['Header A', 'Header B'], $bold);
+ $sheet->appendRow([new StreamedCell(1234.5, $money), 'plain']);
+ $writer->close();
+
+ $worksheet = (new XlsxReader())->load($file)->getSheetByNameOrThrow('Data');
+ self::assertTrue($worksheet->getStyle('A1')->getFont()->getBold());
+ self::assertTrue($worksheet->getStyle('B1')->getFont()->getBold());
+ self::assertSame('#,##0.00', $worksheet->getStyle('A2')->getNumberFormat()->getFormatCode());
+ self::assertFalse($worksheet->getStyle('B2')->getFont()->getBold());
+ }
+
+ public function testUnregisteredStyleIdThrows(): void
+ {
+ $file = $this->tempFile();
+ $writer = new StreamingWriter($file);
+ $sheet = $writer->startSheet('Data');
+ $this->expectException(WriterException::class);
+ $this->expectExceptionMessage('has not been registered');
+ $sheet->appendRow(['x'], 99);
+ }
+
+ public function testSheetFeaturesRoundTrip(): void
+ {
+ $file = $this->tempFile();
+ $writer = new StreamingWriter($file);
+ $sheet = $writer->startSheet('Data');
+ $sheet->setColumnWidths([1 => 25.5, 3 => 8.0]);
+ $sheet->freezePane('A2');
+ $sheet->setAutoFilterToWrittenRange();
+ $sheet->appendRow(['H1', 'H2', 'H3']);
+ $sheet->appendRow(['a', 'b', 'c']);
+ $writer->close();
+
+ $worksheet = (new XlsxReader())->load($file)->getSheetByNameOrThrow('Data');
+ self::assertSame(25.5, $worksheet->getColumnDimension('A')->getWidth());
+ self::assertSame(8.0, $worksheet->getColumnDimension('C')->getWidth());
+ self::assertSame('A2', $worksheet->getFreezePane());
+ self::assertSame('A1:C2', $worksheet->getAutoFilter()->getRange());
+ }
+
+ public function testCalcPrReflectsFormulaPresence(): void
+ {
+ $noFormulaFile = $this->tempFile();
+ $writer = new StreamingWriter($noFormulaFile);
+ $sheet = $writer->startSheet('Data');
+ $sheet->appendRow([1, 2]);
+ $writer->close();
+
+ $formulaFile = $this->tempFile();
+ $writer = new StreamingWriter($formulaFile);
+ $sheet = $writer->startSheet('Data');
+ $sheet->appendRow([1, 2]);
+ $sheet->appendRow(['=SUM(A1:B1)']);
+ $writer->close();
+
+ $noFormulaCalcPr = $this->readCalcPr($noFormulaFile);
+ $formulaCalcPr = $this->readCalcPr($formulaFile);
+
+ self::assertSame('0', $noFormulaCalcPr['fullCalcOnLoad']);
+ self::assertSame('1', $noFormulaCalcPr['calcCompleted']);
+ self::assertSame('0', $noFormulaCalcPr['forceFullCalc']);
+
+ self::assertSame('1', $formulaCalcPr['fullCalcOnLoad']);
+ self::assertSame('0', $formulaCalcPr['calcCompleted']);
+ self::assertSame('1', $formulaCalcPr['forceFullCalc']);
+ }
+
+ /** @return array */
+ private function readCalcPr(string $file): array
+ {
+ $zip = new ZipArchive();
+ $zip->open($file);
+ $workbookXml = $zip->getFromName('xl/workbook.xml');
+ $zip->close();
+ self::assertIsString($workbookXml);
+
+ self::assertMatchesRegularExpression('/]*\/>/', $workbookXml);
+ preg_match('/]*)\/>/', $workbookXml, $matches);
+ $attributesXml = $matches[1] ?? '';
+ preg_match_all('/(\w+)="([^"]*)"/', $attributesXml, $attributeMatches, \PREG_SET_ORDER);
+ $attributes = [];
+ foreach ($attributeMatches as $attributeMatch) {
+ $attributes[$attributeMatch[1]] = $attributeMatch[2];
+ }
+
+ return $attributes;
+ }
+
+ public function testMultipleSheetsWithDataRoundTrip(): void
+ {
+ $file = $this->tempFile();
+ $writer = new StreamingWriter($file);
+ $first = $writer->startSheet('First');
+ $first->appendRow(['first-a1', 'first-b1']);
+ $first->appendRow(['first-a2', 'first-b2']);
+ $second = $writer->startSheet('Second');
+ $second->appendRow(['second-a1', 'second-b1']);
+ $writer->close();
+
+ $spreadsheet = (new XlsxReader())->load($file);
+ self::assertSame(['First', 'Second'], $spreadsheet->getSheetNames());
+
+ $firstSheet = $spreadsheet->getSheetByNameOrThrow('First');
+ self::assertSame('first-a1', self::stringValue($firstSheet->getCell('A1')->getValue()));
+ self::assertSame('first-b1', self::stringValue($firstSheet->getCell('B1')->getValue()));
+ self::assertSame('first-a2', self::stringValue($firstSheet->getCell('A2')->getValue()));
+ self::assertSame('first-b2', self::stringValue($firstSheet->getCell('B2')->getValue()));
+
+ $secondSheet = $spreadsheet->getSheetByNameOrThrow('Second');
+ self::assertSame('second-a1', self::stringValue($secondSheet->getCell('A1')->getValue()));
+ self::assertSame('second-b1', self::stringValue($secondSheet->getCell('B1')->getValue()));
+ self::assertNull($secondSheet->getCell('A2')->getValue());
+
+ $spreadsheet->disconnectWorksheets();
+ }
+
+ public function testColumnWidthsAfterFirstRowThrows(): void
+ {
+ $file = $this->tempFile();
+ $writer = new StreamingWriter($file);
+ $sheet = $writer->startSheet('Data');
+ $sheet->appendRow(['x']);
+ $this->expectException(WriterException::class);
+ $sheet->setColumnWidths([1 => 10.0]);
+ }
+
+ public function testFreezePaneAfterFirstRowThrows(): void
+ {
+ $file = $this->tempFile();
+ $writer = new StreamingWriter($file);
+ $sheet = $writer->startSheet('Data');
+ $sheet->appendRow(['x']);
+ $this->expectException(WriterException::class);
+ $sheet->freezePane('A1');
+ }
+
+ public function testColumnWidthsRejectsInvalidColumnNumber(): void
+ {
+ $file = $this->tempFile();
+ $writer = new StreamingWriter($file);
+ $sheet = $writer->startSheet('Data');
+ $this->expectException(WriterException::class);
+ $this->expectExceptionMessage('is invalid; column numbers are 1-based');
+ $sheet->setColumnWidths([0 => 10.0]);
+ }
+
+ public function testColumnWidthsRejectsNonPositiveWidth(): void
+ {
+ $file = $this->tempFile();
+ $writer = new StreamingWriter($file);
+ $sheet = $writer->startSheet('Data');
+ $this->expectException(WriterException::class);
+ $this->expectExceptionMessage('is invalid; width must be positive');
+ $sheet->setColumnWidths([1 => 0.0]);
+ }
+
+ public function testUnsupportedStreamedCellDataTypeThrows(): void
+ {
+ $file = $this->tempFile();
+ $writer = new StreamingWriter($file);
+ $sheet = $writer->startSheet('Data');
+ $this->expectException(WriterException::class);
+ $this->expectExceptionMessage('Unsupported StreamedCell data type');
+ $sheet->appendRow([new StreamedCell(42, null, DataType::TYPE_NUMERIC)]);
+ }
+
+ public function testNanThrows(): void
+ {
+ $file = $this->tempFile();
+ $writer = new StreamingWriter($file);
+ $sheet = $writer->startSheet('Data');
+ $this->expectException(WriterException::class);
+ $this->expectExceptionMessage('not a finite number');
+ $sheet->appendRow([NAN]);
+ }
+
+ public function testInfinityThrows(): void
+ {
+ $file = $this->tempFile();
+ $writer = new StreamingWriter($file);
+ $sheet = $writer->startSheet('Data');
+ $this->expectException(WriterException::class);
+ $this->expectExceptionMessage('not a finite number');
+ $sheet->appendRow([INF]);
+ }
+
+ public function testInvalidUtf8StringThrows(): void
+ {
+ $file = $this->tempFile();
+ $writer = new StreamingWriter($file);
+ $sheet = $writer->startSheet('Data');
+ $this->expectException(WriterException::class);
+ $this->expectExceptionMessage('not valid UTF-8');
+ $sheet->appendRow(["bad \xC3\x28 utf8"]);
+ }
+
+ public function testInvalidUtf8FormulaThrows(): void
+ {
+ $file = $this->tempFile();
+ $writer = new StreamingWriter($file);
+ $sheet = $writer->startSheet('Data');
+ $this->expectException(WriterException::class);
+ $this->expectExceptionMessage('not valid UTF-8');
+ $sheet->appendRow(["=\"bad \xC3\x28\""]);
+ }
+
+ public function testStringOverExcelLimitThrows(): void
+ {
+ $file = $this->tempFile();
+ $writer = new StreamingWriter($file);
+ $sheet = $writer->startSheet('Data');
+ $this->expectException(WriterException::class);
+ $this->expectExceptionMessage('32767');
+ $sheet->appendRow([str_repeat('x', DataType::MAX_STRING_LENGTH + 1)]);
+ }
+
+ public function testStringAtExcelLimitRoundTrips(): void
+ {
+ $file = $this->tempFile();
+ $value = str_repeat('x', DataType::MAX_STRING_LENGTH);
+ $writer = new StreamingWriter($file);
+ $writer->startSheet('Data')->appendRow([$value]);
+ $writer->close();
+
+ $worksheet = (new XlsxReader())->load($file)->getSheetByNameOrThrow('Data');
+ self::assertSame($value, self::stringValue($worksheet->getCell('A1')->getValue()));
+ }
+
+ public function testInvalidUtf8ForcedStringThrows(): void
+ {
+ $file = $this->tempFile();
+ $writer = new StreamingWriter($file);
+ $sheet = $writer->startSheet('Data');
+ $this->expectException(WriterException::class);
+ $this->expectExceptionMessage('not valid UTF-8');
+ $sheet->appendRow([new StreamedCell("bad \xC3\x28 utf8", null, DataType::TYPE_STRING)]);
+ }
+
+ public function testStreamedCellWithNullValueLeavesCellEmpty(): void
+ {
+ $file = $this->tempFile();
+ $writer = new StreamingWriter($file);
+ $writer->startSheet('Data')->appendRow([new StreamedCell(null), 'b']);
+ $writer->close();
+
+ $worksheet = (new XlsxReader())->load($file)->getSheetByNameOrThrow('Data');
+ self::assertNull($worksheet->getCell('A1')->getValue());
+ self::assertSame('b', self::stringValue($worksheet->getCell('B1')->getValue()));
+ }
+
+ public function testFreezePaneNormalizesAbsoluteReference(): void
+ {
+ $file = $this->tempFile();
+ $writer = new StreamingWriter($file);
+ $sheet = $writer->startSheet('Data');
+ $sheet->freezePane('$B$2');
+ $sheet->appendRow(['a', 'b']);
+ $writer->close();
+
+ $worksheet = (new XlsxReader())->load($file)->getSheetByNameOrThrow('Data');
+ self::assertSame('B2', $worksheet->getFreezePane());
+ }
+
+ public function testFreezePaneA1IsANoOp(): void
+ {
+ $file = $this->tempFile();
+ $writer = new StreamingWriter($file);
+ $sheet = $writer->startSheet('Data');
+ $sheet->freezePane('A1');
+ $sheet->appendRow(['x']);
+ $writer->close();
+
+ $worksheet = (new XlsxReader())->load($file)->getSheetByNameOrThrow('Data');
+ self::assertNull($worksheet->getFreezePane());
+ }
+}