bernskiold / laravel-import-engine
Import CSV and Excel files into Laravel. Map the columns, preview the result, run it on the queue and keep a report of each row.
Package info
github.com/bernskiold/laravel-import-engine
pkg:composer/bernskiold/laravel-import-engine
Requires
- php: ^8.3
- illuminate/contracts: ^11.0 || ^12.0 || ^13.0
- illuminate/database: ^11.0 || ^12.0 || ^13.0
- illuminate/filesystem: ^11.0 || ^12.0 || ^13.0
- illuminate/http: ^11.0 || ^12.0 || ^13.0
- illuminate/queue: ^11.0 || ^12.0 || ^13.0
- illuminate/support: ^11.0 || ^12.0 || ^13.0
- illuminate/validation: ^11.0 || ^12.0 || ^13.0
- openspout/openspout: ^4.30 || ^5.0
- spatie/simple-excel: ^3.11
Requires (Dev)
- laravel/pint: ^1.0
- nunomaduro/collision: ^8.0 || ^9.0
- orchestra/testbench: ^9.0 || ^10.0 || ^11.0
- pestphp/pest: ^3.0 || ^4.0 || ^5.0
- pestphp/pest-plugin-laravel: ^3.0 || ^4.0 || ^5.0
- phpstan/phpstan: ^2.1
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
This package imports CSV and Excel files into a Laravel application. You write one class for each type of import. The class tells which columns it reads and what to do with one row. The engine does the other work:
- It reads the file with tolerance. It finds the headings under a title row, it reads semicolon files, it finds the text encoding, and it keeps the line numbers of the file.
- It guesses the column map from the headings, and it accepts a map from a person.
- It makes a preview: it runs the import in a transaction, and then it rolls the transaction back.
- It runs the import on the queue, as the person who started it.
- It keeps a report with one entry for each row: created, updated, unchanged, conflicted, skipped or failed.
use Bernskiold\LaravelImportEngine\Actions\StartImport; $import = app(StartImport::class)->execute(ImportType::Contacts, $request->file('file')); // Later, on the queue: $import->refresh()->importReport()->countOf(ImportOutcome::Created); // 412
Contents
- Installation
- The import types
- Write an importer
- Write a simple importer
- Validate the rows
- Read the values of a row
- Record what occurred
- Run an import
- Make a preview
- Build a mapping screen
- Give a template to download
- Events
- Extend the import model
- What your application must do
- Prune old imports
- Configuration
- Test your importers
- Testing
Installation
Install the package with Composer:
composer require bernskiold/laravel-import-engine
Publish the migration and the configuration file. Then run the migration:
php artisan vendor:publish --tag=import-engine-migrations php artisan vendor:publish --tag=import-engine-config php artisan migrate
The migration makes the imports table. Each row of the table is one run of one importer on one file.
The package requires PHP 8.3 or higher, Laravel 11, 12 or 13, and spatie/simple-excel. It supports openspout 4 and 5.
The import types
Make a string-backed enum that lists the imports of your application. Put the ImportType contract on the enum. Then set the enum in config/import-engine.php.
use Bernskiold\LaravelImportEngine\Contracts\ImportType as ImportTypeContract; use Bernskiold\LaravelImportEngine\Importer; enum ImportType: string implements ImportTypeContract { case Contacts = 'contacts'; case Sectors = 'sectors'; public function label(): string { return match ($this) { self::Contacts => __('Contacts'), self::Sectors => __('Sectors'), }; } public function importer(): Importer { return app(match ($this) { self::Contacts => ContactImporter::class, self::Sectors => SectorImporter::class, }); } }
// config/import-engine.php 'types' => App\Domain\Imports\Enums\ImportType::class,
The engine stores the value of the case in the type column. Do not change the values after you use them.
Resolve each importer from the container. Then the importer can get its dependencies in its constructor.
Write an importer
Extend Importer. Give the columns in columns(), and write importRow().
use Bernskiold\LaravelImportEngine\Importer; use Bernskiold\LaravelImportEngine\Support\Column; use Bernskiold\LaravelImportEngine\Support\ImportReport; use Bernskiold\LaravelImportEngine\Support\ImportRow; class ContactImporter extends Importer { public function columns(): array { return [ Column::make('client', __('Client'))->required()->aliases('client name', 'account'), Column::make('email', __('Email'))->required()->aliases('email address', 'e-mail'), Column::make('name', __('Name')), Column::make('title', __('Job title'))->aliases('position', 'role') ->description(__('The title of the person at the client.')), ]; } protected function importRow(ImportRow $row, ImportReport $report): void { $client = Client::query()->firstWhere('name', $row->get('client')); if ($client === null) { $report->conflicted($row, $row->get('email'), [__('No client has this name.')]); return; } $contact = $client->contacts()->firstOrNew(['email' => $row->get('email')]); $contact->fill(array_filter(['name' => $row->get('name'), 'title' => $row->get('title')])); $created = ! $contact->exists; $changed = $contact->isDirty(); $contact->save(); match (true) { $created => $report->created($row, $contact->email), $changed => $report->updated($row, $contact->email), default => $report->unchanged($row, $contact->email), }; } }
Obey these rules:
- Record one entry for each row. Use
unchanged()when the record already had the values of the row. Then a second run of the same file shows that nothing changed. - If
importRow()throws an exception, the engine records the row as failed and reads the next row. See Messages for the person for the note that the row gets. - Each row is in its own database transaction. If a row fails, the engine rolls back the writes of that row only. To stop this, return
falsefromtransactionPerRow(). - If the importer writes to a database connection that is not the default, return the connections from
connections(). The transactions and the preview then use these connections.
A column
Column::make($key, $label) makes a column. The key is the name of the field in each row. The label is the name that a person sees.
| Method | Use |
|---|---|
required() |
The run stops when the file, or the map, does not supply the column. |
aliases(...) |
Other headings that mean this field. The engine compares headings without case, spaces and punctuation, so give only the headings that use different words. |
description($text) |
A description for a screen that tells the person what the file must contain. |
heading($text) |
The heading in a blank template. The default is the label in snake case. |
An import that does not work row by row
Override handle() when the import must read all the rows first, or write in batches. Use rows() to read the mapped rows. An override must catch its own failures. It does not get a transaction for each row.
protected function handle(ImportReport $report): void { $rows = collect($this->rows()); // Validate all the rows, then write them in one transaction. }
Messages for the person
The notes and the reason for a failure are shown to people. An exception message can contain SQL, host names or paths, so the engine shows only the messages that are written for a person:
| Exception | The person sees | The exception handler gets it |
|---|---|---|
RowException, thrown in importRow() |
The message | No |
ValidationException, thrown in importRow() |
The validation messages | No |
ImportFileException, for example MissingColumnsException |
The message, as the reason for the failure | No |
| Another exception | A fixed sentence | Yes |
use Bernskiold\LaravelImportEngine\Exceptions\RowException; if ($row->date('sent')?->isFuture()) { throw new RowException(__('The date is in the future.')); }
Set show_exception_messages to true to show every message, for example in an internal tool.
Options
An import can need values in addition to the file, for example the ID of a survey. Give them as options when you start the import. The importer reads them with option():
$surveyId = $this->option('survey_id');
Write a simple importer
For a file with fixed headings, extend SimpleImporter. List the headings in two properties. You do not need a column map, but the importer accepts one.
use Bernskiold\LaravelImportEngine\SimpleImporter; class SectorImporter extends SimpleImporter { protected array $requiredHeadings = ['name']; protected array $optionalHeadings = ['is_active']; protected function importRow(ImportRow $row, ImportReport $report): void { $sector = Sector::query()->firstOrNew(['name' => $row->get('name')]); $sector->is_active = $row->boolean('is_active', default: true); $created = ! $sector->exists; $sector->save(); $created ? $report->created($row, $sector->name) : $report->updated($row, $sector->name); } }
Run it directly from a console command or a test:
$report = app(SectorImporter::class)->execute($path);
A simple importer is an importer. It can go in the import types enum, and it can have a preview and a queued run. To add labels or descriptions, override columns().
Validate the rows
Give Laravel validation rules in rules(). The engine validates each row before it gives the row to importRow(). A row that is not valid is a failed entry, with the messages of the validator as its notes.
public function rules(): array { return [ 'email' => ['required', 'email'], 'rank' => ['nullable', 'integer', 'min:1'], 'title' => ['nullable', Rule::in($this->option('titles', []))], ]; }
- The values are the strings of
ImportRow::toArray(). An empty cell or a placeholder isnull. - The messages use the labels of the columns. To change them, override
messages()orvalidationAttributes(). - The engine reads the rules one time for each run, so the rules can use
option(). - An override of
handle()can callvalidationErrors($row).
Read the values of a row
ImportRow gives tolerant access to the values. Each value is a trimmed string.
| Method | Result |
|---|---|
get('field') |
The value, or null. A placeholder such as -, n/a, TBC or #N/A is null. |
has('field') |
true when get() is not null. |
values('field') |
A list. Commas, semicolons, pipes, slashes and line breaks separate the items. |
lines('field') |
A list with one item on each line. Use it for names that contain commas. |
integer('field') |
A whole number, or null. #4, 4. and 4.0 are 4. 4.5 and top 10 are null. |
number('field') |
A number, or null. |
boolean('field', default: null) |
yes, y, true, 1 and x are true. no, n, false and 0 are false. Other values give the default. |
date('field', format: null, timezone: null) |
A CarbonImmutable, or null. See below. |
all() |
All the mapped values, without the placeholders. |
toArray() |
Each mapped field, with null for an empty cell or a placeholder. |
original() |
The row as it is in the file, keyed by the headings of the file. |
line |
The line number in the file. The headings are on line one, or lower when the file has a title row. |
Dates
Without a format, date() reads these values:
2026-10-25,2026-10-25 14:30and2026-10-25T14:30:00.25/10/2026,25.10.2026and25-10-2026, with the day first. Setdate_ordertomdyto read the month first.- A value with the name of the month, for example
25 Oct 2026orOctober 25, 2026.
A value that is not a valid date, for example 31/02/2026, is null. With a format, the value must have that format: $row->date('sent', 'Ymd').
An Excel date cell is read as 2026-10-25, or as 2026-10-25 14:30:00 when its time is not midnight.
Text encoding
A CSV file is read as UTF-8 when it is valid UTF-8 or has a UTF-8 byte order mark. A file with a UTF-16 byte order mark, as the "Unicode text" export of Excel makes, is read as UTF-16. Another file is read with the csv_fallback_encoding config, which is Windows-1252 by default. That is the encoding that Excel on Windows uses for CSV files. Spreadsheet::encoding() tells which encoding the engine used.
Record what occurred
Each entry has a line, an outcome, a subject, notes and warnings:
- The line is the line number in the file. Then a person can find the row in the spreadsheet.
- The subject is the name of the row, for example a client name or an email address.
- The notes tell what the importer did.
- The warnings tell about problems that did not stop the row.
$report->created($row, $subject, notes: [...], warnings: [...]); $report->updated($row, $subject); $report->unchanged($row, $subject); $report->conflicted($row, $subject, [__('The code belongs to a different client.')]); $report->skipped($row, $subject, [__('There is no email in this row.')]); $report->failed($row, $subject, [__('The date is not valid.')]);
Each outcome has a severity:
| Outcome | Severity |
|---|---|
| Created, Updated, Unchanged | Success |
| Conflicted, Skipped | Warning |
| Failed | Error |
Conflicted is not a failure. It means that the file does not agree with the data that you have, and the importer did not change the data.
Use tallies for counts that are not rows. Give their labels in tallies() on the importer:
$report->tally('links_made');
Read the report:
$report->rows(); // The number of entries. $report->countOf(ImportOutcome::Created); $report->countOfSeverity(ImportSeverity::Warning); $report->needingAttention(); // The entries that are not a success. $report->countWithWarnings(); $report->changedNothing(); // True when all entries are unchanged. $report->entries(); // A collection of ImportEntry. $report->tallies();
Run an import
StartImport keeps the file on a disk, makes the record and sends the run to the queue:
use Bernskiold\LaravelImportEngine\Actions\StartImport; $import = app(StartImport::class)->execute( type: ImportType::Contacts, file: $request->file('file'), // An upload, or the path of a local file. map: ['Client Name' => 'client', 'E-mail' => 'email', 'Notes' => null], options: ['survey_id' => $survey->id], user: $request->user(), // The default is the authenticated user. );
An empty map tells the engine to guess the map from the headings.
The record is saved before the job is sent. The job runs after the database transaction commits. The job:
- Sets the status to processing.
- Signs in the person who started the import, so that activity logs and "created by" columns get the correct user. The previous user comes back after the run.
- Reads the file and writes the report to the record.
- Sets the status to completed, or to failed with a reason.
During the run, the record keeps the progress: total_rows is the number of rows in the file, and processed_rows is the number of rows that the run read. The engine writes the progress after each progress_interval rows, and at the end. These writes do not cause model events. A screen can poll the record:
$import->progress(); // 0.42, or null when the total is not known yet.
An override of handle() calls advance() for each row that it reads.
The job does not try again. If the file is incorrect, for example if a required column is missing, the job fails but it does not send the exception to the exception handler.
Each record runs one time. RunImport claims the record with one conditional update, and only a pending record can be claimed. If the queue delivers the job two times, the second job does nothing. If you call RunImport on a record that already started, it throws ImportAlreadyStartedException. To import the file again, start a new import.
To run an import without a queue, use RunImport:
app(RunImport::class)->execute($import);
Read a record:
$import->status; // ImportStatus::Pending, Processing, Completed or Failed. $import->isSettled(); // True when completed or failed. Stop to poll when this is true. $import->importReport(); // An ImportReport. It is empty when there is no report. $import->countOf(ImportOutcome::Created); $import->countOf('needing_attention'); $import->failure_reason; $import->user;
A list of imports
A report can contain thousands of entries. The record also keeps a summary of the counts in a column of its own. For a list of imports, use the withoutReport() scope and read the counts with countOf():
$imports = Import::query()->withoutReport()->latestFirst()->paginate(); $import->countOf(ImportOutcome::Failed);
countOf() accepts an outcome, or one of the keys rows, needing_attention and with_warnings.
Other scopes: ofType($type), startedBy($user) and latestFirst().
Make a preview
PreviewImport runs the import in a transaction, and then rolls the transaction back. Thus the preview shows what the run will do, not a prediction.
use Bernskiold\LaravelImportEngine\Actions\PreviewImport; $report = app(PreviewImport::class)->execute(ImportType::Contacts, $path, $map, limit: 500);
The rollback does not undo side effects outside the database, for example an email or a queued job. In the importer, call previewing() to stop them:
if (! $this->previewing()) { Mail::to($contact)->send(new Welcome($contact)); }
Build a mapping screen
The importer gives the data that a mapping screen needs:
$importer = ImportType::Contacts->importer(); $spreadsheet = Spreadsheet::open($path); $spreadsheet->headers(); // ['Client Name', 'E-mail', 'Notes'] $spreadsheet->preview(5); // The first five rows. $spreadsheet->count(); // The number of rows. $importer->columns(); // The columns, with labels and descriptions. $importer->guessMap($spreadsheet->headers()); // ['Client Name' => 'client', 'E-mail' => 'email', 'Notes' => null] $importer->missingRequiredFields($map); // ['Email'] when no column gives the email.
The engine reads .csv, .txt (as CSV) and .xlsx files. It does not read .ods files, because it can not keep the line numbers of an ODS file. Validate uploads with mimes:csv,txt,xlsx.
Give a template to download
Workbook::download() makes an Excel file. Use it for a blank template, or for an export that a person completes and imports again:
use Bernskiold\LaravelImportEngine\Support\Workbook; return Workbook::download('contacts-template.xlsx', $importer->template()); return Workbook::download('contacts.xlsx', $importer->template(), $rows);
The headings come from columns(), so a completed template maps itself. The workbook writes text as text. It does not write a value that starts with = as a formula.
Events
| Event | When |
|---|---|
ImportStarted |
The queue started to read the file. |
ImportCompleted |
The run read the file to the end. The report is on the record. |
ImportFailed |
The run stopped. The reason is in failure_reason. |
Each event has the record in $event->import. Rows that fail do not cause ImportFailed. They are entries in the report of a completed run.
Event::listen(ImportCompleted::class, function (ImportCompleted $event) { $event->import->user?->notify(new ImportFinished($event->import)); });
Extend the import model
Extend the model to add columns, relations or scopes. Then set your class in the configuration:
class Import extends \Bernskiold\LaravelImportEngine\Models\Import { public function url(): string { return route('imports.show', $this); } }
// config/import-engine.php 'model' => App\Models\Import::class,
To set your own columns when an import starts, give them as attributes:
app(StartImport::class)->execute(ImportType::Contacts, $file, attributes: ['team_id' => $team->id]);
The model deletes the file when you delete the record. The route key is the uuid column.
Prune old imports
Set prune_after_days to delete the completed and failed imports that are older than that number of days. The command deletes each record as a model, so its file goes too. Imports that are pending or processing stay.
Laravel looks for prunable models in app/Models only. Schedule the command with the import model:
Schedule::command('model:prune', ['--model' => [Import::class]])->daily();
What your application must do
The package reads, runs and records. These decisions stay with your application:
- Validate the upload. Accept only the formats that the engine reads, with
mimes:csv,txt,xlsx, and set a maximum size. - Authorize. The package has no policies. Check that the person can start the import type, and can see the record, before you show its report.
- Keep personal data for a limited time. A report keeps the subject of each row, for example an email address. Set
prune_after_days, and setdelete_files_after_runtotruewhen you do not need the files after the run. - Use a queue with a long enough retry time. The
retry_afterof the queue connection must be longer thanqueue.timeout. Otherwise a second worker gets the job while the first worker still runs it. - Set the user key type before you migrate. If your users have UUID or ULID keys, set
user_key_typebefore you publish and run the migration.
Configuration
| Key | Default | Use |
|---|---|---|
types |
null |
The enum of the import types. |
model |
Import::class |
The import model. |
table |
imports |
The table of the import model. |
user_model |
null |
The user model. The default is the model of the default auth provider. |
user_key_type |
id |
The key type of the user model: id, uuid or ulid. The migration uses it. |
disk |
null |
The disk for uploaded files. The default is the default filesystem disk. |
directory |
imports |
The directory on the disk. |
delete_files_after_run |
false |
Delete the file when the run stops. Set this to true for files with personal data. |
csv_fallback_encoding |
Windows-1252 |
The encoding of a CSV file that is not UTF-8 and has no byte order mark. |
date_order |
dmy |
The order of a date such as 03/04/2026: dmy or mdy. |
progress_interval |
50 |
Write the progress to the record after this number of rows. |
prune_after_days |
null |
Delete the settled imports after this number of days. null keeps them. |
show_exception_messages |
false |
Show the message of every exception to the person. |
report_row_failures |
true |
Send the exception of a failed row to the exception handler. A RowException or a ValidationException is never sent. |
queue.connection |
null |
The queue connection of the job. |
queue.queue |
null |
The queue of the job. |
queue.timeout |
900 |
The maximum time of one run, in seconds. |
Test your importers
Spreadsheets makes files for tests. Give the contents as text, or as rows with the headings first:
use Bernskiold\LaravelImportEngine\Testing\Spreadsheets; $path = Spreadsheets::csv("Name,Email\nAda,ada@example.com\n"); $path = Spreadsheets::xlsx([['Name', 'Email'], ['Ada', 'ada@example.com']]); $upload = Spreadsheets::upload([['Name', 'Email']], 'contacts.xlsx'); $report = app(ContactImporter::class)->execute($path);
StartImport::fake() replaces the action. The fake keeps the file and makes the record, but it does not send the job:
$imports = StartImport::fake(); Livewire::test(ImportContacts::class)->set('file', $upload)->call('start'); $imports->assertStarted(ImportType::Contacts, fn (Import $import) => $import->options['list'] === 4); $imports->assertStartedTimes(ImportType::Contacts, 1); $imports->assertNotStarted(ImportType::Sectors); $imports->assertNothingStarted();
Testing
The tests use SQLite. To run them on MySQL or Postgres, set DB_CONNECTION to mysql or pgsql, and set DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME and DB_PASSWORD.
composer test
composer phpstan
composer format
Changelog
Refer to CHANGELOG for the changes.
Contributing
Refer to CONTRIBUTING for the procedure.
Security
Refer to our security policy for the procedure to report a vulnerability.
Credits
License
The MIT License (MIT). Refer to the License File for more information.