# php_excel puts LibXL behind a native PHP extension

> php_excel is a C extension that gives PHP direct access to LibXL for reading and writing XLS and XLSX, with the README claiming 7-10 times the speed of PhpSpreadsheet on a benchmark it publishes. It suits PHP teams that must move large spreadsheets, and it carries a commercial dependency and a set of sharp edge cases around formulas, text encoding and stale sheet handles.

**iliaal/php_excel** — PHP Extension interface to the Excel writing/reading library

- Repository: https://github.com/iliaal/php_excel
- Website: http://ilia.ws
- Stars: 604 · Forks: 128
- Language: PHP
- License: NOASSERTION
- Published: 2026-10-22 · Updated: 2026-10-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/iliaal-php-excel

## A C extension rather than a PHP library

php_excel is a native C extension for reading and writing Excel files in PHP, powered by LibXL. That single sentence sets the whole trade-off. PhpSpreadsheet and the other pure PHP options work anywhere PHP runs, with no compiler and no native dependency. php_excel instead requires that you can build and install an extension, that you have the LibXL headers and libraries on the machine, and that your deployment pipeline can carry a compiled .so. In exchange you get a different performance profile, and the README states the claim plainly: 7-10x faster than PhpSpreadsheet with substantially lower memory pressure, with full XLS and XLSX support including rich text, conditional formatting, formulas, autofilters, form controls, embedded charts and structured tables. PHP 8.1 is the stated minimum, and the README notes a 2.0 ground-up modernization shipped in April 2026. The repository layout backs the C claim: excel.c, excel.stub.php, excel_arginfo.h, php_excel.h, config.m4 and config.w32 are the files of a PHP extension, with composer.json alongside them for the stub and tooling.

## Installing through PIE, or building with phpize

There are two install routes and both need you to point at LibXL. The first uses PIE, which the README describes as the PHP Foundation's PECL successor.

```bash
pie install iliaal/php-excel \
  --with-libxl-incdir=/path/to/libxl/include_c \
  --with-libxl-libdir=/path/to/libxl/lib64
```

The second is the classic source build.

```bash
phpize
./configure --with-excel \
  --with-libxl-incdir=/path/to/libxl/include_c \
  --with-libxl-libdir=/path/to/libxl/lib64
make
sudo make install
```

After either route you add extension=excel.so to your php.ini. The dependency is not optional and it is not free. LibXL is described as a commercial library, downloaded from libxl.com, and the include_c and lib64 directories in the flags are where its headers and its shared library live. The repository carries CREDITS and a .upstream/ directory, which is a common convention for tracking the upstream library a wrapper depends on, and the badges show separate CI workflows for tests and for Windows builds, so the extension is built and tested on more than one platform even though the install story in the README is Unix shaped.

## Writing a workbook, and how untrusted text is handled

The getting started example is a small, complete program. A book is created in xlsx mode, a sheet is added, rows are written, a formula is written, a date is written with a number format, and the file is saved.

```php
<?php
$book = new ExcelBook(null, null, true); // xlsx mode

$sheet = $book->addSheet('Sheet1');

$data = [
    [1, 1500, 'John', 'Doe'],
    [2,  750, 'Jane', 'Doe'],
];
```

One line in that example deserves your attention. The untrusted value is written with ExcelFormat::AS_TEXT, and the comment explains that AS_TEXT stores the string verbatim with no equals-sign formula promotion, that it takes a string so you must cast first, and that SECURITY.md is the reference. A value that begins with an equals sign written into a cell without that flag becomes a formula, which is the classic spreadsheet injection path when a row of user data lands in an export. The extension gives you the tool to close that hole, and the example shows the intended call. Whether you use it consistently across every write path is your responsibility, which is the usual state of affairs with a low level library.

## Saving is staged and renamed into place

The save semantics are more careful than most Excel writers, and the README explains them in detail. The save() call stages the workbook to a temporary sibling file and then renames it into place, so a failed save leaves the destination untouched. If the rename fails, local-path saves fail closed and return false, while stream wrappers that omit a rename() fall back to a direct write with a warning. Failed handle creation and rejected header, footer, font-size and staging inputs emit warnings that name the cause, and the documentation advises suppressing those warnings with the error control operator only in places where you handle the false return explicitly. The pattern to take from this is that a failure is signalled rather than thrown, so a caller who ignores return values will silently produce nothing. On a request that writes a file a customer downloads, that is worth checking deliberately rather than assuming, because a silent failure and a successful save look identical to the caller unless you look at the return value.

## Deleting a sheet invalidates every wrapper from that book

This is the sharpest lifecycle rule in the library and it is documented in its own section. 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, while constructors given stale sheet-owned wrappers throw an exception. Workbook-owned fonts and formats remain usable, which is a useful distinction. The documented fix is to re-fetch a surviving sheet and its children before using them again.

```php
$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'
}
```

Deleting also shifts the indexes of later sheets, so you re-fetch by name or use the updated zero-based index with getSheet(). A failed deletion returns false and leaves existing wrappers usable. The design is defensible for a native binding that cannot easily keep every cross reference alive, but it means a wrapper is a snapshot of state, not a live handle, and code that caches one will read stale data.

## Text is UTF-8 always, and non-UTF-8 input is rejected

Books read and write text as UTF-8 whatever the process locale is, so non-ASCII text behaves the same in Docker containers, PHP-FPM pools and systemd services running under the plain C locale. That is a real operational benefit in containerised deployments. The library is also strict about invalid input. Methods that store text reject a string that is not valid UTF-8 with a warning and return false, while the ExcelSheet and ExcelTable constructors throw an exception instead. There is a version wrinkle: 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 a question mark in its place. If your strings use another byte encoding, setLocale() is the escape hatch, with setLocale('de_DE.ISO-8859-1') given as the Latin-1 example. The practical consequence for a team with legacy data is that the encoding rules are per library version, so the same input can behave differently after a LibXL upgrade.

## Reading: bulk APIs, coordinate conventions and the offset trap

The read side has a convention that will bite you if you skim it. Cell coordinates are zero-based, lastRow() and lastCol() return the exclusive end of the used range, while bulk readers take inclusive end coordinates, so you subtract one when passing a used-range end to readRange(). The readRow() and readCol() methods do that subtraction automatically for their default end of -1. The README also warns that the used range can extend beyond nonempty values, for example because of formatting, and that you should check a range is nonempty before subtracting and reading it. Dense readers return packed arrays keyed from zero even when the requested range starts elsewhere, while sparse readers keep the original sheet indexes and omit empty cells but retain values such as numeric zero and boolean false.

```php
$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]
```

The comment lines make the difference explicit, and they are also a warning about the dense form. A dense read of columns 1 to 3 returns a packed array whose key 0 holds the value from column 1, so an off by one in either the start or the end index shifts every value in the row.

## The published benchmark and its caveats

The performance section gives a concrete table: 100,000 rows times 20 columns against PhpSpreadsheet 5.5.0 on PHP 8.4.19 NTS on an Apple M3, with the row and cell counts for each step. The 1,000 row case is listed at 0.05 seconds and 85 MB for php_excel against 0.45 seconds and 162 MB for PhpSpreadsheet, and the 100,000 row case at 5.37 seconds and 908 MB against 51.1 seconds and 1,415 MB. Reads are stated as 8-9x faster than PhpSpreadsheet and 3x faster than OpenSpout, with the caveat that memory grows with the file, where OpenSpout streams in a flat 130 MB. Two things are worth noting about how to read this. It is a benchmark published by the project on one machine with one comparison, so it tells you the shape of the difference rather than what you will see. And the memory caveat is the flip side of the speed, because a reader that loads the whole file is the wrong shape for a very large import. The documented advice is to prefer readRow(), readCol() or readRange() over per-cell read() loops, to pass false for the read formula parameter when cached values are enough, and to use readSparseRow() and readSparseCol() for sparse sheets.

## Conclusion

Adopt php_excel if your PHP application writes or reads spreadsheets large enough that a pure PHP library dominates your request time, and if your organisation can accept a commercial LibXL licence. Do not adopt it for small one-off exports, for workloads that need a format LibXL does not cover, or if you cannot ship a compiled extension, because the whole approach depends on a native build step. Verify two things before you commit: that you have a LibXL licence and its include_c and lib64 directories, since the configure flags point at both, and that your call sites handle the documented failure modes, including a false return from a failed save and the warning-and-false rejection of strings that are not valid UTF-8. The practical first step is to build the extension, enable extension=excel.so, and run the getting started example to confirm that writeRow and save produce an output.xlsx you can open.

## FAQ

### How do I install php_excel?

Install it with PIE, the PHP Foundation's PECL successor, or build it from source with phpize, ./configure --with-excel, make and sudo make install. Both routes need --with-libxl-incdir and --with-libxl-libdir pointing at your LibXL installation, and you then add extension=excel.so to your php.ini.

### Does php_excel require a paid library?

Yes. It is powered by LibXL, which the README describes as a commercial library that you download from libxl.com. The extension itself is open source, but the dependency is not free.

### How do I write a cell that should never become a formula?

Write it with the AS_TEXT format flag, casting the value to a string first, as the getting started example does with ExcelFormat::AS_TEXT. The flag stores the string verbatim with no equals-sign formula promotion, and the README points to SECURITY.md for the reasoning.

### What happens if I delete a sheet in php_excel?

A successful deleteSheet() invalidates every existing ExcelSheet wrapper from that workbook, including for sheets that were not deleted, along with their sheet-owned wrappers such as autofilters and tables. Calls through stale wrappers warn and return false, so you re-fetch surviving sheets and their children before using them again.

### How do I read rows efficiently with php_excel?

Use the bulk readers readRow(), readCol() or readRange() instead of per-cell read() loops, pass false for the read formula parameter when cached values are enough, and use readSparseRow() and readSparseCol() for sparse sheets. Note that bulk readers take inclusive end coordinates while lastRow() and lastCol() return an exclusive end.

### What does php_excel do with text that is not valid UTF-8?

Methods that store text reject a string that is not valid UTF-8 with a warning and return false, while the ExcelSheet and ExcelTable constructors throw an exception. Books always read and write text as UTF-8 regardless of the process locale, and setLocale() is the escape hatch for other byte encodings.

## Sources

- [iliaal/php_excel on GitHub](https://github.com/iliaal/php_excel)
- [Issues](https://github.com/iliaal/php_excel/issues)
- [Project website](http://ilia.ws)
- [README](https://github.com/iliaal/php_excel/blob/master/README.md)
- [Releases](https://github.com/iliaal/php_excel/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/iliaal-php-excel
