Back to blog

Importing Excel (XLSX) files in Laravel

Manch Minasyan · · 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.

#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:

This structure has real consequences for imports:

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

#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

#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

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

#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

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

#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

#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

#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:

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.

Almost there — confirm your subscription via email.

Related posts