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)
composer create-project laravel/laravel csv-import-demo
cd csv-import-demo
Configure your .env database credentials:
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:
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:
php artisan make:migration create_csv_users_table
Edit the migration:
<?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:
php artisan migrate
Step 4: Create the Eloquent Model
php artisan make:model CsvUser
Edit app/Models/CsvUser.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
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
php artisan make:controller CsvImportController
Edit app/Http/Controllers/CsvImportController.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:
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:
<!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:
php artisan serve
Visit http://localhost:8000/csv-import.
Create a test CSV file (test.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:
Excel::queueImport($import, $request->file('csv_file'));
Make sure your queue worker is running:
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:
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 theWithBatchInsertsinterface 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.):
- Edit the migration — change column names and types
- Edit the model — update
$fillablearray - Edit the import class — update
model()method andrules()to match your CSV columns - 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:
- Always use
WithChunkReadingandWithBatchInserts— even for “small” files, because you never know when a user will upload a huge one - 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.
Be the first to comment