Case Studies & Success Stories

How We Scaled a SaaS Platform to Handle 100K Active Users with Laravel 12

Learn how we scaled a Laravel 12 SaaS platform to 100K active users. Real-world strategies for database optimization, caching, queue management, and infrastructure scaling.

Muhammad Waqas

Muhammad Waqas

CEO at CentoSquare

|
10-Nov-2025
10 min read
How We Scaled a SaaS Platform to Handle 100K Active Users with Laravel 12

Introduction

Scaling a SaaS platform from thousands to 100,000+ active users presents unique challenges: database bottlenecks, cache invalidation complexity, queue congestion, and infrastructure costs. When our Laravel-based SaaS platform experienced explosive growth, we faced critical decisions about architecture, optimization, and infrastructure.

This is the real story of how we scaled our Laravel 12 SaaS platform to handle 100,000 concurrent active users, processing millions of daily requests while maintaining sub-200ms response times and 99.9% uptime. These aren't theoretical optimizations—they're battle-tested strategies implemented in production.


The Challenge: Breaking Points at Scale

Initial Architecture (Pre-Scale)

  • Infrastructure: Single DigitalOcean droplet (8GB RAM, 4 vCPUs)
  • Database: Single MySQL instance
  • Cache: Redis on the same server
  • Queue: Database-backed queues
  • Users: ~5,000 daily active users
  • Response Time: 800ms average

Problems at 20K Users

  1. Database Saturation: Connection pool exhaustion, slow queries
  2. Memory Issues: Redis consuming 6GB RAM
  3. Queue Delays: Jobs taking 30+ minutes to process
  4. Session Bottlenecks: File-based sessions causing locks
  5. Asset Delivery: Static files overwhelming server

Phase 1: Database Optimization (0-30K Users)

1. Query Optimization and Indexing

The first bottleneck was inefficient database queries. We identified and optimized the worst offenders:

// ❌ Before: N+1 query loading user subscriptions
public function dashboard()
{
    $users = User::all(); // 1 query
    foreach ($users as $user) {
        $subscription = $user->subscription; // N queries
        $usage = $user->currentUsage(); // N more queries
    }
}

// ✅ After: Eager loading with optimized queries
public function dashboard()
{
    $users = User::with(['subscription', 'currentUsage'])
        ->select('id', 'name', 'email', 'created_at')
        ->limit(50)
        ->get();
}

2. Strategic Indexing

We added composite indexes for frequently queried columns:

Schema::table('activities', function (Blueprint $table) {
    // Composite index for common query pattern
    $table->index(['user_id', 'created_at', 'type']);
    
    // Covering index includes commonly selected columns
    $table->index(['tenant_id', 'status', 'priority']);
});

// Analyze query execution
DB::listen(function ($query) {
    if ($query->time > 100) {
        Log::warning('Slow query detected', [
            'sql' => $query->sql,
            'time' => $query->time,
        ]);
    }
});

3. Read Replicas

We implemented MySQL read replicas for analytics and reporting:

// config/database.php
'mysql' => [
    'read' => [
        'host' => [
            env('DB_READ_HOST_1'),
            env('DB_READ_HOST_2'),
        ],
    ],
    'write' => [
        'host' => [env('DB_WRITE_HOST')],
    ],
    'sticky' => true,
],

// Force read from replica
$analytics = DB::connection('mysql::read')
    ->table('activities')
    ->whereBetween('created_at', [$start, $end])
    ->count();

Results: Query time reduced from 800ms to 120ms average.


Phase 2: Caching Strategy (30K-50K Users)

1. Multi-Layer Cache Architecture

We implemented a sophisticated caching hierarchy:

namespace App\Services;

class CacheService
{
    // Layer 1: Application memory cache (APCu)
    public function getFromMemory(string $key)
    {
        return apcu_fetch($key);
    }
    
    // Layer 2: Redis cache
    public function getFromRedis(string $key)
    {
        return Cache::store('redis')->get($key);
    }
    
    // Layer 3: Database with cache-aside pattern
    public function getUserSubscription(int $userId)
    {
        return Cache::tags(['users', "user:{$userId}"])
            ->remember("user:{$userId}:subscription", 3600, function () use ($userId) {
                return User::with('subscription.plan')
                    ->find($userId)
                    ->subscription;
            });
    }
}

2. Smart Cache Invalidation

Cache invalidation became critical at scale:

namespace App\Observers;

class SubscriptionObserver
{
    public function updated(Subscription $subscription)
    {
        // Clear user-specific cache
        Cache::tags(["user:{$subscription->user_id}"])->flush();
        
        // Clear aggregated stats
        Cache::forget('stats:active_subscriptions');
        Cache::forget("tenant:{$subscription->tenant_id}:stats");
        
        // Update search index asynchronously
        dispatch(new UpdateSearchIndex($subscription));
    }
}

3. Query Result Caching

We cached expensive aggregation queries:

public function getTenantStats(int $tenantId)
{
    return Cache::remember("tenant:{$tenantId}:stats", 600, function () use ($tenantId) {
        return DB::table('activities')
            ->where('tenant_id', $tenantId)
            ->selectRaw('
                COUNT(*) as total_activities,
                COUNT(DISTINCT user_id) as active_users,
                AVG(duration) as avg_duration
            ')
            ->first();
    });
}

Results: Cache hit ratio of 87%, response time down to 45ms.


Phase 3: Queue and Background Processing (50K-70K Users)

1. Horizon with Multiple Queues

We switched from database queues to Redis with Laravel Horizon:

// config/horizon.php
'environments' => [
    'production' => [
        'supervisor-high-priority' => [
            'connection' => 'redis',
            'queue' => ['high', 'default'],
            'balance' => 'auto',
            'maxProcesses' => 50,
            'tries' => 3,
            'timeout' => 300,
        ],
        'supervisor-notifications' => [
            'connection' => 'redis',
            'queue' => ['notifications'],
            'maxProcesses' => 20,
            'tries' => 5,
            'timeout' => 60,
        ],
        'supervisor-reports' => [
            'connection' => 'redis',
            'queue' => ['reports'],
            'maxProcesses' => 10,
            'tries' => 1,
            'timeout' => 1800,
        ],
    ],
],

2. Job Prioritization

We implemented priority-based job dispatching:

// High priority: User-facing operations
dispatch(new SendWelcomeEmail($user))->onQueue('high');

// Normal priority: Background tasks
dispatch(new GenerateReport($data))->onQueue('default');

// Low priority: Analytics, cleanup
dispatch(new CalculateDailyStats())->onQueue('low');

3. Batch Processing

For bulk operations, we used job batching:

use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;

$batch = Bus::batch([
    new ProcessChunk($users->slice(0, 1000)),
    new ProcessChunk($users->slice(1000, 1000)),
    new ProcessChunk($users->slice(2000, 1000)),
])->then(function (Batch $batch) {
    // All chunks processed
    Cache::put('migration:status', 'completed');
})->catch(function (Batch $batch, Throwable $e) {
    // Handle failure
})->dispatch();

Results: Job processing time reduced from 30 minutes to under 2 minutes.


Phase 4: Infrastructure Scaling (70K-100K Users)

1. Horizontal Scaling with Load Balancing

We moved to a horizontally scaled architecture:

# Nginx load balancer configuration
upstream laravel_backend {
    least_conn;
    server app1.internal:9000 weight=1 max_fails=3 fail_timeout=30s;
    server app2.internal:9000 weight=1 max_fails=3 fail_timeout=30s;
    server app3.internal:9000 weight=1 max_fails=3 fail_timeout=30s;
    server app4.internal:9000 weight=1 max_fails=3 fail_timeout=30s;
    
    keepalive 32;
}

server {
    location / {
        proxy_pass http://laravel_backend;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $host;
    }
}

2. Session Management

We moved sessions to Redis cluster:

// config/session.php
'driver' => 'redis',
'connection' => 'session',

// config/database.php
'redis' => [
    'session' => [
        'url' => env('REDIS_SESSION_URL'),
        'host' => env('REDIS_SESSION_HOST'),
        'password' => env('REDIS_SESSION_PASSWORD'),
        'port' => env('REDIS_SESSION_PORT', 6379),
        'database' => 1,
    ],
],

3. CDN Integration

Static assets moved to CloudFront CDN:

// config/filesystems.php
'disks' => [
    's3' => [
        'driver' => 's3',
        'key' => env('AWS_ACCESS_KEY_ID'),
        'secret' => env('AWS_SECRET_ACCESS_KEY'),
        'region' => env('AWS_DEFAULT_REGION'),
        'bucket' => env('AWS_BUCKET'),
        'url' => env('AWS_URL'),
        'endpoint' => env('AWS_ENDPOINT'),
    ],
],

// Asset helper
function asset_cdn($path)
{
    return config('app.cdn_url') . '/' . $path;
}

Results: Application servers handling 10,000+ req/s with 99.9% uptime.


Phase 5: Advanced Optimizations

1. Database Connection Pooling

We implemented PgBouncer for PostgreSQL connection pooling:

# pgbouncer.ini
[databases]
laravel = host=localhost port=5432 dbname=laravel_production

[pgbouncer]
listen_port = 6432
listen_addr = 127.0.0.1
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 25
reserve_pool_size = 5
reserve_pool_timeout = 3

2. Optimized Eloquent Usage

We reduced memory usage with cursor pagination:

// ❌ Memory intensive for large datasets
User::where('status', 'active')->get()->each(function ($user) {
    $this->processUser($user);
});

// ✅ Memory efficient with cursor
User::where('status', 'active')->cursor()->each(function ($user) {
    $this->processUser($user);
});

// ✅ Even better with lazy collections
User::where('status', 'active')->lazy(1000)->each(function ($user) {
    $this->processUser($user);
});

3. API Response Caching

We implemented aggressive API response caching:

namespace App\Http\Middleware;

class CacheApiResponse
{
    public function handle(Request $request, Closure $next)
    {
        if ($request->method() !== 'GET') {
            return $next($request);
        }
        
        $key = 'api:' . md5($request->fullUrl());
        
        return Cache::remember($key, 300, function () use ($request, $next) {
            return $next($request);
        });
    }
}

4. Database Partitioning

For the activities table (500M+ rows), we implemented time-based partitioning:

-- Partition by month
CREATE TABLE activities_2024_01 PARTITION OF activities
    FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');

CREATE TABLE activities_2024_02 PARTITION OF activities
    FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');

-- Automatic partition management
CREATE OR REPLACE FUNCTION create_monthly_partition()
RETURNS void AS $$
DECLARE
    start_date date;
    end_date date;
    partition_name text;
BEGIN
    start_date := date_trunc('month', CURRENT_DATE + interval '1 month');
    end_date := start_date + interval '1 month';
    partition_name := 'activities_' || to_char(start_date, 'YYYY_MM');
    
    EXECUTE format('CREATE TABLE IF NOT EXISTS %I PARTITION OF activities FOR VALUES FROM (%L) TO (%L)',
        partition_name, start_date, end_date);
END;
$$ LANGUAGE plpgsql;

Monitoring and Observability

1. Application Performance Monitoring

We integrated New Relic for deep insights:

// Custom instrumentation
public function processPayment(Payment $payment)
{
    newrelic_start_transaction(config('app.name'));
    newrelic_name_transaction("payment/process");
    
    try {
        $result = $this->gateway->charge($payment);
        newrelic_add_custom_parameter('payment_id', $payment->id);
        newrelic_add_custom_parameter('gateway', $this->gateway->name);
        
        return $result;
    } catch (\Exception $e) {
        newrelic_notice_error($e);
        throw $e;
    }
}

2. Custom Health Checks

Real-time system health monitoring:

Route::get('/health', function () {
    $checks = [
        'database' => DB::connection()->getPdo() !== null,
        'redis' => Redis::ping(),
        'queue' => Queue::size() < 10000,
        'storage' => Storage::disk('s3')->exists('health-check.txt'),
    ];
    
    $healthy = !in_array(false, $checks, true);
    
    return response()->json([
        'status' => $healthy ? 'healthy' : 'degraded',
        'checks' => $checks,
        'timestamp' => now(),
    ], $healthy ? 200 : 503);
});

3. Performance Metrics Dashboard

// Store metrics in time-series database
public function recordMetric(string $metric, float $value)
{
    InfluxDB::writePoints([
        new Point(
            $metric,
            $value,
            ['environment' => config('app.env')],
            [],
            time()
        )
    ]);
}

// Usage
$this->recordMetric('api.response_time', $responseTime);
$this->recordMetric('database.query_count', $queryCount);
$this->recordMetric('cache.hit_rate', $hitRate);

Cost Optimization Strategies

Infrastructure Costs at Scale

Before Optimization:

  • Monthly cost: $12,000
  • Per-user cost: $0.12

After Optimization:

  • Monthly cost: $8,500
  • Per-user cost: $0.085

Key Cost Reductions

  1. Reserved Instances: 40% savings on compute
  2. S3 Intelligent Tiering: 30% savings on storage
  3. CloudFront: 50% reduction in bandwidth costs
  4. RDS Optimization: Right-sized instances saved 25%

Final Architecture at 100K Users

                    ┌─────────────┐
                    │  CloudFlare │
                    │     CDN     │
                    └──────┬──────┘
                           │
                    ┌──────▼──────┐
                    │   Route 53  │
                    └──────┬──────┘
                           │
                    ┌──────▼──────────┐
                    │  Load Balancer  │
                    └──────┬──────────┘
                           │
        ┌──────────────────┼──────────────────┐
        │                  │                  │
   ┌────▼────┐        ┌───▼────┐       ┌────▼────┐
   │  App 1  │        │ App 2  │       │  App 3  │
   └────┬────┘        └───┬────┘       └────┬────┘
        │                 │                  │
        └─────────┬───────┴──────────────────┘
                  │
         ┌────────┼────────┐
         │        │        │
    ┌────▼───┐ ┌─▼────┐ ┌─▼─────┐
    │ Redis  │ │MySQL │ │  S3   │
    │Cluster │ │Master│ │Bucket │
    └────────┘ └──┬───┘ └───────┘
                  │
           ┌──────┴──────┐
           │   Replicas  │
           └─────────────┘

Infrastructure Specifications:

  • Application Servers: 4x c5.2xlarge EC2 instances
  • Database: RDS db.r5.2xlarge (Master) + 2 read replicas
  • Cache: ElastiCache Redis Cluster (3 nodes)
  • Queue Workers: 3x t3.large instances
  • Storage: S3 with CloudFront CDN
  • Load Balancer: Application Load Balancer

Key Metrics Achieved

Performance Metrics

Metric Before After Improvement
Avg Response Time 800ms 180ms 77% faster
95th Percentile 2.5s 450ms 82% faster
Database Queries 45/request 8/request 82% reduction
Cache Hit Rate 45% 87% 93% improvement
Queue Processing 30min 90s 95% faster
Uptime 99.2% 99.95% 0.75% increase

Business Metrics

  • User Growth: 5K → 100K users (2000% increase)
  • Cost per User: $0.12 → $0.085 (29% decrease)
  • Customer Satisfaction: 3.8 → 4.6 stars
  • Churn Rate: 12% → 4% (67% improvement)

Lessons Learned

1. Optimize Before Scaling

Database optimization and caching provided 10x more impact than adding servers.

2. Measure Everything

Without metrics, optimization is guesswork. New Relic and custom monitoring were invaluable.

3. Cache Aggressively

87% cache hit rate eliminated millions of database queries daily.

4. Queue Everything Async

Moving slow operations to queues improved user experience dramatically.

5. Plan for Failure

Circuit breakers, retries, and graceful degradation prevented cascading failures.


Conclusion

Scaling a Laravel SaaS platform to 100,000 active users required systematic optimization across every layer: database, application, caching, queuing, and infrastructure. The key was identifying bottlenecks through metrics, implementing targeted optimizations, and continuously monitoring performance.

Laravel 12's robust ecosystem—Horizon, Redis, Eloquent optimization, and excellent package support—made this scaling journey achievable without rewriting the application. By following these strategies, you can scale your Laravel SaaS platform efficiently and cost-effectively.

Key takeaways:

  • Profile and optimize queries before scaling infrastructure
  • Implement multi-layer caching strategy
  • Use Redis-backed queues with Horizon
  • Scale horizontally with load balancing
  • Monitor everything with APM tools
  • Optimize costs with reserved instances and CDN
  • Plan architecture for 10x current scale

Need help scaling your Laravel SaaS platform? NeedLaravelSite specializes in performance optimization and scalability consulting. Contact us for expert Laravel development services.


Related Resources:


Article Tags

Laravel scalability Laravel 12 scaling SaaS scaling Laravel Laravel performance 100K users Laravel horizontal scaling Laravel database scaling Laravel Redis optimization Laravel queue scaling Laravel load balancing SaaS architecture Laravel Laravel multi-tenancy scaling Laravel CDN integration Laravel caching strategy Database sharding Laravel Laravel microservices Laravel Kubernetes Laravel auto-scaling High traffic Laravel Laravel infrastructure scaling Laravel optimization case study

About the Author

Muhammad Waqas

Muhammad Waqas

CEO at CentoSquare

Founder & CEO at CentoSquare | Creator of NeedLaravelSite | Helping Businesses Grow with Cutting-Edge Web, Mobile & Marketing Solutions | Building Innovative Products