Performance Database Eloquent Optimization

Laravel Performance Tuning: How to Insert 100,000+ Records Efficiently (Beyond Eloquent)

Stefan Izdrail

Stefan Izdrail

Founder & Senior Architect · 2026-07-15

Laravel Company

If you've ever tried to insert 30,000 records using Eloquent and watched your script crawl along for 30+ seconds, you know the pain. The ORM that makes Laravel so productive for day-to-day development becomes a bottleneck the moment you need to move large volumes of data. In this guide, we'll cover practical Laravel bulk insert performance techniques that take you from minutes to seconds, using the tools Laravel gives you out of the box. For teams needing expert guidance on enterprise-scale architecture, a Laravel Agency can help design the right data pipelines from day one.

Why Eloquent Is Slow for Bulk Inserts

Eloquent models are expensive. Every time you call Model::create() or $model->save(), Laravel hydrates a full Eloquent model instance, fires events (creating, created, saving, saved), runs model validation, and casts attributes. For a single record, this overhead is negligible. For 100,000 records, it adds up fast.

The numbers: Inserting 100,000 records via Eloquent create() inside a loop takes roughly 90-120 seconds. Using the DB::insert() facade with a single batch query? Under 3 seconds. That's a 30-40x improvement with essentially zero additional complexity.

The DB Facade: Your First Step

Laravel's DB::insert() method lets you bypass the ORM entirely and send raw parameterized SQL directly to the database. Here's how you'd insert a batch of records:

$chunkSize = 500;
$records = [];

foreach ($data as $row) {
    $records[] = $row;

    if (count($records) >= $chunkSize) {
        DB::insert('INSERT INTO users (name, email, status) VALUES (?, ?, ?)', $records);
        $records = [];
    }
}

// Insert remaining records
if (!empty($records)) {
    DB::insert('INSERT INTO users (name, email, status) VALUES (?, ?, ?)', $records);
}

This approach avoids model hydration, event dispatching, and attribute casting entirely. The database receives a clean SQL statement with bound parameters, processes it, and moves on.

Transaction Wrapping for Integrity

When inserting large datasets, you want atomicity. If the process fails halfway through, you don't want orphaned records. Wrap your entire insert operation in a database transaction:

DB::transaction(function () use ($data) {
    foreach (array_chunk($data, 500) as $chunk) {
        DB::insert('INSERT INTO users (name, email, status) VALUES (?, ?, ?)', $chunk);
    }
});

Be mindful of transaction size. A single transaction holding millions of records can exhaust your database's transaction log. Stick to chunk sizes of 500-1000 records per batch, and consider using DB::beginTransaction() / DB::commit() manually with periodic commits for very large datasets.

Move Imports to the CLI

Web requests have hard timeout limits. Even with set_time_limit(0), you're fighting against proxy servers, load balancers, and good architectural practice. Heavy data imports belong in Artisan commands:

class ImportUsersCommand extends Command
{
    protected $signature = 'import:users {file}';

    public function handle()
    {
        $this->output->title('Starting user import...');
        $bar = $this->output->createProgressBar($totalChunks);

        DB::transaction(function () use ($data, $bar) {
            foreach (array_chunk($data, 500) as $chunk) {
                DB::insert('INSERT INTO users ...', $chunk);
                $bar->advance();
            }
        });

        $bar->finish();
    }
}

Running imports via the CLI gives you unlimited execution time, full access to Laravel's service container, and a progress bar for feedback. Pair this with Supervisor or Laravel Horizon for scheduled imports.

Raw PDO for Maximum Throughput

For truly extreme throughput, you can drop down to raw PDO and prepared statements. This gives you control over buffered queries and fetch modes:

$pdo = DB::getPdo();
$statement = $pdo->prepare('INSERT INTO users (name, email, status) VALUES (:name, :email, :status)');

foreach ($data as $row) {
    $statement->execute([
        ':name' => $row['name'],
        ':email' => $row['email'],
        ':status' => $row['status'],
    ]);
}

Pro tip: MariaDB and MySQL support LOAD DATA LOCAL INFILE for CSV-based bulk imports. This is the fastest method available, often processing millions of rows per minute. Laravel's DB::unprepared() can execute this directly.

Summary

Bulk inserting data in Laravel doesn't have to be slow. Drop Eloquent for mass operations, wrap inserts in transactions, move heavy lifting to Artisan commands, and use raw SQL or PDO when you need maximum speed. These patterns are what separate hobby projects from production-grade enterprise systems.