Surge Pricing in Laravel: Demand-Based Multipliers with Multi-Layer Caching and Kafka Events
Dinesh Wijethunga
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.
Reviews & Ratings
Sign in to leave a review.
