php 126 lines · 3 tabs

Chunked CSV Product Import with Laravel Queued Jobs and LazyCollection

Shared by codesnips Aug 2026
3 tabs
<?php

namespace App\Http\Controllers;

use App\Jobs\ImportProductChunk;
use Illuminate\Http\Request;
use Illuminate\Support\LazyCollection;
use Illuminate\Support\Facades\Storage;

class ProductImportController extends Controller
{
    public function store(Request $request)
    {
        $validated = $request->validate([
            'file' => ['required', 'file', 'mimes:csv,txt', 'max:51200'],
        ]);

        $path = $validated['file']->store('imports', 'local');
        $absolute = Storage::disk('local')->path($path);

        $header = null;

        LazyCollection::make(function () use ($absolute) {
            $file = new \SplFileObject($absolute, 'r');
            $file->setFlags(\SplFileObject::READ_CSV | \SplFileObject::SKIP_EMPTY);

            foreach ($file as $row) {
                if ($row === [null] || $row === false) {
                    continue;
                }
                yield $row;
            }
        })->each(function ($row) use (&$header) {
            $header = $header ?? array_map('trim', $row);
        })->skip(1)->chunk(500)->each(function ($chunk) use (&$header) {
            $rows = $chunk->map(fn ($row) => array_combine($header, $row))->all();
            ImportProductChunk::dispatch($rows);
        });

        return response()->json(['status' => 'queued'], 202);
    }
}
3 files · php Explain with highlit

Importing a large CSV of products in a single web request is a reliable way to blow past memory limits and PHP execution timeouts. This snippet shows the idiomatic Laravel approach: accept the upload synchronously, stream the file lazily, and hand chunks off to queued jobs that do the actual database work.

In ProductImportController, the uploaded file is stored to the local disk rather than being parsed in-request. The controller then opens the stored file with SplFileObject and wraps it in a LazyCollection via LazyCollection::make. The generator yields one line at a time, so the entire CSV is never held in memory — only the current row. The header row is captured on the first iteration, then skip(1)->chunk(500) groups the remaining rows into batches of 500. Each chunk is mapped into associative arrays keyed by the header and dispatched as an ImportProductChunk job. Because dispatching happens inside the lazy pipeline, the process stays flat in memory regardless of whether the file has a thousand rows or a million.

The ImportProductChunk job carries just the rows for its batch. It implements ShouldQueue so it runs on a worker, and defines $tries and backoff() so transient database hiccups get retried with spacing instead of failing immediately. The heavy lifting is a single Product::upsert call, which issues one INSERT ... ON DUPLICATE KEY UPDATE (or the Postgres equivalent) for the whole chunk. The sku column is the unique key used to decide insert-versus-update, and only name, price, and stock are refreshed on conflict. This makes the import idempotent: re-running the same file updates existing products rather than creating duplicates.

normalizeRow guards against malformed data by casting price to a float and stock to an int, and uniqueBy on the chunk prevents a single batch from containing two rows with the same SKU, which would otherwise trigger a database error during upsert.

The products migration defines the schema that makes this work — most importantly the unique index on sku, without which upsert has no conflict target. This division of labor keeps the request fast, the memory footprint constant, and the actual writes retryable and idempotent, which is exactly what a robust bulk-import pipeline needs.


Related snips

Share this code

Here's the card — post it anywhere.

Chunked CSV Product Import with Laravel Queued Jobs and LazyCollection — share card
Link copied