Импорт XLSX в модели Eloquent за три строки: заголовки и маппинг колонок

aVadim • 22.08.2026 00:00 •

Импорт Excel-файла в базу почти всегда пишется по одному сценарию: открыть файл, пройти циклом по строкам, для каждой строки руками разобрать ячейки по буквам колонок, собрать массив, создать модель, сохранить. Кода получается много, и он хрупкий: стоит пользователю поменять порядок колонок или прислать файл с лишним столбцом — и всё едет.

avadim/fast-excel-laravel (версия 4.2) убирает эту рутину. Разберём три способа импорта — от самого короткого до гибкого маппинга — и честно обозначим, где заканчиваются возможности «из коробки».

Базовый случай: первая строка — имена полей

Если первая строка файла содержит заголовки, совпадающие с именами атрибутов модели, импорт занимает буквально пару строк:

php
// Формат (XLSX, устаревший XLS или CSV) определяется по сигнатуре файла
$excel = \Excel::open(storage_path('imports/users.xlsx'));

// Первая строка — имена полей, остальные строки создают модели
$excel->withHeadings()->importModel(User::class);

Что здесь происходит. withHeadings() без аргументов включает режим «первая строка — ключи»: её значения становятся именами атрибутов, а сама она в данные не попадает. importModel(User::class) для каждой оставшейся строки создаёт экземпляр модели, заполняет его через fill() и вызывает save().

Важное следствие: работает штатный $fillable модели. Атрибуты, не перечисленные в $fillable (или отсечённые $guarded), не будут заполнены — это ровно то же поведение, что у обычного Model::create(), и оно защищает от массового присвоения лишних полей из файла.

Когда заголовков в файле нет или они «не те»

Реальные файлы редко идеальны. Заголовки бывают на русском, с пробелами, в другом регистре — словом, не совпадают с именами колонок в базе. На этот случай withHeadings() принимает свои имена атрибутов в порядке колонок:

php
// Первая строка файла всё равно пропускается (там «человеческие» заголовки),
// но её значения игнорируются — вместо них берутся эти имена, слева направо
$excel->withHeadings(['name', 'birthday', 'email'])
    ->importModel(User::class);

Здесь первая колонка ляжет в name, вторая — в birthday, третья — в email, как бы они ни назывались в шапке файла.

Можно также ограничить область импорта — указать диапазон колонок или левую верхнюю ячейку данных:

php
// Только колонки A и B
$excel->withHeadings()->importModel(User::class, 'A:B');

// Данные начинаются с ячейки B4 (первая строка области — заголовки)
$excel->withHeadings()->importModel(User::class, 'B4');

// Прямоугольная область
$excel->withHeadings()->importModel(User::class, 'B4:D7');

Гибкая привязка: mapping()

withHeadings() хорош, когда порядок колонок соответствует нужным полям один-к-одному. Но часто нужно больше: взять не все колонки, поменять их местами, склеить два поля в одно, привести тип. Для этого есть mapping() — в двух формах.

Форма первая — массив соответствий «колонка → атрибут». Компактно, когда нужна просто перестановка:

php
$excel->mapping(['B' => 'name', 'C' => 'birthday', 'D' => 'email'])
    ->importModel(User::class, 'B5');

// То же самое, но короче — маппинг третьим аргументом
$excel->importModel(User::class, 'B5', ['B' => 'name', 'C' => 'birthday', 'D' => 'email']);

Форма вторая — колбэк. Даёт полный контроль над строкой: получаете сырой массив ячеек (ключи — буквы колонок), возвращаете готовый массив атрибутов модели.

php
$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');

Колбэк — место, где удобно нормализовать данные до записи: обрезать пробелы, привести дату к Carbon, привести регистр e-mail, преобразовать «да/нет» в булево. Массив, который вернёт колбэк, попадёт в fill().

Когда нужно больше контроля: ручной цикл

importModel() рассчитан на одну модель на строку. Если из одной строки файла надо создать несколько связанных записей (например, пользователя и его заказ) или встроить сложную логику, спускайтесь на уровень чтения строк и пишите цикл сами:

php
$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() — генератор: он отдаёт строки по одной и не держит весь лист в памяти. Поэтому такой цикл спокойно переваривает большие файлы, оставаясь потоковым — как и importModel().

Файл не обязан лежать на диске

Пользовательский файл редко оказывается на локальном диске в удобном месте: он приходит загрузкой, лежит в S3 через Storage или вообще хранится BLOB'ом в базе. Класть его во временный файл руками не нужно — кроме open() есть ещё два входа:

php
// Книга из строки: Storage::get(), BLOB из базы, тело HTTP-ответа
\Excel::openString(Storage::get('imports/users.xlsx'))
    ->withHeadings()
    ->importModel(User::class);

// Книга из потока: Storage::readStream(), fopen('https://...'), php://memory
\Excel::openStream(Storage::readStream('imports/users.xlsx'))
    ->withHeadings()
    ->importModel(User::class);

Дальше всё как обычно: формат определяется по содержимому, работают области чтения, withHeadings(), mapping() и importModel(). Временную копию библиотека удаляет сама при завершении скрипта, а переданный поток не закрывает — закрыть его ваша забота.

Честные границы

Чтобы импорт не принёс сюрпризов в проде, держите в голове, чего этот API не делает сам:

  • Нет встроенной валидации строк. importModel() заполняет модель и сохраняет — он не проверяет типы, обязательность или уникальность и не собирает отчёт об ошибках «строка N, поле X». Если данные грязные, валидируйте их сами: удобное место — колбэк mapping() или ручной цикл, где можно прогнать строку через Validator::make() и решить, пропустить её или прервать импорт.
  • Сохранение построчно. Каждая строка — это save(), то есть отдельный запрос. Для очень больших файлов это медленнее пакетной вставки; при необходимости оберните импорт в транзакцию сами (DB::transaction(...)), чтобы частично загруженный файл не оставил базу в несогласованном состоянии при сбое на середине.
  • Срабатывают события Eloquent. Поскольку идёт save() на каждую модель, отрабатывают наблюдатели и события (creating, saved и т. д.). Это удобно, но на больших импортах учитывайте их стоимость.
  • Нет постановки в очередь «из коробки». Тяжёлый импорт пользовательского файла лучше выносить в очередь — это вы организуете штатными job'ами Laravel, читая внутри job'а нужную область листа.

Итог

  • Совпадают заголовки с полями модели — хватит withHeadings()->importModel(User::class).
  • Заголовки «не те» или их нет — передайте свои имена в порядке колонок: withHeadings(['name', 'birthday', 'email']).
  • Нужна перестановка, выбор колонок или нормализация — mapping() массивом или колбэком.
  • Сложная логика (несколько моделей из строки) — ручной цикл по nextRow(), тоже потоковый.
  • Файл пришёл загрузкой или лежит в Storage — openString() и openStream() вместо open(), остальной код не меняется.
  • Валидацию и транзакции добавляете сами — библиотека отвечает за быстрое чтение, а не за бизнес-правила импорта.
bash
composer require avadim/fast-excel-laravel

Импорт читает XLSX, устаревший XLS и CSV одинаково — формат определяется по содержимому файла, а не по расширению. Репозиторий — github.com/aVadim483/fast-excel-laravel.

Комментарии (0)