avadim/fast-excel-reader
Fast, memory-efficient Excel reader for PHP and Laravel. Streams XLSX worksheets with XMLReader to read rows and cells without loading whole files, making it suitable for large spreadsheets and background jobs. Simple API with minimal dependencies.
avadim/fast-excel-reader is a pure-PHP, dependency-free reader for .xlsx and .csv. It does not use PhpSpreadsheet — it unzips the XLSX and parses the XML directly with SimpleXML, which is why it is roughly an order of magnitude faster and far lighter on memory. In a Laravel app that means it is a natural fit for read-only import pipelines, especially large files that would otherwise time out or exhaust memory_limit.
composer require avadim/fast-excel-reader
There is no service provider, no config file, and no facade. It is a plain library — you use it directly. Requirements are just ext-zip, ext-simplexml/ext-xml, and PHP 8.x. Nothing to publish.
Where to look first: the README's Usage section for Excel::open() and the sheet-level readRows() config array. That config array (range, row window, header handling, empty-row behaviour) is where 90% of day-to-day work happens, and its exact keys are the thing most worth verifying against the version you pin.
First use case — a minimal controller import:
use avadim\FastExcelReader\Excel;
$excel = Excel::open($request->file('import')->getRealPath());
foreach ($excel->getSheetNames() as $name) {
$sheet = $excel->getSheet($name);
$rows = $sheet->readRows(); // numeric-keyed array of row arrays
// $rows[1] = ['A' => 'id', 'B' => 'name', ...]
}
That is the whole onboarding. The next step up is readRowsWithHeader(), which turns the first row into associative keys so you can hand rows straight to a validator.
The single most common shape in a Laravel app. Keep the controller thin: validate, move the file to a durable location, dispatch a job, and do all reading in the job.
public function store(ImportRequest $request): RedirectResponse
{
$path = $request->file('file')->store('imports'); // must be durable — tmp files vanish
ImportContacts::dispatch($path, auth()->id());
return back()->with('status', 'Import queued');
}
class ImportContacts implements ShouldQueue
{
use Queueable, Batchable;
public function __construct(public string $path, public int $userId) {}
public function handle(): void
{
$excel = Excel::open(Storage::path($this->path));
$sheet = $excel->getSheet($excel->getSheetNames()[0]);
foreach ($sheet->readRowsWithHeader() as $row) {
// $row is keyed by the header cells: ['email' => ..., 'name' => ...]
ContactImporter::dispatch($row, $this->userId)->onQueue('imports');
}
}
}
Spreadsheets arrive with " Email ", E-Mail, email_address. Normalise once, at the boundary, then use your own DTOs inside the app:
$rows = $sheet->readRowsWithHeader(); // first row = keys
$normalise = fn (string $k) => Str::slug(Str::lower(trim($k)), '_');
$records = collect($rows)->map(
fn (array $row) => collect($row)->mapWithKeys(fn ($v, $k) => [$normalise((string) $k) => $v])->all()
);
Mapping to a ImportRowData DTO here also means a schema change in the customer's file only breaks one class.
Never call readRows() with no bounds on user-supplied files. Read a header, then walk the file in windows so a 200k-row sheet stays at a flat memory profile:
$sheet->readRowsWithHeader(...); // or read the header row alone
$chunk = 1000;
$min = 2; // row 1 is the header
do {
$rows = $sheet->readRows(0, ['minRow' => $min, 'maxRow' => $min + $chunk - 1]);
if (!$rows) break;
DB::transaction(fn () => Contact::insert($this->mapRows($rows)));
$min += $chunk;
} while (count($rows) === $chunk);
The exact window keys (minRow / maxRow / range) are worth confirming in the README for your pinned tag — but the pattern is stable and is the reason this library exists.
For vendor exports and form templates, an explicit read area is more robust than positional array access:
$sheet->setReadArea('B4:F200'); // or pass 'range' => ... in the read call
$rows = $sheet->readRows();
This also gives you a cheap "did the file change shape?" guard: if readRows() returns a row count of zero, the vendor moved their columns.
For round-trip features (export → user edits → re-import) use the companion avadim/fast-excel-writer rather than pulling PhpSpreadsheet in for one feature. The cell-addressing model and date helpers match.
Because there's no facade, bind a small reader in the container so tests can fake it and so option defaults live in one place:
final class SpreadsheetReader
{
public function rows(string $path, ?string $area = null): array
{
$sheet = Excel::open($path)->getSheet(0);
if ($area) { $sheet->setReadArea($area); }
return $sheet->readRowsWithHeader();
}
}
Read-only, and only .xlsx / .csv. Legacy .xls (BIFF) is not supported and will fail — validate the extension and the mime type up front (Excel::isValidFile($path) does a cheap structural check). If customers still send .xls, reject with a clear message rather than letting the parser throw something cryptic.
Formulas are not evaluated. You get the cached value stored in the file, or possibly the formula string, depending on how the file was last saved. A workbook written by a script that never recalculated can therefore read as empty. If users depend on computed columns, that is a real failure mode.
Dates and numbers are raw. Excel stores dates as serial floats with a 1900 leap-year quirk. Expect 45123 rather than 2023-07-15. Convert at the boundary with the library's date helper (Excel::dateToDateTime() / Excel::dateToString() — check the exact name in your version) rather than Carbon::parse(), which will happily mis-parse small floats.
Don't trust a file because it has the right extension. XLSX is a zip; a .xlsx that doesn't unzip should be caught before you Excel::open(). Wrap the whole read in a try/catch and surface a domain-level "the file could not be read" error, because the underlying SimpleXML/zip errors are not user-facing.
Uploaded temp files die with the request. $file->getRealPath() is valid only inside the current request. Any queued import must operate on a stored path. This bites nearly everyone the first time.
Merged cells only carry a value in the top-left corner — the rest read as empty. If a customer's template merges label cells down a column, forward-fill in your mapping step.
Empty rows and trailing whitespace. Blank rows can appear in the middle of real data (users press Enter, then delete), and header cells frequently carry \u{00A0} or a BOM. trim() plus an explicit Str::of($v)->trim()->replace("\u{00A0}", ' ') on header keys avoids a whole class of "column not found" bugs. There is generally a flag to include or skip empty rows — pick deliberately rather than relying on the default.
CSV auto-detection is a guess. Delimiters, quoting and encodings (Windows-1251, latin1) are detected heuristically. If users routinely upload semicolon-CSV exported from European Excel, detect the delimiter yourself from the first line and pass it in, and convert to UTF-8 before reading. Also check whether the file starts with a BOM.
Memory is bounded by your call, not by the library. readRows() with no arguments materialises every row as a PHP array. On a 50 MB sheet that array is hundreds of MB. Always window large reads, and keep max_execution_time in mind for queued workers.
Zip handles. If you process many files in a long-running worker, let the Excel object go out of scope (or call its close/destruct method where available) between files, otherwise file descriptors can accumulate.
Testing. Fixtures are just .xlsx files — put a handful in tests/Fixtures covering: header-only, merged cells, dates, empty interior rows, a semicolon CSV, and a corrupt file. Those six cover almost every production incident this package produces.
When not to use it: anything needing cell styling, formula evaluation, charts, or writing. That's the point where maatwebsite/excel / PhpSpreadsheet earns its weight — and the migration path is easy because your rows are already plain arrays behind the wrapper service.
How can I help you explore Laravel packages today?