Download the PHP package kolay/xlsx-stream without Composer
On this page you can find all versions of the php package kolay/xlsx-stream. It is possible to download/install these versions without Composer. Possible dependencies are resolved automatically.
Download kolay/xlsx-stream
More information about kolay/xlsx-stream
Files in kolay/xlsx-stream
Package xlsx-stream
Short Description Streaming XLSX reader and writer for PHP and Laravel. Constant memory regardless of file size, direct S3 multipart streaming, optional born-indexed random access.
License MIT
Homepage https://github.com/turgutahmet/kolay-xlsx-stream
Informations about the package xlsx-stream
Kolay XLSX Stream
Bidirectional XLSX streaming for PHP and Laravel — and the only library in any language that makes the spreadsheet itself queryable. Write millions of rows straight to S3 with zero disk I/O, read them back with bounded memory, seek to any row in O(1), and ask for a column's sum, median, p99 or distinct count without reading a single row — over HTTP range requests, from a file Excel opens like any other.
- Write: ~289K rows/s locally at 6 MB peak RAM; direct S3 multipart streaming at bounded memory — synchronous by default (flat ~part-size working set, true O(1) regardless of file size), optional parallel upload window, no temp files
- Read: ~127K rows/s full scans with bounded memory, any file size
- Seek:
rowAt(1_000_000)in milliseconds via the born-indexed sidecar - Query:
columnStats/rowsWhere/findRow/groupStatswith Parquet-style block pruning;median/quantile/countDistinctfrom embedded sketches with zero row reads - Open format: the sidecar is a published spec (SPEC.md) with a byte-pinned conformance suite
Why this package?
Most PHP Excel libraries load whole documents into RAM (unusable at scale), spill temp files before uploading to S3, and can only read forward — reaching row 900,000 means scanning 899,999 rows first.
This package streams in both directions with constant memory, and its
born-indexed mode embeds a small binary sidecar (xl/_kxs/index.bin)
that vanilla readers ignore but this library uses for random access,
block-pruned queries and sidecar-only analytics. Excel, LibreOffice,
Numbers, PhpSpreadsheet and OpenSpout all open the files normally.
Performance
The trajectory — same canonical workloads, every release
| v1.x (Sep 2025) | v2.2 (May 2026) | v3.0 (May 2026) | v3.1 (Jul 2026) | v3.2 (Jul 2026) | v3.3 (Jul 2026) | |
|---|---|---|---|---|---|---|
| Write, local | ~182K rows/s | ~210K | ~215K | ~289K | ~289K | ~289K |
| Write, S3 | ~9K rows/s | ~107K | ~107K | +36% same-link A/B | + parallel window | O(1) memory (was O(file)) |
| Read, local | — | — | ~70K rows/s | ~106K | ~127K | ~127K |
| Random access | — | — | O(1) rowAt |
+ block-pruned queries | + within-block skip (~19×) | + bounded ranged fetch |
| Query engine | — | — | — | columnStats/rowsWhere |
+ groupStats |
AND / topRows / explain / by-name |
| Analytics | — | — | — | 0-request sums | median/p99/distinct 0-request | + Bucket::month GROUP BY |
| Integrity | — | — | — | — | — | verify() + S3 per-part checks |
| Peak RAM (write/read) | 0-2 MB / — | ~6 MB / — | 6 / 24 MB | 6 / 24 MB | 6 / 6 MB | 6 / 6 MB |
Absolute S3 throughput tracks the network path far more than the
library (the same 1M-row export measured 59K–153K rows/s across
sessions) — the honest S3 claim is the same-day A/B: v3.2's writer is
+36% over v3.0.2 on an identical link. Uploads are synchronous by
default — part memory stays flat at ~part-size no matter how large the
file, and throughput is steady; a parallel upload window is opt-in
(concurrency) for high-latency links where hiding per-request
round-trips outweighs its higher (sawtooth) memory. Benchmark on your
own link.
Cross-package, 100K rows (May 2026, latest stables)
| Write | Time | rows/s | Read | Time | rows/s | |
|---|---|---|---|---|---|---|
| kolay/xlsx-stream | 0.65s | 153K | kolay/xlsx-stream | 1.75s | 57K | |
| avadim/fast-excel-writer | 5.23s | 19K | avadim/fast-excel-reader | 4.60s | 22K | |
| openspout | 5.77s | 17K | fast-excel | 8.50s | 12K | |
| fast-excel | 7.30s | 14K | openspout | 9.90s | 10K | |
| phpspreadsheet | 30.62s | 3K | phpspreadsheet | 29.95s | 3K |
(Numbers predate the v3.1/v3.2 speedups. PhpSpreadsheet plays a different game — full Excel feature support at memory-bound cost.)
Every historical table (per-version scaling runs from 100 rows to
4.5M, random-access speedups, memory profiles, methodology) lives in
BENCHMARK.md. All numbers come from the committed
bench/ harnesses — fresh process per run, medians, generation cost
subtracted; re-run them yourself.
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
setCompressionLevel(int $level) accepts 1–9. The default is 5
(v3.1+): measured on XLSX-shaped XML, level 5 produces a file within
~0.2 % of level 6's size at ~20 % less wall time — level 6 spends its
extra effort on entropy (unique cell refs) that doesn't compress
anyway. Pick by use case:
| Use case | Level | Tradeoff |
|---|---|---|
| Queue job, fastest export | 1 | fastest, ~20 % larger file |
| Balanced default | 5 | knee of the size/speed curve for XLSX data |
| Marginally smaller | 6 | ~0.2 % smaller than 5, measurably slower |
| Archive, smallest file | 9 | much slower, ~6 % smaller file |
For S3 uploads, a lower level typically wins because compute is the bottleneck. Level 9 only helps if you're storing the file long-term.
Comparison with Other Libraries
| Package | 1M Rows Write | 1M Rows Read | Memory (Read) | Disk Usage | Random Access | S3 Support |
|---|---|---|---|---|---|---|
| PHPSpreadsheet | ❌ Crashes | ❌ Crashes | ~8 GB | Full file | ❌ | Indirect |
| Spout / OpenSpout | ~60 sec | ~30 sec | ~100MB+ | Full file | ❌ | Indirect |
| Laravel 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
For most Laravel exports — use 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 when:
- You need formulas, charts, conditional formatting, or pivot tables
- File size is small enough for in-memory operations (< 50 K rows)
- You're editing existing workbooks rather than producing new ones
Use 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
- 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 for the full v3.2 highlights.
Upgrading from v1.x? See UPGRADE.md for the v2.0 migration guide as well.
Installation
Publish Configuration (Optional)
Use cases
Each scenario below links to the how-to section further down.
1. The million-row queued export that stopped eating RAM.
A Laravel queue job streams FromQuery-style data straight to S3 —
no temp file, no reopen-per-chunk, ~6 MB writer footprint, progress
callbacks for the UI. Add withRandomAccessIndex() and the artifact is
instantly seekable for every scenario below. → Direct S3 Streaming,
Laravel Job Example, Progress Reporting.
2. "Download report" endpoints that start instantly.
Stream the workbook into the HTTP response as it's generated
(PhpStreamSink → php://output) — first bytes reach the browser
while row 1,000,000 is still being written. → Streaming directly to
an HTTP response.
3. Importing customer uploads without fear.
Read files produced by Excel/openpyxl/PhpSpreadsheet with bounded
memory (shared-strings tables up to 64 MB compressed), correct dates
via autoDetectDates(), validate row-by-row, and batch-insert.
→ Reading XLSX Files, Reading dates and times.
4. Parallel imports: wall clock = slowest worker.
shards(8) splits a born-indexed sheet into eight independently
decompressible, JSON-serializable ranges — dispatch one queue job per
shard, zero coordination. → Parallel reads.
5. Admin file preview without importing to a database.
Paginate a 4M-row S3 export in a UI: rowCount() is O(1), page 40,000
costs the same as page 1 via rowRange(), "jump to ID" is findRow()
— two range requests on a sorted column. → Random-Access Reading.
6. Dashboard numbers straight from the file.
"Total payroll", "median salary", "p99 order value", "distinct
customers" — answered from the sidecar with zero row reads
(columnStats, quantile, countDistinct), and per-month breakdowns
via groupStats() reading only group-boundary blocks. The file IS the
report backend. → Queryable XLSX, Grouped aggregates.
7. Styled corporate reports, still streaming. Header styling, per-row highlight styles, ₺/date/weekday number formats, frozen header, autofilter, auto column widths — all single-pass compatible. → Header & Column Styling.
8. Tight environments: Lambda, small pods, multi-tenant SaaS. Constant memory on both directions means the same code runs in a 128 MB function and a shared worker without per-tenant memory math.
Quick Start
Basic Usage - Local File
Direct S3 Streaming (Zero Disk I/O)
Reading XLSX Files (v3.0+)
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 (
__destructcallsclose()). For long-lived workers processing many files, calling$reader->close()orunset($reader)between iterations frees underlying handles eagerly.
Reading dates and times (v3.0+)
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:
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'returnsnullbecauseis_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: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+)
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():
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+)
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.
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.
The physics of deflate streaming — seekability requires
Z_FULL_FLUSHmarkers, and each marker resets the compressor's dictionary, which in principle costs compression ratio. In practice the cost is negligible at our default cadence: a sync point every 10K rows resets a 32 KB window once per ~1 MB of XML, measured at +0.04 % file size on the 500K-row benchmark workload (predicted ceiling ≤0.5 %). You would only notice dictionary-reset overhead at extreme settings likeevery: 100— if you shrink the period for query granularity, re-measure your file sizes.Performance tip: When you need many adjacent rows, prefer
rowRange($from, $to)over a loop ofrowAt()calls.rowRange()seeks once and reuses a single inflate stream; repeatedrowAt()re-seeks on every call. For 1000 nearby rows the difference is ~1000× — a single ~ms seek versus 1000 × ms per call.
Queryable XLSX — zone maps & aggregates (v3.1+)
Born-indexed files can additionally carry per-block column statistics (min/max/sum/count for every ~10K-row block — the same idea as Parquet row-group stats, embedded in a plain .xlsx that Excel still opens normally). Track the columns you'll query when writing:
The reader then answers three kinds of questions without scanning row data:
Ops: =, <, <=, >, >=, between. Predicates match numeric
cells (ints, floats, dates as serials); on files without stats the same
calls degrade gracefully to a full-scan filter with identical results.
Grouped aggregates & approximate analytics (v3.2+)
Two more layers on the same sidecar:
The sketches are a merging t-digest (~1-4 KB/column, p01/p99 within
0.2% rank error) and a HyperLogLog (2 KB, ±5% pinned) per column — both
merge associatively, which is what future segment/partition stitching
builds on. The full binary layout is public: KXSI is an open spec
(see SPEC.md) with committed conformance vectors under
tests/SpecVectors/, so other implementations can verify byte-for-byte.
The query engine grows up (v3.3+)
The sidecar turns into a small SQL-shaped engine — all answered by reading only the blocks that can match, and all addressable by header name (not just 1-based index):
String lookups — find a record by code on S3 (v3.4+)
withStringStats([...]) adds per-block lexicographic zone maps (STRZ),
so a string predicate prunes to the one block that can hold the value — a
point lookup in a multi-GB S3 file in a couple of range requests:
Collation is unsigned UTF-8 byte order (= Unicode code-point order), the
only sound basis for a streaming zone map — NOT locale. In Turkish, İ/ı
sort by their bytes, not tr_TR rules; use this for exact / prefix / range
lookups (codes, SKUs, IDs), not locale-correct sorting.
Data profiling & exact analytics (v3.4+)
The sidecar grows a profiling layer — a full per-column report, exact
quantiles with a deterministic certificate, histograms, frequent values,
and correlation — answered from the index cached at open, reading no data
rows (profile() reads only the header once, bounded, to name columns):
Every number comes from the sidecar (zero row I/O), so it is not free of
CPU — profile() runs each column's sketch math; "one range request" is a
statement about I/O, not latency. A percentile's certificate width tracks
row-order locality, not value clustering: a column sorted by (or
covarying with) the sheet's order certifies tightly, a scattered one is
honestly reported as [0, N]. The certificate resolution is bounded by the
block size — on a sorted column its width is ≈ the sync interval (every),
so it is a knob: a smaller every tightens the certificate and thins the
exact-scan pruning, at the cost of a larger sidecar. histogram() also
takes mode: 'depth' for equi-depth bins (each ≈ equal count), which reads
a skewed column far better than the default equi-width. See
SPEC.md §4–§6 for the format.
Integrity — verified reads & writes (v3.3+)
For data that matters (payroll, HR, audit exports):
Bigger, cheaper writes (v3.3+)
S3 writes are now O(1) memory. Multipart uploads default to synchronous (
concurrency: 1): part memory stays flat at ~part-size no matter the file size. (Earlier versions defaulted to a parallel window that could grow memory toward the whole file.) Parallel is still an opt-in for high-latency links — see UPGRADE.md.
Parallel reads — shard a sheet across queue workers (v3.1+)
Every sync point in a born-indexed file is an independently decompressible boundary, so a sheet can be split into self-contained row ranges. The shard plan is plain JSON-friendly data — dispatch one queue job per shard and each worker streams only its slice, with zero coordination:
A 4M-row import's wall clock becomes max(worker time) instead of the
sum. Shard boundaries snap to sync points (balanced to within one sync
period); on non-indexed files shards() returns a single whole-sheet
shard — same contract, no parallelism.
Laravel Job Example
Advanced Features
Laravel Storage Disk Integration (v2.1+)
Skip the manual S3Client setup — forDisk() reads everything from
config('filesystems.disks.{$disk}'):
Streaming from Eloquent with lazy() (v2.1+)
writeRows() accepts any iterable — pass an Eloquent lazy() cursor for
constant-memory streaming over millions of rows:
Generators work too:
Progress Reporting for Queue Jobs (v2.1+)
Register a callback that fires every N rows with (rows, bytes). Zero
overhead when not used:
Note on
$bytes— the byte counter only advances when zlib emits compressed output. With small datasets (or whensetBufferFlushInterval()is large relative tosetProgressInterval()), several events in a row may report the same byte count between flushes. The row counter is always exact; if you need accurate streamed-byte progress on small files, lowersetBufferFlushInterval()below your progress interval.
Supported Cell Data Types
The writer infers the right Excel cell type from each PHP value:
| PHP value | Excel cell |
|---|---|
int, float |
numeric (t="n") |
bool |
native boolean (t="b") — 1 for true, 0 for false |
\DateTimeInterface (DateTime, DateTimeImmutable, Carbon, …) |
numeric serial date with yyyy-mm-dd hh:mm:ss format — sortable as a date in Excel |
| numeric string ≤ 15 digits | numeric (t="n") |
numeric string > 15 digits, leading-zero ("00123"), or +-prefixed |
inline string — preserves precision and formatting |
null or '' |
empty cell |
| anything else | inline string (t="inlineStr") |
Header & Column Styling (v2.2+)
A small set of opt-in styling APIs that costs ~2-3% throughput and adds ~3% to the file size. Skip them and the writer takes the v1.x-equivalent hot path.
Available format presets: date, datetime, datetime_iso, time,
integer, decimal, percent, currency_try, currency_usd,
currency_eur, currency_gbp. Pass any other string to use a raw Excel
format code (e.g. 0.000, #,##0.00 "kg").
setAutoColumnWidth() derives a width from the header text length but
also respects a per-format minimum so a currency_try column with the
header Salary won't render as ####. Override per column with
setColumnWidths([1 => 8, 2 => 30]) when you want exact control.
Template mode — stream into someone else's layout (v3.5+)
The expensive part of an .xlsx is not its layout, it is its rows. Template
mode takes the layout from a workbook another producer authored — Excel,
PhpSpreadsheet, or this package — and streams only the rows into it. The
sheet is cut once at <sheetData>; everything else in the archive is moved
across without being inflated.
The template doubles as a style oracle. The rows below dataStartRow
are sample rows, one per look you want, and they are read rather than
written: their s="…" ids tell the writer how that producer encoded each
style, so nothing has to be re-specified here.
writeRows() takes the same choice as a callback:
What the template owns, and what this writer will therefore refuse:
the header rows and their styles, column widths, freeze panes, the auto
filter, per-column number formats, and the style table itself. Calling
startFile(), newSheet(), compact(), setHeaderStyle(),
setColumnWidths(), setAutoColumnWidth(), freezeFirstRow(),
enableAutoFilter(), setColumnFormat() or registerRowStyle() in
template mode throws, rather than half-applying over a layout that already
decided. A row's look comes from its variant, so passing a registered
style id to writeRow() throws too — one argument, one meaning.
Writing a template. Author it in Excel or PhpSpreadsheet, put one sample row per style variant below the header, and keep every range in the sheet above the data:
- A merge, an auto filter, a conditional format or a data validation whose
range reaches
dataStartRowor below is refused, because the part below the data is copied verbatim and such a range would keep covering only the rows the template declared — a filter that silently stops after four rows. Draw the filter across the header row instead. - A sheet backed by a table is refused for the same reason: its range
lives in another part of the archive. An empty
<tableParts count="0"/>is fine — PhpSpreadsheet 1.x writes one on every sheet. - A file this package produced with
enableAutoFilter()therefore cannot serve as a template — that filter spans the whole sheet by construction.
Identity numbers stay text. A numeric-looking string is written as a number unless precision would be lost — a leading zero, a leading plus, or more than fifteen digits. A national identity number, a tax number or an account number sits inside that window, so it would otherwise land as a right-aligned number. Declare those columns:
Columns are 1-based. The declaration governs string values, so an int
stays an int — pass the identifier as a string when you want it as text.
It applies to the sheet chosen by the last sheet() call, and the next
sheet() clears it.
Number formats. Where the template holds an opinion it wins outright,
including for dates: a DateTimeInterface written into a column the sample
row styled takes that column's format, not this package's default. Past the
sample row's last styled cell the template says nothing, so the classic date
format applies and is appended to the style table on first use.
Text cells join the template's xl/sharedStrings.xml when it has one,
which is what PhpSpreadsheet expects on the way back in. A template without
that part gets inline strings instead. The dictionary is the one place this
mode holds memory proportional to the data, so it has a ceiling:
Past the ceiling new strings are written inline, so the file stays valid and memory stops growing.
Random access works on the streamed sheet: withRandomAccessIndex()
and the analytics opt-ins behave exactly as they do for a classic write, and
the sidecar's content type is declared in the template's
[Content_Types].xml. Sheets carried across untouched are not described by
the sidecar; the reader answers from them by scanning.
Header addressing. The reader treats physical row 1 as the header. If a
template's first row is a merged report title rather than column names,
address columns by index (profile([2]), rowsWhere(2, …)) instead of by
name.
When not to use it. Template mode covers a layout that is fixed before the data arrives. A merge whose row span is only known while streaming is not covered — that is first-class writer styling, and it is on the roadmap for v3.6.
Templates from PhpSpreadsheet 1.x and 5.x are both covered by the parity suite, which runs against each major on tag day.
Measured on a real 8,000 × 10 report against building the same file with PhpSpreadsheet: 17× faster, 14× less memory. A different workload gives a different ratio — PhpSpreadsheet's per-row style cost is not linear — so BENCHMARK.md §5 reports each number with the workload it was measured on, alongside the parity result: 400 rows × 6 columns compared against PhpSpreadsheet's own output on 12 properties, zero differences.
Manual Multi-Sheet Workbooks (v2.2+)
newSheet($name, $headers = null) carves a workbook into named domain
sheets — orthogonal to the auto-split fallback at 1,048,576 rows.
clearColumnFormats() is the convenient way to drop the previous sheet's
per-column registrations before starting a new one with a different
column layout.
Multi-Sheet Support (Automatic)
The writer automatically creates new sheets when reaching Excel's row limit (1,048,576 rows):
Reading auto-split files back (v3.2.2+): on born-indexed files the
reader detects the continuation chain (consecutive exactly-full sheets
with identical headers) and treats it as ONE logical table — rowCount(),
rowsWhere(), findRow(), columnStats(), quantile(), shards() and
friends span every continuation sheet with continuous global row numbers.
Intentional multi-sheet workbooks (different headers, or sheets that
aren't exactly full) keep per-sheet semantics. Before v3.2.2 queries
silently answered from the active sheet alone — treat that as a reason
to upgrade if you export past one sheet.
Measured on a 2.1M-row, 3-sheet chain (local disk, 6 MB peak RAM):
chain detection + rowCount() 1.5 ms on first call (~1 µs warmed),
findRow() into the second sheet 9.5 ms, rowAt() 2.2 ms,
columnStats() 1.5 ms, quantile() 1.7 ms. Single-sheet files take an
early-out before any chain logic — the A/B benchmark against v3.2.1
shows every single-sheet read path within ±1 % (measurement noise).
Performance Tuning
Custom S3 Parameters
Error Handling
Configuration
Defaults come from the published config file; code-level setters always
override them at call time. (Fixed in v3.2.2 — earlier releases
shipped these keys without reading them; re-publish the config to opt
in: php artisan vendor:publish --tag=xlsx-stream-config --force. The
new file carries a 'version' => 2 marker; pre-3.2.2 copies stay
inert so their stale defaults can't silently change your output.)
Transient S3 retries belong to the AWS SDK — configure them on your
S3Client ('retries' => N); the sink adds one last-resort re-dispatch
after the SDK gives up. Progress/"logging" is the onProgress()
callback: wire it to your logger of choice.
How It Works
The Architecture
The Magic Behind Zero Disk I/O
-
Binary XLSX Generation
- XLSX files are ZIP archives containing XML files
- We generate ZIP structure directly in memory
- No intermediate files or DOM tree building
-
Streaming Compression
- Data is compressed using PHP's
deflate_add()in chunks - Each row is immediately compressed and streamed
- No need to store uncompressed data
- Data is compressed using PHP's
-
Smart Buffering
- Configurable row buffer (default 10,000 rows)
- Flushes periodically to maintain streaming
- Prevents memory accumulation
- S3 Multipart Upload
- Direct streaming to S3 using multipart upload
- Default 8 MB parts, uploaded synchronously and released as they land (O(1) memory); parallel uploads are opt-in
- No local file required at any point
Memory model
- Local writes are O(1): a row buffer (default 10K rows) plus the deflate context — ~6 MB peak regardless of row count.
- S3 writes are O(1) (v3.3): the default synchronous sink
(
concurrency: 1) holds ~part_size(one part buffer + the part being uploaded), flat at 1M rows and at 10M rows alike — measured ~30 MB peak on a 3M-row write. The optional parallel upload window (concurrency > 1) trades that for latency-hiding on high-RTT links; it holds more (a higher sawtooth) and runs a per-part GC to bound it. (Through v3.2 the parallel window was the default and its memory grew with the file — the AWS SDK's async promise graph retained each part's body; v3.3's synchronous default fixes that.) - Reads are bounded by construction: inflate chunks + one row in flight — ~6 MB for files this package wrote (24 MB ceiling with large external shared-strings tables, which now parse streaming — the full XML never materializes).
The random-access / query layer is built on deflate FULL_FLUSH sync
points plus the KXSI sidecar — the full binary format, its invariants
and the conformance suite are documented in SPEC.md.
Real-World Performance
Production Test Results (4.5 Million Rows)
Our production systems successfully export massive datasets daily:
Key Performance Metrics (v3.3)
- Write — Local: ~289K rows/s sustained, 6 MB peak
- Write — S3: network-bound; +36% over v3.0.2 same-link; O(1) memory (synchronous default, ~30 MB flat peak regardless of file size)
- Read — Local: ~127K rows/s full scan, bounded memory at any size
- Point reads:
rowAt~1.1 ms within a block;rows(skip: 1M)2.5 ms - Analytics:
median/quantile/countDistinctanswer with zero row reads;groupStatsover 1M rows in ~57 ms
Compatibility
The optional xl/_kxs/index.bin sidecar emitted by withRandomAccessIndex()
is declared as application/octet-stream in [Content_Types].xml, so
editors that don't recognise it leave the file alone instead of flagging
it for repair.
Files produced by the v3.0 writer (both with and without the sidecar) open cleanly without repair mode in:
- Microsoft Excel for Mac 16.98 (build 25060824) — sidecar ignored, no repair prompt
- Apple Numbers 14.2 (7041.0.109) — opaque sidecar passed through as expected
If a downstream editor strips or rewrites the sheet, the next indexed
read silently falls back to a sequential scan via the embedded sheet
CRC32 cross-check — same end result, just without the O(1) speedup. The
reader-side fallback is verified by tests/Writers/RandomAccessIndexWriterTest.php.
v3.1/v3.2 extend the same sidecar with additional TLV sections
(STAT/SCRC/TDIG/CHLL) under the identical opaque-part contract;
manual open-tests are repeated against real Excel before every tag.
- Multi-tenant SaaS applications
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
Testing
License
The MIT License (MIT). Please see License File for more information.
Credits
Support
For issues and questions, please use the GitHub issue tracker.
All versions of xlsx-stream with dependencies
ext-zlib Version *
ext-hash Version *
illuminate/support Version ^10.0|^11.0|^12.0|^13.0
aws/aws-sdk-php Version ^3.180