Getting CSV right from the start
You've got 100,000 product records sitting in a CSV file that need to land in your database by end of day. Your first instinct might be to loop through every row and insert it one by one—until your server runs out of memory at row 50,000. Importing and exporting CSV in Laravel, explained simply, means understanding how to handle bulk data without memory issues, and that requires the right tools and patterns. Whether you're syncing inventory from a supplier, generating reports for clients, or letting users upload their data, Laravel gives us several solid approaches.
The challenge isn't just reading and writing CSV files; it's doing so at scale without crashing. We'll walk through Laravel Excel for both sides of the equation, chunking large files to keep memory under control, validating each row before it touches the database, and queuing imports so your web requests don't timeout.
Laravel Excel for imports and exports
Laravel Excel (via the Maatwebsite package) is the community standard for handling spreadsheets. It abstracts away PHPOffice\PhpSpreadsheet complexity and gives you a clean API for mapping CSV rows to models and vice versa.
Start by installing it:
composer require maatwebsite/excelFor exports, you define an Export class that implements the FromQuery interface:
namespace App\Exports;
use App\Models\Product;
use Maatwebsite\Excel\Concerns\FromQuery;
use Maatwebsite\Excel\Concerns\WithHeadings;
class ProductsExport implements FromQuery, WithHeadings
{
public function query()
{
return Product::query();
}
public function headings(): array
{
return ['ID', 'Name', 'SKU', 'Price', 'Stock'];
}
}Then trigger it from a controller:
use App\Exports\ProductsExport;
use Maatwebsite\Excel\Facades\Excel;
Route::get('/products/export', function () {
return Excel::download(new ProductsExport(), 'products.csv');
});For imports, you create an Import class implementing ToModel and WithHeadingRow:
namespace App\Imports;
use App\Models\Product;
use Maatwebsite\Excel\Concerns\ToModel;
use Maatwebsite\Excel\Concerns\WithHeadingRow;
class ProductsImport implements ToModel, WithHeadingRow
{
public function model(array $row)
{
return new Product([
'name' => $row['name'],
'sku' => $row['sku'],
'price' => $row['price'],
'stock' => $row['stock'],
]);
}
}And use it in a controller:
use App\Imports\ProductsImport;
Route::post('/products/import', function (Request $request) {
Excel::import(new ProductsImport(), $request->file('csv'));
return redirect('/products')->with('success', 'Products imported');
});This works well for small to medium files. But once you hit 50,000+ rows, you'll see memory spike and requests timeout. That's where chunking comes in.
Chunk reads for large files
Chunking means reading the CSV in batches instead of all at once. Laravel Excel supports this with the WithChunkReading concern. You specify a chunk size—typically 1,000 rows—and the importer processes that batch, then releases memory before moving to the next.
Here's a chunked import class for a million-row product sync scenario:
namespace App\Imports;
use App\Models\Product;
use Maatwebsite\Excel\Concerns\ToModel;
use Maatwebsite\Excel\Concerns\WithHeadingRow;
use Maatwebsite\Excel\Concerns\WithChunkReading;
class LargeProductsImport implements ToModel, WithHeadingRow, WithChunkReading
{
public function model(array $row)
{
return new Product([
'name' => $row['name'],
'sku' => $row['sku'],
'price' => (float) $row['price'],
'stock' => (int) $row['stock'],
]);
}
public function chunkSize(): int
{
return 1000;
}
}The difference is subtle but powerful. With chunking enabled, Laravel Excel processes 1,000 rows, inserts them via Eloquent mass assignment, garbage-collects the batch, then loads the next 1,000. Memory usage stays flat.
For exports on large tables, use FromQuery with chunking instead of FromCollection:
namespace App\Exports;
use App\Models\Product;
use Maatwebsite\Excel\Concerns\FromQuery;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithChunkReading;
class LargeProductsExport implements FromQuery, WithHeadings, WithChunkReading
{
public function query()
{
return Product::query();
}
public function headings(): array
{
return ['ID', 'Name', 'SKU', 'Price', 'Stock'];
}
public function chunkSize(): int
{
return 2000;
}
}Chunking also pairs well with database transactions. Wrap each chunk in a transaction so if one batch fails, you can retry without duplicating earlier batches.
Validate each row
You can't trust CSV input. A supplier might send NULL prices, duplicate SKUs, or missing required fields. Validating before insert saves debugging headaches and keeps your data clean.
Use Laravel's validator with custom rules in your import class:
namespace App\Imports;
use App\Models\Product;
use Illuminate\Support\Facades\Validator;
use Maatwebsite\Excel\Concerns\ToModel;
use Maatwebsite\Excel\Concerns\WithHeadingRow;
use Maatwebsite\Excel\Concerns\WithChunkReading;
use Maatwebsite\Excel\Validators\Failure;
class ValidatedProductsImport implements ToModel, WithHeadingRow, WithChunkReading
{
private $failures = [];
public function model(array $row)
{
$validator = Validator::make($row, [
'name' => 'required|string|max:255',
'sku' => 'required|unique:products,sku|regex:/^[A-Z0-9]{4,8}$/',
'price' => 'required|numeric|min:0.01',
'stock' => 'required|integer|min:0',
]);
if ($validator->fails()) {
foreach ($validator->errors()->all() as $error) {
$this->failures[] = new Failure(
$row['sku'] ?? 'unknown',
'validation',
[$error]
);
}
return null;
}
return new Product([
'name' => $row['name'],
'sku' => $row['sku'],
'price' => (float) $row['price'],
'stock' => (int) $row['stock'],
]);
}
public function chunkSize(): int
{
return 1000;
}
public function getFailures()
{
return $this->failures;
}
}Then in your controller, capture and report failures:
Route::post('/products/import', function (Request $request) {
$import = new ValidatedProductsImport();
Excel::import($import, $request->file('csv'));
if (! empty($import->getFailures())) {
return redirect('/products')
->withErrors('Some rows failed validation')
->with('failures', $import->getFailures());
}
return redirect('/products')->with('success', 'All products imported');
});This approach stops bad data before it enters the database. If you need stricter control, store failed rows in a import_failures table for manual review later.
Queue large imports
Importing a million-row CSV synchronously will timeout. Queue the job so the user gets an instant response, and the import runs in the background.
Create a queueable job:
namespace App\Jobs;
use App\Imports\ValidatedProductsImport;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Maatwebsite\Excel\Facades\Excel;
class ImportProductsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public string $filePath,
public ?int $userId = null
) {}
public function handle()
{
$import = new ValidatedProductsImport();
Excel::import($import, $this->filePath);
if ($this->userId) {
// Notify user via email or notification that import completed
\App\Models\User::find($this->userId)
?->notify(new \App\Notifications\ImportCompleted());
}
}
}Dispatch it from your controller:
Route::post('/products/import', function (Request $request) {
$path = $request->file('csv')->store('imports');
ImportProductsJob::dispatch($path, auth()->id())
->onQueue('imports')
->delay(now()->addSecond());
return redirect('/products')
->with('success', 'Import queued. You\'ll be notified when done.');
});Using a separate queue (like imports) lets you scale workers independently for batch jobs. Set up your queue runner—whether Redis, database, or Amazon SQS—and let it churn through imports without blocking user-facing code.
Common mistakes
Not using chunk reading on large files. Without chunking, Laravel loads the entire file into memory. For a million-row CSV, that's multiple gigabytes. Always use WithChunkReading and set an appropriate chunk size (usually 1,000–2,000 rows).
Skipping validation. CSV data is messy. SKUs might already exist, prices could be malformed, stock might be negative. Validate every row and collect failures so you can report what went wrong, not just "import failed."
Running imports synchronously on the web request. Even with chunking, a million-row import takes minutes. Queue it and let the user know work is in progress. They'll see a success message and get notified when it's done.
Using insert() without transactions on chunked imports. If the server crashes mid-import, you end up with duplicate or partial data. Wrap each chunk in a transaction so you can cleanly retry.
Not handling encoding. CSV files from different systems (Windows, Mac, legacy databases) often have different character encodings. Specify UTF-8 explicitly in your import or add encoding detection logic.
Key takeaways
- Use Laravel Excel (Maatwebsite) for both imports and exports—it's the de facto standard and handles most complexity for you.
- Enable
WithChunkReadingon large files to keep memory flat; 1,000–2,000 rows per chunk is a good starting point. - Validate every row before insert using Laravel's validator. Collect failures and report them so users know what went wrong.
- Queue imports via jobs for files over 10,000 rows. Sync processing will timeout; async lets users get instant feedback.
- For exports, use
FromQueryinstead ofFromCollectionto avoid loading entire tables into memory. - Wrap chunked imports in database transactions so you can safely retry failed batches without duplicating data.






Comments
Loading comments…