What's new in FastExcelReader 4: XLS support, custom column names, and 1.5× faster XLSX reading
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:
- Reading the old XLS format (Excel 97–2003) — with the same code you use for XLSX.
withHeader()can now take your own column names.- 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:
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:
$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:
// 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:
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
nullinstead 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 =:
// 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:
$excel = Excel::open('demo-01-base.xlsx');
$rows = $excel->sheet()->withHeader()->readRows();
[
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:
$excel = Excel::open('demo-01-base.xlsx');
$rows = $excel->sheet()
->setReadArea('B2:D6')
->withHeader(['category', 'contractor', 'date'])
->readRows();
[
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:
$rows = $excel->sheet()
->setReadArea('A1:C3')
->withHeader(['product', 'kind']) // the third column is left as is
->readRows();
[
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():
$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:
$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:
$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:
$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:
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:
$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:
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:
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
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
SheetorExcelin type declarations — replace them withAbstractSheetandAbstractBook.
Links: repository · release 4.0.0 · XLS documentation