Inserting many rows via a loop of individual create() calls works, but a genuine bulk insert is significantly faster — the trade-off is that it skips model events, timestamps, and other Eloquent conveniences that a loop of individual creates would normally handle automatically.
The naive loop approach (correct, but slow at scale)
foreach ($products as $productData) {
Product::create($productData);
}
Each create() call here is a separate database round-trip — for a handful of rows this is fine, but inserting hundreds or thousands of rows this way is meaningfully slower than a genuine bulk insert, since every single row incurs its own full query execution.
A genuine bulk insert with the query builder
DB::table('products')->insert([
['name' => 'Mouse', 'price' => 20, 'created_at' => now(), 'updated_at' => now()],
['name' => 'Keyboard', 'price' => 50, 'created_at' => now(), 'updated_at' => now()],
['name' => 'Monitor', 'price' => 200, 'created_at' => now(), 'updated_at' => now()],
]);
This generates a single SQL INSERT statement covering all three rows at once — dramatically fewer round-trips than the loop approach above, which matters increasingly as the number of rows grows into the hundreds or thousands.
Why created_at/updated_at must be set manually here
Unlike Model::create(), the query builder's insert() bypasses Eloquent entirely — none of Eloquent's automatic timestamp management applies, which is exactly why created_at/updated_at need to be included explicitly in each row's array, or they'll be left null in the database.
Eloquent's own bulk-insert-friendly alternative: insert via a Collection
$products = collect($productDataArray)->map(function ($data) {
$data['created_at'] = now();
$data['updated_at'] = now();
return $data;
})->toArray();
Product::insert($products);
Model::insert() (inherited from the query builder) works the same way as the raw DB::table()->insert() call — still no model events fire, and timestamps still need to be added manually, since this is fundamentally the same bulk operation just called through the Eloquent model class.
What's genuinely lost with a bulk insert
Model events (creating, created), mass-assignment protection via $fillable, and any custom logic in a model's booted() method (like the slug-generation trait covered elsewhere on this site) all get bypassed entirely with a raw bulk insert — this is a genuine trade-off worth being aware of, not just a performance-only decision, since any application logic depending on those events simply won't run for bulk-inserted rows.
Inserting in chunks, for a very large dataset
collect($largeDataset)->chunk(500)->each(function ($chunk) {
DB::table('products')->insert($chunk->toArray());
});
A single INSERT statement covering tens of thousands of rows can hit the database's own maximum packet size limit or become impractically slow to build — chunking into smaller batches (500 rows here) avoids this, trading a few more round-trips for staying safely within practical limits.
When the loop-of-creates approach is still the right choice
If model events, timestamp auto-management, or mass-assignment protection are genuinely needed for the data being inserted, the loop-of-create() approach remains correct despite being slower — the bulk insert's speed advantage is only worth the trade-off when those Eloquent conveniences aren't actually required for the specific data being inserted.