avadim/fast-excel-laravel
Fast Excel for Laravel wraps the avadim/fast-excel library to provide simple, memory-efficient spreadsheet import and export. Read and write XLSX/CSV files with a clean Laravel facade and minimal setup.
Install and confirm discovery
composer require avadim/fast-excel-laravel
Laravel's package auto-discovery registers the service provider; there is nothing mandatory to configure. Verify before writing code:
php artisan about | grep -i excel # or check bootstrap/cache/packages.php
ls vendor/avadim/fast-excel-laravel
Look first at vendor/avadim/fast-excel-laravel/README.md and the facade class itself — the wrapper is thin, so the real API surface lives in the underlying avadim/fast-excel-writer / avadim/fast-excel-reader packages. Reading those two READMEs is usually faster than guessing at wrapper method names.
First use case: stream a query result to XLSX without blowing memory
The whole point of this package versus maatwebsite/excel + PhpSpreadsheet is that rows are written incrementally, so you can export 100k rows with a flat memory profile. Start there:
use Avadim\FastExcelLaravel\Facades\FastExcel; // confirm alias in the README
Route::get('/exports/users', function () {
$path = storage_path('app/private/users-'.now()->timestamp.'.xlsx');
$excel = FastExcel::make($path); // or FastExcel::create(...)
$sheet = $excel->sheet('Users');
$sheet->writeHeader(['ID', 'Name', 'Email', 'Created']);
App\Models\User::query()->lazy(1000)->each(function ($user) use ($sheet) {
$sheet->writeRow([
$user->id,
$user->name,
$user->email,
$user->created_at->toDateTimeString(),
]);
});
$excel->save(); // or ->output() / ->download()
return response()->download($path)->deleteFileAfterSend();
});
If the facade name collides with something in your app, resolve the class out of the container instead of aliasing it.
Wrap it in a dedicated export class, not in controllers. The package is a low-level writer; your app's column mapping, formatting, and file naming belong in a class you own:
final class UserExport
{
public function __construct(private array $columns, private string $disk = 'local') {}
public function build(string $path): string
{
$excel = FastExcel::make(Storage::disk($this->disk)->path($path));
$sheet = $excel->sheet('Users');
$sheet->writeHeader(array_values($this->columns));
User::query()->lazyById(1000)->each(fn ($u) => $sheet->writeRow(
collect($this->columns)->keys()->map(fn ($k) => data_get($u, $k))->all()
));
$excel->save();
return $path;
}
}
This indirection is your escape hatch: if the package stalls (41 stars, no dependents), you swap the body for PhpSpreadsheet and nothing else changes.
Long exports belong in a queued job. Write to storage/app/private/exports/{uuid}.xlsx, then notify the user with a temporary URL:
class BuildUserExport implements ShouldQueue
{
public function __construct(public int $userId, public string $path) {}
public function handle(): void
{
(new UserExport(config('exports.users')))->build($this->path);
Notification::send(User::find($this->userId),
new ExportReady(Storage::disk('local')->temporaryUrl($this->path, now()->addHour())));
}
}
Keep the job's timeout proportional to row count and give it its own queue — a 200k-row export will hold a worker for a while.
Import: iterate, don't materialize. The reader exposes row iteration; use it and insert in chunks rather than building an array of all rows:
use Avadim\FastExcelLaravel\Facades\FastExcel; // reader facade
$excel = FastExcel::open($request->file('sheet')->getRealPath());
$sheet = $excel->sheet();
$headers = null;
$buffer = [];
foreach ($sheet->rows() as $row) { // confirm iterator method name
if ($headers === null) { $headers = $row; continue; }
$buffer[] = array_combine($headers, $row);
if (count($buffer) >= 500) {
User::insert($buffer); // or upsert() for re-imports
$buffer = [];
}
}
if ($buffer) { User::insert($buffer); }
Header-driven mapping is the pattern that survives spreadsheet edits. Users reorder columns constantly. Resolve field names from row 1 into a key→index map, then read by index on every subsequent row; reject the file outright if a required header is missing.
Formatting and multi-sheet exports. Styling is passed as arrays to the sheet, which is where the underlying writer shines compared to writing raw XML:
$sheet->writeTo('A1', 'Report');
$sheet->applyStyle('A1:D1', ['font-style' => 'bold', 'fill' => 'FFEEDD']);
$sheet->setColWidths(['A' => 8, 'B' => 30, 'C' => 35]);
$sheet->freezeHeader(); // or freeze panes at 'A2'
$sheet->writeCell('=SUM(D2:D100)');
Multiple sheets come from calling ->sheet('Name') repeatedly on the same $excel instance — good for "summary + detail" deliverables in one file.
Validate uploads before touching the reader. Wrap import in a FormRequest: mimes:xlsx, max:10240, and a whitelist of expected header names. Excel is not a trusted input format; a malformed file should produce a 422, not a stack trace from deep in the XML parser.
The Excel facade alias is the number one conflict. If the app also has maatwebsite/excel (or any package registering Excel), you will get "A facade root has not been set" or, worse, calls silently hitting the wrong class. Register this package's facade under a custom alias in config/app.php ('FastXlsx' => Avadim\...\Facades\...::class) or type-hint the concrete class.
Excel serial dates. Date cells come back as integers, not Carbon. Convert with the reader's formatter if it exposes one, otherwise \PhpOffice\PhpSpreadsheet\Shared\Date::excelToDateTimeObject() — or just pull in Carbon and do Carbon::create(1899, 12, 30)->addDays($serial). Watch the 1900 leap-year bug and timezone drift; store UTC.
Formula injection is a real security issue on export. Any cell starting with =, +, -, or @ that came from user input will execute when opened. Sanitize on write:
$safe = fn ($v) => is_string($v) && preg_match('/^[=+\-@]/', $v) ? "'".$v : $v;
Everything is a string unless you say otherwise. Excel will happily turn "007" into 7 and "2024-01-01" into a date on the user's machine. For ID columns, write as explicit string cells (or prefix with ') so round-trips don't corrupt data.
Memory: the writer streams, the reader mostly does not. lazy()/cursor() in Eloquent plus writeRow() in a loop keeps the writer flat, but the reader caches dimension and shared-string data. Huge import files still need memory_limit headroom — test with a 50MB file, not a 5-row fixture.
Temp file hygiene. These libraries write to sys_get_temp_dir() or to the path you gave. For queued exports, always deleteFileAfterSend() on downloads and prune abandoned export files with a scheduled command; otherwise storage/app/private/exports grows forever.
Config publishing may not exist. Try php artisan vendor:publish --tag=fast-excel then --tag=fast-excel-config; if both return nothing, fall back to --provider="Avadim\FastExcelLaravel\FastExcelServiceProvider" and read the source. Don't build a config file by hand from blog posts — the keys change between the wrapper and the underlying packages.
Pin the version and check PHP/ext requirements. ext-zip and ext-xml (or ext-simplexml) are hard requirements, and the package tracks the underlying reader/writer majors closely. In composer.json, use a caret constraint on a specific minor ("avadim/fast-excel-laravel": "^1.2") rather than *; with a single maintainer and a fast release cadence (a 2026-09 release on a 41-star repo), a silent breaking change in a patch is plausible. Commit composer.lock and run the export/import test suite on every composer update.
Testing. Use Storage::fake('local') and assert the file exists plus a spot-check of contents:
Storage::fake('local');
(new UserExport(['id' => 'ID', 'email' => 'Email']))->build('exports/test.xlsx');
Storage::disk('local')->assertExists('exports/test.xlsx');
Read the file back with the same package to assert row count and header order — cheaper and more robust than asserting binary contents. If the facade is hard to fake, resolve the underlying class through the container and ->instance() a stub.
Column letters break after Z. Hand-rolled chr(65 + $i) produces [ at index 26. Use writeRow() (positional, handled for you) or the writer's column-letter helper rather than computing AA yourself.
Debugging a failed write. Exceptions from the underlying writer are often generic ("Cannot write to file") — nine times out of ten it's a directory permission, a missing parent directory, or a filename with characters the ZIP layer rejects. storage_path() on a fresh container without storage/app/private created is the classic first-run failure; php artisan storage:link does not create it.
Extension points worth knowing. Styling arrays, per-cell formatters/callbacks, and direct cell addressing (writeTo('C3', ...)) are the levers the package actually gives you. Anything more exotic — conditional formatting, charts, pivot caches — is out of scope; for those, go straight to PhpSpreadsheet rather than fighting the wrapper.
How can I help you explore Laravel packages today?