How to fix slow CSV imports in Laravel
How to fix slow CSV imports in Laravel
Your CSV import works fine in development on a 200-row test file. Then a user uploads 15,000 rows from their CRM export and you get one of these:
PHP Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 262144 bytes)
Fatal error: Maximum execution time of 30 seconds exceeded
502 Bad Gateway
These are not random failures. Each one points to a specific architectural problem with a known fix. This guide walks through each failure mode, how to confirm which one you are hitting, and how to fix it -- from quick bandaids to proper solutions.
For the larger picture of queue-based import architecture, see Queue-powered imports: processing 100K rows in Laravel.
#Diagnosing the bottleneck
Before reaching for a fix, confirm what you are actually hitting. The three main bottlenecks in slow CSV imports are memory, execution time, and database queries -- and they behave differently under load.
Laravel Telescope gives you the most complete picture. Under the "Queries" tab, you can see every SQL statement triggered by a request, including duplicates. If you see the same SELECT repeated 15,000 times in the query log, that is your N+1 problem. The "Requests" tab shows request duration and peak memory consumption for recent requests.
DB::listen() is the lightweight alternative if you are not running Telescope:
DB::listen(function ($query) {
logger()->info('Query', [
'sql' => $query->sql,
'time_ms' => $query->time,
'bindings' => $query->bindings,
]);
});
Add this to a service provider or directly in your import controller during debugging, then watch storage/logs/laravel.log while running a test import.
memory_get_peak_usage() tells you the actual memory high-water mark for the current request. The PHP documentation covers the $real_usage flag -- pass true to get the actual system allocation rather than the PHP-tracked value.
The following diagnostic closure wraps a chunk-based import and logs time and memory per chunk so you can spot exactly where the process degrades:
use Illuminate\Support\Facades\Log;
$chunkSize = 500;
$chunkNumber = 0;
$startTime = microtime(true);
collect($rows)->chunk($chunkSize)->each(function ($chunk) use (&$chunkNumber, $startTime) {
$chunkNumber++;
$chunkStart = microtime(true);
// Your import logic here
foreach ($chunk as $row) {
Contact::create($row);
}
Log::info('Import chunk complete', [
'chunk' => $chunkNumber,
'chunk_time_s' => round(microtime(true) - $chunkStart, 2),
'elapsed_s' => round(microtime(true) - $startTime, 2),
'memory_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 1),
]);
});
If memory climbs consistently chunk over chunk, you have an identity map problem. If chunk time grows even as memory stays flat, you likely have a query count problem. If the first chunk succeeds and the rest do not, timeout is the culprit.
#Allowed memory size exhausted
This is the most common CSV import error message. The full error looks like:
PHP Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 262144 bytes) in /var/www/html/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Model.php
The bytes vary (128MB is 134217728, 256MB is 268435456), but the pattern is the same.
#Why it happens
Eloquent holds every model instance in an identity map -- a registry that ensures you get the same PHP object back when you fetch the same database row twice. When you loop through 15,000 CSV rows and call Contact::create() for each one, Eloquent accumulates 15,000 model instances in memory for the duration of the request. Each instance carries its attributes, relationships, casts, and the identity map reference. The memory adds up.
#Fix 1: flush the Eloquent identity map between chunks
Eloquent's identity map accumulates instances across the entire request. You can flush it manually:
use Illuminate\Database\Eloquent\Model;
$chunkSize = 500;
foreach (array_chunk($rows, $chunkSize) as $chunk) {
foreach ($chunk as $row) {
Contact::create($row);
}
// Flush Eloquent's identity map
Model::clearBootedModels();
// Force PHP to garbage collect
gc_collect_cycles();
}
clearBootedModels() clears the booted model registry. Combined with gc_collect_cycles(), this forces the garbage collector to reclaim memory between chunks.
#Fix 2: toBase() for insert-only operations
If you only need to insert records and do not need Eloquent events, observers, or casts, bypass the ORM entirely with toBase():
// Before: creates 15,000 Eloquent model instances
foreach ($rows as $row) {
Contact::create([
'first_name' => $row['first_name'],
'email' => $row['email'],
]);
}
// After: raw query builder, no model hydration
$chunks = array_chunk($rows, 500);
foreach ($chunks as $chunk) {
Contact::toBase()->insert(
collect($chunk)->map(fn ($row) => [
'first_name' => $row['first_name'],
'email' => $row['email'],
'created_at' => now(),
'updated_at' => now(),
])->all()
);
}
The toBase() approach uses the query builder directly against the model's table without hydrating model instances. Memory usage drops from linear (one instance per row) to constant (just the current chunk). The tradeoff: no model events (creating, created), no observers, no automatic timestamp handling unless you set created_at and updated_at manually.
#Maximum execution time exceeded
The second most common import failure:
Fatal error: Maximum execution time of 30 seconds exceeded in /var/www/html/app/Http/Controllers/ImportController.php
Or its nginx equivalent:
504 Gateway Time-out
#Why it happens
PHP-FPM defaults to a 30-second execution time limit. Nginx and Apache add their own upstream timeouts. A 10,000-row import with any meaningful per-row logic (validation, relationship resolution, database writes) will exceed these limits. Running it inside an HTTP request is the root cause.
#Fix 1: move to queue jobs
The correct fix is to move the import off the HTTP request entirely. The controller stores the uploaded file and dispatches a job; the user gets an immediate response:
// ImportController.php
public function store(Request $request): RedirectResponse
{
$path = $request->file('csv')->store('imports');
$import = Import::create([
'user_id' => auth()->id(),
'file_path' => $path,
'status' => 'pending',
'total_rows' => 0,
]);
ProcessImportJob::dispatch($import->id);
return redirect()
->route('imports.show', $import)
->with('status', 'Import queued. We will notify you when it completes.');
}
The job itself handles all the processing without any web server timeout constraint:
// ProcessImportJob.php
class ProcessImportJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 1800; // 30 minutes max
public int $tries = 3;
public function __construct(public readonly int $importId) {}
public function handle(): void
{
$import = Import::findOrFail($this->importId);
$import->update(['status' => 'processing']);
$rows = SimpleExcelReader::create(storage_path("app/{$import->file_path}"))
->getRows()
->chunk(500);
foreach ($rows as $chunk) {
foreach ($chunk as $row) {
Contact::toBase()->insert([...$row, 'created_at' => now(), 'updated_at' => now()]);
$import->increment('processed_rows');
}
Model::clearBootedModels();
}
$import->update(['status' => 'complete']);
}
}
#Fix 2: increase timeout (bandaid)
You can raise the PHP execution time limit in your controller:
set_time_limit(300); // 5 minutes
This is a bandaid, not a fix. It does not address the connection drop problem (browser holds the connection open for 5 minutes), the memory accumulation problem, or the retry problem when the process fails halfway. Use it only as a temporary measure while you implement proper queue processing.
#Fix 3: chunked batch jobs with Bus::batch()
For imports that benefit from parallelism and progress tracking, Laravel's job batching dispatches multiple jobs that can run concurrently and report aggregate progress. The Laravel documentation on job batching covers the full API:
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;
public function handle(): void
{
$import = Import::findOrFail($this->importId);
$filePath = storage_path("app/{$import->file_path}");
$jobs = collect(
SimpleExcelReader::create($filePath)->getRows()->chunk(500)
)->map(fn ($chunk, $index) => new ProcessChunkJob($import->id, $chunk->all(), $index));
Bus::batch($jobs)
->name("import-{$import->id}")
->allowFailures()
->then(function (Batch $batch) use ($import) {
$import->update(['status' => 'complete']);
// Notify user
})
->catch(function (Batch $batch, \Throwable $e) use ($import) {
$import->update(['status' => 'failed', 'error' => $e->getMessage()]);
})
->dispatch();
}
Batches run chunks in parallel (up to worker concurrency), report progress through $batch->processedJobs() / $batch->totalJobs, and continue processing remaining chunks even if some fail (because of allowFailures()).
#Too many SQL queries
This failure does not always produce an error message -- it just makes your import slow. An import that should take 10 seconds takes 4 minutes. The DB::listen() output shows the same pattern repeating thousands of times.
#Why it happens
The N+1 problem in imports is usually one of two things: unique checks per row, or relationship resolution per row.
// Unique check per row: 1 SELECT per row
foreach ($rows as $row) {
if (! User::where('email', $row['email'])->exists()) {
User::create($row);
}
}
// Relationship per row: 1 SELECT per row
foreach ($rows as $row) {
$company = Company::where('name', $row['company_name'])->first();
Contact::create([...$row, 'company_id' => $company?->id]);
}
With 10,000 rows, the first example runs 10,000 SELECT EXISTS queries. The second runs 10,000 SELECT * FROM companies WHERE name = ? queries. The database overhead compounds fast.
#Fix 1: preload lookup tables before the import loop
For relationship lookups where the related table is small enough to fit in memory, load the entire table once and use it as a lookup map:
// Load all companies into memory once, keyed by name
$companyMap = Company::all()->keyBy(fn ($c) => strtolower($c->name));
foreach ($rows as $row) {
$company = $companyMap->get(strtolower($row['company_name']));
Contact::create([
'first_name' => $row['first_name'],
'email' => $row['email'],
'company_id' => $company?->id,
]);
}
One query upfront instead of one per row. For a 10,000-row import with 500 companies, this reduces the query count from 10,000 to 1.
This works well when the lookup table has a bounded size. For tables that could be arbitrarily large (e.g., looking up existing contacts to deduplicate against 1M records), preloading everything is not feasible -- use the batch upsert approach instead.
#Fix 2: batch upserts with upsert()
Instead of updateOrCreate() per row (which runs a SELECT then either an INSERT or UPDATE for each row), collect a chunk and use upsert():
// Before: 2 queries per row (SELECT + INSERT/UPDATE)
foreach ($rows as $row) {
Contact::updateOrCreate(
['email' => $row['email']],
['first_name' => $row['first_name'], 'last_name' => $row['last_name']]
);
}
// After: 1 query per 500 rows
$chunks = array_chunk($rows, 500);
foreach ($chunks as $chunk) {
Contact::upsert(
collect($chunk)->map(fn ($row) => [
'email' => $row['email'],
'first_name' => $row['first_name'],
'last_name' => $row['last_name'],
'updated_at' => now(),
'created_at' => now(),
])->all(),
uniqueBy: ['email'],
update: ['first_name', 'last_name', 'updated_at']
);
}
upsert() compiles a single INSERT ... ON DUPLICATE KEY UPDATE (MySQL) or INSERT ... ON CONFLICT DO UPDATE (PostgreSQL) statement per chunk. For a 10,000-row import in 500-row chunks, that is 20 queries instead of 20,000.
#Fix 3: in-memory cache for relationship resolution
When relationship data is too large to preload but has high repetition within a single import file (same company name appears on 500 rows), an in-memory cache prevents redundant lookups:
// Before: 1 query per row, even for repeated values
$companyCache = [];
foreach ($rows as $row) {
$name = strtolower($row['company_name']);
if (! isset($companyCache[$name])) {
$companyCache[$name] = Company::firstOrCreate(
['name' => $row['company_name']],
['created_by' => auth()->id()]
)->id;
}
Contact::create([
'first_name' => $row['first_name'],
'email' => $row['email'],
'company_id' => $companyCache[$name],
]);
}
The cache is a plain PHP array. The first occurrence of "Acme Corp" runs a firstOrCreate(). Every subsequent row with "Acme Corp" hits the array. For imports where 10,000 contacts map to 200 companies, this reduces company lookups from 10,000 to 200.
Clear the cache between import jobs if you are reusing the same process for multiple imports.
#The file is too large to upload
Before any PHP code runs, the file hits PHP and web server upload limits. The error varies by server:
The file you are trying to upload is too large.
413 Request Entity Too Large
HTTP 413 - Payload Too Large
#PHP configuration
PHP enforces two limits for file uploads. Both must be large enough to accept the file:
; php.ini
upload_max_filesize = 50M ; Maximum size of a single uploaded file
post_max_size = 55M ; Maximum size of all POST data (must be >= upload_max_filesize)
max_input_time = 300 ; Seconds PHP will wait for the upload to complete
post_max_size must be larger than upload_max_filesize because the POST body includes multipart boundaries and other form fields in addition to the file.
Apply these to your Laravel Herd or server environment. In Herd, create or edit the PHP ini file for your PHP version. You can also set these per-request with ini_set(), but that does not help for the upload phase (ini directives are parsed before your code runs):
// This does NOT affect upload_max_filesize -- too late in the request cycle
ini_set('memory_limit', '512M'); // This one does work at runtime
#Nginx configuration
Nginx rejects large uploads before PHP ever sees them:
server {
# ...
client_max_body_size 55M;
}
Set this to match or exceed your post_max_size value. Without this change, Nginx returns 413 Request Entity Too Large and PHP never runs.
#Apache configuration
# .htaccess or httpd.conf
LimitRequestBody 57671680
php_value upload_max_filesize 50M
php_value post_max_size 55M
LimitRequestBody is in bytes. 55 * 1024 * 1024 = 57671680.
#Validating in Laravel
After configuring the server limits, add validation in your form request so users get a clear error message before the upload fails at the server layer:
public function rules(): array
{
return [
'csv' => ['required', 'file', 'mimes:csv,txt', 'max:51200'], // 50MB in kilobytes
];
}
max in Laravel file validation takes kilobytes. 50MB = 51,200KB.
#When optimization isn't enough
The fixes above -- chunked inserts, batch upserts, preloaded lookup maps, queue jobs -- represent a pattern. Each one addresses a specific failure mode, but together they describe the same architecture: a queued, chunked import pipeline with in-memory caching for relationship resolution and batch SQL for writes. That is the architecture Tapix implements internally, which is why it handles 100,000-row imports without memory errors or timeout failures.
If you are spending engineering time rebuilding these patterns for a user-facing import feature, it is worth comparing that against a packaged solution. For the scope of work involved, see The hidden cost of building your own CSV importer. For a walkthrough of the complete architecture these patterns converge on, see The complete guide to CSV imports in Laravel. Tapix licenses start at $99/yr -- pricing and plans at tapix.dev.