<?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);
}
}
<?php
namespace App\Jobs;
use App\Models\Product;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
class ImportProductChunk implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public function __construct(public array $rows)
{
}
public function backoff(): array
{
return [10, 30, 90];
}
public function handle(): void
{
$products = (new Collection($this->rows))
->map(fn (array $row) => $this->normalizeRow($row))
->filter(fn (array $row) => $row['sku'] !== '')
->uniqueBy('sku')
->values()
->all();
if (empty($products)) {
return;
}
Product::upsert(
$products,
['sku'],
['name', 'price', 'stock']
);
}
private function normalizeRow(array $row): array
{
return [
'sku' => trim((string) ($row['sku'] ?? '')),
'name' => trim((string) ($row['name'] ?? '')),
'price' => (float) ($row['price'] ?? 0),
'stock' => (int) ($row['stock'] ?? 0),
];
}
}
<?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('products', function (Blueprint $table) {
$table->id();
$table->string('sku');
$table->string('name');
$table->decimal('price', 10, 2)->default(0);
$table->unsignedInteger('stock')->default(0);
$table->timestamps();
$table->unique('sku');
});
}
public function down(): void
{
Schema::dropIfExists('products');
}
};
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
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
<?php
namespace App\Providers;
use App\Contracts\PaymentGateway;
use App\Services\StripePaymentGateway;
Laravel service container and dependency injection
{
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build"
},
Laravel mix/Vite for asset compilation
package pools
import (
"bytes"
"sync"
)
sync.Pool for bytes.Buffer to reduce allocations in hot paths
class CreateTopSellersMv < ActiveRecord::Migration[7.0]
def up
execute <<~SQL
CREATE MATERIALIZED VIEW top_sellers AS
SELECT p.id AS product_id,
p.name AS product_name,
Cache-Friendly “Top N” with Materialized View Refresh
class CreateDeadJobs < ActiveRecord::Migration[7.1]
def change
create_table :dead_jobs do |t|
t.string :jid, null: false
t.string :queue, null: false
t.string :klass, null: false
Background Job Dead Letter Queue (DLQ) Table
Share this code
Here's the card — post it anywhere.