<?php
namespace App\Http\Controllers;
use App\Jobs\ImportProductChunk;
use App\Models\Import;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\LazyCollection;
class ProductImportController extends Controller
{
public function store(Request $request)
{
$request->validate([
'file' => ['required', 'file', 'mimes:csv,txt', 'max:20480'],
]);
$path = $request->file('file')->store('imports');
$import = Import::create([
'user_id' => $request->user()->id,
'path' => $path,
'status' => 'pending',
]);
$rows = LazyCollection::make(function () use ($import) {
$file = new \SplFileObject(storage_path('app/' . $import->path));
$file->setFlags(\SplFileObject::READ_CSV | \SplFileObject::SKIP_EMPTY);
foreach ($file as $line) {
if ($line !== [null]) {
yield $line;
}
}
});
$header = $rows->first();
$jobs = $rows->skip(1)->chunk(500)->map(function ($chunk) use ($import, $header) {
return new ImportProductChunk($import->id, $header, $chunk->values()->all());
});
$batch = Bus::batch($jobs->all())
->name("product-import:{$import->id}")
->allowFailures()
->then(fn () => $import->markCompleted())
->catch(fn ($batch, $e) => $import->markFailed($e->getMessage()))
->finally(fn () => $import->refresh()->finalize())
->dispatch();
$import->update(['batch_id' => $batch->id, 'status' => 'processing']);
return response()->json(['import_id' => $import->id, 'batch_id' => $batch->id]);
}
}
<?php
namespace App\Jobs;
use App\Models\Product;
use Illuminate\Bus\Batchable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
class ImportProductChunk implements ShouldQueue
{
use Batchable, Dispatchable, InteractsWithQueue, SerializesModels;
public int $tries = 3;
public function __construct(
public int $importId,
public array $header,
public array $rows
) {
}
public function backoff(): array
{
return [10, 30, 60];
}
public function middleware(): array
{
return [new WithoutOverlapping('import:' . $this->importId)];
}
public function handle(): void
{
if ($this->batch()?->cancelled()) {
return;
}
$records = collect($this->rows)->map(function ($row) {
$mapped = array_combine($this->header, $row);
return [
'sku' => trim($mapped['sku']),
'name' => $mapped['name'],
'price' => (int) round(((float) $mapped['price']) * 100),
'updated_at' => now(),
'created_at' => now(),
];
})->filter(fn ($r) => $r['sku'] !== '')->values()->all();
if ($records === []) {
return;
}
Product::upsert($records, ['sku'], ['name', 'price', 'updated_at']);
}
}
<?php
namespace App\Models;
use Illuminate\Bus\Batch;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Storage;
class Import extends Model
{
protected $fillable = ['user_id', 'path', 'batch_id', 'status', 'error'];
public function getProgressAttribute(): int
{
$batch = $this->batch_id ? Bus::findBatch($this->batch_id) : null;
return $batch instanceof Batch ? $batch->progress() : 0;
}
public function markCompleted(): void
{
$this->update(['status' => 'completed', 'error' => null]);
}
public function markFailed(string $message): void
{
$this->update(['status' => 'failed', 'error' => substr($message, 0, 1000)]);
}
public function finalize(): void
{
if (in_array($this->status, ['completed', 'failed'], true)) {
Storage::delete($this->path);
}
}
}
Importing a large product CSV in a single HTTP request is a recipe for timeouts and memory blowups. This snippet shows the Laravel-idiomatic alternative: parse the file lazily, split it into fixed-size chunks, and dispatch each chunk as its own queued job inside a Bus::batch, so the work is durable, resumable, and observable.
In ProductImportController, the uploaded file is stored on disk (never held entirely in memory) and an Import record is created to track state. LazyCollection::make streams the file line by line with SplFileObject, and ->chunk(500) groups rows so each ImportProductChunk job handles a bounded slice. Wrapping the jobs in Bus::batch(...) gives a single batch id, progress counters, and lifecycle hooks: then marks the import completed, catch records the first failure, and finally always finalizes state regardless of outcome. allowFailures() lets good chunks succeed even when one row batch dies. The controller returns the batch->id so a frontend can poll progress.
ImportProductChunk is the unit of work. It carries the raw rows and the header, and its handle method maps each row to an associative array keyed by header. The crucial detail is Product::upsert(...): a single bulk statement that inserts new SKUs and updates existing ones by the unique sku column, making the job idempotent. If the queue retries the job after a transient failure, re-running the same rows produces the same result rather than duplicate products. WithoutOverlapping keyed by import id prevents two chunks of the same import from racing on the same rows, and $tries/backoff give bounded retries with delay.
The Import model in Import model exposes a progress accessor and small state transitions (markCompleted, markFailed) so controllers and views read intent, not raw columns. The trade-offs: chunk size balances per-job overhead against memory and retry granularity, and upserts require a real unique index on sku. This pattern fits any large-file ingestion — orders, contacts, inventory — where partial progress, retries, and back-pressure matter more than raw speed.
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
module EmailNormalization
extend ActiveSupport::Concern
included do
attr_accessor :soft_warnings
Soft Validation: Normalize + Validate Email
{
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build"
},
Laravel mix/Vite for asset compilation
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.