spatie/simple-excel
Spatie Simple Excel lets Laravel/PHP apps read and write simple Excel and CSV files with low memory usage via generators. It streams rows as LazyCollections, making large imports and exports easy and efficient.
Install with Composer — there is no service provider, no config file and nothing to publish:
composer require spatie/simple-excel
Where to look first. The whole package is essentially two classes: Spatie\SimpleExcel\SimpleExcelReader and Spatie\SimpleExcel\SimpleExcelWriter. Skim the README once, then read the two source files (~400 lines each) — that's faster than hunting for API docs, and it makes the fluent methods' interaction (headers → filters → rows) obvious. Check CHANGELOG.md before upgrading, because the writer's streaming API and a few reader options have shifted between major versions.
Key mental model. The reader wraps openspout/spout and returns an Illuminate\Support\LazyCollection. Nothing is loaded into memory; the file is read as you iterate. File type is inferred from the extension (.csv / .xlsx); pass a second argument to create() when the extension is missing or wrong (e.g. an uploaded temp file).
First use case — an import command. The 80% case is "read a spreadsheet someone emailed me and put it in the database":
use Spatie\SimpleExcel\SimpleExcelReader;
SimpleExcelReader::create(storage_path('app/imports/contacts.csv'))
->headersToSnakeCase()
->trimValues()
->getRows()
->each(function (array $row) {
Contact::updateOrCreate(
['email' => $row['email']],
['first_name' => $row['first_name'], 'last_name' => $row['last_name'] ?? null],
);
});
Wrap that in an artisan command (php artisan contacts:import path/to/file.csv) rather than a controller — imports are long-running and should be re-runnable.
Always inspect before importing. A dry-run mode saves hours of debugging malformed client files:
$reader = SimpleExcelReader::create($path);
$headers = $reader->getHeaders(); // ['email', 'first_name', ...]
$preview = $reader->take(5)->getRows()->all();
Use getSheetNames() / hasSheet('Sheet1') and prefer fromSheetName() over fromSheet(); headerOnRow(3) plus trimHeaderRow() handles the classic "three title rows above the real table" Excel file, and noHeaderRow() / useHeaders([...]) handles positional reports.
Map awkward headers instead of scattering array keys everywhere. formatHeadersUsing(fn ($h) => Str::slug($h, '_')) or useHeaders([...]) lets you normalise once and keep the rest of the codebase clean. formatValuesUsing(fn ($value, $key) => ...) does the same for cell contents — ideal for lowercasing emails or stripping currency symbols per column.
Validate per row, collect and continue. A thrown exception inside each() aborts the whole import (and leaves the underlying reader open). Validate into a failure list instead:
$errors = [];
$rows->each(function (array $row, $index) use (&$errors) {
$v = Validator::make($row, ['email' => 'required|email']);
if ($v->fails()) {
$errors[$index + 2] = $v->errors()->all(); // +2 ≈ spreadsheet line number
return;
}
// persist
});
For large imports, batch inside the lazy chain (->chunk(500) then Model::upsert($chunk)) rather than one query per row.
Export with cursor() and a generator. The writer accepts any iterable via addRows(), so you never build an array of models:
$writer = SimpleExcelWriter::create(storage_path('app/exports/users.csv'))
->addHeader(['id', 'name', 'email']);
$writer->addRows(
User::query()->orderBy('id')->cursor()
->map(fn (User $u) => ['id' => $u->id, 'name' => $u->name, 'email' => $u->email])
);
$writer->close(); // mandatory for .xlsx
Stream a download straight to the browser for on-demand exports in a controller:
return SimpleExcelWriter::streamDownload('users.xlsx')
->addRows(User::query()->cursor()->map(fn ($u) => [...]));
(Older 3.x releases required a trailing ->toBrowser(); recent versions return the streamed response directly.)
Remote files need a local path. Neither class accepts a stream or a filesystem disk, so for S3/uploaded files download to a temp path first:
$temp = tempnam(sys_get_temp_dir(), 'import') . '.xlsx';
Storage::disk('s3')->put($temp, Storage::disk('s3')->get($key)); // or copy()
// ... read, then unlink($temp)
Background exports. Put the export in a queued job that writes to storage/app/exports/{uuid}.xlsx, then expose a signed download route — do not attempt to stream from a queue worker.
.xlsx files are not written until the writer is closed. The file is finalised on close() or garbage collection. In queue workers, Octane and long-lived processes, GC may never run — always call ->close() explicitly, and never return response()->download() on a file before closing.getRows() is single-pass. Don't iterate the same LazyCollection twice or call ->all() on a big file. Call getRows() again if you genuinely need a second pass.email become one key in the row array (last wins). Detect duplicates from getHeaders() and rename via useHeaders()/formatHeadersUsing().null values; rows shorter than the header set can leave keys missing. Always use $row['x'] ?? null, and preserveEmptyRows() if blank lines carry meaning.int/float, so phone numbers, IBANs, ISBNs and ZIP codes lose leading zeros and can hit float precision or scientific notation. Format those columns as Text in the source, or cast defensively and warn the user.DateTimeImmutable from .xlsx — they break 'date' validation rules, json_encode, and strtotime expectations. Use preserveDateTimeFormatting() to get the display string, or convert explicitly. CSV dates stay plain strings, so imports behave differently per format.null or stale if the file was generated programmatically (Excel never recalculated it). Use keepFormulas() when you want the =SUM(...) text.;-separated CSVs. Specify the delimiter on create()/useDelimiter(), and enable the BOM option when writing CSVs that must open cleanly in Excel with accented characters.headerOnRow() counts non-blank lines only — a stray blank line above the header shifts your index. Verify with the preview pattern above.trimValues() takes a character set, not a suffix. trimValues('.com') will also turn Tom into T. Default trimValues() (whitespace) is almost always what you want.fromSheetName() after calling getSheetNames(), and hasSheet() to guard against client files missing the expected tab.getReader() returns the underlying OpenSpout\Reader\ReaderInterface for advanced configuration (encodings, options the wrapper doesn't expose). Configure it before calling getRows(), since iteration captures state.Storage::fake()-adjacent temp paths, then read the output back with SimpleExcelReader and assert on rows — testing the round trip catches header and closing bugs cheaply.How can I help you explore Laravel packages today?