rap2hpoutre/fast-excel
Fast Excel import/export for Laravel, powered by Spout. Export Eloquent models, queries, or collections to xlsx, ods, or csv with a simple API, customize columns via callbacks, and handle large files efficiently. Benchmark-tested for speed.
Install and use, no setup ceremony:
composer require rap2hpoutre/fast-excel
There is no service provider to publish, no config file, no migration. The package is a thin, opinionated wrapper around OpenSpout (formerly Box/Spout), so the fastest path to value is: install it, then export one query and import one file. If you want the FastExcel facade, alias it manually in config/app.php ('FastExcel' => Rap2hpoutre\FastExcel\Facades\FastExcel::class); otherwise just use Rap2hpoutre\FastExcel\FastExcel;. The fastexcel() global helper is available without any registration and is the shortest way to get going in a controller or artisan command.
First use case — a controller that downloads a CSV:
public function export()
{
return (new FastExcel(User::query()->orderBy('id')))->download('users.csv');
}
Note you can pass a Builder, not just a collection — this is the single most important thing to learn early, because it is what keeps memory flat. export() returns the absolute path of the written file when you write to disk rather than download, which is what you want for scheduled reports, mail attachments, or pushing to S3 afterwards.
Read these first, in order: the README's Quick start → Export large collections → Import large files → Advanced usage. The memory section is the real differentiator versus Laravel Excel; the feature list is deliberately short (the README says "use it only for simple tasks"), so do not expect formulas, charts, cell merging, or multi-format styling beyond OpenSpout's Style.
Positioning decision to make on day one: if your requirement is "let users download a filtered table" or "ingest a partner's CSV into the DB", FastExcel is the right tool. If the requirement mentions templates with formatting, pivot tables, or formula injection, use Laravel Excel instead. Mixing both in one codebase is fine and common.
Large exports: feed a cursor or generator, never ::all().
(new FastExcel(User::cursor()))->export('users.xlsx');
(new FastExcel(User::query()->where('active', 1)->lazy()))->download('active.xlsx');
The README's benchmark table is the argument: ~4 MB peak versus >512 MB for a materialized collection. Any custom source can be wrapped in a generator with yield.
Large imports: return null from the callback, or use importLazy().
// Streaming side-effect import
(new FastExcel)->import($path, function ($line) {
User::create(['name' => $line['Name'], 'email' => $line['Email']]);
return null; // critical: do not accumulate
});
// Or keep the Collection API while staying lazy
(new FastExcel)->importLazy($path)
->chunk(1000)
->each(fn ($chunk) => User::insert($chunk->all()));
importLazy()->chunk() is the pattern I'd standardize on for anything that touches the database, because per-row create() is an order of magnitude slower than batched insert()/upsert(). Wrap the whole thing in DB::transaction() or process in an idempotent job.
Column shaping with closures. The export callback is where you decouple storage schema from spreadsheet headers, compute derived values, and normalize formatting:
(new FastExcel(User::cursor()))->export('users.xlsx', fn ($u) => [
'Email' => $u->email,
'First Name' => $u->firstname,
'Phone' => $u->phone,
]);
When the callback needs helper values that shouldn't appear as columns, use hideColumnsPrefixedWith() — prefixed keys stay available inside the closure but are stripped from headers and rows. It is opt-in, so existing underscore columns are unaffected.
Multi-sheet work. Export with SheetCollection, using string keys for named sheets:
$sheets = new SheetCollection(['Users' => User::cursor(), 'Projects' => Project::cursor()]);
(new FastExcel($sheets))->export('report.xlsx');
On the import side, importSheets() returns all sheets, withSheetsNames() keeps the sheet names as keys, withSheetContext() hands the sheet name as the callback's first argument (useful for per-sheet validation rules), and sheet(3) / sheet('Users') selects one sheet — the name variant removes the fragile positional coupling from your code.
Formatting and type fidelity. Excel silently eats leading zeros. Use stringValues() for phone numbers, IBANs, and long numeric IDs, then relax it per column with setColumnFormat(['id' => 'number', 'phone' => 'string']), which takes precedence. For presentation, headerStyle(), rowsStyle(), and setHeaderColumnStyles([0 => ...]) accept OpenSpout\Common\Entity\Style\Style instances.
CSV quirks. For non-standard files, configureCsv(';', '#', 'gbk') before import() handles delimiter, enclosure, and encoding in one call. Pair with withoutHeaders() for headerless files and limitRows(100) for "dry run / preview the first N rows" UI features.
Uploaded files. Pass the uploaded file path directly — since v5.9.0, extension-less uploads from $request->file('file') are detected correctly. Do not invent the extension from the client-supplied filename.
Facade for terse call sites: FastExcel::data($list)->export('file.xlsx') — remember the facade has no constructor, so data() is mandatory there.
import() without a callback is a memory trap. It returns a Collection containing every row; on a file beyond memory_limit it fails outright. The fix is a callback returning null, or importLazy().return User::create([...]); looks harmless and defeats streaming. Return null when you only need the side effect.cursor() alone is not enough for multi-sheet exports. transpose() and SheetCollection need buffering; transpose() cannot stream at all, and transpose() is also unsupported with importLazy()..xls is not supported. Only xlsx, ods, csv. Reject .xls at validation time with a clear message rather than letting the reader throw.Carbon::parse() or format inside your import callback; do not assume the spreadsheet's display format survives the round trip.null from $line['Email']. When debugging, dump(array_keys($firstRow)) before anything else. Header de-duplication exists, but silently renamed duplicates are worse than a hard failure — validate the header row explicitly for user-uploaded files and reject files with missing required columns.hideColumnsPrefixedWith(), be aware it changes nothing until called; teams mixing code that assumes it is on will produce files with stray _role columns.insert()/upsert().max_execution_time and web server timeouts will kill a synchronous 10-minute export. The return-path idiom works well here: export to storage/app/exports/..., then attach or stream it.export() gives you the path — use it. It's the cleanest hook for "write file, then Storage::disk('s3')->putFile(...)" or "write file, attach to Mailable", and it composes far better than download().Storage::fake(), assert file_exists on the returned path, then re-import the generated file and compare — round-tripping is the cheapest end-to-end assertion for export/import code, and avoids fixture drift.How can I help you explore Laravel packages today?