Skip to main content

How I Handled 15k req/min in a Laravel + Vue Campaign: Architecture That Scales

Author
Ignacio AmatIgnacio Amat
Published
Reading Time11 min
Server room with hardware racks and blue lights representing high-performance infrastructure

Global brand campaigns don’t warn you before they explode. You get a launch date, an estimated traffic budget, and a tacit expectation that your stack won’t fall over. In this case, the real number exceeded the initial estimate by almost an order of magnitude. Here’s what we did to keep the application responding.


The Context: A 48-Hour Window

The campaign had a 48-hour activation window with concentrated spikes in the first 30 minutes of each phase. The original stack was Laravel + Vue 2 with a monolithic frontend that loaded the entire bundle upfront. Under normal load it worked. Under 15,000 concurrent requests per minute, the server started returning 502s and the frontend froze on mid-range mobile devices.

The problem wasn’t a single bottleneck. It was a cascade: the heavy frontend generated more API requests, the API had no strategic caching, and payment transactions blocked workers synchronously.


1. Reducing Initial Bundle with Vue 3 + Suspense

The first change was migrating the frontend to Vue 3 with route-based lazy loading. We split the application into chunks by route and used Suspense to handle loading states without blocking the main render.

<!-- App.vue - Critical shell only -->
<script setup lang="ts">
import { Suspense } from 'vue'
import AppHeader from '@/components/AppHeader.vue'
import AppFooter from '@/components/AppFooter.vue'

// Lazy routes — only loaded when visited
const CampaignHero = defineAsyncComponent(() => import('@/pages/CampaignHero.vue'))
const ProductCatalog = defineAsyncComponent(() => import('@/pages/ProductCatalog.vue'))
const AdminDashboard = defineAsyncComponent(() => import('@/pages/AdminDashboard.vue'))
const Analytics = defineAsyncComponent(() => import('@/pages/Analytics.vue'))
</script>

<template>
  <AppHeader />
  <main>
    <Suspense fallback="Loading campaign…">
      <router-view v-slot="{ Component }">
        <component :is="Component" />
      </router-view>
    </Suspense>
  </main>
  <AppFooter />
</template>
// router/index.ts — Route-based code splitting
const routes = [
  {
    path: '/',
    name: 'hero',
    component: () => import('@/pages/CampaignHero.vue'), // Critical: in initial bundle
  },
  {
    path: '/catalog',
    name: 'catalog',
    component: () => import('@/pages/ProductCatalog.vue'), // Lazy chunk
  },
  {
    path: '/admin',
    name: 'admin',
    component: () => import('@/pages/AdminDashboard.vue'), // Lazy — most users never see
    meta: { requiresAuth: true, roles: ['admin'] }
  },
  {
    path: '/analytics',
    name: 'analytics',
    component: () => import('@/pages/Analytics.vue'), // Lazy
  },
]

Results:

  • Initial bundle: 180 KB → 95 KB gzipped (47% reduction)
  • 3G connections: >1s improvement in perceived load time
  • Browser no longer downloads admin/analytics components that 95% of users never touch
  • First paint occurs before rest of app hydrates

2. Smart Caching with Redis + Dynamic TTL

The second bottleneck was backend. Every visit queried campaign config, product catalog, and stock status. All changed, but not every second.

// app/Services/Cache/CampaignCacheService.php
class CampaignCacheService
{
    public function __construct(
        private Redis $redis,
        private CampaignConfigRepository $configRepo,
        private ProductCatalogRepository $catalogRepo,
    ) {}

    public function getConfig(string $campaignId): CampaignConfig
    {
        return $this->redis->remember(
            "campaign:{$campaignId}:config",
            300, // 5 min TTL — only changes when marketing activates new phase
            fn() => $this->configRepo->findActive($campaignId)
        );
    }

    public function getCatalog(string $campaignId): ProductCollection
    {
        return $this->redis->remember(
            "campaign:{$campaignId}:catalog",
            120, // 2 min TTL — stock updated from queues, not user request
            fn() => $this->catalogRepo->getActiveWithStock($campaignId)
        );
    }

    public function invalidateCatalog(string $campaignId): void
    {
        // Called from StockUpdatedJob after queue processes stock change
        $this->redis->del("campaign:{$campaignId}:catalog");
    }
}
// app/Http/Middleware/SkipCacheMiddleware.php — Explicit opt-out for real-time
class SkipCacheMiddleware
{
    public function handle(Request $request, Closure $next): Response
    {
        $response = $next($request);
        
        // Endpoints that MUST be real-time (results, payments, user-specific)
        if ($this->isRealTimeEndpoint($request)) {
            $response->headers->set('X-Cache', 'SKIP');
            $response->headers->set('Cache-Control', 'no-store, private');
        }
        
        return $response;
    }
    
    private function isRealTimeEndpoint(Request $request): bool
    {
        $realTimePatterns = [
            'api/v1/campaigns/*/results',
            'api/v1/payments/*',
            'api/v1/user/*',
        ];
        
        return collect($realTimePatterns)->contains(fn($pattern) => 
            $request->is($pattern)
        );
    }
}

Cache Strategy Summary:

DataTTLInvalidationWhy
Campaign config5 minManual (phase change)Changes only when marketing activates phase
Product catalog2 minStockUpdatedJob eventStock updated from queues, not user request
Real-time resultsNoneN/AMust reflect live state
User session/dataNoneN/APersonalized, never cache

Key principle: Be selective. Caching everything creates inconsistencies. Caching nothing creates unnecessary load. Document every decision in code so the next developer understands why one endpoint has 120s TTL and another has zero.


3. Laravel Queues for Payments + Ticket Generation

The third-party payment integration was the most fragile point. The provider had variable latency and a concurrency limit. Processing payments synchronously meant a traffic spike could saturate workers and block requests from users just browsing the catalog.

// app/Http/Controllers/PaymentController.php
class PaymentController extends Controller
{
    public function initiate(InitiatePaymentRequest $request): JsonResponse
    {
        $user = $request->user();
        $campaign = $request->validated('campaign_id');
        
        // 1. Create pending payment record (fast, no external calls)
        $payment = Payment::create([
            'user_id' => $user->id,
            'campaign_id' => $campaign,
            'amount' => $request->validated('amount'),
            'currency' => 'EUR',
            'status' => 'pending',
            'idempotency_key' => $request->header('Idempotency-Key') ?? Str::uuid(),
        ]);
        
        // 2. Dispatch to queue — IMMEDIATE response to user
        ProcessPaymentJob::dispatch($payment)
            ->onQueue('payments') // Dedicated queue, dedicated workers
            ->delay(now()->addSeconds(1)); // Small delay for DB consistency
        
        return response()->json([
            'payment_id' => $payment->id,
            'status' => 'processing',
            'message' => 'Your payment is being processed. You\'ll receive confirmation shortly.',
        ], 202);
    }
}
// app/Jobs/ProcessPaymentJob.php
class ProcessPaymentJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
    
    public $tries = 3;
    public $backoff = [30, 60, 120]; // Exponential backoff: 30s, 60s, 120s
    public $timeout = 60;
    
    public function __construct(
        public Payment $payment,
    ) {}
    
    public function handle(
        PaymentProvider $provider,
        NotificationService $notifications,
    ): void {
        DB::transaction(function () use ($provider, $notifications) {
            // Refresh to get latest state
            $this->payment->refresh();
            
            if ($this->payment->status !== 'pending') {
                return; // Already processed (idempotency)
            }
            
            try {
                // 3. Call external provider (variable latency, rate limited)
                $result = $provider->charge([
                    'amount' => $this->payment->amount,
                    'currency' => $this->payment->currency,
                    'customer' => $this->payment->user->stripe_customer_id,
                    'idempotency_key' => $this->payment->idempotency_key,
                    'metadata' => [
                        'campaign_id' => $this->payment->campaign_id,
                        'payment_id' => $this->payment->id,
                    ],
                ]);
                
                $this->payment->update([
                    'status' => 'completed',
                    'provider_transaction_id' => $result->id,
                    'completed_at' => now(),
                ]);
                
                // 4. Async side effects — also queued
                GenerateTicketJob::dispatch($this->payment)->onQueue('documents');
                SendPaymentConfirmationEmail::dispatch($this->payment)->onQueue('notifications');
                
            } catch (ProviderRateLimitException $e) {
                // Re-queue with delay — don't fail the job
                $this->release($e->retryAfter ?? 60);
            } catch (ProviderException $e) {
                $this->payment->update([
                    'status' => 'failed',
                    'failure_reason' => $e->getMessage(),
                    'failed_at' => now(),
                ]);
                
                $notifications->notifyUser($this->payment->user, 'payment_failed', [
                    'payment_id' => $this->payment->id,
                    'reason' => $e->getMessage(),
                ]);
                
                throw $e; // Let Laravel handle retry/backoff
            }
        });
    }
    
    public function failed(Throwable $exception): void
    {
        $this->payment->update([
            'status' => 'failed',
            'failure_reason' => $exception->getMessage(),
            'failed_at' => now(),
        ]);
        
        // Alert team — critical path failure
        report($exception);
    }
}
// config/horizon.php — Dedicated queue for payments
'queues' => [
    'critical' => ['payments', 'billing'],      // balance=3, timeout=60
    'default'  => ['integrations', 'emails'],   // balance=2, timeout=120
    'low'      => ['reports', 'cleanup'],       // balance=1, timeout=300
],

'environments' => [
    'production' => [
        'supervisor-critical' => [
            'connection' => 'redis',
            'queue' => ['payments', 'billing'],
            'balance' => 'simple',
            'processes' => 5,  // 5 dedicated workers for payments
            'tries' => 3,
            'timeout' => 60,
            'memory' => 256,
        ],
        'supervisor-default' => [
            'connection' => 'redis',
            'queue' => ['integrations', 'emails', 'documents', 'notifications'],
            'balance' => 'simple',
            'processes' => 3,
        ],
    ],
],

Why This Was the Highest-Impact Decision:

Before (Sync)After (Async Queue)
User waits 3-5s for paymentUser gets 202 Accepted in <200ms
Provider 3s latency → all workers blockedDedicated 5 workers absorb latency
502s under load when provider slowSystem stable, queue buffers spikes
No retry logicExponential backoff + idempotency
No observabilityHorizon dashboard + Sentry tracing

4. Blue/Green Deploy with Zero Downtime

In a 48-hour campaign, you can’t afford 30 seconds of downtime for a migration or hotfix.

# .github/workflows/deploy-campaign.yml
name: Deploy Campaign (Blue/Green)

on:
  workflow_dispatch:
    inputs:
      version:
        description: 'Version tag to deploy'
        required: true
      environment:
        description: 'Target environment'
        required: true
        type: choice
        options: [staging, production]

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ github.event.inputs.environment }}
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: redis, pdo, pdo_mysql
          coverage: none
      
      - name: Install dependencies
        run: composer install --prefer-dist --no-progress --no-interaction
      
      - name: Build frontend
        run: |
          npm ci
          npm run build
      
      - name: Deploy to Forge (parallel environment)
        env:
          FORGE_API_TOKEN: ${{ secrets.FORGE_API_TOKEN }}
        run: |
          # Deploy to NEW server (blue/green)
          forge deploy:server \
            --server= ${{ secrets.FORGE_SERVER_NEW }} \
            --site= ${{ secrets.FORGE_SITE }} \
            --commit= ${{ github.sha }} \
            --branch= main
      
      - name: Run migrations on new server
        run: |
          forge ssh ${{ secrets.FORGE_SERVER_NEW }} \
            "cd /home/forge/${{ secrets.FORGE_SITE }} && php artisan migrate --force --isolated"
      
      - name: Health check new server
        run: |
          for i in {1..10}; do
            if curl -sf "https://new.${{ secrets.FORGE_SITE }}/health"; then
              echo "✅ New server healthy"
              exit 0
            fi
            sleep 3
          done
          echo "❌ Health check failed"
          exit 1
      
      - name: Switch traffic (atomic nginx reload)
        run: |
          forge site:update ${{ secrets.FORGE_SERVER_NEW }} ${{ secrets.FORGE_SITE }} \
            --root-directory=/home/forge/${{ secrets.FORGE_SITE }}/current/public
          forge nginx:reload ${{ secrets.FORGE_SERVER_NEW }}
      
      - name: Verify post-switch
        run: |
          sleep 5
          curl -sf "https://${{ secrets.FORGE_SITE }}/health" || exit 1
          echo "✅ Deploy complete"
      
      - name: Cleanup old server (after 30 min)
        if: success()
        run: |
          sleep 1800
          forge server:delete ${{ secrets.FORGE_SERVER_OLD }} --force
// routes/web.php — Health check with dependency verification
Route::get('/health', function () {
    $checks = [
        'database' => fn() => DB::connection()->getPdo() ? 'ok' : 'fail',
        'redis' => fn() => Redis::connection()->ping() === 'PONG' ? 'ok' : 'fail',
        'queue' => fn() => Horizon::isRunning() ? 'ok' : 'degraded',
        'storage' => fn() => Storage::disk('local')->exists('.health') ? 'ok' : 'fail',
    ];
    
    $results = collect($checks)->map(fn($check) => $check())->toArray();
    $status = collect($results)->every(fn($v) => $v === 'ok') ? 'ok' : 'degraded';
    
    return response()->json([
        'status' => $status,
        'version' => config('app.version', 'unknown'),
        'git_sha' => trim(shell_exec('git rev-parse --short HEAD 2>/dev/null') ?? ''),
        'deployed_at' => now()->toISOString(),
        'checks' => $results,
    ])->header('Cache-Control', 'no-store, private');
})->middleware('throttle:60,1');

During Campaign: 2 Hotfixes, 0 Incidents

DeployReasonSwitch TimeRollback TimeUser Incidents
v2.1.3Fix hero copy typo1.8sN/A0
v2.1.4Adjust stock cache TTL2.1sN/A0

Key: Tests + migrations + health check in parallel environment BEFORE switch. If anything fails, traffic stays on previous version.


Final Numbers: What Matters to Business

KPITargetResultBusiness Impact
Campaign uptime99.5%99.97%0 lost sales
API p95 (peak)<500ms120msSmooth UX, 0 complaints
Initial bundle<200KB95 KB+34% mobile conversion
Failed payments<2%0.3%€42k recovered
Critical incidents00Product team slept well
Stack reuse3 more campaignsArchitecture ROI ×4

Conclusion: The Hard Part Isn’t The Technique

Handling high traffic isn’t adding servers. It’s understanding where the real bottleneck lives:

BottleneckSymptomSolution Applied
Heavy frontendParse bundle >3s mobileVue 3 lazy loading + Suspense
Repeated queries150ms/request catalogRedis semantic TTL (2-5 min)
Sync paymentsWorkers blocked 3sLaravel queues + dedicated workers
Deploy risk30s downtime unacceptableBlue/Green + health checks

None of these techniques are exotic. The hard part is knowing when to apply each, measuring before/after, and documenting so the next dev doesn’t break what works.


Is Your Team Preparing a Campaign With Uncertain Traffic?

If you use Laravel and/or Vue and need architecture that handles spikes without waking you at 3 AM, I can help:

  • Architecture audit (2 days): Identify bottlenecks, propose prioritized fixes
  • Hands-on implementation (1-2 weeks): Caching, queues, frontend, zero-downtime deploy
  • Realistic load testing: Simulate your traffic pattern, validate limits, document runbooks

Modalities:

  • EOR (Deel, Remote, Oyster) — core indefinite role
  • Freelance B2B (Autónomo, intra-community invoice 0% VAT) — 3-12 month projects
  • Direct permanent — if you have Spanish entity

Current stack: Laravel 11, PHP 8.3+, Vue 3 + TS, Inertia.js, Livewire 3, Astro, Docker, GitHub Actions, Laravel Pulse, Sentry, Claude Code / Cursor / MCP servers.

View my profile, full stack & terms →


Related articles

Review my developer profile

If this article matches the kind of product work your team is facing, review my stack or professional availability.

Tell me what you need

You can reach out about a role, contract, technical collaboration, question, or general inquiry. A few lines of context are usually enough for me to reply within 24 business hours.

0/500
Availability