Install and confirm the baseline
composer require dcat/easy-excel
The package is a thin, fluent wrapper around PhpSpreadsheet (pulled in transitively). Requirements you actually care about in a container:
ext-zip, ext-xml, ext-gd — a missing ext-zip is the #1 "works locally, explodes in Docker" failureLaravel auto-discovery registers Dcat\EasyExcel\Laravel\ExcelServiceProvider and aliases the Excel facade to Dcat\EasyExcel\Laravel\Facades\Excel. No config file is required to get going. If you'd rather not depend on the facade, the standalone static Dcat\EasyExcel\Excel class does the same job outside the container.
Where to look first
src/Excel.php — the entry point; every method returns an Exporter or Importer.src/Exporters/ and src/Importers/ — this is where headings(), download(), store(), toArray(), toCollection() live. When you're unsure whether a method exists, grep here rather than guessing.composer.lock for the resolved phpoffice/phpspreadsheet version — most surprises trace back to it.First use case: export a report from a controller
use Dcat\EasyExcel\Laravel\Facades\Excel;
public function export()
{
$rows = Order::query()
->where('created_at', '>=', now()->subMonth())
->get()
->map(fn ($o) => [
'order_no' => $o->order_no,
'customer' => $o->customer_name,
'total' => $o->total,
'date' => $o->created_at->toDateTimeString(),
]);
return Excel::export($rows)
->headings(['Order No', 'Customer', 'Total', 'Date'])
->download('orders-'.now()->format('Ymd').'.xlsx');
}
Two things to internalise immediately: headings() is positional (it maps by index, not by array key), and download() returns a StreamedResponse-like object that must be returned from the controller action.
And the mirror image — reading an upload:
$rows = Excel::import($request->file('sheet'))->toArray();
// [ ['Order No', 'Customer', ...], ['SO-1001', 'ACME', ...], ... ]
That's the whole pitch: no writers, no readers, no sheet objects, until you need them.
Export: download vs. store vs. hand off
// Inline download (browser)
return Excel::export($rows)->headings($headings)->download('report.xlsx');
// Persist to a disk, then attach/mail/link it
Excel::export($rows)->headings($headings)->store('exports/report.xlsx', 's3');
store() is what you want for scheduled/queued jobs — never download() there. Keep the file path deterministic (exports/{tenant}/{date}.xlsx) so a failed job can be retried and a stale file overwritten rather than accumulating orphans.
Export: multi-sheet workbooks
Pass an array of sheet definitions instead of a flat array of rows when a report has multiple tabs (Summary / Detail / Errors). Verify the exact array shape against the README for your pinned version before wiring it into a job — the multi-sheet syntax is the least obvious part of the API. For anything beyond a plain grid, drop to the underlying objects:
$exporter = Excel::export($rows)->headings($headings);
// Reach into PhpSpreadsheet for widths, freeze panes, number formats
$exporter->sheet()->getColumnDimension('B')->setWidth(30);
return $exporter->download('report.xlsx');
This escape hatch is the real value of a thin wrapper: you get semantics for the 90% case and full PhpSpreadsheet access for the last 10%.
Import: the standard pipeline
There is no Eloquent importer, no validation layer, and no queued-import machinery. You build a three-stage pipeline and keep each stage testable:
$raw = Excel::import($request->file('sheet'))->toArray();
$headings = array_shift($raw); // 1. peel the header row
$records = collect($raw)
->filter(fn ($row) => filled($row[0] ?? null)) // 2. drop blank tail rows
->map(fn ($row) => [
'order_no' => (string) $row[0],
'customer' => trim((string) $row[1]),
'total' => (float) $row[2],
]);
$validated = $records->map(fn ($r) => validator($r, [
'order_no' => 'required|string|max:64',
'total' => 'required|numeric|min:0',
])->validate());
DB::transaction(fn () => Order::upsert($validated->all(), ['order_no'], ['total']));
Notice the array_shift for the header: the importer gives you raw rows including the header, and mapping is on you. Build a small header→column-index map (array_flip($headings)) instead of hard-coding $row[2] — columns move, and a positional index is how you ship a silent data-corruption bug.
Import: choosing a return type
->toArray() — plain arrays, cheapest to reason about, fine for anything under a few thousand rows.->toCollection() — same data, but you get map/filter/chunk for free and it reads better in Laravel.->first() for the active one, or ->sheetByName('Detail')), then convert. Calling toArray() on a workbook without selecting a sheet gives you the first sheet, which is often not what you want.Reading from Laravel disks
The importer wants a real filesystem path, not a Storage URL. For the local driver that's Storage::path($key); for s3 you must copy to a temp file first (Storage::readStream → tmpfile/tempnam). Uploaded files (UploadedFile, an SplFileInfo) are accepted directly.
CSV and other formats
The writer/reader format is inferred from the file extension. .csv export/import works, but CSV means encoding decisions — state UTF-8 with BOM explicitly if the consumer is Excel on Windows, or you'll get mojibake in customer-facing files.
Testing
Fixtures in tests/Fixtures/*.xlsx, Storage::fake('local'), and assert on the parsed output rather than byte-comparing files (PhpSpreadsheet output isn't stable across patch versions). For export tests, store() to the fake disk and re-import the file to round-trip it — that single test catches most column-mapping regressions.
The maintenance question is the headline risk. The last tagged release is 2022-03-03. That's not a dead package (it's a small wrapper over a stable API), but it means the constraint on phpoffice/phpspreadsheet was written against a 2022-era version. Before adopting it greenfield, run composer why phpoffice/phpspreadsheet and check for conflicts with anything else in your tree that wants a newer PhpSpreadsheet. If you're already in the Dcat Admin ecosystem, this is the natural choice and the constraint is already baked in; if you're starting a fresh Laravel 11/12 app with no Dcat dependency, weigh maatwebsite/excel or using PhpSpreadsheet directly — you're buying convenience, not longevity.
Memory blows up at exactly the wrong size. PhpSpreadsheet loads the whole worksheet into memory, and toArray() then materialises a second copy as PHP arrays. A 50 MB xlsx can easily need 500 MB+. On import, keep memory_limit generous and consider raising it per-job (ini_set at the top of a queued job) rather than globally. For genuinely large files, the honest answer is that this package is not the tool — you need streaming readers or pre-split files.
Numbers lose their identity. Order numbers, phone numbers, ID card numbers, and anything with a leading zero arrive as floats/scientific notation. Symptom: 13800138000 becomes 1.38E+10, 007 becomes 7. On import, cast aggressively and treat identifier columns as strings at the format level. On export, format those columns as text before writing, or the recipient's Excel does the mangling for you.
Dates are not dates. A date cell may come back as an Excel serial integer, a formatted string, or a DateTime depending on the cell's format and how it was written. Never strtotime() blindly. Pin down what your input files actually contain with one debug dump:
dd(Excel::import($path)->first()->toArray());
headings() maps by position, not key. If you pass headings(['A','B']) with a three-column dataset, the third column is written with no header. If you pass more headings than columns, you get blank columns. Keep the headings array and the row-shaping closure physically next to each other in the code so they can't drift.
Empty rows and phantom ranges. Excel files frequently have a trailing block of "empty" rows that PhpSpreadsheet still reports (a stray space, a leftover format). Always filter imported rows for at least one non-blank cell before validating — otherwise a validation error fires on row 5000 and nobody can find the problem.
Formula cells. A cell containing a formula may yield the formula string or the cached computed value depending on how the file was saved. If you're importing files produced by another system, assume cached values exist; if you're importing files freshly generated and never opened in Excel, they may not. Test with a real-world file, not one you generated yourself.
Concurrency on store(). Two queued jobs writing the same path will corrupt each other. Include the job/tenant/user ID in the filename and clean up with a scheduled prune.
Non-ASCII filenames in download() need care in the response headers (RFC 5987 filename*). If users want Chinese filenames, test in the actual target browsers, not just curl.
Debugging tip: when output looks wrong, bypass the wrapper. Grab the underlying writer/reader (->writer(), or the exporter's sheet) and dd() the raw cell values. Almost every "the package is broken" moment is either a stray header row, a numeric format, or a merged cell — all visible in three lines of raw inspection.
Extension point to remember: the wrapper deliberately stops short of styling, charts, images, and conditional formatting. That's not a gap to work around — it's the design. Reach for ->sheet() and the PhpSpreadsheet API the moment you need it, and don't fight the abstraction.
How can I help you explore Laravel packages today?