What's new in FastExcelReader 4: XLS support, custom column names, and 1.5× faster XLSX reading

aVadim 23.07.2026 06:44

avadim/fast-excel-reader is a PHP library that reads Excel spreadsheets while keeping memory use low. It never loads the whole file into memory — it walks through it as a stream, row by row. That is why a hundred-thousand-row file costs tens of megabytes rather than gigabytes.

Version 4.0.0 brings three notable things:

  1. Reading the old XLS format (Excel 97–2003) — with the same code you use for XLSX.
  2. withHeader() can now take your own column names.
  3. XLSX reading became about 1.5× faster — without a single change in your code.

Plus a handful of bug fixes and one change that made the release a major one. Let's go through it all.


1. Reading old XLS files

The problem

The XLS format is over a quarter of a century old, and it has not gone anywhere. Exports from accounting systems, bank statements, reports from legacy software — all of it still arrives as .xls. And XLS and XLSX are two completely different formats. An XLSX file is a plain ZIP archive of XML files, while XLS is a binary OLE2 container — the same one that old .doc files use.

Until now that meant installing a second library and branching your code.

How it works now

No branching needed. Excel::open() looks at the file itself and picks the right reader:

php
use avadim\FastExcelReader\Excel;

// XLSX
$excel = Excel::open(__DIR__ . '/report.xlsx');
$rows = $excel->readRows();

// XLS — the very same call
$excel = Excel::open(__DIR__ . '/report.xls');
$rows = $excel->readRows();

The rest of the API behaves identically for both formats. Here is literally the same code run against demo-04-styles.xlsx and demo-04-styles.xls:

php
$excel = Excel::open($file);

echo json_encode($excel->getSheetNames());
// {"1":"Demo"}   — for xlsx and for xls alike

$cells = $excel->sheet()->setReadArea('A1:B2')->readCellsWithStyles('fill-color');
echo json_encode($cells['A1']['s']);
// {"fill-color":"#9FC63C"}   — for xlsx and for xls alike

Read areas, key modes, withHeader(), the row generator, date formatting, styles, images — all of it is written once and works for both formats.

An important detail: the file extension is not consulted at all

Excel::open() detects the format from the first bytes of the file, not from whatever follows the dot. That is not pedantry but a fix for a real problem: .xls is the most popular extension for files that are in fact XLSX, HTML, or CSV. Legacy web applications produce such exports regularly, and that is exactly where naive pathinfo($file, PATHINFO_EXTENSION) checks used to break.

If you need the opposite — to make sure you are dealing with a genuine XLS — there are two methods:

php
// returns true/false without opening anything
if (Excel::isXls($file)) {
    echo "This is a real XLS\n";
}

// opens XLS only, throws on anything else
$excel = Excel::openXls($file);

Let's try it on real files:

php
var_dump(Excel::isXls('demo-01-base.xlsx'));   // bool(false)
var_dump(Excel::isXls('continue-sst.xls'));    // bool(true)

try {
    Excel::openXls('demo-01-base.xlsx');
}
catch (\avadim\FastExcelReader\Exception $e) {
    echo $e->getMessage();
    // Not an OLE2 compound file: ".../demo-01-base.xlsx"
}

What is supported

  • Multiple sheets, including hidden and “very hidden” ones
  • All cell types: text, numbers, booleans, errors, empty cells
  • Date auto-detection by number formats, and the same formatting API as for XLSX
  • Styles: fonts, fills, borders, alignment, number formats, palette colors
  • Merged cells, sheet dimensions, column widths, row heights
  • Formula text, including shared formulas
  • Embedded images

What not to expect

An honest list of the limitations — better to learn about them now than in production:

  • BIFF8 only. Files from Excel 5.0/95 (BIFF5/BIFF7) are rejected with a clear message. Re-save them as “Excel 97-2003” or as XLSX.
  • Encrypted workbooks are not supported — the library refuses honestly instead of returning garbage.
  • Charts and macro sheets are skipped — they hold no cell data anyway.
  • Formula text cannot always be reconstructed. Formulas with 3D references to other sheets, named ranges, arrays, and add-in calls return null instead of text. Importantly, the cached result of such a formula is always available, so reading data is not affected at all.
  • The format itself is limited to 65,536 rows and 256 columns per sheet.

A small thing worth remembering

This is a property of the formats rather than of the library, but it shows up in the results.

Number format IDs differ. A cell formatted as General may carry a built-in format-num-id of 0 in XLSX and an arbitrary 164 in XLS, because the numbering is assigned by whichever program wrote the file. The format pattern itself and the value type still match.

Formula text, on the other hand, is now identical for both formats — with a leading =:

php
// formulas.xlsx and formulas.xls give the same result
{"B2":"=A2+1","C2":"=B2+1","D2":"=C2+1"}

XLS used to return the formula without the = (A2+1), but since 4.0.1 this is aligned: whenever a formula exists, its text always starts with =, regardless of the format. Formulas whose text could not be reconstructed still return null.


2. withHeader() with your own column names

How it used to be

withHeader() takes the first row of the sheet as the header and turns the remaining rows into associative arrays:

php
$excel = Excel::open('demo-01-base.xlsx');
$rows = $excel->sheet()->withHeader()->readRows();
php
[
    2 => ['Item' => 'Vermicelli', 'Category' => 'Pasta', 'City' => 'Tokyo',  'Quantity' => 1400, ...],
    3 => ['Item' => 'Eggplant',   'Category' => 'Vegetables', 'City' => 'Moscow', 'Quantity' => 2076, ...],
]

Convenient — as long as the headers in the file are tidy. In real life they come with trailing spaces, line breaks, typos, and sometimes they change from one export to the next. Then your code turns into $row['Qty, pcs. '], with a trailing space that will disappear one day and break everything.

How it is now

withHeader() accepts a list of names. The header row is still skipped, but the keys come from your list:

php
$excel = Excel::open('demo-01-base.xlsx');

$rows = $excel->sheet()
    ->setReadArea('B2:D6')
    ->withHeader(['category', 'contractor', 'date'])
    ->readRows();
php
[
    3 => ['category' => 'Vegetables', 'contractor' => 'Foody Stock',            'date' => 1286928000],
    4 => ['category' => 'Pasta',      'contractor' => 'Horns & Hooves Company', 'date' => 1327795200],
    5 => ['category' => 'Vegetables', 'contractor' => 'Overseas Company',       'date' => 1319155200],
    6 => ['category' => 'Fruits',     'contractor' => 'Overseas Company',       'date' => 1325808000],
]

Three things matter here:

The names are positional. The first name goes to the first column of the read area, the second to the second, and so on. You do not need to know the column letters. That is why the same call works both for a sheet whose data starts at A1 and for one that starts at B2, as in the example above.

The list may be shorter than the number of columns. Columns you did not cover keep the names from the header row:

php
$rows = $excel->sheet()
    ->setReadArea('A1:C3')
    ->withHeader(['product', 'kind'])   // the third column is left as is
    ->readRows();
php
[
    2 => ['product' => 'Vermicelli', 'kind' => 'Pasta',      'Contractor' => 'Food Paradise Corp.'],
    3 => ['product' => 'Eggplant',   'kind' => 'Vegetables', 'Contractor' => 'Foody Stock'],
]

Calling it without arguments works exactly as before — backward compatibility is intact.

It works for XLSX, XLS, and CSV. The name was chosen to match writeHeader() from the sibling library fast-excel-writer.


3. XLSX reading is about 1.5× faster

What was wrong

Internally, reading a sheet works like this: the library walks the XML with the XMLReader streaming parser and, on every <c> cell, used to call XMLReader::expand().

That sounds harmless, but expand() is not “take a reference to the current node.” It is a full copy of the node into a new DOMDocument, plus a PHP DOMElement object created on top of that copy. On a sheet with a million cells that is a million allocate-and-free pairs, all to read a couple of attributes and the text of one nested tag.

What it is now

The cell value is now assembled during the same read() traversal, without building a DOM node. The logic that casts values to types has not changed by a single line — it was simply separated from XML parsing.

Measurements on the standard data sets (PHP 8.4, Xdebug and OPcache disabled, best of five runs, “before” and “after” alternated):

File Before After Speedup
1,000 rows 63.6 ms 42.4 ms 1.50×
20,000 rows (296K cells) 1,593 ms 1,035 ms 1.54×
100,000 rows (980K cells) 5,278 ms 3,471 ms 1.52×
40,000 rows with dates 1,781 ms 1,129 ms 1.58×
40,000 rows, shared strings 2,037 ms 1,464 ms 1.39×
40,000 rows, inline strings 1,674 ms 1,066 ms 1.57×
2,000 rows × 150 columns 1,645 ms 1,129 ms 1.46×

The spread runs from 1.39× to 1.58×, roughly one and a half on average. Where the gain is smaller, most of the time goes not into XML parsing but into other work — the shared strings table, for instance.

Peak memory did not change at all — it matches to the third decimal place across every data set. That is expected: expand() accumulated nothing (each copy lived until the next iteration), it merely wasted CPU time.

Nothing needs to change in your code. Values, types, and row order stayed the same — verified by a byte-for-byte comparison of results across seven data sets in eleven reading modes: zero differences.

A reminder about memory

While we are on the subject of performance — the library's main technique has not gone anywhere. If the file is large, read it with the nextRow() generator rather than with readRows():

php
$excel = Excel::open('huge.xlsx');

// this puts the entire result in memory
$rows = $excel->sheet()->readRows();

// this keeps a single row in memory at a time
foreach ($excel->sheet()->withHeader()->nextRow() as $rowNum => $row) {
    echo "$rowNum: {$row['Item']} / {$row['City']}\n";
}
// 2: Vermicelli / Tokyo
// 3: Eggplant / Moscow
// 4: Spaghetti / Tokyo

4. Bug fixes

Crash on files with a formatted styles.xml

The nastiest of the bugs. Reading complete styles — getCompleteStyleByIdx(), readCellsWithStyles(), and everything built on top of them — died with a fatal error:

Error: Call to undefined method DOMText::getAttribute()

The cause: style parsing walked every child node of the <fonts>, <fills>, <borders>, and <cellXfs> tags. If the file is written with indentation — and many XLSX generators do exactly that — text nodes with line breaks sit between the tags, and the library was asking whitespace for its attributes.

The same bug had a second, silent half: those text nodes would land in the indexed style tables and shift the positions that fontId, fillId, borderId, and xfId refer to. In other words, even where nothing crashed, styles could drift. Only elements are counted now.

readCellsWithStylesFrom() returned values without styles

The method called readCells() internally instead of readCellsWithStyles(), so the styles were simply lost. Now it returns what it promises:

php
$cells = $excel->sheet()->readCellsWithStylesFrom('A1:A2');
echo json_encode(array_keys($cells['A1']));
// ["v","s","f","t","o"]   — v is the value, s is the style

readCellsWithStyles($styleKey) did not narrow the result

The example from the method's own documentation — 'fill-color' — never worked: the key was looked up at the wrong nesting level, and instead of a single property the whole style came back. It works now:

php
$cells = $excel->sheet()->setReadArea('A1:B1')->readCellsWithStyles('fill-color');

echo json_encode($cells['A1']['s']);   // {"fill-color":"#9FC63C"}
echo json_encode($cells['B1']['s']);   // {"fill-color":"#3C636F"}

One subtlety: if a cell does not have the requested property, the full style is returned rather than null. That is deliberate — so a typo in the key name does not silently lose data. A group name works too and returns the whole group:

php
$cells = $excel->sheet()->readCellsWithStyles('font');
// ['font' => ['font-size' => '10', 'font-name' => 'Arial', ...]]

5. What may break when you upgrade

The release is a major one, and here is the single reason for that.

The base classes AbstractBook and AbstractSheet appeared internally — they are what let XLSX and XLS share one API implementation. Because of this, methods that used to return a concrete Sheet are now declared as returning AbstractSheet, and the workbook's fluent setters return AbstractBook:

php
Excel::open():   AbstractBook    // was Excel
Excel::sheet():  ?AbstractSheet  // was ?Sheet

The objects themselves have not changed. Open an XLSX and you still get Excel and Sheet:

php
$excel = Excel::open('demo-01-base.xlsx');
echo get_class($excel);            // avadim\FastExcelReader\Excel
echo get_class($excel->sheet());   // avadim\FastExcelReader\Sheet

Only code with explicit type declarations can break. Like this — it will fail:

php
use avadim\FastExcelReader\Sheet;

function processSheet(Sheet $sheet): void { /* ... */ }   // ← too narrow

processSheet(Excel::open('report.xls')->sheet());
// TypeError: processSheet(): Argument #1 ($sheet) must be of type
// avadim\FastExcelReader\Sheet, avadim\FastExcelReader\Xls\XlsSheet given

Note that such code keeps working on XLSX and fails only on XLS. That is the sneakiest variant — the error surfaces not when you upgrade, but when someone uploads an .xls for the first time.

The fix is to widen the type to the base class:

php
use avadim\FastExcelReader\AbstractSheet;

function processSheet(AbstractSheet $sheet): void { /* ... */ }   // works for xlsx and xls

If there are no type hints in your signatures — and in most code there are none — the upgrade will pass unnoticed.


How to upgrade

bash
composer require avadim/fast-excel-reader:^4.0

The requirements have not changed: PHP 7.4 or newer, plus the zip, mbstring, ctype, and xmlreader extensions.

In short

  • Excel::open() now opens both XLSX and XLS — the format is detected from the file content, not from the extension. The rest of your code stays the same.
  • withHeader(['name', 'other_name']) sets column names on your terms, independent of what the file's header row says.
  • XLSX reading became about 1.5× faster with identical results and identical memory use.
  • Fixed: the crash on files with a formatted styles.xml, and two style-reading methods.
  • If your code mentions Sheet or Excel in type declarations — replace them with AbstractSheet and AbstractBook.

Links: repository · release 4.0.0 · XLS documentation

Comments (0)