PHPackages                             kolay/xlsx-stream - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [File &amp; Storage](/categories/file-storage)
4. /
5. kolay/xlsx-stream

ActiveLibrary[File &amp; Storage](/categories/file-storage)

kolay/xlsx-stream
=================

Streaming XLSX reader and writer for PHP and Laravel. Constant memory regardless of file size, direct S3 multipart streaming, optional born-indexed random access.

v3.0.2(1w ago)437.9k↓20.1%1[1 issues](https://github.com/turgutahmet/kolay-xlsx-stream/issues)MITPHPPHP ^8.1CI passing

Since Sep 7Pushed 1w ago3 watchersCompare

[ Source](https://github.com/turgutahmet/kolay-xlsx-stream)[ Packagist](https://packagist.org/packages/kolay/xlsx-stream)[ Docs](https://github.com/turgutahmet/kolay-xlsx-stream)[ RSS](/packages/kolay-xlsx-stream/feed)WikiDiscussions main Synced 2d ago

READMEChangelog (10)Dependencies (19)Versions (16)Used By (0)

Kolay XLSX Stream
=================

[](#kolay-xlsx-stream)

[![Latest Version on Packagist](https://camo.githubusercontent.com/2bfbd3a532c298ec3c65aa20cb42dd38305e7a193d39207a33521aa58a896230/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f6b6f6c61792f786c73782d73747265616d2e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/kolay/xlsx-stream)[![Tests](https://camo.githubusercontent.com/6b5bb3ca54ff16aa7cf9846cd2e8cfe5526297a43f2225a229e73ae2af0a4cb4/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f74757267757461686d65742f6b6f6c61792d786c73782d73747265616d2f74657374732e796d6c3f6272616e63683d6d61696e266c6162656c3d7465737473267374796c653d666c61742d737175617265)](https://github.com/turgutahmet/kolay-xlsx-stream/actions/workflows/tests.yml)[![Total Downloads](https://camo.githubusercontent.com/a422ffb96f6ac6c2d04f515f62d6421609892a96d050bc24a6544c83398b53aa/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f6b6f6c61792f786c73782d73747265616d2e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/kolay/xlsx-stream)[![License](https://camo.githubusercontent.com/23855a715c9b625b916f560f3ccbb3b7b2311d5dc8d119d583180f8b66c375c6/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f6c2f6b6f6c61792f786c73782d73747265616d2e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/kolay/xlsx-stream)[![PHP Version](https://camo.githubusercontent.com/d92901701ad2a8845384b083375dd5ccec5017a2aa7b0f177416092f6f7787e8/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f7068702d762f6b6f6c61792f786c73782d73747265616d2e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/kolay/xlsx-stream)

High-performance bidirectional XLSX streaming for Laravel — write to local or S3 with **zero disk I/O**, read back with **bounded memory**, and seek to any row in **O(1)** via the optional born-indexed sidecar. Built for exporting and ingesting millions of rows without spiking RAM.

Why This Package?
-----------------

[](#why-this-package)

### The Problem with Existing Solutions

[](#the-problem-with-existing-solutions)

Most PHP Excel libraries (PHPSpreadsheet, Spout, Laravel Excel) have critical limitations:

- **Memory Issues**: Load entire documents in RAM (unusable for large files)
- **Disk I/O**: Write temporary files then upload to S3 (2x I/O, slow)
- **No True Streaming**: Can't stream directly to S3
- **No Random Access**: O(N) full scan to reach any specific row

### Our Solution

[](#our-solution)

- **Zero Disk I/O**: Direct streaming to S3 using multipart upload
- **Constant Memory**: O(1) memory for the writer (32 MB part buffer) and ~24 MB for the reader regardless of file size
- **Bidirectional**: Write *and* read XLSX files through the same package — including files produced by other writers (PhpSpreadsheet, openpyxl, …) via shared-strings support
- **Random Access**: Optional `xl/_kxs/index.bin` sidecar lets `rowAt(N)`, `rowRange(a, b)`, and `rowCount()` skip ahead in **O(1)** instead of full-scanning. Backward-compatible — Excel and every other reader ignore the sidecar. (Per-lookup work is bounded by the writer-chosen sync period, default 10,000 rows; independent of file size.)
- **Blazing Fast**: 2-3× faster than alternatives on writes; ~70K rows/s sustained reads with constant RAM
- **Production Tested**: Successfully exported and re-read 4.5 million rows (500MB+ files)

Performance Comparison
----------------------

[](#performance-comparison)

### Cross-package comparison — 100K rows (May 2026)

[](#cross-package-comparison--100k-rows-may-2026)

Fresh `composer create-project` of each package, latest stable versions, identical 8-column mixed-type payload. `kolay/xlsx-stream` configured with `setCompressionLevel(1)->setBufferFlushInterval(10000)`; all other packages run at their out-of-the-box defaults. Each row matters — methodology and the trade-off table (file size, RAM, compression level) live in [BENCHMARK.md](BENCHMARK.md).

**Write — 100,000 rows**

\#PackageVersionTimerows/secPeak RAMFile1**kolay/xlsx-stream****3.0.0****0.65 s****153,216**11.79 MB5.64 MB2avadim/fast-excel-writer6.12.05.23 s19,1274 MB4.34 MB3openspout/openspout5.7.05.77 s17,3212 MB4.33 MB4rap2hpoutre/fast-excel5.7.07.30 s13,6974 MB4.06 MB5phpoffice/phpspreadsheet5.7.030.62 s3,265531 MB4.82 MB**Read — 100,000 rows** (each reader on the same package's writer output)

\#PackageVersionTimerows/secPeak RAM1**kolay/xlsx-stream****3.0.0****1.75 s****57,043**4 MB2avadim/fast-excel-reader3.0.14.60 s21,7522 MB3rap2hpoutre/fast-excel5.7.08.50 s11,7614 MB4openspout/openspout5.7.09.90 s10,1052 MB5phpoffice/phpspreadsheet5.7.029.95 s3,339434 MBThat's **~8× faster write** and **~2.6× faster read** than the next-fastest streaming peer (avadim 6.12 / 3.0.1). Against `kolay/xlsx-stream`'s own default config (`lvl=6, flush=1K`) the gap is still ~5× and ~2.4×.

**PhpSpreadsheet is in a different category** — it provides full Excel feature support (charts, pivot tables, conditional formatting) at the cost of memory-bound architecture. For pure data pipelines the streaming packages are 5–47× faster and 100×+ smaller in RAM.

---

### Latest Benchmark — v3.0 (May 2026)

[](#latest-benchmark--v30-may-2026)

v3.0 introduces the streaming **reader** plus the optional born-indexed **random-access** primitive on top of the v2.2.2 writer. Measured on the same Apple Silicon laptop, PHP 8.2.28, AWS SDK 3.379, against the `xlsx-test-package` bucket in `us-east-2`. Same 8-column mixed-type workload as v1.x and v2.2.2 — every release uses the canonical `benchmark-*.php` scripts in the repo root so the numbers stay comparable across versions.

#### Sequential read

[](#sequential-read)

```
php benchmark-read.php

```

RowsLocal Read SpeedLocal TimeLocal Peak RAMS3 Read SpeedS3 TimeS3 Peak RAMFile Size10027,216 rows/s0.00s22 MB56 rows/s1.79s24 MB0.01 MB50061,830 rows/s0.01s22 MB281 rows/s1.78s24 MB0.02 MB1,00069,553 rows/s0.01s22 MB480 rows/s2.08s24 MB0.04 MB5,00069,433 rows/s0.07s24 MB2,278 rows/s2.19s24 MB0.20 MB10,00075,843 rows/s0.13s24 MB4,235 rows/s2.36s24 MB0.40 MB25,00066,957 rows/s0.37s24 MB9,664 rows/s2.59s24 MB1.00 MB50,00072,895 rows/s0.69s24 MB16,967 rows/s2.95s22 MB2.00 MB100,00075,899 rows/s1.32s24 MB27,412 rows/s3.65s22 MB4.00 MB250,00072,267 rows/s3.46s24 MB43,139 rows/s5.80s22 MB10.01 MB500,00068,772 rows/s7.27s24 MB52,204 rows/s9.58s22 MB20.02 MB750,00071,360 rows/s10.51s24 MB57,230 rows/s13.11s24 MB30.03 MB1,000,00069,946 rows/s14.30s24 MB60,253 rows/s16.60s24 MB40.03 MB1,500,00069,517 rows/s21.58s24 MB59,746 rows/s25.11s22 MB59.66 MB2,000,00068,710 rows/s29.11s24 MB61,306 rows/s32.62s24 MB79.24 MB3,000,000–––62,447 rows/s48.04s24 MB119.04 MB4,000,000–––62,956 rows/s63.54s22 MB158.43 MB4,500,000–––62,198 rows/s72.35s22 MB178.09 MB**Reading is bounded-memory by construction.** Peak RAM stays at 22-24 MB across every row count, from 100 rows to 4.5 million — independent of file size. Local sustains ~67-76K rows/s. S3 cold-cache ramps with file size as TTFB amortises; saturates at ~60-62K rows/s for files ≥750K rows. Multi-sheet workbooks (above the 1,048,576-rows-per-sheet limit) read every sheet automatically; there is no per-sheet cap from the reader's side.

#### Random access — `rowAt(N)` and `rowCount()`

[](#random-access--rowatn-and-rowcount)

```
php benchmark-random-access.php 500000

```

500,000-row workbook, sync period 10,000. Same plain-vs-indexed comparison methodology as the POC's BENCHMARK.md — the two writers produce visually identical files and the indexed reader uses the embedded sidecar to fresh-init inflate from the nearest sync point.

Target RowPlain (full scan)Indexed (sync + scan)Speedup11.3 ms0.2 ms6.1×50,000665.8 ms132.6 ms5.0×125,0001,645.9 ms67.6 ms24.3×250,0003,426.5 ms136.8 ms25.0×375,0005,087.3 ms68.2 ms**74.6×**450,0006,291.8 ms136.6 ms46.0×499,9006,897.9 ms135.6 ms50.9×500,0006,768.0 ms135.9 ms49.8×`rowCount()` on the same file: **7,014 ms plain** (full inflate scan) vs **&lt;1 ms indexed** (constant lookup from the sidecar header) — **&gt;260,000× speedup**, the indexed call returns inside measurement noise.

**Index cost:** writing with `withRandomAccessIndex()` adds **−0.33 % wall time** (within measurement noise — i.e. zero detectable cost), **+0.032 % file size**, and zero detectable RAM overhead. The original design budget allowed up to 0.5 % file size; we measured ~16× below that ceiling at 500K rows.

#### Write benchmark — v3.0 vs v2.2.2

[](#write-benchmark--v30-vs-v222)

v3.0's writer default code path is byte-identical to v2.2.2 — the only new writer-side addition is the opt-in `withRandomAccessIndex()`. We re-ran the full write benchmark on v3.0 to verify zero regression:

Workloadv3.0v2.2.2DiffLocal 100K221,895 rows/s188,771 rows/s+18 %Local 1M214,912 rows/s209,905 rows/s+2.4 %Local 2M209,462 rows/s208,512 rows/s+0.5 %S3 1M109,562 rows/s106,924 rows/s+2.5 %S3 4.5M119,914 rows/s130,293 rows/s−8 % (network jitter)Deltas are inside measurement noise. Memory profile is unchanged. For the full v2.2.2 write table, see **Write benchmark — v2.2.2 (May 2026)** below.

For the full v3.0 measurement detail (write, read, random-access plus methodology and reproducibility notes), see [BENCHMARK.md](BENCHMARK.md).

---

### Write benchmark — v2.2.2 (May 2026)

[](#write-benchmark--v222-may-2026)

Re-measured on an Apple Silicon laptop with PHP 8.2.28 and AWS SDK 3.379 against the same `xlsx-test-package` bucket in `us-east-2`. The workload is identical to the v1.x baseline below (8 columns, mixed types, compression level 1).

RowsLocal SpeedLocal TimeS3 SpeedS3 MemoryS3 TimeFile Size10045,290 rows/s0.00s112 rows/s0 MB0.89s0.01 MB500191,346 rows/s0.00s491 rows/s0 MB1.02s0.02 MB1,000184,836 rows/s0.01s966 rows/s0 MB1.03s0.04 MB5,000195,825 rows/s0.03s3,345 rows/s0 MB1.49s0.2 MB10,000198,898 rows/s0.05s2,551 rows/s0 MB3.92s0.4 MB25,000210,498 rows/s0.12s10,170 rows/s0 MB2.46s1 MB50,000217,123 rows/s0.23s15,597 rows/s2 MB3.21s2 MB100,000188,771 rows/s0.53s24,258 rows/s4 MB4.12s4 MB250,000215,340 rows/s1.16s63,713 rows/s12 MB (±6)3.92s10 MB500,000209,428 rows/s2.39s87,679 rows/s20 MB (±18)5.70s20 MB750,000211,315 rows/s3.55s84,221 rows/s30 MB (±26)8.91s30 MB1,000,000209,905 rows/s4.76s106,924 rows/s40 MB (±38)9.35s40 MB1,500,000207,503 rows/s7.23s117,631 rows/s60 MB (±58)12.75s60 MB2,000,000208,512 rows/s9.59s112,708 rows/s79 MB (±77)17.74s79 MB3,000,000––112,229 rows/s119 MB (±117)26.73s119 MB4,000,000––128,930 rows/s160 MB (±156)31.02s158 MB4,500,000––130,293 rows/s178 MB (±178)34.54s178 MB#### What changed since v1.x

[](#what-changed-since-v1x)

- **Local throughput is now ~15–25% *faster* than the v1.x baseline**(1M rows: 210K rows/s vs 183K). The v2.0+ per-cell type detection added a small overhead at first (v2.0 was about 5% slower than v1.x), but v2.2.2 fixed a long-standing bug in the XML escape fast path — the `strpbrk` needle was a single-quoted literal so `\xNN` escapes were embedded as the characters `\`, `x`, `0..9`, `A..F` instead of as actual control bytes. The fix shrank the needle from 129 to 36 chars and per-cell sanitization got ~3.5× cheaper as a side effect.
- **S3 throughput is up 2.5–3× over v1.x** for any workload above 50K rows (1M: 107K rows/s vs 43K, 4.5M: 130K rows/s vs 46K). Drivers: AWS SDK 3.379+, faster measurement-machine network, and a smaller share from the per-row hot-path improvement.
- **Memory is unchanged** — local stays at 0–2 MB constant, S3 keeps the same sawtooth pattern as the buffer fills and flushes per part.

### Original Benchmark — v1.x (September 2025)

[](#original-benchmark--v1x-september-2025)

Kept here for historical context. Different machine and PHP version, so direct cell-by-cell deltas reflect environment variance as well as code changes.

RowsLocal SpeedLocal MemoryLocal TimeS3 SpeedS3 MemoryS3 TimeFile Size10047,913 rows/s2 MB0.00s99 rows/s0 MB1.01s0.01 MB500161,183 rows/s0 MB0.00s362 rows/s0 MB1.38s0.02 MB1,000161,711 rows/s0 MB0.01s913 rows/s0 MB1.09s0.04 MB5,000167,101 rows/s0 MB0.03s3,092 rows/s0 MB1.62s0.2 MB10,000183,281 rows/s0 MB0.05s5,411 rows/s0 MB1.85s0.4 MB25,000187,322 rows/s0 MB0.13s11,482 rows/s0 MB2.18s1 MB50,000187,455 rows/s2 MB0.27s5,829 rows/s2 MB8.58s2 MB100,000182,167 rows/s0 MB0.55s9,288 rows/s4 MB10.77s4 MB250,000185,586 rows/s0 MB1.35s59,744 rows/s12 MB (±6)4.18s10 MB500,000184,268 rows/s0 MB2.71s28,553 rows/s20 MB (±18)17.51s20 MB750,000182,648 rows/s0 MB4.11s33,504 rows/s30 MB (±26)22.39s30 MB1,000,000182,693 rows/s0 MB5.47s43,215 rows/s40 MB (±38)23.14s40 MB1,500,000180,578 rows/s0 MB8.31s36,733 rows/s60 MB (±58)40.84s60 MB2,000,000177,012 rows/s0 MB11.30s51,323 rows/s79 MB (±77)38.97s79 MB3,000,000–––39,150 rows/s117 MB (±117)76.63s119 MB4,000,000–––42,500 rows/s160 MB (±156)94.12s158 MB4,500,000–––46,462 rows/s178 MB (±178)96.85s178 MB*Note: Tests with 1M+ rows automatically create multiple sheets (Excel limit: 1,048,576 rows per sheet)*
*Note: ± values in S3 Memory column indicate memory fluctuation during streaming due to periodic part uploads*

### Understanding Memory Behavior

[](#understanding-memory-behavior)

> **Reader bounded-RAM contract — when it holds:** Files written by this package use inline strings exclusively, so the reader's bounded 22-24 MB peak RAM applies unconditionally. Files produced by other tools that store text in `xl/sharedStrings.xml` are loaded into memory **provided the table fits** — compressed ≤ 20 MB AND uncompressed ≤ 100 MB. Files exceeding either threshold are rejected with a clear error rather than silently exhausting RAM. On-disk shared-strings table support is tracked for a future release.

#### Local File System

[](#local-file-system)

- **True O(1) Memory**: Constant memory usage regardless of file size
- **No Growth**: Memory stays at 0-2MB even for millions of rows
- **Speed**: 180,000+ rows/second consistently

#### S3 Streaming Memory Fluctuation

[](#s3-streaming-memory-fluctuation)

The ± values in S3 memory represent **normal memory fluctuation** during streaming:

1. **Buffer Accumulation Phase** (↑ Memory Growth)

    - Data is compressed and buffered until reaching 32MB
    - Memory grows gradually as buffer fills
2. **Part Upload Phase** (↓ Memory Drop)

    - When buffer reaches 32MB, it's uploaded to S3
    - After upload, memory drops back to baseline
    - This creates the characteristic sawtooth pattern
3. **Example: 1M Rows Test**

    - Average memory: 40MB
    - Fluctuation: ±38MB
    - Pattern: Memory oscillates between ~2MB (after upload) and ~78MB (before upload)
    - This is **completely normal** and expected behavior

### Performance Highlights *(v3.0, May 2026)*

[](#performance-highlights-v30-may-2026)

- **Write — Local**: ~190,000–222,000 rows/second with true O(1) memory
- **Write — S3**: 109,000–129,000 rows/second above 750K rows (2.5–3× the v1.x baseline, identical to v2.2.2)
- **Read — Local**: ~67,000–76,000 rows/second sustained, 22-24 MB peak RAM regardless of file size
- **Read — S3**: 60,000–62,000 rows/second saturation on multi-MB files (cold cache, single-stream)
- **Random Access**: O(1) `rowAt(N)`, `rowRange(a, b)`, and `rowCount()` via opt-in `withRandomAccessIndex()` — **up to 74.6× speedup** vs full scan, **&gt;260,000× speedup** on `rowCount()`, **+0.032 % file size cost**
- **Memory Efficiency**: Local writer uses &lt;2 MB, S3 writer averages 40 MB per million rows; reader caps at ~24 MB independent of file size
- **Multi-sheet Support**: Automatic sheet creation at Excel's 1,048,576 row limit, transparent multi-sheet reading
- **External XLSX**: Reads files produced by PhpSpreadsheet, openpyxl, Apache POI, Excel etc. via shared-strings table support
- **Production Ready**: Successfully written and round-tripped 4.5 million rows

### File size limits

[](#file-size-limits)

The writer emits **ZIP32** archives. Each output is bounded by:

- **4 GB compressed** total archive size
- **4 GB uncompressed** per ZIP entry (single sheet)
- **65,535 entries** in the central directory

These ceilings are far above any realistic single-export workload (4.5 M rows ≈ 178 MB compressed). If a workload approaches them the writer aborts with a clear `ZIP32 limit exceeded` exception instead of silently truncating size fields and producing a corrupt file — split the export across multiple files or sheets as a workaround. ZIP64 writer support is tracked for a future release.

### Compression level

[](#compression-level)

`setCompressionLevel(int $level)` accepts 1–9. The default is 6 (zlib's standard balance). Pick by use case:

Use caseLevelTradeoffQueue job, fastest export1~30 % faster than default, ~5 % larger fileBalanced default6zlib default — used unless you set otherwiseArchive, smallest file9~15 % slower than default, ~2 % smaller fileFor S3 uploads, level 1 typically wins because compute is the bottleneck. For local file output where disk is fast, level 6 is fine; level 9 only helps if you're storing the file long-term.

### Comparison with Other Libraries

[](#comparison-with-other-libraries)

Package1M Rows Write1M Rows ReadMemory (Read)Disk UsageRandom AccessS3 SupportPHPSpreadsheet❌ Crashes❌ Crashes~8 GBFull file❌IndirectSpout / OpenSpout~60 sec~30 sec~100MB+Full file❌IndirectLaravel Excel~90 sec~60 sec~500MB+Full file❌Indirect**Kolay XLSX Stream (Local)**✅ **4.65 sec**✅ **14.30 sec**✅ **24 MB**✅ **Zero**✅ **O(1)\***N/A**Kolay XLSX Stream (S3)**✅ **9.13 sec**✅ **16.60 sec**✅ **24 MB**✅ **Zero**✅ **O(1)\***✅ **Direct***\*With opt-in `withRandomAccessIndex()` on the writer. Per-lookup work is bounded by the writer-chosen sync period (default 10,000 rows), independent of file size. `rowCount()` is constant straight from the index header. Tune for latency-sensitive seeks with `withRandomAccessIndex(every: 1000)` or `every: 100` for very dense random reads (file size grows ~1% per 10× density).*

### When to use this package vs alternatives

[](#when-to-use-this-package-vs-alternatives)

For most Laravel exports — use [fast-excel](https://github.com/rap2hpoutre/fast-excel). Simpler API, supports CSV/ODS, includes import functionality, battle-tested across millions of installs.

**Use `kolay/xlsx-stream` when:**

- You need to stream directly to S3 with no temporary disk usage — Lambda, Cloud Run, Fargate, read-only filesystems
- Your dataset exceeds available memory — 1M+ rows on small instances, multi-million-row exports on standard ones
- You need **O(1) random access** into large XLSX files via `rowAt(N)` / `rowRange(a, b)` (born-indexed mode — first random-access XLSX primitive in PHP)
- You want HTTP-streamed downloads via `PhpStreamSink::output()` — zero temp file, immediate first byte to the client

**Use [PhpSpreadsheet](https://github.com/PHPOffice/PhpSpreadsheet) when:**

- You need formulas, charts, conditional formatting, or pivot tables
- File size is small enough for in-memory operations (&lt; 50 K rows)
- You're editing existing workbooks rather than producing new ones

**Use [OpenSpout](https://github.com/openspout/openspout) when:**

- You need ODS or CSV alongside XLSX
- You're already in a non-Laravel ecosystem and want a streaming writer/reader without S3 specifics

Requirements
------------

[](#requirements)

- PHP 8.1+
- Laravel 10, 11, 12 or 13
- AWS SDK (only if using S3 streaming or the S3 reader)

> Upgrading from v2.x? Reader and random-access APIs are purely additive — no breaking changes. See [CHANGELOG.md](CHANGELOG.md) for the full v3.0 highlights.
>
> Upgrading from v1.x? See [UPGRADE.md](UPGRADE.md) for the v2.0 migration guide as well.

Installation
------------

[](#installation)

```
composer require kolay/xlsx-stream
```

### Publish Configuration (Optional)

[](#publish-configuration-optional)

```
php artisan vendor:publish --tag=xlsx-stream-config
```

Quick Start
-----------

[](#quick-start)

### Basic Usage - Local File

[](#basic-usage---local-file)

```
use Kolay\XlsxStream\Writers\SinkableXlsxWriter;
use Kolay\XlsxStream\Sinks\FileSink;

// Create writer with file sink
$sink = new FileSink('/path/to/output.xlsx');
$writer = new SinkableXlsxWriter($sink);

// Set headers
$writer->startFile(['Name', 'Email', 'Phone']);

// Write rows
$writer->writeRow(['John Doe', 'john@example.com', '+1234567890']);
$writer->writeRow(['Jane Smith', 'jane@example.com', '+0987654321']);

// Or write multiple rows at once
$writer->writeRows([
    ['Bob Johnson', 'bob@example.com', '+1111111111'],
    ['Alice Brown', 'alice@example.com', '+2222222222'],
]);

// Finish and close file
$stats = $writer->finishFile();

echo "Generated {$stats['rows']} rows in {$stats['sheets']} sheet(s)";
```

### Direct S3 Streaming (Zero Disk I/O)

[](#direct-s3-streaming-zero-disk-io)

```
use Kolay\XlsxStream\Writers\SinkableXlsxWriter;
use Kolay\XlsxStream\Sinks\S3MultipartSink;
use Aws\S3\S3Client;

// Create S3 client
$s3Client = new S3Client([
    'region' => 'us-east-1',
    'version' => 'latest',
    'credentials' => [
        'key' => env('AWS_ACCESS_KEY_ID'),
        'secret' => env('AWS_SECRET_ACCESS_KEY'),
    ],
]);

// Create S3 sink with 32MB parts
$sink = new S3MultipartSink(
    $s3Client,
    'my-bucket',
    'exports/report.xlsx',
    32 * 1024 * 1024 // 32MB parts for optimal performance
);

$writer = new SinkableXlsxWriter($sink);

// Configure for maximum performance
$writer->setCompressionLevel(1)      // Fastest compression
       ->setBufferFlushInterval(10000); // Flush every 10K rows

$writer->startFile(['ID', 'Name', 'Email', 'Status']);

// Stream millions of rows with constant 32MB memory
User::query()
    ->select(['id', 'name', 'email', 'status'])
    ->chunkById(1000, function ($users) use ($writer) {
        foreach ($users as $user) {
            $writer->writeRow([
                $user->id,
                $user->name,
                $user->email,
                $user->status
            ]);
        }
    });

$stats = $writer->finishFile();
```

### Reading XLSX Files *(v3.0+)*

[](#reading-xlsx-files-v30)

```
use Kolay\XlsxStream\Readers\StreamingXlsxReader;

// From a local file
foreach (StreamingXlsxReader::fromFile('/path/to/big.xlsx')->rows() as $row) {
    DB::table('users')->insert($row);
}

// Directly from S3 — bounded RAM (~24 MB), no temp file
$reader = StreamingXlsxReader::fromS3($s3Client, 'my-bucket', 'imports/big.xlsx');
foreach ($reader->rows(skip: 1) as $row) {           // skip the header row
    User::create([
        'id'    => $row[0],
        'name'  => $row[1],
        'email' => $row[2],
    ]);
}

// Bulk insert via chunked()
foreach ($reader->chunked(1000, skip: 1) as $batch) {
    User::insert($batch);
}
```

The reader supports both files written by this package (zero indirection via inline strings) and files produced by other writers (PhpSpreadsheet, openpyxl, Apache POI, Excel itself) — the shared-strings table is loaded transparently when present.

> **Memory:** Reader peak RAM is bounded — measured delta from baseline stays under 4 MB regardless of file size (CI-pinned via `MemoryFootprintTest`). The PHP runtime adds a ~20 MB baseline, so total RSS lands around 22-24 MB on real workloads.
>
> **Lifecycle:** Reader resources are released automatically when the object goes out of scope (`__destruct` calls `close()`). For long-lived workers processing many files, calling `$reader->close()`or `unset($reader)` between iterations frees underlying handles eagerly.

### Reading dates and times *(v3.0+)*

[](#reading-dates-and-times-v30)

Excel stores dates as numeric serials (e.g. `46148` for 2026-05-06). The reader returns those as numeric strings by default — opt into automatic conversion per column:

```
$reader = StreamingXlsxReader::fromFile('orders.xlsx');
$reader->castColumn(2, 'date');                          // → DateTimeImmutable (date)
$reader->castColumn(3, 'datetime');                      // → DateTimeImmutable (with time)
$reader->castColumn(4, 'int');                           // → int
$reader->castColumn(5, fn ($v) => (int) $v * 100);       // custom callable

// Bulk
$reader->castColumns([0 => 'int', 2 => 'date', 3 => 'datetime']);

foreach ($reader->rows(skip: 1) as $row) {
    $row[2]; // DateTimeImmutable
}
```

> **Always use `rows(skip: 1)` with casts.** Casts run on every row the generator yields, including row 1 (the header). A header string like `"id"` cast as `'int'` returns `null` because `is_numeric("id")` is false. Read the header separately via `$reader->header()` (cast-free) and skip it on data iteration.

> **Timezone:** Excel serials are timezone-naive. The reader returns datetimes in **UTC by default** so the same file produces the same result on every server regardless of `date_default_timezone_get()`. If your file's dates were authored in a specific timezone, set it explicitly:
>
> ```
> $reader->castTimezone('Europe/Istanbul');
> ```
>
>
>
> Mac-origin Excel files using the 1904 epoch (rare): `$reader->use1904Epoch();`

Built-in cast names: `date`, `datetime`, `int`, `float`, `bool`. Pass any callable for custom transformations (parse to a value object, trim, normalise, etc.).

### Streaming directly to an HTTP response *(v3.0+)*

[](#streaming-directly-to-an-http-response-v30)

Use `PhpStreamSink::output()` to stream a workbook into the active HTTP response — no temp file, constant memory, immediate first byte to the client. Pairs naturally with Laravel's `Response::stream()`:

```
use Kolay\XlsxStream\Sinks\PhpStreamSink;
use Kolay\XlsxStream\Writers\SinkableXlsxWriter;

return response()->stream(function () {
    $writer = new SinkableXlsxWriter(PhpStreamSink::output());
    $writer->startFile(['id', 'name', 'email']);
    User::query()->lazy()->each(fn ($u) =>
        $writer->writeRow([$u->id, $u->name, $u->email])
    );
    $writer->finishFile();
}, 200, [
    'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
    'Content-Disposition' => 'attachment; filename="users.xlsx"',
]);
```

The sink also has `temp()` (in-memory until 2 MB, then a tmp file) and `memory()` (in-memory only) factories for capturing workbooks for later inspection — handy in tests.

### Random-Access Reading *(v3.0+)*

[](#random-access-reading-v30)

Files written with `withRandomAccessIndex()` can be seeked into in O(1). The opt-in costs ~0.03 % file size and adds a single hidden ZIP part (`xl/_kxs/index.bin`) that vanilla XLSX readers ignore.

```
// Producer side — opt-in once during configuration
$writer = new SinkableXlsxWriter(new FileSink('/path/to/report.xlsx'));
$writer->withRandomAccessIndex(every: 10000);   // sync point every 10K rows
$writer->startFile(['ID', 'Name', 'Email']);
foreach ($users as $u) {
    $writer->writeRow([$u->id, $u->name, $u->email]);
}
$writer->finishFile();

// Consumer side — same StreamingXlsxReader, gains rowAt / rowRange / O(1) rowCount
$reader = StreamingXlsxReader::fromFile('/path/to/report.xlsx');

$reader->rowCount();              // O(1) — read straight from the index header
$reader->rowAt(250_001);          // O(period) — fresh inflate from nearest sync point
foreach ($reader->rowRange(100_000, 100_500) as $rowNumber => $row) {
    // ... process 500 rows starting at row 100,000 without scanning the prefix
}
```

`rowAt()` and `rowRange()` work even on files **without** an index — they fall back to a sequential O(N) scan from the first row. Only the cost differs; the API contract is identical.

> **Performance tip:** When you need many adjacent rows, prefer `rowRange($from, $to)` over a loop of `rowAt()` calls. `rowRange()`seeks once and reuses a single inflate stream; repeated `rowAt()`re-seeks on every call. For 1000 nearby rows the difference is ~1000× — a single ~ms seek versus 1000 × ms per call.

### Laravel Job Example

[](#laravel-job-example)

```
