Importing Excel (XLSX) files in Laravel
Importing Excel (XLSX) files in Laravel
Most import tutorials treat CSV and XLSX as interchangeable. They are not. Drop a 50,000-row XLSX into an approach built for CSV and you will hit memory limits, mangled dates, and formula cells returning unexpected values. The fix is not a configuration tweak -- it requires understanding what XLSX actually is and choosing a tool designed for it.
This post covers every serious approach to Laravel XLSX import: PhpSpreadsheet directly, Laravel Excel, Spatie Simple Excel, Filament's built-in action, and Tapix. Each section has working code, real gotchas, and a clear picture of when it fits.
For broader context on the CSV side, see The complete guide to CSV imports in Laravel.
#CSV vs XLSX: what's actually different
Understanding the format difference matters before picking a tool.
CSV is a plain text file. Each line is a row, values are separated by a delimiter (usually a comma), and every value is a string. There is no concept of types, no multiple sheets, no formulas, no styles. A CSV parser only needs to split text -- it is fast, memory-efficient, and dead simple.
XLSX is a ZIP archive containing XML files. Unzip any .xlsx and you will find a xl/worksheets/sheet1.xml file alongside relationship definitions, shared string tables, and style definitions. The format supports:
- Typed cells: numbers, booleans, dates, and strings are distinct types in the underlying XML
- Multiple sheets: one file can contain many worksheets
- Formulas: cells can hold formulas (
=SUM(A1:A10)) alongside their last calculated value - Styles: fonts, colors, number formats stored in
xl/styles.xml
This structure has real consequences for imports:
- Memory: parsing XLSX means either loading the entire ZIP into memory (PhpSpreadsheet's default mode) or streaming the XML row by row (OpenSpout's approach). A 50K-row CSV might use 20MB; the same data in XLSX can easily hit 500MB with a non-streaming reader
- Date handling: Excel stores dates as floating-point serial numbers (days since January 1, 1900). A cell displaying "2024-06-15" is stored as
45458.0in the XML. Your reader must convert that number to a PHP date -- and most do this inconsistently - Formula cells: cells with formulas return the formula string, the last calculated value, or both, depending on which reader you use and how it is configured
- Sheet selection: you must explicitly tell the reader which sheet to use, or it defaults to the first one
None of these issues exist with CSV. They all exist with XLSX, and every library handles them differently.
#PhpSpreadsheet directly
PhpSpreadsheet is the underlying engine that most PHP XLSX libraries use. Using it directly gives maximum control at the cost of significant memory usage.
#When to use
- One-off scripts where simplicity beats efficiency
- Files where you need access to cell formatting, styles, or formulas
- Situations where the other options add too many abstractions
#Basic usage
use PhpOffice\PhpSpreadsheet\IOFactory;
$spreadsheet = IOFactory::load('/path/to/file.xlsx');
$sheet = $spreadsheet->getActiveSheet();
foreach ($sheet->getRowIterator(2) as $row) { // skip header row
$cells = $row->getCellIterator();
$cells->setIterateOnlyExistingCells(false);
$data = [];
foreach ($cells as $cell) {
$data[] = $cell->getValue();
}
Contact::create([
'first_name' => $data[0],
'email' => $data[1],
]);
}
#Selecting a specific sheet
use PhpOffice\PhpSpreadsheet\IOFactory;
$spreadsheet = IOFactory::load('/path/to/file.xlsx');
// By name
$sheet = $spreadsheet->getSheetByName('Contacts');
// By index (zero-based)
$sheet = $spreadsheet->getSheet(1);
#Date handling in PhpSpreadsheet
Date cells are the most common source of bugs. A cell formatted as a date is stored as a serial number in the XML. PhpSpreadsheet can convert it for you, but only if you ask:
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
foreach ($sheet->getRowIterator(2) as $row) {
$cells = $row->getCellIterator();
$cells->setIterateOnlyExistingCells(false);
foreach ($cells as $cell) {
$value = $cell->getValue();
// Check if the cell is formatted as a date
if (Date::isDateTime($cell)) {
$value = Date::excelToDateTimeObject($value);
// $value is now a \DateTime object
}
}
}
If you skip this check and store the serial number directly, dates will look like 45458 in your database.
#Formula cells
By default, getValue() returns the formula string (=SUM(A1:A10)). To get the last calculated result instead:
$cell->getCalculatedValue();
This runs PhpSpreadsheet's formula engine, which adds CPU overhead. For imports where you just want the displayed values, use getOldCalculatedValue() -- it returns the cached result without recalculating:
$cell->getOldCalculatedValue();
#Memory gotcha
IOFactory::load() loads the entire file into memory before you can iterate a single row. For a 10K-row XLSX with styles and formatting, this typically uses 150-300MB. For 100K rows, you will likely hit PHP's memory limit.
PhpSpreadsheet ships a ReadFilter to limit which rows and columns are loaded, but it requires knowing the range upfront:
use PhpOffice\PhpSpreadsheet\Reader\IReadFilter;
class ChunkReadFilter implements IReadFilter
{
public function __construct(
private int $startRow,
private int $endRow,
) {}
public function readCell(string $columnAddress, int $row, string $worksheetName = ''): bool
{
return $row >= $this->startRow && $row <= $this->endRow;
}
}
$reader = IOFactory::createReader('Xlsx');
$reader->setReadFilter(new ChunkReadFilter(1, 1000));
$spreadsheet = $reader->load('/path/to/file.xlsx');
This helps, but it means reading the file multiple times for chunked processing. For large XLSX files, Spatie Simple Excel (covered below) is a better fit.
#Laravel Excel (maatwebsite/excel)
Laravel Excel is the most widely used XLSX import package in the Laravel ecosystem, with 146M+ Packagist downloads. It uses PhpSpreadsheet under the hood but wraps it in a concern-based architecture that integrates tightly with Laravel's queue system, validation, and Eloquent.
#When to use
- Existing codebases already using Laravel Excel
- Projects that need both import and export from the same package
- When queue-backed chunk processing matters and you want Laravel's queue infrastructure
- Scheduled imports from known file formats (S3 syncs, vendor exports)
#Basic XLSX import
use App\Models\Contact;
use Maatwebsite\Excel\Concerns\ToModel;
use Maatwebsite\Excel\Concerns\WithHeadingRow;
use Maatwebsite\Excel\Concerns\WithChunkReading;
use Illuminate\Contracts\Queue\ShouldQueue;
class ContactsImport implements ToModel, WithHeadingRow, WithChunkReading, ShouldQueue
{
public function model(array $row): Contact
{
return new Contact([
'first_name' => $row['first_name'],
'last_name' => $row['last_name'],
'email' => $row['email'],
]);
}
public function chunkSize(): int
{
return 500;
}
}
Dispatch it in a controller:
use Maatwebsite\Excel\Facades\Excel;
Excel::import(new ContactsImport, $request->file('spreadsheet'));
The WithHeadingRow concern reads the first row as column headers and passes each subsequent row as an associative array. WithChunkReading breaks the file into chunks for queue processing.
#Selecting a sheet in Laravel Excel
Implement WithMultipleSheets to control which sheets are processed:
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
class ContactsImport implements WithMultipleSheets
{
public function sheets(): array
{
return [
0 => new ContactsSheetImport(), // first sheet by index
'Contacts' => new ContactsSheetImport(), // or by name
];
}
}
ContactsSheetImport is a separate class that implements ToModel and the other concerns for that sheet.
#Date handling in Laravel Excel
Laravel Excel inherits PhpSpreadsheet's date behavior. Cells formatted as dates arrive as \Carbon\Carbon objects when WithHeadingRow is used -- most of the time. The edge cases are when the cell type is not recognized correctly or when the XLSX was generated by a tool that stores dates as strings instead of serial numbers.
To handle both cases safely:
use Carbon\Carbon;
public function model(array $row): Contact
{
$birthDate = null;
if ($row['birth_date'] instanceof Carbon) {
$birthDate = $row['birth_date'];
} elseif (is_string($row['birth_date']) && $row['birth_date'] !== '') {
$birthDate = Carbon::parse($row['birth_date']);
} elseif (is_numeric($row['birth_date'])) {
$birthDate = Carbon::createFromTimestamp(
\PhpOffice\PhpSpreadsheet\Shared\Date::excelToTimestamp($row['birth_date'])
);
}
return new Contact([
'email' => $row['email'],
'birth_date' => $birthDate,
]);
}
#Limitations
- No user interface. Column mapping, error review, and progress tracking are your responsibility.
- Validation errors are reported after the fact. There is no review step where users can fix errors before data is persisted.
- Memory:
WithChunkReadinguses chunked queue jobs, but PhpSpreadsheet still processes each chunk by loading it fully into memory. Very large XLSX files can still cause problems. - Relationship resolution (matching a "Company" text value to an existing
companiesrecord) requires custom code insidemodel().
For a detailed comparison of Laravel Excel and Tapix, see Laravel Excel vs Tapix.
#Spatie Simple Excel
Spatie Simple Excel (spatie/simple-excel) is the lightweight streaming option. It wraps OpenSpout -- a library that reads XLSX, CSV, and ODS files row-by-row without loading the entire file into memory. Constant memory usage regardless of file size.
#When to use
- Large XLSX files where memory is a concern
- Pipeline-style processing (read, transform, write to another system)
- Scripts and commands where a minimal API is preferred
- When you do not need UI, validation UI, or export
#Basic usage
use Spatie\SimpleExcel\SimpleExcelReader;
SimpleExcelReader::create('/path/to/file.xlsx')
->getRows()
->each(function (array $row) {
Contact::create([
'first_name' => $row['first_name'],
'email' => $row['email'],
]);
});
Simple Excel reads the first row as headers automatically. The getRows() method returns a lazy collection, so rows are only loaded as you iterate them.
#Chunk processing for large files
use Spatie\SimpleExcel\SimpleExcelReader;
SimpleExcelReader::create('/path/to/file.xlsx')
->getRows()
->chunk(500)
->each(function (\Illuminate\Support\Collection $chunk) {
Contact::insert($chunk->toArray());
});
This keeps memory flat: only 500 rows live in memory at any time, regardless of whether the file has 5,000 or 500,000 rows.
#Sheet selection
Simple Excel does not support selecting sheets by name -- it always reads the first sheet. If your XLSX has multiple sheets and you need to pick one, Simple Excel is not the right tool. PhpSpreadsheet or Laravel Excel with WithMultipleSheets handles this.
#Date handling
OpenSpout returns date cells as formatted strings (e.g., "2024-06-15"), not as serial numbers or Carbon objects. This is actually more predictable than PhpSpreadsheet's behavior -- you get what the user sees in the spreadsheet. The tradeoff is that the format depends on how the XLSX was created, so Carbon::parse() might fail if the format is unusual.
#Limitations
- Read-only. Spatie Simple Excel has no write support for XLSX.
- First sheet only. No sheet selection.
- Minimal transformation API. For complex transformations, you write the logic yourself.
- No validation layer, no UI, no queue integration.
For a comparison of Simple Excel alongside other CSV packages, see 5 best CSV import packages for Laravel in 2026.
#Filament Import Action
Filament v3+ ships a built-in import action that adds a column-mapping UI to any Filament admin panel table. It works without additional packages and covers the common "admin uploads a file and maps columns" use case.
#When to use
- Filament admin panel (the action is panel-specific)
- Simple imports where an admin user uploads files, not end-users
- When the built-in column mapping UI is sufficient
#Setup
Define import columns on a model-specific importer:
use App\Models\Contact;
use Filament\Actions\Imports\ImportColumn;
use Filament\Actions\Imports\Importer;
use Filament\Actions\Imports\Models\Import;
class ContactImporter extends Importer
{
protected static ?string $model = Contact::class;
public static function getColumns(): array
{
return [
ImportColumn::make('first_name')
->requiredMapping()
->rules(['required', 'max:255']),
ImportColumn::make('email')
->requiredMapping()
->rules(['required', 'email']),
];
}
public function resolveRecord(): ?Contact
{
return Contact::firstOrNew([
'email' => $this->data['email'],
]);
}
public static function getCompletedNotificationBody(Import $import): string
{
return 'Imported ' . number_format($import->successful_rows) . ' contacts.';
}
}
Register the action on a list page:
use App\Filament\Imports\ContactImporter;
use Filament\Actions\ImportAction;
protected function getHeaderActions(): array
{
return [
ImportAction::make()
->importer(ContactImporter::class),
];
}
#XLSX support
ImportAction accepts both CSV and XLSX uploads. Internally, XLSX files are converted to a temporary CSV before processing -- which means all the XLSX-specific features (multiple sheets, typed cells, formula values) are lost. The action reads the first sheet and treats all values as strings.
For most admin import scenarios this is fine. If the XLSX has date cells that need type-preserving handling, or if users need to select a specific sheet, this approach will not work.
#Limitations
- No sheet selection. The first sheet is always used.
- Internal CSV conversion strips XLSX type information.
- Filament admin only. Not for user-facing import flows in your main application.
- Limited error handling UI -- failures are reported after the import runs, not in a review step.
#Tapix
Tapix is a Laravel import wizard that gives end-users a guided 4-step import flow: upload, column mapping, validation review, and execute. The same importer definition handles both CSV and XLSX files -- the file type distinction is handled transparently.
#When to use
- User-facing imports in SaaS products (not just admin panels)
- When users need to pick which sheet to import from
- When column mapping should be user-controlled, not developer-controlled
- When validation errors should be reviewable and fixable before data is persisted
- When you want the same import flow to work for both CSV and XLSX without separate code paths
#Defining fields
The importer API is identical for CSV and XLSX:
use Tapix\Core\Importing\BaseImporter;
use Tapix\Core\Fields\ImportField;
use Tapix\Core\Fields\ImportFieldCollection;
class ContactImporter extends BaseImporter
{
public static function fields(): ImportFieldCollection
{
return ImportFieldCollection::make([
ImportField::make('first_name')
->label('First Name')
->required(),
ImportField::make('last_name')
->label('Last Name'),
ImportField::make('email')
->label('Email')
->required()
->rules(['email']),
ImportField::make('joined_at')
->label('Join Date')
->type(\Tapix\Core\Fields\FieldType::Date),
]);
}
protected function model(): string
{
return \App\Models\Contact::class;
}
}
When a user uploads an XLSX file, Tapix detects the format, extracts headers from the selected sheet, and presents the column mapping step. The FieldType::Date field type handles the Excel serial number conversion automatically -- the same field definition works whether the upload was CSV or XLSX.
#XLSX-specific: sheet detection
When an XLSX file has multiple sheets, Tapix presents a sheet selection step before column mapping. Users see the sheet names and choose which one to import. No additional importer code is needed.
#Validation review
After mapping, Tapix validates every row and presents a review table. Users can correct invalid values inline, skip specific rows, or go back and re-map columns. Only clean rows proceed to the execute step. This is the key difference from Laravel Excel and Simple Excel -- errors are fixed before any data touches the database.
#Queue-powered execution
The execute step dispatches ExecuteImportJob per chunk. Progress is tracked in real time via Livewire. For large XLSX files, this is comparable to Laravel Excel's WithChunkReading + ShouldQueue combination, but with the review step already complete.
For a complete walkthrough of the Tapix import wizard, see The complete guide to CSV imports in Laravel. For a direct feature comparison with Laravel Excel, see Laravel Excel vs Tapix.
Ready to stop building import UI from scratch? See Tapix pricing.
#Decision matrix
| PhpSpreadsheet | Laravel Excel | Spatie Simple Excel | Filament Import | Tapix | |
|---|---|---|---|---|---|
| XLSX streaming | No (full load) | Partial (chunk jobs) | Yes (OpenSpout) | No | Yes |
| Sheet selection | Yes | Yes | No (first only) | No | Yes (UI) |
| Memory for 100K rows | High (500MB+) | High per chunk | Low (constant) | High | Low |
| Column mapping UI | None | None | None | Basic (admin) | Full wizard |
| Inline error review | None | None | None | None | Yes |
| CSV + XLSX same code | No | Yes (same import class) | Yes (same reader) | Yes | Yes |
| Price | Free | Free | Free | Free (Filament req.) | Paid |
Summary:
- Use PhpSpreadsheet when you need raw cell access (styles, formulas, multi-sheet data manipulation) and memory is not a concern.
- Use Laravel Excel when you are already in the Laravel Excel ecosystem, need export too, or want queue-backed chunked processing for developer-triggered imports.
- Use Spatie Simple Excel when memory matters and the first sheet is all you need. Best for pipeline-style scripts.
- Use Filament Import Action when you are building in a Filament admin panel and the basic mapping UI is sufficient.
- Use Tapix when you are building a user-facing import feature in a SaaS product and need sheet selection, column mapping, inline error review, and queue processing without building any of it yourself.
The right choice usually comes down to one question: who is triggering the import? If it is a developer running an artisan command or a cron job, Laravel Excel or Simple Excel will serve you well. If it is an end-user uploading a file through your application's UI, Tapix is the tool that handles the full experience.