Web Development

Laravel Query Performance Fix for SaaS Applications

Arun Tyagi
June 06, 2026
33 views
Laravel Query Performance Fix for SaaS Applications

Table of Contents

  1. Why Query Performance Matters in SaaS
  2. Fix the N+1 Query Problem with Eager Loading
  3. Select Only What You Need
  4. Use Chunking for Large Datasets
  5. Add the Right Database Indexes
  6. Cache Expensive Queries with Redis
  7. Reuse Logic with Query Scopes
  8. Profile Queries with Laravel Telescope & Debugbar
  9. 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

Arun Tyagi

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.