Eloquent is one of the reasons developers love Laravel. It makes database interaction feel natural, readable, and fast to write. But that convenience comes with traps - patterns that look harmless in development and quietly destroy performance under real load. These are the eight mistakes that show up most often in code reviews, and each one has a straightforward fix.
📋 Table of Contents
- #1 - Missing select(): Loading Every Column
- #2 - count() Inside a Loop
- #3 - No Index on Filtered or Sorted Columns
- #4 - Business Logic in the Model or Controller
- #5 - Forgetting withTrashed() and onlyTrashed()
- #6 - firstOrCreate vs updateOrCreate vs firstOrNew
- #7 - No chunk() on Mass Operations
- #8 - all() on Collection vs get() on Query Builder
- ✅ Conclusion
#1 - Missing select(): Loading Every Column
When you write User::all() or User::get() without select(), Eloquent fetches every column in the table. That includes password hashes, remember tokens, large text columns, JSON blobs - everything gets pulled from the database, transferred over the wire, hydrated into PHP objects, and serialized into JSON.
// Bad - fetches all 30 columns including password, remember_token, settings JSON
$users = User::all();
// Good - fetch only what the endpoint actually needs
$users = User::select('id', 'name', 'email')->get();
Real cost:
| Approach | Columns | Memory per 1000 rows |
|---|---|---|
User::all() | 30 | ~4.2 MB |
User::select('id','name','email')->get() | 3 | ~0.4 MB |
The gap grows with table width. A users table with a settings JSON column or a bio text field makes all() expensive very fast.
In API Resources, be explicit:
// app/Http/Resources/UserResource.php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
];
}
}
Even with a Resource, if you loaded all columns, the unnecessary data still hit the database and PHP memory. Select first, transform after.
#2 - count() Inside a Loop
Every call to ->count() on a relationship fires a new SQL query. Inside a loop over 50 posts, that is 50 COUNT queries.
// Bad - 1 + N queries (one per post)
$posts = Post::all();
foreach ($posts as $post) {
echo $post->comments()->count(); // SELECT COUNT(*) FROM comments WHERE post_id = ?
}
// Good - 2 queries total
$posts = Post::withCount('comments')->get();
foreach ($posts as $post) {
echo $post->comments_count; // Already loaded
}
withCount() translates to a single subquery appended to the main select. You can load multiple counts at once:
$posts = Post::withCount(['comments', 'likes', 'shares'])->get();
// $post->comments_count, $post->likes_count, $post->shares_count
Need the count conditionally?
$posts = Post::withCount([
'comments',
'comments as approved_comments_count' => fn ($q) => $q->where('approved', true),
])->get();
Related aggregates - same problem, same fix:
// Bad - fires a SUM query per order in the loop
foreach ($orders as $order) {
$total = $order->items()->sum('price');
}
// Good - withSum() loads it in one query
$orders = Order::withSum('items', 'price')->get();
// Access: $order->items_sum_price
Laravel also provides withAvg(), withMin(), withMax(), and withExists() - all single-query aggregates.
#3 - No Index on Filtered or Sorted Columns
Adding an Eloquent where() does not make the query fast. The database still scans every row unless there is an index on the column.
// This looks fine in code - but what does MySQL do?
$orders = Order::where('status', 'pending')
->orderBy('created_at', 'desc')
->get();
Without an index on status and created_at, MySQL performs a full table scan and then sorts. On a table with 500k rows, this query goes from milliseconds to seconds.
Columns that almost always need indexes:
- Every foreign key (
user_id,product_id,order_id) - Every column used in
where()filters in frequent queries - Every column used in
orderBy() - Columns used in
unique()validation rules
Add a composite index for multi-column filters:
// database/migrations/2026_04_01_add_index_to_orders_table.php
declare(strict_types=1);
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::table('orders', function (Blueprint $table) {
// Covers WHERE status = ? ORDER BY created_at DESC
$table->index(['status', 'created_at']);
});
}
public function down(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->dropIndex(['status', 'created_at']);
});
}
};
The rule: column order in a composite index matters. Put the equality column (status) first, the range/sort column (created_at) second.
Verify with EXPLAIN:
EXPLAIN SELECT * FROM orders WHERE status = 'pending' ORDER BY created_at DESC LIMIT 20;
-- key: orders_status_created_at_index ✅
-- rows: 312 (not 94823) ✅
#4 - Business Logic in the Model or Controller
Fat Models and Fat Controllers are a code smell that compounds over time. They make logic hard to test in isolation, hard to reuse, and eventually impossible to reason about.
// Bad - business logic inside the controller method
class OrderController extends Controller
{
public function store(Request $request): JsonResponse
{
$validated = $request->validate([...]);
// discount calculation in controller
$discount = 0;
if (auth()->user()->orders()->count() > 10) {
$discount = 0.1;
}
$order = Order::create([
'user_id' => auth()->id(),
'total' => $validated['total'] * (1 - $discount),
'discount' => $discount,
]);
// sending notification in controller
$order->user->notify(new OrderPlaced($order));
return response()->json($order);
}
}
Better - move it to a Query Scope (for DB logic) or an Action (for business logic):
// app/Models/Order.php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
// Query Scope - reusable filter
public function scopePending(Builder $query): Builder
{
return $query->where('status', 'pending');
}
public function scopeForUser(Builder $query, int $userId): Builder
{
return $query->where('user_id', $userId);
}
}
// Usage - clean and readable
Order::pending()->forUser(auth()->id())->latest()->get();
// app/Actions/PlaceOrderAction.php
declare(strict_types=1);
namespace App\Actions;
use App\Models\Order;
use App\Models\User;
use App\Notifications\OrderPlaced;
class PlaceOrderAction
{
public function handle(User $user, array $data): Order
{
$discount = $user->orders()->count() > 10 ? 0.1 : 0;
$order = Order::create([
'user_id' => $user->id,
'total' => $data['total'] * (1 - $discount),
'discount' => $discount,
]);
$user->notify(new OrderPlaced($order));
return $order;
}
}
The controller becomes thin and readable. The Action is testable without HTTP context.
#5 - Forgetting withTrashed() and onlyTrashed()
Soft deletes add a deleted_at column. Once you add SoftDeletes to a model, Eloquent automatically appends WHERE deleted_at IS NULL to every query. This is easy to forget - especially when building admin panels, audit logs, or restore features.
// Silently excludes soft-deleted records - you won't see the bug
$order = Order::find($id); // Returns null for deleted orders
// Include soft-deleted records
$order = Order::withTrashed()->find($id);
// Only soft-deleted records
$orders = Order::onlyTrashed()->get();
// Restore
Order::withTrashed()->find($id)->restore();
// Permanently delete
Order::withTrashed()->find($id)->forceDelete();
Common traps:
// Relationship queries also apply the global scope
$user->orders()->count(); // Excludes deleted orders - is that what you want?
// If you need all orders including deleted:
$user->orders()->withTrashed()->count();
In route model binding - if a route resolves a model by ID and the model is soft-deleted, you get a 404. To allow resolving soft-deleted models:
// app/Http/Controllers/Api/OrderController.php
public function show(int $id): JsonResponse
{
$order = Order::withTrashed()->findOrFail($id);
return response()->json($order);
}
Or register a custom binding in AppServiceProvider:
Route::bind('order', fn ($value) => Order::withTrashed()->findOrFail($value));
#6 - firstOrCreate vs updateOrCreate vs firstOrNew
These three methods are frequently confused, leading to duplicate records, unexpected updates, or extra queries.
| Method | Finds record | Creates if missing | Updates existing |
|---|---|---|---|
firstOrNew() | ✅ | Builds (not saved) | ❌ |
firstOrCreate() | ✅ | Creates (saved) | ❌ |
updateOrCreate() | ✅ | Creates (saved) | ✅ |
// firstOrNew - find or build, NOT saved yet
// Use when you want to modify before saving
$user = User::firstOrNew(
['email' => '[email protected]'], // search criteria
['name' => 'Jan', 'role' => 'guest'], // default attributes if not found
);
$user->last_seen_at = now();
$user->save(); // You control when it's saved
// firstOrCreate - find or create immediately, saved automatically
// Use when defaults are sufficient and no modification needed
$tag = Tag::firstOrCreate(
['slug' => 'laravel'],
['name' => 'Laravel', 'color' => '#FF2D20'],
);
// updateOrCreate - find and update, or create if not found
// Use for upsert patterns - syncing external data, idempotent imports
User::updateOrCreate(
['email' => '[email protected]'], // search criteria
['name' => 'Jan Kowalski', 'last_login' => now()], // always applied
);
The most common mistake: using firstOrCreate when you need updateOrCreate, resulting in stale data after re-import:
// Bad - second import won't update the name if the record already exists
User::firstOrCreate(['email' => $data['email']], ['name' => $data['name']]);
// Good - name is always kept in sync
User::updateOrCreate(['email' => $data['email']], ['name' => $data['name']]);
#7 - No chunk() on Mass Operations
Processing thousands of records with ->get() loads everything into memory at once. On a table with 500k rows, that is a PHP out-of-memory error waiting to happen.
// Bad - loads all 500k users into memory
User::where('subscribed', true)->get()->each(function ($user) {
$user->sendWeeklyDigest();
});
// Good - processes 200 at a time, constant memory usage
User::where('subscribed', true)->chunk(200, function ($users) {
foreach ($users as $user) {
$user->sendWeeklyDigest();
}
});
chunkById() is safer when you modify records inside the chunk:
// chunk() uses OFFSET - if rows are deleted/inserted, you can skip records
// chunkById() uses WHERE id > last_id - consistent and safe during modifications
User::where('needs_migration', true)->chunkById(500, function ($users) {
foreach ($users as $user) {
$user->migrateData();
}
});
lazy() and lazyById() - cursor-based iteration without callback nesting:
// Uses a cursor - one row at a time through a PHP generator
foreach (User::where('subscribed', true)->lazy(200) as $user) {
$user->sendWeeklyDigest();
}
For purely database-side operations, update() or delete() in bulk beats any PHP loop:
// 1 query instead of 500k updates
User::where('last_login', '<', now()->subYear())->update(['is_active' => false]);
#8 - all() on Collection vs get() on Query Builder
This one trips up developers who are new to Eloquent - all() and get() look similar but work very differently.
// Model::all() - static method, always fetches ALL records from the table
// Cannot be chained with query builder methods
$users = User::all(); // SELECT * FROM users - no WHERE, no LIMIT
// Wrong - does NOT apply the where, silently ignored (or throws error)
$users = User::all()->where('active', true); // This is a Collection filter, not SQL
// Model::get() - called on a query builder, respects all chained constraints
$users = User::where('active', true)->orderBy('name')->limit(50)->get();
// SELECT * FROM users WHERE active = 1 ORDER BY name LIMIT 50
The Collection where() vs Query Builder where():
$users = User::all(); // Already in PHP memory - all 10k users
// This filters in PHP - 10k objects already loaded, just hidden
$active = $users->where('active', true);
// vs - filters in MySQL, only active users transferred
$active = User::where('active', true)->get();
When you need to apply filters, always use the Query Builder (chain before get()). Use Collection methods only for transformations on data you've already loaded intentionally.
all() has one legitimate use case - when you genuinely want all records and the table is small (config values, roles, categories). For anything user-generated or unbounded, always use get() with constraints.
✅ Conclusion
- Always use
select()to fetch only the columns you need - especially in API responses - Replace
count()in loops withwithCount(),withSum(), and other aggregate methods - Add indexes to every column used in
where(),orderBy(), and foreign keys - check withEXPLAIN - Move business logic out of Models and Controllers into Query Scopes and Action classes
- Soft deletes apply a global scope - always consider whether you need
withTrashed() - Choose the right upsert method:
firstOrNew,firstOrCreate, orupdateOrCreate- they are not interchangeable - Use
chunk()orchunkById()for any operation processing more than a few hundred records Model::all()is notModel::get()- learn the difference before it costs you in production
Follow me on LinkedIn for more Laravel tips! Have you run into a different Eloquent trap in production? Share it in the comments below!