shuchkin/simplexlsx
SimpleXLSX is a lightweight PHP library for reading and parsing Microsoft Excel .xlsx files without heavy dependencies. It extracts worksheet rows, cell values, dates, and basic formatting, making spreadsheet import simple and fast for PHP applications.
Install via Composer; the package ships a namespaced class and is PSR-4 autoloaded (no service provider needed):
composer require shuchkin/simplexlsx
Requires ext-zip (and libxml/SimpleXML). Check with php -m | grep -i zip — this is the most common install failure on shared hosting and minimal Docker images (use docker-php-ext-install zip).
The first thing to read is the README's examples for SimpleXLSX::parse(), ->rows(), and ->rowsEx(). The class is small (~1 file); skim the source so you understand the false-on-error contract.
Minimal first use case — a controller that ingests an uploaded sheet:
use Shuchkin\SimpleXLSX;
$path = $request->file('sheet')->getRealPath();
if ( $xlsx = SimpleXLSX::parse($path) ) {
$rows = $xlsx->rows(); // array of arrays, row 0 = header
$header = array_shift($rows);
// ... map and persist
} else {
return back()->withErrors(['sheet' => SimpleXLSX::parseError()]);
}
parse() never throws on malformed input — it returns false, and the reason lives in SimpleXLSX::parseError(). Wire that into your validation feedback early; it saves hours of "silent failure" debugging.
FormRequest validation. Don't rely on the extension alone. xlsx is a ZIP container, so validate both mime and size:
'file' => ['required','file','max:10240',
'mimetypes:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/zip,application/octet-stream'],
application/octet-stream appears often (browsers/OS disagree on xlsx mime), so include it or your users will hit false rejections.
Header-mapped rows (the workhorse pattern). Most imports assume row 0 is a header. Normalize it once so downstream code is key-based, not index-based:
$rows = $xlsx->rows();
$header = array_map(fn ($h) => Str::slug(trim((string) $h), '_'), array_shift($rows));
$records = collect($rows)
->filter(fn ($r) => collect($r)->filter(fn ($v) => $v !== '' && $v !== null)->isNotEmpty())
->map(fn ($r) => array_combine($header, array_pad($r, count($header), null)));
This also drops trailing empty rows, which Excel adds liberally.
Streaming for large files. ->rows() loads the entire sheet into memory. For anything over a few thousand rows, use the lazy reader so you can process row-by-row and keep memory flat:
foreach ( $xlsx->readRows() as $index => $row ) {
if ( $index === 0 ) { $header = $row; continue; }
// validate, accumulate, flush every N rows
}
Pair this with a queued job for user experience — dispatch ImportSheetJob from the controller, store the upload on the local disk, and let the worker stream it.
Chunked persistence. Don't create() per row. Accumulate a buffer and Model::upsert($buffer, ['external_id'], ['name','email']) every 500 rows inside a transaction. Batch DB writes are the single biggest speedup for imports.
Multiple sheets. ->sheetNames() lists tabs; ->setSheet($i) (0-based) or ->getSheet($i) selects one. For "first sheet only" imports be explicit — don't assume the active tab is the data tab users expect.
Rich cell data. ->rowsEx() returns per-cell metadata (type, formatted value, href, format). Use it when you need hyperlinks, detect dates vs strings reliably, or preserve display formatting:
foreach ( $xlsx->rowsEx() as $row ) {
$href = $row[0]['href'] ?? null;
$type = $row[0]['type'] ?? null; // 's', 'n', 'd' etc.
}
In-memory binaries. SimpleXLSX::parseData($binary) lets you parse a file straight from Storage::get() without touching disk — handy in jobs and tests.
Testing. Use Storage::fake('local'), copy a small fixture .xlsx into the fake disk, and assert rows land in the DB. Keep fixtures tiny (2–3 rows) to keep the suite fast; add one "bad file" fixture to cover the parseError() branch.
false is not an exception. Always branch on the return value or wrap the call yourself (throw_unless($xlsx, ...)). parseError() is static and reflects the last failure.SimpleXLSX::unixstamp($serial) (returns a Unix timestamp) then Carbon::createFromTimestamp(...), or manually: Carbon::create(1899,12,30)->addDays($serial). The 1899-12-30 epoch accounts for Excel's leap-year bug. Watch the timezone on convert — serials are naive, treat them as app timezone deliberately.$xlsx->merged (or similar) and fan the value out — otherwise your row logic sees blanks."00123" → 123). Excel only stores what it was told; prefer reading with rowEx() and checking the cell type, and document for users that ID-like columns should be formatted as text in the source file.readRows() vs rows(): only readRows() is lazy. Mixing them, or calling rows() first, defeats streaming. Also cap ini_set('memory_limit', ...) only as a last resort — prefer streaming.getRealPath() is unreliable for UploadedFile in some SAPI/test scenarios and after certain moves. Prefer $file->store(...) then parse Storage::path(...), or $file->getPathname().simplexlsx covers only xlsx. For legacy .xls you need shuchkin/simplexls or PhpSpreadsheet. Detect the extension early and reject with a clear message.Shuchkin\SimpleXLSX; older docs/examples show a global SimpleXLSX. If you get "class not found," check composer.lock and the use statement before blaming the code.[]. Always filter empty rows before mapping headers, or array_combine will throw on mismatched counts.SheetReader service in your app. That's also the right seam for swapping to PhpSpreadsheet later without touching controllers.fgetcsv/LazyCollection path and xlsx to SimpleXLSX, otherwise users uploading CSVs (very common) hit a confusing parseError().How can I help you explore Laravel packages today?