Importing XLSX into Eloquent models in three lines: headings and column mapping
Importing an Excel file into the database is almost always written the same way: open the file, loop over the rows, pick the cells apart by column letter for each row, assemble an array, create a model, save it. That is a lot of code, and it is fragile: the moment a user reorders the columns or sends a file with one extra column, everything shifts.
avadim/fast-excel-laravel (version 4.2) removes that routine. Let's go through three ways to import —
from the shortest one to flexible mapping — and be honest about where the out-of-the-box capabilities end.
The basic case: the first row holds field names
If the first row of the file contains headings that match your model's attribute names, the import takes literally a couple of lines:
// The format (XLSX, legacy XLS, or CSV) is detected from the file signature
$excel = \Excel::open(storage_path('imports/users.xlsx'));
// The first row holds field names, the remaining rows create models
$excel->withHeadings()->importModel(User::class);
Here is what happens. withHeadings() without arguments turns on the “first row is the keys” mode: its
values become attribute names, and the row itself does not end up in the data. importModel(User::class)
creates a model instance for every remaining row, fills it via fill(), and calls save().
An important consequence: the model's regular $fillable applies. Attributes not listed in $fillable (or
cut off by $guarded) will not be filled — exactly the behavior of an ordinary Model::create(), and it
protects you from mass-assigning extra fields out of a file.
When the file has no headings, or the wrong ones
Real files are rarely perfect. Headings come in another language, with spaces, in a different case — in
short, they do not match your database column names. For that case withHeadings() accepts your own
attribute names in column order:
// The first row of the file is still skipped (it holds the human-readable headings),
// but its values are ignored — these names are used instead, left to right
$excel->withHeadings(['name', 'birthday', 'email'])
->importModel(User::class);
Here the first column lands in name, the second in birthday, the third in email, no matter what they
are called in the file's header row.
You can also limit the import area — specify a column range or the top-left cell of the data:
// Columns A and B only
$excel->withHeadings()->importModel(User::class, 'A:B');
// The data starts at cell B4 (the first row of the area holds the headings)
$excel->withHeadings()->importModel(User::class, 'B4');
// A rectangular area
$excel->withHeadings()->importModel(User::class, 'B4:D7');
Flexible binding: mapping()
withHeadings() is good when the column order matches the fields you need one to one. But often you need
more: take some of the columns rather than all, swap them around, join two fields into one, cast a type.
That is what mapping() is for — in two forms.
Form one — an array of “column → attribute” pairs. Compact, when all you need is a rearrangement:
$excel->mapping(['B' => 'name', 'C' => 'birthday', 'D' => 'email'])
->importModel(User::class, 'B5');
// The same thing, shorter — the mapping as the third argument
$excel->importModel(User::class, 'B5', ['B' => 'name', 'C' => 'birthday', 'D' => 'email']);
Form two — a callback. It gives you full control over the row: you get the raw array of cells (the keys are column letters) and return a ready array of model attributes.
$excel->mapping(function (array $record) {
return [
'name' => trim($record['B']),
'birthday' => new \Carbon\Carbon($record['C']),
'email' => mb_strtolower($record['D']),
];
})->importModel(User::class, 'B:D');
The callback is a convenient place to normalize data before writing: trim whitespace, cast a date to
Carbon, lowercase an email, turn “yes/no” into a boolean. The array the callback returns goes into
fill().
When you need more control: a manual loop
importModel() is built for one model per row. If a single row of the file must create several related
records (a user and their order, say) or embed complex logic, drop down to the row-reading level and write
the loop yourself:
$excel = \Excel::open($file);
$sheet = $excel->sheet('Articles');
$sheet->setReadArea('B5');
foreach ($sheet->nextRow() as $rowData) {
$user = User::create([
'name' => $rowData['B'],
'birthday' => new \Carbon\Carbon($rowData['C']),
'password' => bcrypt($rowData['D']),
]);
Article::create([
'user_id' => $user->id,
'title' => $rowData['E'],
'public' => $rowData['F'] === 'yes',
]);
}
nextRow() is a generator: it yields rows one at a time and never holds the whole sheet in memory. So such
a loop digests large files calmly, staying streaming — just like importModel().
The file does not have to be on disk
A user's file rarely sits on the local disk in a convenient place: it arrives as an upload, lives in S3
behind Storage, or is stored as a BLOB in the database. You do not need to write it to a temporary file
by hand — besides open() there are two more entry points:
// A workbook from a string: Storage::get(), a BLOB from the database, an HTTP response body
\Excel::openString(Storage::get('imports/users.xlsx'))
->withHeadings()
->importModel(User::class);
// A workbook from a stream: Storage::readStream(), fopen('https://...'), php://memory
\Excel::openStream(Storage::readStream('imports/users.xlsx'))
->withHeadings()
->importModel(User::class);
Everything afterwards is as usual: the format is detected from the content, and read areas,
withHeadings(), mapping(), and importModel() all work. The library removes its temporary copy itself
when the script ends, and it does not close the stream you passed in — closing it is your responsibility.
Honest boundaries
So the import brings no surprises in production, keep in mind what this API does not do for you:
- No built-in row validation.
importModel()fills a model and saves it — it does not check types, required fields, or uniqueness, and it does not collect a “row N, field X” error report. If the data is dirty, validate it yourself: a convenient place is themapping()callback or a manual loop, where you can run a row throughValidator::make()and decide whether to skip it or abort the import. - Row-by-row saving. Every row means a
save(), that is, a separate query. For very large files that is slower than a batch insert; wrap the import in a transaction yourself (DB::transaction(...)) if you need a partially loaded file not to leave the database inconsistent after a failure halfway through. - Eloquent events fire. Since there is a
save()per model, observers and events (creating,saved, and so on) run. That is convenient, but on large imports account for their cost. - No queueing out of the box. A heavy import of a user's file is better moved to a queue — you arrange that with Laravel's own jobs, reading the sheet area you need inside the job.
Summary
- Headings match the model's fields —
withHeadings()->importModel(User::class)is enough. - Headings are wrong or missing — pass your own names in column order:
withHeadings(['name', 'birthday', 'email']). - You need rearranging, column selection, or normalization —
mapping()with an array or a callback. - Complex logic (several models from one row) — a manual loop over
nextRow(), streaming as well. - The file arrived as an upload or lives in
Storage—openString()andopenStream()instead ofopen(), and the rest of the code stays the same. - Validation and transactions are on you — the library is responsible for fast reading, not for the business rules of your import.
composer require avadim/fast-excel-laravel
The import reads XLSX, legacy XLS, and CSV alike — the format is detected from the file content, not from the extension. The repository is github.com/aVadim483/fast-excel-laravel.