Mastering Eloquent ORM: Laravel Setup and N+1 Query Optimization
A common problem: Eloquent is slow due to N+1 queries, incorrect relationships, or missing indexes. This causes page load times over 2 seconds and high database load. For example, on one project we reduced queries from 150 to 5, speeding up the catalog page from 3.5s to 0.3s — 10x faster. We configure Eloquent ORM turnkey, fixing these bottlenecks. Our team has 5+ years of Laravel experience and has optimized over 50 projects, reducing queries by up to 90% and speeding up responses 3–5x. This article covers the full setup cycle: from configuration to advanced techniques like mutators and global scopes. Contact us for a project assessment – we'll analyze your current DB and propose an optimization plan.
Laravel documentation recommends using preventLazyLoading in development.
Connection Configuration
config/database.php — modify only critical parameters, the rest comes from .env:
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DATABASE_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8',
'prefix' => '',
'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'),
'options' => [
PDO::ATTR_PERSISTENT => env('DB_PERSISTENT', false),
PDO::ATTR_TIMEOUT => 10,
],
],
For production behind a reverse proxy, set ATTR_PERSISTENT = false — persistent connections conflict with pgBouncer in transaction mode.
Model
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Builder;
class Product extends Model
{
use SoftDeletes;
protected $table = 'products';
protected $fillable = [
'title', 'slug', 'price', 'status', 'category_id',
];
protected $casts = [
'price' => 'decimal:2',
'meta' => 'array', // JSON column
'published_at' => 'datetime',
'is_featured' => 'boolean',
];
// Scope for filtering
public function scopePublished(Builder $query): Builder
{
return $query->where('status', 'published');
}
public function scopeInCategory(Builder $query, int $categoryId): Builder
{
return $query->where('category_id', $categoryId);
}
// Computed attribute (Laravel 9+)
protected function priceWithTax(): Attribute
{
return Attribute::make(
get: fn () => round($this->price * 1.2, 2),
);
}
// Relationships
public function category(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Category::class);
}
public function tags(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
{
return $this->belongsToMany(Tag::class, 'product_tags')
->withTimestamps();
}
public function images(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(ProductImage::class)->orderBy('sort_order');
}
}
Migrations
<?php
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::create('products', function (Blueprint $table) {
$table->id();
$table->string('title', 500);
$table->string('slug', 520)->unique();
$table->foreignId('category_id')->constrained()->restrictOnDelete();
$table->decimal('price', 12, 2);
$table->string('status', 20)->default('draft');
$table->jsonb('meta')->nullable();
$table->boolean('is_featured')->default(false);
$table->timestamp('published_at')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['status', 'created_at']);
$table->index(['category_id', 'status']);
});
}
public function down(): void
{
Schema::dropIfExists('products');
}
};
foreignId('category_id')->constrained() creates an FK named products_category_id_foreign referencing categories.id. restrictOnDelete() is preferred over cascadeOnDelete() for product catalogs: it prevents accidental deletion of a category with products.
How to Avoid N+1 Queries in Eloquent?
For a catalog endpoint with pagination, use eager loading:
$products = Product::query()
->published()
->with(['category', 'tags', 'images' => fn ($q) => $q->limit(1)])
->whereHas('category', fn ($q) => $q->where('is_active', true))
->orderBy('created_at', 'desc')
->paginate(24);
with() executes three additional queries instead of N+1. The limit(1) on images is a common trick for previews. Properly configured eager loading speeds up pages by 10x compared to lazy loading when many related models exist. To globally prevent N+1 in the test environment, add Model::preventLazyLoading() in AppServiceProvider – it throws a LazyLoadingViolationException on first lazy load, forcing you to fix the query before production.
Transactions
use Illuminate\Support\Facades\DB;
DB::transaction(function () use ($orderId, $items) {
$order = Order::findOrFail($orderId);
$order->update(['status' => 'processing']);
foreach ($items as $item) {
Product::where('id', $item['product_id'])
->decrement('stock', $item['quantity']);
}
$order->payments()->create([
'amount' => $order->total,
'gateway' => 'stripe',
]);
});
On exception, automatic rollback. The third argument DB::transaction($fn, 3) sets retry attempts on deadlock.
Why Use restrictOnDelete Instead of cascadeOnDelete?
In a product catalog, accidental deletion of a category should not destroy products. cascadeOnDelete leads to data loss. restrictOnDelete blocks deletion while related records exist. This is a frequent mistake in migrations that we fix during audits. In one project, a client lost 15,000 products due to cascade — after our audit, we restored the data and configured proper constraints.
Mutators and Accessors
Mutators and accessors transform attribute values on write and read. For example, using Attribute::make you can create a virtual field price_with_tax that is computed on the fly. This simplifies data handling and reduces logic duplication. Here's another example: a mutator to hash password on save:
protected function password(): Attribute
{
return Attribute::make(
set: fn (string $value) => bcrypt($value),
);
}
Common Eloquent Setup Errors
| Error | Consequence | Solution |
|---|---|---|
| Missing indexes on WHERE/ORDER BY columns | Full table scans, slow queries | Add indexes: $table->index('status') |
| Using cascadeOnDelete for important data | Data loss on parent deletion | Use restrictOnDelete or softDeletes |
| Lazy loading relationships in a loop | N+1 queries, increased execution time | Replace with eager loading via with() |
| Storing JSON in a text field | Cannot use indexes and JSON operations | Use jsonb type (PostgreSQL) |
What's Included in Turnkey Eloquent Setup
| Stage | Description |
|---|---|
| Audit | Check current models, relationships, indexes; identify bottlenecks |
| Design | Design DB schema considering business logic and load |
| Migrations | Write migrations with indexes, foreign keys, and constraints |
| Seeders | Create test data for development and testing |
| Optimization | Eliminate N+1, configure eager loading, add indexes |
We also provide API documentation and team training on Eloquent. We guarantee quality and offer recommendations for future improvements. Order Eloquent setup turnkey — your DB will be fast and stable. Infrastructure savings after optimization can be significant: for example, typical savings amount to $500–$2000 per month on hosting due to reduced DB load and faster response times.
Timeline
New Laravel project Eloquent setup: from 1 day (models, migrations, seeders, basic tests). Audit and refactoring of existing projects with N+1 problems, suboptimal indexes, and missing FK constraints: 1–2 days. Get a consultation on your project — we'll assess the work scope for free.
Effective Eloquent ORM setup is not only about performance but also code maintainability. Eloquent ORM is the standard for Laravel, and its proper configuration is crucial for scalable applications. Contact us to start optimization.







