How to fix slow CSV imports in Laravel | Tapix                  [ ![Tapix](/img/tapix-logo-light.svg) ![Tapix](/img/tapix-logo-dark.svg) ](https://tapix.dev) [Features](https://tapix.dev#features) [Pricing](https://tapix.dev#pricing) [Docs](https://docs.tapix.dev) [Blog](https://tapix.dev/blog)

   Try Demo  [ Get Tapix from $99](https://tapix.dev#pricing)

  [Features](https://tapix.dev#features) [Pricing](https://tapix.dev#pricing) [Docs](https://docs.tapix.dev) [Blog](https://tapix.dev/blog)   Try Demo  [ Get Tapix from $99](https://tapix.dev#pricing)

   ![Tapix](https://tapix.dev/img/tapix-logo-light.svg)

 TutorialsHow to fix slow CSV imports in Laravel
======================================

 tapix.dev/blog

  [    Back to blog ](https://tapix.dev/blog) [ Tutorials ](https://tapix.dev/blog/category/tutorials)

How to fix slow CSV imports in Laravel
======================================

 Manch Minasyan ·  July 21, 2026  · 12 min read

 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](/blog/queue-powered-imports-100k-rows).

[\#](#diagnosing-the-bottleneck "Permalink")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](https://www.php.net/manual/en/function.memory-get-peak-usage.php) 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 "Permalink")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 "Permalink")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 "Permalink")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 "Permalink")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 "Permalink")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-1 "Permalink")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 "Permalink")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 "Permalink")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-busbatch "Permalink")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](https://laravel.com/docs/queues#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 "Permalink")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-2 "Permalink")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 "Permalink")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 "Permalink")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 "Permalink")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 "Permalink")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 "Permalink")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 "Permalink")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 "Permalink")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 "Permalink")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-isnt-enough "Permalink")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](/blog/hidden-cost-building-csv-importer). For a walkthrough of the complete architecture these patterns converge on, see [The complete guide to CSV imports in Laravel](/blog/complete-guide-csv-imports-laravel). Tapix licenses start at $99/yr -- [pricing and plans at tapix.dev](/#pricing).

 ### Enjoyed this post?

Get notified when we publish new articles about Laravel imports and data handling.

  Email address   Subscribe

Almost there — confirm your subscription via email.

 Related posts
-------------

 [  Tutorials   Jul 3, 2026

 Multi-value fields in CSV imports: emails, phones, and tags
-------------------------------------------------------------

One cell, multiple values: 'a@x.com, b@x.com'. How to split, validate, and store multi-value email, phone, and tag fields during CSV import.

 ](https://tapix.dev/blog/email-phone-multi-value-csv) [  Tutorials   Jun 26, 2026

 Adding a CSV import wizard to your Filament panel
---------------------------------------------------

Three lines in your panel provider. That's all it takes to add a full CSV import wizard to any Filament panel with auto-discovery and tenant support.

 ](https://tapix.dev/blog/filament-csv-import-panel) [  Tutorials   Jun 19, 2026

 Handling boolean and choice fields in CSV imports
---------------------------------------------------

Yes, no, true, false, 1, 0, on, off -- and that's just booleans. Here's how to normalize boolean, single-choice, and multi-choice CSV values.

 ](https://tapix.dev/blog/boolean-choice-fields-csv)

   [ ![Tapix](/img/tapix-logo-light.svg) ![Tapix](/img/tapix-logo-dark.svg) ](https://tapix.dev)CSV and Excel import wizard for Laravel.

  Product [Pricing](https://tapix.dev#pricing) [Docs](https://docs.tapix.dev) [Blog](https://tapix.dev/blog) [Contact](mailto:hello@tapix.dev)

 Compare [vs Laravel Excel](https://tapix.dev/vs/laravel-excel) [vs Filament Import](https://tapix.dev/vs/filament-import)

 Legal [Privacy](https://tapix.dev/privacy-policy) [Terms](https://tapix.dev/terms-of-service)

© 2026 Tapix. All rights reserved.
