Article

Surge Pricing in Laravel: Demand-Based Multipliers with Multi-Layer Caching and Kafka Events

D

Dinesh Wijethunga

May 23, 2026 4 min readBeginner 131 views
πŸ“

Surge pricing is not just "multiply the fare by 1.5x at peak hours." A production implementation needs to handle: automatic rules that trigger from demand signals, manual admin overrides for special events, cache invalidation when rules change, and non-blocking event publishing so the HTTP response doesn't wait on Kafka.

Here's how it's built in Taggo.

Two Surge Mechanisms

Surge Rules β€” database records that define trigger conditions:

// Trigger when: pending_requests >= 10 AND available_drivers <= 3
// β†’ Apply multiplier: 1.5x
// Priority: 1 (higher wins if multiple rules trigger)

Surge Events β€” admin-created time-bounded overrides:

// "Dubai Shopping Festival" β€” 1.8x from Dec 26 to Jan 1
// Overrides any matching rule while active

Rules fire automatically based on demand data from Node.js microservices. Events are set manually by admins. Events take priority over rules.

The SurgeRule Model

// Modules/BookingConfig/app/Models/SurgeRule.php

class SurgeRule extends Model
{
    use HasUuids, SoftDeletes;

    protected $casts = [
        'min_requests'         => 'integer',
        'max_drivers'          => 'integer',
        'multiplier'           => 'decimal:2',
        'priority'             => 'integer',
        'cooldown_minutes'     => 'integer',
        'max_duration_minutes' => 'integer',
        'is_active'            => 'boolean',
    ];

    public function isTriggered(int $pendingRequests, int $availableDrivers): bool
    {
        return $pendingRequests >= $this->min_requests
            && $availableDrivers <= $this->max_drivers;
    }
}

The Evaluator: Priority Hierarchy

// Modules/BookingConfig/app/Services/SurgeEvaluatorService.php

private static array $requestCache = [];  // In-memory, per-request

public function resolve(
    string $zoneId,
    string $serviceTypeId,
    int $pendingRequests = 0,
    int $availableDrivers = 0,
): float {
    $key = "$zoneId:$serviceTypeId";

    // Layer 1: Request-level static cache
    if (isset(self::$requestCache[$key])) {
        return self::$requestCache[$key];
    }

    // Layer 2: Redis cache (10-second TTL β€” surge changes fast)
    $multiplier = (float) Cache::remember(
        $this->surgeCacheKey($zoneId, $serviceTypeId),
        10,
        fn () => $this->computeMultiplier($zoneId, $serviceTypeId, $pendingRequests, $availableDrivers)
    );

    self::$requestCache[$key] = $multiplier;

    return $multiplier;
}

private function computeMultiplier(
    string $zoneId,
    string $serviceTypeId,
    int $pendingRequests,
    int $availableDrivers,
): float {
    // Priority 1: Active admin-created surge event
    $event = SurgeEvent::active()
        ->forZoneAndService($zoneId, $serviceTypeId)
        ->orderByDesc('multiplier')
        ->value('multiplier');

    if ($event) {
        return (float) $event;
    }

    // Priority 2: Triggered demand-based rule
    $rule = SurgeRule::active()
        ->forZoneAndService($zoneId, $serviceTypeId)
        ->where('min_requests', '<=', $pendingRequests)
        ->where('max_drivers', '>=', $availableDrivers)
        ->orderByDesc('priority')
        ->value('multiplier');

    return $rule ? (float) $rule : 1.0;
}

A fare estimate request that calls resolve() twice (rare, but possible if two services both need the multiplier) hits the static array on the second call β€” zero Redis round-trips.

Cache Invalidation on Rule Changes

When an admin creates, updates, or deletes a surge rule, the Redis cache for that zone/service pair is invalidated immediately:

public function createRule(array $data): SurgeRule
{
    return DB::transaction(function () use ($data) {
        $rule = SurgeRule::create($data);
        $this->invalidateCache($rule->zone_id, $rule->service_type_id);
        $this->publishRuleUpdated($rule, 'created');
        return $rule->load(['zone', 'serviceType']);
    });
}

private function invalidateCache(string $zoneId, string $serviceTypeId): void
{
    Cache::forget($this->surgeCacheKey($zoneId, $serviceTypeId));
}

The next fare estimate after a rule change will miss the Redis cache, hit the DB, and rebuild β€” then serve from Redis for the next 10 seconds.

Kafka Event Publishing

Every surge rule change publishes a Kafka event for downstream consumers (node-realtime, analytics):

private function publishRuleUpdated(SurgeRule $rule, string $action): void
{
    try {
        $this->kafkaPublisher->publish(
            config('bookingconfig.kafka.topics.surge_rule_updated'),
            [
                'action'          => $action,
                'zone_id'         => $rule->zone_id,
                'service_type_id' => $rule->service_type_id,
                'multiplier'      => (float) $rule->multiplier,
                'is_active'       => $rule->is_active,
                'timestamp'       => now()->toIso8601String(),
            ]
        );
    } catch (\Throwable $e) {
        // Non-blocking β€” log and continue. HTTP response never waits on Kafka.
        Log::error("SurgeEvaluator: Kafka publish failed: {$e->getMessage()}", [
            'rule_id' => $rule->id,
        ]);
    }
}

The try/catch around the Kafka publish is intentional. A Kafka broker outage should not block an admin from creating surge rules. The HTTP response returns. The event is lost in the worst case β€” but the rule is saved, the cache is invalidated, and fares are updated. mateusjunges/laravel-kafka <!-- REFERRAL: mateusjunges/laravel-kafka --> handles the producer internals.

Demand Signals from Node.js

The pendingRequests and availableDrivers values come from a Node.js demand service. It calls an internal API endpoint on every fare estimate:

POST api/v1/internal/booking-config/fare/estimate
X-Internal-Key: {secret}

{
  "pickup_lat": 25.2048,
  "pickup_lng": 55.2708,
  "pending_requests": 14,
  "available_drivers": 2
}

The InternalApiKeyMiddleware validates the X-Internal-Key header using hash_equals() (timing-safe comparison). The demand numbers feed directly into SurgeEvaluatorService::resolve().

At 14 pending requests and 2 available drivers, a rule set with min_requests >= 10 and max_drivers <= 3 triggers β€” and the customer's fare estimate reflects the 1.5x surge before they confirm the booking.

D

Dinesh Wijethunga

Senior Full Stack Developer Β· Building SaaS products & teaching Laravel/React Β· 10+ years experience Β· Founder of Orion360 Β· Based in Dubai, UAE.

Reviews & Ratings

Sign in to leave a review.

Comments(0)

Guest comments are held for moderation.

Building Surge Pricing in Laravel: Demand-Based Multipliers, Caching, | DineshStack