Importing Excel (XLSX) files in Laravel | Tapix                  [ ![Tapix](/img/tapix-logo-light.svg) ![Tapix](/img/tapix-logo-dark.svg) ](https://tapix.dev) [Features](https://tapix.dev#features) [Pricing](https://tapix.dev#pricing) [Docs](https://docs.tapix.dev) [Blog](https://tapix.dev/blog)

   Try Demo  [ Get Tapix from $99](https://tapix.dev#pricing)

  [Features](https://tapix.dev#features) [Pricing](https://tapix.dev#pricing) [Docs](https://docs.tapix.dev) [Blog](https://tapix.dev/blog)   Try Demo  [ Get Tapix from $99](https://tapix.dev#pricing)

   ![Tapix](https://tapix.dev/img/tapix-logo-light.svg)

 TutorialsImporting Excel (XLSX) files in Laravel
=======================================

 tapix.dev/blog

  [    Back to blog ](https://tapix.dev/blog) [ Tutorials ](https://tapix.dev/blog/category/tutorials)

Importing Excel (XLSX) files in Laravel
=======================================

 Manch Minasyan ·  July 24, 2026  · 14 min read

 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](/blog/complete-guide-csv-imports-laravel).

[\#](#csv-vs-xlsx-whats-actually-different "Permalink")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.0` in 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 "Permalink")PhpSpreadsheet directly
-----------------------------------------------------------------

[PhpSpreadsheet](https://github.com/PHPOffice/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 "Permalink")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 "Permalink")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 "Permalink")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 "Permalink")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 "Permalink")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 "Permalink")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 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-maatwebsiteexcel "Permalink")Laravel Excel (maatwebsite/excel)
----------------------------------------------------------------------------------

[Laravel Excel](https://laravel-excel.com/) 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-1 "Permalink")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 "Permalink")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 "Permalink")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 "Permalink")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 "Permalink")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: `WithChunkReading` uses 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 `companies` record) requires custom code inside `model()`.

For a detailed comparison of Laravel Excel and Tapix, see [Laravel Excel vs Tapix](/blog/laravel-excel-vs-tapix).

[\#](#spatie-simple-excel "Permalink")Spatie Simple Excel
---------------------------------------------------------

[Spatie Simple Excel](https://github.com/spatie/simple-excel) (`spatie/simple-excel`) is the lightweight streaming option. It wraps [OpenSpout](https://github.com/openspout/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-2 "Permalink")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-1 "Permalink")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 "Permalink")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 "Permalink")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 "Permalink")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-1 "Permalink")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](/blog/best-csv-import-packages-laravel).

[\#](#filament-import-action "Permalink")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-3 "Permalink")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 "Permalink")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 "Permalink")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-2 "Permalink")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 "Permalink")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-4 "Permalink")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 "Permalink")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 "Permalink")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 "Permalink")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 "Permalink")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](/blog/complete-guide-csv-imports-laravel). For a direct feature comparison with Laravel Excel, see [Laravel Excel vs Tapix](/blog/laravel-excel-vs-tapix).

Ready to stop building import UI from scratch? [See Tapix pricing](/#pricing).

[\#](#decision-matrix "Permalink")Decision matrix
-------------------------------------------------

PhpSpreadsheetLaravel ExcelSpatie Simple ExcelFilament ImportTapix**XLSX streaming**No (full load)Partial (chunk jobs)Yes (OpenSpout)NoYes**Sheet selection**YesYesNo (first only)NoYes (UI)**Memory for 100K rows**High (500MB+)High per chunkLow (constant)HighLow**Column mapping UI**NoneNoneNoneBasic (admin)Full wizard**Inline error review**NoneNoneNoneNoneYes**CSV + XLSX same code**NoYes (same import class)Yes (same reader)YesYes**Price**FreeFreeFreeFree (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.

 ### Enjoyed this post?

Get notified when we publish new articles about Laravel imports and data handling.

  Email address   Subscribe

Almost there — confirm your subscription via email.

 Related posts
-------------

 [  Tutorials   Jul 21, 2026

 How to fix slow CSV imports in Laravel
----------------------------------------

A diagnostic guide to the four most common Laravel CSV import performance problems -- memory exhaustion, timeout errors, N+1 queries, and file size limits -- with copy-pasteable fixes for each.

 ](https://tapix.dev/blog/fix-slow-csv-imports-laravel) [  Tutorials   Jul 3, 2026

 Multi-value fields in CSV imports: emails, phones, and tags
-------------------------------------------------------------

One cell, multiple values: 'a@x.com, b@x.com'. How to split, validate, and store multi-value email, phone, and tag fields during CSV import.

 ](https://tapix.dev/blog/email-phone-multi-value-csv) [  Tutorials   Jun 26, 2026

 Adding a CSV import wizard to your Filament panel
---------------------------------------------------

Three lines in your panel provider. That's all it takes to add a full CSV import wizard to any Filament panel with auto-discovery and tenant support.

 ](https://tapix.dev/blog/filament-csv-import-panel)

   [ ![Tapix](/img/tapix-logo-light.svg) ![Tapix](/img/tapix-logo-dark.svg) ](https://tapix.dev)CSV and Excel import wizard for Laravel.

  Product [Pricing](https://tapix.dev#pricing) [Docs](https://docs.tapix.dev) [Blog](https://tapix.dev/blog) [Contact](mailto:hello@tapix.dev)

 Compare [vs Laravel Excel](https://tapix.dev/vs/laravel-excel) [vs Filament Import](https://tapix.dev/vs/filament-import)

 Legal [Privacy](https://tapix.dev/privacy-policy) [Terms](https://tapix.dev/terms-of-service)

© 2026 Tapix. All rights reserved.
