iliaal / php-excel
PHP extension for reading and writing Excel files using LibXL
Package info
Type:php-ext
Ext name:ext-excel
pkg:composer/iliaal/php-excel
Requires
- php: >=8.1
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-master
- 2.9.1
- 2.9.0
- 2.8.0
- 2.7.0
- 2.6.0
- 2.5.0
- 2.4.0
- 2.3.0
- 2.2.0
- 2.1.0
- 2.0.1
- v2.0.0
- dev-vulcan/docs-text-encoding-20261016
- dev-rev-wincp2-ci
- dev-win-coreprops-ci
- dev-win-coreprops-fix
- dev-win-coreprops-base
- dev-rev-wincp-base
- dev-rev-wincp-ci
- dev-diag-windows-utf8
- dev-diag/windows-utf8
- dev-fix/setup-php-2.40.0
- dev-review/php-excel-validation
- dev-claude/remove-fake-border-stubs
- dev-claude/fix-constructor-dead-return
This package is auto-updated.
Last update: 2026-10-16 17:55:32 UTC
README
Native C extension for reading and writing Excel files in PHP, powered by LibXL. 7-10× faster than PhpSpreadsheet with substantially lower memory pressure. Full XLS and XLSX support: rich text, conditional formatting, formulas, autofilters, form controls, embedded charts, and structured tables. PHP 8.1+ minimum. 2.0 ground-up modernization shipped April 2026.
🚀 Install
Via PIE (PHP Foundation's PECL successor):
pie install iliaal/php-excel \ --with-libxl-incdir=/path/to/libxl/include_c \ --with-libxl-libdir=/path/to/libxl/lib64
Or build from source:
phpize ./configure --with-excel \ --with-libxl-incdir=/path/to/libxl/include_c \ --with-libxl-libdir=/path/to/libxl/lib64 make sudo make install
Then add extension=excel.so to your php.ini.
LibXL is a commercial library; download it from libxl.com and point the configure flags at its include_c and lib64 directories.
🛠️ Getting started
<?php $book = new ExcelBook(null, null, true); // xlsx mode $sheet = $book->addSheet('Sheet1'); $data = [ [1, 1500, 'John', 'Doe'], [2, 750, 'Jane', 'Doe'], ]; $row = 1; foreach ($data as $item) { $sheet->writeRow($row++, $item); } // formula $sheet->write($row, 1, '=SUM(B1:B3)'); // untrusted input: AS_TEXT stores the string verbatim, with no '=' formula // promotion. It takes a string, so cast first. See SECURITY.md. $sheet->write($row, 2, (string) $userSuppliedValue, null, ExcelFormat::AS_TEXT); // date with format $dateFormat = new ExcelFormat($book); $dateFormat->numberFormat(ExcelFormat::NUMFORMAT_DATE); $sheet2 = $book->addSheet('Sheet2'); $sheet2->write(1, 0, (new DateTime('2024-08-02'))->getTimestamp(), $dateFormat, ExcelFormat::AS_DATE); $book->save('output.xlsx');
save() stages the workbook to a temporary sibling file and renames it into place, so a failed save leaves the destination untouched. If the rename fails, local-path saves fail closed (return false); stream wrappers that omit rename() fall back to a direct write with a warning. Failed handle creation and rejected header, footer, font-size, and staging inputs emit warnings naming the cause. Suppress them with @ only where you handle false explicitly.
Using sheets after deletion
A successful deleteSheet() invalidates every existing ExcelSheet wrapper
from that workbook, including wrappers for sheets that were not deleted.
Their sheet-owned wrappers, such as autofilters, filter columns, tables, form
controls, and conditional formatting, also become stale. Calls through stale
wrappers warn and return false; constructors given stale sheet-owned wrappers
throw an exception. Re-fetch a surviving sheet and its children before using
them again. Workbook-owned fonts and formats remain usable.
$book = new ExcelBook(null, null, true); $book->addSheet('Temporary'); $keep = $book->addSheet('Keep'); $keep->write(1, 0, 'retained'); if ($book->deleteSheet(0)) { // The old $keep wrapper is stale, even though its sheet still exists. $keep = $book->getSheetByName('Keep'); $keep->read(1, 0); // 'retained' }
Books read and write text as UTF-8 whatever the process locale is, so non-ASCII text works the same in Docker containers, PHP-FPM pools, and systemd services that run under the plain C locale. Methods that store text reject a string that is not valid UTF-8 with a warning and return false; the ExcelSheet and ExcelTable constructors throw an exception instead. LibXL 4.6 drops characters outside the Basic Multilingual Plane, such as emoji, so builds against LibXL older than 5.1 reject them the same way. Text that another application stored with an unpaired UTF-16 surrogate reads back with ? in its place. Call setLocale() only when your strings use another byte encoding, for example setLocale('de_DE.ISO-8859-1') for Latin-1.
Deleting a sheet also shifts the indexes of later sheets. Re-fetch by name,
or use the updated zero-based index with getSheet(). A failed deletion
returns false and leaves existing wrappers usable.
📊 Performance
Write 100,000 rows × 20 columns vs PhpSpreadsheet 5.5.0 on PHP 8.4.19 NTS, Apple M3:
| Rows | Cells | php_excel | PhpSpreadsheet | Speedup |
|---|---|---|---|---|
| 1,000 | 20K | 0.05s / 85 MB | 0.45s / 162 MB | 10× |
| 10,000 | 200K | 0.55s / 153 MB | 4.59s / 282 MB | 9× |
| 50,000 | 1M | 2.72s / 508 MB | 24.7s / 790 MB | 9× |
| 100,000 | 2M | 5.37s / 908 MB | 51.1s / 1,415 MB | 10× |
Reads are 8-9× faster than PhpSpreadsheet and 3× faster than OpenSpout, at the cost of memory that grows with the file, where OpenSpout streams in a flat 130 MB.
For read-heavy import paths, prefer readRow(), readCol(), or readRange() over per-cell read() loops. Pass false for $read_formula when cached values are enough, to skip the per-cell formula-text probe. For sparse sheets, readSparseRow() and readSparseCol() return only occupied cells keyed by their original column or row indexes.
Bulk-read bounds and indexes
Cell coordinates are zero-based. lastRow() and lastCol() return the
exclusive end of the used range, while bulk readers take inclusive end
coordinates. Subtract one when passing a used-range end to readRange();
readRow() and readCol() do this automatically for their default end of -1.
Check that a range is nonempty before subtracting and reading it. The used
range can extend beyond nonempty values, for example because of formatting.
Dense readers return packed arrays keyed from zero, even when the requested range starts elsewhere. Sparse readers keep the original sheet indexes and omit empty cells, but retain values such as numeric zero and boolean false.
$sheet = $book->addSheet('Read example'); $sheet->write(1, 1, 'A'); $sheet->write(1, 3, false); $sheet->write(3, 1, 0); $sheet->readRow(1, 1, 3); // [0 => 'A', 1 => '', 2 => false] $sheet->readSparseRow(1, 1, 3); // [1 => 'A', 3 => false] // Read the data from row/column 1 through the last used cell. $rowEnd = $sheet->lastRow(); $colEnd = $sheet->lastCol(); $rows = ($rowEnd > 1 && $colEnd > 1) ? $sheet->readRange(1, $rowEnd - 1, 1, $colEnd - 1) : []; // $rows[0][0] corresponds to sheet cell (1, 1).
Bulk-write array keys
writeRow() and writeCol() consume values in PHP array iteration order and
write consecutive cells starting at $start_column or $start_row. Array keys
are ignored: neither numeric gaps nor associative keys select cell coordinates,
and values are not sorted by key.
In particular, passing a readSparseRow() or readSparseCol() result directly
to a bulk writer compacts its positions. To preserve sparse coordinates, write
each returned value using its key (and an explicit offset if needed):
$source = $book->addSheet('Source'); $target = $book->addSheet('Copy'); $source->write(1, 1, 'A'); $source->write(1, 3, false); $values = $source->readSparseRow(1, 1, 3); // [1 => 'A', 3 => false] $target->writeRow(1, $values, 1); // writes to columns 1 and 2 foreach ($values as $column => $value) { $target->write(2, $column, $value); // preserves columns 1 and 3 }
For a sparse column, iterate readSparseCol() as $row => $value and call
write($row, $targetColumn, $value). This copies returned values only; it does
not copy formatting or clear cells omitted by the sparse reader. If empty
positions should also be written, use a dense reader and pass the matching
start coordinate to the bulk writer instead, subject to excel.skip_empty.
Formula text versus cached results
read(), readRow(), readCol(), readRange(), readSparseRow(), and
readSparseCol() return formula text for formula cells by default, without its
leading =.
Pass false for $read_formula to read the stored cached result instead.
This does not evaluate the formula: LibXL has no calculation engine.
Cached results may be stale or absent, especially after writing a formula or changing its inputs. Saving and reloading through php_excel does not refresh them. If you need current calculated results, recalculate and save the workbook in Excel or another compatible calculation engine before importing it.
$sheet->write(1, 1, 7); // B2 $sheet->write(1, 2, '=B2*2'); // C2 $sheet->readRow(1, 2, 2); // ['B2*2'] $sheet->readRow(1, 2, 2, false); // [0.0], not the calculated value 14
The zero above is the initial cached value for this newly written numeric formula, not a general signal that a formula is missing or invalid. Existing files can contain other cached values; php_excel does not check their freshness.
Native memory and PHP's memory_limit
PHP's memory_get_peak_usage() reports about 2 MB for php_excel in the benchmark above because LibXL allocates on the C heap, outside PHP's memory manager and memory_limit. PHP's memory counters do not include these native allocations, even with real_usage set to true. This allows php_excel to write 100,000 rows without raising PHP's 128 MB limit, but the same run still peaked at 908 MB of process virtual memory (VmPeak). save() without a path, to a stream-wrapper URL, or while open_basedir is set copies the entire workbook into a PHP string, so that copy also counts against memory_limit.
php_excel can still run out of memory or hit an operating-system or container memory limit. Size your workers and concurrency using process-level memory measurements (such as resident set size), not PHP's memory counters alone. PHP arrays, strings, and buffered stream input also consume PHP-managed memory and remain subject to memory_limit; see stream-loading limits.
📦 Classes
| Class | Description |
|---|---|
| ExcelBook | Workbook management: create, load, save, sheets, fonts, formats, pictures |
| ExcelSheet | Cell read/write, formatting, printing, protection, hyperlinks, data validation |
| ExcelFormat | Cell formatting: colors, borders, number formats, alignment, patterns |
| ExcelFont | Font properties: name, size, bold, italic, underline, color |
| ExcelAutoFilter | AutoFilter operations and sorting |
| ExcelFilterColumn | Filter column criteria |
| ExcelRichString | Mixed-font text in a single cell |
| ExcelFormControl | Form controls: checkboxes, dropdowns, spinners, buttons |
| ExcelConditionalFormat | Conditional formatting style rules |
| ExcelConditionalFormatting | Conditional formatting ranges and rule application |
| ExcelCoreProperties | Workbook metadata: title, author, dates, categories |
| ExcelTable | Structured table support (xlsx) |
2.0 added six new classes (ExcelRichString, ExcelFormControl, ExcelConditionalFormat, ExcelConditionalFormatting, ExcelCoreProperties, ExcelTable) plus full arginfo coverage: 399 typed parameters and 277 typed return values across the surface.
php.ini settings
Store LibXL credentials in php.ini instead of source code. The extension reads them automatically when you pass null to the constructor.
[excel] excel.license_name="YOUR_LICENSE_NAME" excel.license_key="YOUR_LICENSE_KEY" excel.skip_empty=0
Skipping empty writes
excel.skip_empty controls write(), writeRow(), and writeCol():
0(default): writenullas a blank cell and write empty strings.1: skipnull; still write empty strings.2: skip bothnulland empty strings, unless the write passesExcelFormat::AS_TEXT.
A skipped write returns success but leaves the existing cell value and format
unchanged. It does not clear the cell. Bulk writes still advance to the next
coordinate, so skipped values leave gaps rather than shifting later values.
Numeric zero, boolean false, and the string '0' are never skipped.
$sheet->writeRow(1, ['keep', 'replace', 'old'], 1); $previous = ini_set('excel.skip_empty', '2'); $sheet->writeRow(1, [null, '', 0], 1); $sheet->readRow(1, 1, 3); // ['keep', 'replace', 0.0] ini_set('excel.skip_empty', $previous);
The setting is request-wide, not per workbook, and can be changed with
ini_set(). Restore the previous value after a temporary override. To clear an
existing cell with null, use mode 0. An explicit ExcelFormat::AS_TEXT
writes an empty string even in mode 2. A blank formula, meaning an empty or
whitespace-only body after an optional leading = (such as '' with
ExcelFormat::AS_FORMULA, or '='), is written as an empty string, so mode 2
skips it. ExcelFormat::AS_NUMERIC_STRING empty strings are skipped like the
default type. A lone ' is unescaped to an empty string and always written.
null follows excel.skip_empty regardless of the data type.
🔗 Native PHP extensions
Companion native PHP extensions:
- mdparser: native CommonMark + GFM markdown parser via md4c. 15-30× faster than pure-PHP libraries.
- php_clickhouse: native ClickHouse client speaking the wire protocol directly. Picks up where SeasClick left off.
- pdo_duckdb: PDO driver for DuckDB, analytical SQL in your PHP stack.
- fastjson: drop-in faster
ext/json, backed by yyjson. 6× encode, 2.7× decode, 5× validate. - phpser: decoder-optimized binary serializer for cache workloads. Faster than igbinary on packed numerics and DTO batches.
- fast_uuid: high-throughput UUID generation (v1/v4/v7), batched CSPRNG and SIMD hex formatter, ramsey-compatible API.
- fastchart: native chart-rendering extension. 38 chart types behind one fluent OO API, SVG-canonical with PNG/JPG/WebP and optional PDF output.
- statgrab: system statistics (CPU, memory, disk, network) via libstatgrab, no parsing /proc by hand.
- phonetic: native phonetic name matching (Double Metaphone, Beider-Morse, Daitch-Mokotoff, NYSIIS, Match Rating), the encoders PHP core lacks.
📚 Read more
The launch post covers background, design rationale, and benchmark methodology: php_excel 2.0: The C Extension for Excel That PHP Should Have Had All Along.
API reference lives in docs/; usage examples live in tests/.
License
PHP License 3.01. See LICENSE.
LibXL itself is commercial. See libxl.com for licensing.
Follow @iliaa on X • Blog • If this sped up your stack, ⭐ star it!
