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.
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/lib64Or 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 installThen 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.
<?php
$book = new ExcelBook(null, null, true); // xlsx mode
$book->setLocale('UTF-8');
$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.
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.
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).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.
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 14The 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.
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.
| 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.
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=0excel.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.
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.
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/.
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!
