Luke Anderson Laravel

CSV Import Laravel tutorial

Importing CSV files is one of the most common features in Laravel projects — bulk-uploading users, products, contacts, or migrating data from spreadsheets to your database. Done wrong, it crashes on large files, silently skips bad rows, or imports duplicates. Done right, it handles a million rows without breaking a sweat.

This tutorial walks you through building a production-ready CSV import feature in Laravel from scratch — with validation, chunked processing, batch inserts, and proper error handling. Every step has the complete code, ready to copy into your own project.

What You’ll Build

A clean CSV import page that:

  • Uploads a CSV file via a styled form
  • Validates every row before saving
  • Imports thousands of rows quickly using batch inserts
  • Handles huge files (millions of rows) without running out of memory
  • Skips bad rows and reports errors instead of crashing
  • Shows imported records in a paginated table

Prerequisites

  • PHP 8.2+ and Composer installed
  • Laravel 11 or 12 project (or willing to create one)
  • A database (MySQL, PostgreSQL, or SQLite all work)

Step 1: Create a Fresh Laravel Project (Skip if You Have One)

bash
composer create-project laravel/laravel csv-import-demo
cd csv-import-demo

Configure your .env database credentials:

env
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=csv_demo
DB_USERNAME=root
DB_PASSWORD=

Step 2: Install the Maatwebsite Laravel-Excel Package

We’ll use Maatwebsite/Laravel-Excel, the de facto standard for CSV and Excel handling in Laravel:

bash
composer require maatwebsite/excel

This package handles CSV, XLSX, XLS, and ODS — but we’ll focus on CSV here. It’s built on top of PhpSpreadsheet but adds Laravel-specific features like chunked imports, queued jobs, batch inserts, and per-row validation.

Why not just use native PHP fgetcsv()? For simple, small files it works fine, but you’d have to manually build validation, batching, queuing, and error handling. Maatwebsite gives you all of that out of the box.

Step 3: Create the Database Table

Create a migration for the table that will store imported data:

bash
php artisan make:migration create_csv_users_table

Edit the migration:

php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('csv_users', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('email')->unique();
            $table->string('phone')->nullable();
            $table->string('city')->nullable();
            $table->string('country')->nullable();
            $table->date('joined_date')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('csv_users');
    }
};

Run it:

bash
php artisan migrate

Step 4: Create the Eloquent Model

bash
php artisan make:model CsvUser

Edit app/Models/CsvUser.php:

php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class CsvUser extends Model
{
    use HasFactory;

    protected $fillable = [
        'name', 'email', 'phone', 'city', 'country', 'joined_date',
    ];

    protected $casts = [
        'joined_date' => 'date',
    ];
}

The $fillable array allows mass assignment for these fields — required when using Model::create() or new Model($data).

Step 5: Create the Import Class (The Heart of the Feature)

This is where the magic happens. Create the folder app/Imports/ (it doesn’t exist by default), then create CsvUsersImport.php:

php
<?php

namespace App\Imports;

use App\Models\CsvUser;
use Maatwebsite\Excel\Concerns\ToModel;
use Maatwebsite\Excel\Concerns\WithHeadingRow;
use Maatwebsite\Excel\Concerns\WithValidation;
use Maatwebsite\Excel\Concerns\WithChunkReading;
use Maatwebsite\Excel\Concerns\WithBatchInserts;
use Maatwebsite\Excel\Concerns\SkipsOnError;
use Maatwebsite\Excel\Concerns\SkipsErrors;
use Carbon\Carbon;

class CsvUsersImport implements
    ToModel,
    WithHeadingRow,
    WithValidation,
    WithChunkReading,
    WithBatchInserts,
    SkipsOnError
{
    use SkipsErrors;

    public function model(array $row)
    {
        return new CsvUser([
            'name'        => $row['name'],
            'email'       => $row['email'],
            'phone'       => $row['phone'] ?? null,
            'city'        => $row['city'] ?? null,
            'country'     => $row['country'] ?? null,
            'joined_date' => $this->parseDate($row['joined_date'] ?? null),
        ]);
    }

    public function rules(): array
    {
        return [
            'name'  => 'required|string|max:255',
            'email' => 'required|email|max:255',
            'phone' => 'nullable|string|max:30',
            'city'  => 'nullable|string|max:100',
        ];
    }

    public function chunkSize(): int
    {
        return 500;
    }

    public function batchSize(): int
    {
        return 500;
    }

    private function parseDate($value): ?string
    {
        if (empty($value)) return null;
        try {
            return Carbon::parse($value)->toDateString();
        } catch (\Exception $e) {
            return null;
        }
    }
}

What every concern (interface) does:

Interface Purpose
ToModel Maps each row to an Eloquent model
WithHeadingRow First row is treated as column headers (so you reference $row['name'] not $row[0])
WithValidation Validates each row before saving
WithChunkReading Reads CSV in chunks of N rows instead of loading the whole file
WithBatchInserts Inserts N rows per database query instead of one at a time (massive performance boost)
SkipsOnError + SkipsErrors trait If a row fails, skip it and continue instead of crashing the entire import

Why chunkSize and batchSize matter

Without chunking, a 100,000-row CSV would load all 100,000 rows into PHP memory at once → memory exhaustion crash. With chunkSize(500), only 500 rows are in memory at any time.

Without batch inserts, importing 100,000 rows = 100,000 individual INSERT queries → could take minutes. With batchSize(500), it’s 200 queries → seconds.

Step 6: Create the Controller

bash
php artisan make:controller CsvImportController

Edit app/Http/Controllers/CsvImportController.php:

php
<?php

namespace App\Http\Controllers;

use App\Imports\CsvUsersImport;
use App\Models\CsvUser;
use Illuminate\Http\Request;
use Maatwebsite\Excel\Facades\Excel;
use Maatwebsite\Excel\Validators\ValidationException;

class CsvImportController extends Controller
{
    public function index()
    {
        $users = CsvUser::latest()->paginate(20);
        return view('csv-import.index', compact('users'));
    }

    public function import(Request $request)
    {
        $request->validate([
            'csv_file' => 'required|file|mimes:csv,txt|max:10240', // 10MB max
        ]);

        $import = new CsvUsersImport();

        try {
            Excel::import($import, $request->file('csv_file'));
        } catch (ValidationException $e) {
            $failures = collect($e->failures())->map(fn($f) =>
                "Row {$f->row()}: " . implode(', ', $f->errors())
            )->take(20)->implode("\n");

            return back()->with('error', "Validation errors:\n{$failures}");
        }

        return back()->with('success',
            'Import complete! Total users: ' . CsvUser::count() .
            '. Skipped: ' . count($import->errors())
        );
    }
}

The two-layer validation here is important: the controller validates the file (must exist, be a CSV, under 10MB), and the import class validates each row’s data (valid email, required name, etc.).

Step 7: Add the Routes

In routes/web.php:

php
use App\Http\Controllers\CsvImportController;

Route::prefix('csv-import')->group(function () {
    Route::get('/',        [CsvImportController::class, 'index'])->name('csv-import.index');
    Route::post('/import', [CsvImportController::class, 'import'])->name('csv-import.import');
});

Step 8: Create the View

Create resources/views/csv-import/index.blade.php:

blade
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>CSV Import — Laravel</title>
    <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-50 min-h-screen">

<div class="max-w-5xl mx-auto py-10 px-4">

    <h1 class="text-3xl font-bold mb-6">CSV Import Demo</h1>

    @if(session('success'))
        <div class="bg-green-100 border border-green-400 text-green-800 px-4 py-3 rounded mb-6">
            {{ session('success') }}
        </div>
    @endif
    @if(session('error'))
        <div class="bg-red-100 border border-red-400 text-red-800 px-4 py-3 rounded mb-6 whitespace-pre-line">
            {{ session('error') }}
        </div>
    @endif

    <form action="{{ route('csv-import.import') }}" method="POST"
          enctype="multipart/form-data"
          class="bg-white shadow rounded-lg p-6 mb-8">
        @csrf
        <input type="file" name="csv_file" accept=".csv" required class="mb-4">
        <button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white py-2 px-6 rounded">
            Import CSV
        </button>
    </form>

    <div class="bg-white shadow rounded-lg overflow-hidden">
        <h2 class="px-6 py-4 border-b font-semibold">
            Imported Users ({{ $users->total() }})
        </h2>
        <table class="min-w-full divide-y divide-gray-200">
            <thead class="bg-gray-50">
                <tr>
                    <th class="px-6 py-3 text-left text-xs font-medium uppercase">Name</th>
                    <th class="px-6 py-3 text-left text-xs font-medium uppercase">Email</th>
                    <th class="px-6 py-3 text-left text-xs font-medium uppercase">City</th>
                    <th class="px-6 py-3 text-left text-xs font-medium uppercase">Joined</th>
                </tr>
            </thead>
            <tbody class="divide-y divide-gray-200">
                @foreach($users as $user)
                    <tr>
                        <td class="px-6 py-3">{{ $user->name }}</td>
                        <td class="px-6 py-3">{{ $user->email }}</td>
                        <td class="px-6 py-3">{{ $user->city ?? '—' }}</td>
                        <td class="px-6 py-3">{{ $user->joined_date?->format('Y-m-d') }}</td>
                    </tr>
                @endforeach
            </tbody>
        </table>
        <div class="px-6 py-3 border-t">{{ $users->links() }}</div>
    </div>

</div>

</body>
</html>

Step 9: Test It

Start the dev server:

bash
php artisan serve

Visit http://localhost:8000/csv-import.

Create a test CSV file (test.csv):

csv
name,email,phone,city,country,joined_date
Jane Doe,[email protected],+1-555-0101,New York,USA,2024-03-15
John Smith,[email protected],+1-555-0102,Los Angeles,USA,2024-04-22
Maria Garcia,[email protected],+34-600-123456,Madrid,Spain,2024-05-10

Upload it. You should see “Import complete! Total users: 3” and a table with all three users.

Handling Huge Files: Queue the Import

For files with millions of rows, the import shouldn’t block the HTTP request. Run it in the background with Laravel’s queue system. Change one line in the controller:

php
Excel::queueImport($import, $request->file('csv_file'));

Make sure your queue worker is running:

bash
php artisan queue:work

The user gets an instant response while the import runs in the background. Combine with a progress bar (using broadcast events) for the full enterprise experience.

Handling Updates: Upsert Instead of Insert

If your CSV contains existing records and you want to update them instead of creating duplicates, change the model() method in the import class:

php
public function model(array $row)
{
    return CsvUser::updateOrCreate(
        ['email' => $row['email']], // Match on this field
        [
            'name'    => $row['name'],
            'phone'   => $row['phone'] ?? null,
            'city'    => $row['city'] ?? null,
            'country' => $row['country'] ?? null,
        ]
    );
}

Now uploading the same CSV twice won’t create duplicates — it’ll update the existing records based on email.

Note: When using updateOrCreate, you should remove the WithBatchInserts interface from your import class, since batch inserts don’t support upserts.

Common Errors and Fixes

Error Fix
Class 'Maatwebsite\Excel\Facades\Excel' not found Run composer require maatwebsite/excel
Class 'App\Imports\CsvUsersImport' not found Create the app/Imports/ folder; ensure the file is there
View [csv-import.index] not found View path must be resources/views/csv-import/index.blade.php
SQLSTATE[23000]: Integrity constraint violation: Duplicate entry Add email to the unique check, or use updateOrCreate (see above)
Allowed memory size exhausted Lower chunkSize() or increase memory_limit in php.ini
Maximum execution time exceeded Queue the import (see above), or add set_time_limit(0)
Undefined array key "email" Your CSV header doesn’t match — check spelling and case (use lowercase)

Why Validation Per Row Matters

Without WithValidation, the import will happily save garbage data — empty emails, malformed dates, names that are 5000 characters long. With it, every row is checked against your rules and bad rows are either rejected (default) or skipped (with SkipsOnError).

For user-facing imports where partial success is acceptable, always use SkipsOnError. Otherwise, one bad row in row 50,000 rolls back the entire import.

Customizing for Your Own Data

To import a different data type (products, orders, contacts, etc.):

  1. Edit the migration — change column names and types
  2. Edit the model — update $fillable array
  3. Edit the import class — update model() method and rules() to match your CSV columns
  4. Edit the view — update the table columns to display

That’s it. The Maatwebsite package handles the rest.

Quick Reference: Import Class Concerns

Concern Interface When to Use
ToModel Save each row as a model (most common)
ToCollection Process all rows at once as a Collection
ToArray Get rows as plain arrays for custom logic
WithHeadingRow CSV has a header row (always use this)
WithValidation Validate each row’s data
WithChunkReading Read in chunks (always use for large files)
WithBatchInserts Insert in batches (faster, but no upserts)
WithUpserts Update existing records based on unique key
SkipsOnError Continue import when rows fail validation
SkipsEmptyRows Ignore completely empty rows
ShouldQueue Run import in background queue

Wrapping Up

The Maatwebsite Laravel-Excel package combined with a well-designed import class is the standard solution for CSV imports in Laravel — used by thousands of production apps. The pattern shown here (chunked reading + batch inserts + per-row validation + skip-on-error) handles everything from a 10-row contact list to a 10-million-row data migration without breaking.

The two biggest things to remember:

  1. Always use WithChunkReading and WithBatchInserts — even for “small” files, because you never know when a user will upload a huge one
  2. Always validate per row and skip errors — partial success is almost always better than total failure

If you’re building an import for non-technical users, also save the error log somewhere visible (a database table, a downloadable error CSV, an admin dashboard) so they can fix their data and re-upload.

Discussion

Be the first to comment

Leave a comment

Get a quote