Skip to main content

Black Friday 2025! Only until December 1st: coupon FRIDAY25 for 40% off Yearly/Lifetime membership!

Read more here
Premium Members Only
Join to unlock this tutorial and all of our courses.
Tutorial Premium Tutorial

Laravel: Import Very Large CSV With Jobs and Queues

May 30, 2023
8 min read

Tutorial last revisioned on June 19, 2025 with Laravel 12

Have you ever tried to import 1 million rows from CSV into the DB? It's a tricky task that can end up with performance issues, timeouts, or just running out of server resources. In this tutorial, I will show you one way of handling this with Jobs and Queues.

In short, our goal is to make sure the import process is not interrupted by a timeout like this:

We need to split the import process into smaller chunks and run them individually to fix this. This is where Laravel Queues and Jobs come in handy. Let's see how we can use them to import an extensive dataset from a file.


Our Task

Here's the situation:

  • We have a CSV file with 1 million rows (or more)
  • We need to import this file into our database
  • Records that already exist in the database should be updated
  • Records that don't exist in the database should be created
  • We need to make sure that the import process is not interrupted by a timeout

Of course, we've chosen a CSV file for convenience, but you might expect to get a JSON request or an XML request from an API endpoint. The idea is the same.


The Cause of the Problem

The problem becomes obvious if we take a look at the code:

use Illuminate\Http\Request;
 
// ...
 
public function import(Request $request)
{
$file = $request->file('csv');
 
// Saving the file to storage for reading it as CSV
// Otherwise, it will break even faster.
$file = $file->store('csv', ['disk' => 'public']);
 
// Opening the file for reading
$fileStream = fopen(storage_path('app/public/' . $file), 'r');
 
$csvContents = [];
 
// Reading the file line by line into an array
while (($line = fgetcsv($fileStream)) !== false) {
$csvContents[] = $line;
}
 
// Closing the file stream
fclose($fileStream);
 
// Defining the indexes of the CSV
$name = 0;
$category = 1;
$description = 2;
$price = 3;
$stock = 4;
 
// Loading the categories into an array
$categories = ProductCategory::pluck('id', 'name');
 
$skipHeader = true;
// Attempt to import the CSV
foreach ($csvContents as $content) {
if ($skipHeader) {
// Skipping the header column (first row)
$skipHeader = false;
continue;
}
 
$productCategory = $categories[$content[$category]] ?? ProductCategory::create([
'name' => $content[$category]
])->id;
 
// Updating or creating the product based on the name and category
Product::updateOrCreate([
'name' => $content[$name],
'category_id' => $productCategory,
], [
'name' => $content[$name],
'category_id' => $productCategory,
'description' => $content[$description],
'price' => $content[$price],
'stock_left' => $content[$stock],
]);
}
 
// Deleting the file from storage (it was temporary!)
Storage::delete($file);
 
return "Imported!";
}

It should instantly raise a concern as we are iterating over each of the lines. It might be fine if the import size is small, but once we look at a more extensive dataset, it will take a long time to finish. And if the process takes longer than the limit of maximum PHP execution time, it will fail altogether.

And now, we come to the solution: Queues and Jobs. We will base the same import on queued Jobs, which will split the load into smaller chunks, ensuring they are executed individually.

Bonus: This will also make the import process contain a fail-safe mechanism, allowing us to retry the import if it fails for some reason.


Step 1. Creating the Job

Our first Job will be responsible for reading the CSV file and creating another Job for each line. Let's call it ProcessImportJob:

php artisan make:job ProcessImportJob

The Job will look like this:

app/Jobs/ProcessImportJob.php

use App\Jobs\ProductsImport\ProccessProductImportJob;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Storage;
 
class ProcessImportJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
 
// Giving a timeout of 20 minutes to the Job to process the file
public $timeout = 1200;
 
public function __construct(private string $file)
{
}
 
public function handle(): void
{
$fieldMap = [
'name' => 0,
'category' => 1,
'description' => 2,
'price' => 3,
'stock' => 4,
];
 
// Open the file for reading
$fileStream = fopen($this->file, 'r');
 
$skipHeader = true;
while (($line = fgetcsv($fileStream)) !== false) {
if ($skipHeader) {
// Skip the header
$skipHeader = false;
continue;
}
// For each line, we dispatch a job to process the line
dispatch(new ProcessProductImportJob($line, $fieldMap));
}
 
// Close the file
fclose($fileStream);
 
// Deletes the file after processing
unlink($this->file);
}
}

Next, we will create...

Premium Members Only

This advanced tutorial is available exclusively to Laravel Daily Premium members.

Premium membership includes:

Access to all premium tutorials
Video and Text Courses
Private Discord Channel

Comments & Discussion

FB
Florian Bouffard ✓ Link copied!

As always, great tutorial. Thank you very much

SP
Sylvain P ✓ Link copied!

Confusing and not very useful without a concrete working example.

X
x2luilop ✓ Link copied!

Another approach to read the CSV file is using the Standard PHP Library (SPL):

$filepath = storage_path('app/public/' . $file);
$file = new SplFileObject($filepath);

We can refactor the while loop to the following:

while (! $file->eof()) {
	$values = $file->fgetcsv();
}

Hardcoding the position of the headers can bring problems if the user uploads the file with the headers re-arranged.

Let's say we expect the following order:

  • name, category, description, price, stock

But the user uploads the following:

  • category, description, name, price, stock

This import will insert the data into the wrong fields in the database.

However, the CSV file has the expected data in the columns (headers match values) but not the right order.

So, can we still import the file without the right order?

Yes we can by pulling the headers from the file:

$filepath = storage_path('app/public/' . $file);

$file = new SplFileObject($filepath);

// Retrieve the headers and convert them to snake case
$headers = array_map(function($header) {
	return strtolower(str_replace(' ', '_', $header));
}, $file->fgetcsv());

while (! $file->eof()) {
	
	// Retrieve the next line of values
	$values = $file->fgetcsv();
	
	// This map will point each header to its value
	$row = [];
	
	// The index of each header should match the index of its value
	// Lets associate each value to it's header
	foreach ($headers as $index => $header) {
		// check if the index of the value matches the one from its header
		if (! isset($values[$index]) { 
			$row[$header] = ''; // association not found, default to en empty string
		} else {
			$row[$header] = trim($values[$index]); // associate the value with its header
		}
	}
	
	// Now can access the values using the headers as keys
	echo $row['name'], PHP_EOL;
	echo $row['category'], PHP_EOL;
	echo $row['description'], PHP_EOL;
	echo $row['price'], PHP_EOL;
	echo $row['stock'], PHP_EOL;
}

However, for both scenarios we still need to validate that the file contains the expected headers (hardcoded or not) before importing any data.

M
Modestas ✓ Link copied!

Hey, thanks for adding another way of importing!

There are quite a few things you can do, and I'm a big fan of displaying a preview of the data (so, for example, I would show a table with the first five or so rows to the user) where he would be able to select which field is what. That way, it doesn't matter how the order looks like :)

P
peroxovy ✓ Link copied!

Hello you forget to add information about creating the specific migration for jobs, some users couldn't know about how to monitor that.

P
peroxovy ✓ Link copied!

Also useful tip will be, how to pass Model with previously fetched fields to a Job. For example if we select only name from User model or withCount field from relationship. In the default case having model in a constructor parameter will occurs with getting all fields including even timestamps.

M
Modestas ✓ Link copied!

If you only need specific fields - then you should only pass those specific fields as a parameter. Overall, the job will re-fetch the model from a database when it's being processed. This does cause some issues if a model is deleted too.

We have a full course on queues: https://laraveldaily.com/course/laravel-queues?mtm_campaign=search-tag-course

AK
Ara Khachatryan ✓ Link copied!

Hello. I think instead of using updateOrCreate in foreach loop, it would be better at first to divide that 1 milion rows to chunks (for example of 1000 , ofcourse amount of chunks depends on amounts of fields of the excel/csv), later create an array(array of inserting/updating items for any chunk) and at last use upsert() , so the key thing of comment is using upsert() , i think it would be better even in performance.

We'd Love Your Feedback

Tell us what you like or what we can improve

Feel free to share anything you like or dislike about this page or the platform in general.