Laravel Query Performance Fix for SaaS Applications
Table of Contents
- Why Query Performance Matters in SaaS
- Fix the N+1 Query Problem with Eager Loading
- Select Only What You Need
- Use Chunking for Large Datasets
- Add the Right Database Indexes
- Cache Expensive Queries with Redis
- Reuse Logic with Query Scopes
- Profile Queries with Laravel Telescope & Debugbar
- Summary & Checklist
As your Laravel SaaS application scales from hundreds to tens of thousands of users, the database becomes the first bottleneck. A query that takes 20ms with 500 rows can take 4+ seconds with 500,000 rows — and that's before you factor in concurrent users. In this guide, you'll learn the most impactful Laravel query performance fixes, each with real Eloquent code examples.
Why Query Performance Matters in SaaS
In a multi-tenant SaaS product, poor query performance compounds quickly. Every additional tenant amplifies slow queries. A single dashboard page that fires 80 database queries might be acceptable in development, but in production it causes cascading timeouts, elevated server costs, and — worst of all — customer churn.
53%
of users abandon a page that takes >3s to load
100ms
database latency = ~1% revenue loss (Amazon study)
10×
throughput gain from eliminating N+1 queries
Fix the N+1 Query Problem with Eager Loading
The N+1 query problem is the single most common Laravel performance killer in SaaS codebases. It happens when Eloquent executes one query to retrieve a collection and then one additional query per record to load a relationship.
⚠ Problem This loops and fires a separate query for each user's subscription — 1 + N queries total.
Bad — N+1 Pattern PHP
// Fires 1 query for users + 1 query per user for subscription
$users = User::all();
foreach ($users as $user) {
echo $user->subscription->plan_name;
// 🔴 Executes a new SELECT every iteration
}✅ Fix Use
with() to eager-load the relationship in a single JOIN-style query.
Good — Eager Loading PHP
// 2 queries total, regardless of how many users there are
$users = User::with('subscription')->get();
foreach ($users as $user) {
echo $user->subscription->plan_name;
// ✅ No additional query — already in memory
}
// Nested eager loading
$orders = Order::with(['user', 'user.subscription', 'items.product'])->get();
// Conditional / constrained eager loading
$users = User::with(['posts' => function ($query) {
$query->where('published', true)->latest();
}])->get();Select Only What You Need
By default, Eloquent's get() runs SELECT *, pulling every column from the database. For wide tables (billing info, metadata JSON, audit logs), this transfers enormous amounts of data you never use.
Column Selection Optimization PHP
// ❌ Retrieves all 30 columns, including large text blobs
$users = User::all();
// ✅ Only fetch what the view actually needs
$users = User::select('id', 'name', 'email', 'created_at')->get();
// ✅ Use pluck() when you only need a single column
$emails = User::where('active', true)->pluck('email');
// ✅ Use value() for a single scalar
$name = User::where('id', 1)->value('name');Avoid Hydrating Full Models for Aggregates
Aggregates & Raw Queries PHP
// ❌ Loads every subscription into PHP memory just to count
$count = Subscription::where('active', true)->get()->count();
// ✅ Runs COUNT() at the database level — far faster
$count = Subscription::where('active', true)->count();
// ✅ Use exists() instead of count() for presence checks
if (Subscription::where('user_id', $userId)->exists()) {
// Much faster — stops after finding 1 row
}Use Chunking for Large Datasets
Processing thousands of records with ->get() loads everything into PHP memory at once. For SaaS background jobs (billing runs, report generation, bulk emails), this causes memory exhaustion and timeouts.
Chunked Processing PHP
// ❌ Loads 50,000 users into memory at once
$users = User::all(); // 💥 memory exhaustion risk
// ✅ Process 500 at a time — constant memory footprint
User::chunk(500, function ($users) {
foreach ($users as $user) {
SendBillingReminder::dispatch($user);
}
});
// ✅ Even better — lazy() uses cursor-based pagination (PHP generator)
foreach (User::lazy() as $user) {
// One row in memory at a time
processUser($user);
}
// ✅ lazyById() for modification-safe chunking
Invoice::where('status', 'pending')->lazyById(200)->each(function ($invoice) {
$invoice->update(['status' => 'overdue']);
});Add the Right Database Indexes
Eloquent is an abstraction layer — the real query performance lives in MySQL/PostgreSQL. Missing indexes turn fast O(log n) lookups into slow O(n) full-table scans. For SaaS apps with tenant-scoped data, this is critical.
Laravel Migration — Adding Indexes PHP
Schema::table('orders', function (Blueprint $table) {
// Single-column index for common WHERE clause
$table->index('user_id');
// Composite index for multi-column filters (order matters!)
$table->index(['tenant_id', 'status', 'created_at']);
// Unique index ensures no duplicate invoices per tenant
$table->unique(['tenant_id', 'invoice_number']);
// Full-text index for search functionality
$table->fullText(['title', 'description']);
});
Scenario Index Type Impact
| WHERE user_id = ? | Single column | ✅ High
| WHERE tenant_id = ? AND status = ? | Composite | ✅ Very High
| ORDER BY created_at DESC | Column index | ✅ High
| LIKE '%search%' (middle wildcard) | None (use FTS) | ❌ No index used
| Unique email per tenant | Composite unique | ✅ Integrity + speed
Cache Expensive Queries with Redis
Some queries are inherently expensive — tenant analytics, plan usage summaries, leaderboard totals. If the underlying data changes infrequently, caching is the highest-leverage optimization available.
Redis Query Caching PHP
use Illuminate\Support\Facades\Cache;
// ✅ Remember-forever (manual invalidation)
$stats = Cache::rememberForever(
"tenant:{$tenantId}:usage-stats",
fn() => UsageStat::where('tenant_id', $tenantId)->sum('api_calls')
);
// ✅ TTL-based caching for dashboard counters
$monthlyRevenue = Cache::remember(
"tenant:{$tenantId}:revenue:".now()->format('Y-m'),
now()->addMinutes(15),
fn() => Invoice::where('tenant_id', $tenantId)
->whereMonth('created_at', now()->month)
->sum('amount')
);
// ✅ Bust cache when data changes (Observer pattern)
class InvoiceObserver {
public function saved(Invoice $invoice): void {
Cache::forget("tenant:{$invoice->tenant_id}:revenue:*");
}
}Reuse Logic with Query Scopes
In a multi-tenant SaaS app, you often repeat the same where tenant_id = ? filter on every query. Scopes let you encapsulate this logic once and keep queries readable and DRY.
Model Query Scopes PHP
class Order extends Model
{
// Local scope — call as ->forTenant($id)
public function scopeForTenant($query, int $tenantId): $query
{
return $query->where('tenant_id', $tenantId);
}
// Local scope for active orders
public function scopeActive($query): $query
{
return $query->whereIn('status', ['pending', 'processing']);
}
}
// Usage — clean, chainable, and fast
$orders = Order::forTenant($tenantId)
->active()
->with('items')
->latest()
->paginate(25);💡 Pro Tip For true multi-tenancy, consider
Global Scopes — they automatically append the tenant filter to every query on the model, eliminating the risk of accidentally leaking cross-tenant data.
Profile Queries with Laravel Telescope & Debugbar
You can't fix what you can't measure. Before optimizing anything, profile your actual query count and execution times using these two essential tools.
Laravel Telescope
Install & Enable Bash
composer require laravel/telescope --dev php artisan telescope:install php artisan migrate # Visit /telescope → Queries tab to see slow queries
Quick Query Log (Development Only)
DB::listen() Profiler PHP
// In AppServiceProvider::boot()
if (app()->isLocal()) {
DB::listen(function ($query) {
if ($query->time > 100) { // flag queries over 100ms
logger()->warning('Slow query detected', [
'sql' => $query->sql,
'time' => $query->time.'ms',
]);
}
});
}Summary & Checklist
Laravel gives you all the tools to build a blazing-fast SaaS product — the patterns above cover 90% of real-world performance problems. Use this checklist before every feature release:
- ☑ Replaced ->all() / loops with ->with() eager loading
- ☑ Using ->select() to fetch only required columns
- ☑ Replaced ->get()->count() with ->count() and ->exists()
- ☑ Background jobs use ->chunk() or ->lazy()
- ☑ Composite indexes added for tenant-scoped + status queries
- ☑ Expensive aggregates cached in Redis with smart TTLs
- ☑ Common filters extracted into reusable Query Scopes
- ☑ Telescope or Debugbar used to verify query count per page
📌 Remember
Premature optimization is the root of all evil — but in SaaS, latent N+1 problems and missing indexes are rarely premature concerns. Profile first, fix what's measurable, and always test in production-like data volumes.
AT
Full-stack engineer specializing in Laravel, SaaS architecture, and high-performance PHP applications. Writing about real-world backend problems and the fixes that actually work.
Related Posts
Hiring a Laravel & React.js Developer in Dubai and Gurugram: Build Scalable Digital Solutions
In today’s fast-paced digital world, businesses need robust, scalable, and future-ready web applicat...
WordPress Development Services by Arun Tyagi
Professional WordPress development extends far beyond theme installation and plugin configuration. S...
Professional Web Development Services Using Laravel, PHP & WordPress
Discover professional web development services using Laravel, PHP, and WordPress. Learn how custom,...