Horizon, Reverb, and Octane in queue-heavy Laravel systems

A real Horizon/Redis experiment verifies notification progress during an export backlog, while keeping Reverb delivery and Octane request lifetime claims separate.
Find the stage that is holding up the user
A Laravel developer investigating a delayed export notification has several boundaries to inspect: HTTP acceptance, queue wait, handler execution and delivery to the browser. This article runs a controlled queue experiment with real Horizon workers. It does not run a complete export feature or claim that changing an HTTP runtime improves a queue backlog.
In the experiment, one export stand-in waits at a Redis barrier and another stays queued. A notification stand-in completes through a separate supervisor before the barrier is released. Removing that supervisor, or sending the notification onto the export queue, makes the intended progress assertion fail. This demonstrates reserved worker capacity under the fixture’s constraints, not a production throughput gain.
Horizon supervises Laravel Redis queue workers and exposes queue metrics. Reverb serves WebSocket communication. Octane keeps an application loaded between HTTP requests. These roles can coexist, but choosing one does not establish that a delay in another stage has been fixed.
Run separate supervisors against the actual queue
The captured fixture uses Laravel 12.69.3, Horizon 5.50.1, Predis 3.7.0, PHP 8.5.6 and Redis 8.10.2. It runs real Horizon master, supervisor and worker processes. Redis has no TCP listener; PHP connects through a private Unix socket. Base images and Composer dependencies are pinned, and execution uses the built PHP image’s immutable ID. Native pcntl compilation uses transient Alpine build tools, so the build is not promised bit-identical across toolchain updates.
The following exact fragment is included under environments.local by the complete fixture configuration. Each named supervisor gets one worker and one queue, with balancing disabled. The small counts and timeouts are synthetic test bounds. They are not production sizing advice, and this fragment does not replace a complete, authorized Horizon dashboard configuration.
Download the full source and runner below. From the extracted root, run python3 frontend/content-quality/fixtures/horizon/verify.py results.json with Docker available. It builds the locked application, runs the normal case and two deliberately broken variants, and removes its task containers, socket volume and uniquely labeled image. Image and package retrieval use the network before execution; the application and Redis containers do not.
<?php
return [
'exports' => ['connection' => 'redis', 'queue' => ['exports'],
'balance' => false, 'maxProcesses' => 1, 'tries' => 1,
'timeout' => 8, 'sleep' => 1],
'notifications' => ['connection' => 'redis', 'queue' => ['notifications'],
'balance' => false, 'maxProcesses' => 1, 'tries' => 1,
'timeout' => 8, 'sleep' => 1],
];
Hold the export, then check progress rather than a speedup
The driver enqueues export-1 and export-2, starts Horizon, and waits until export-1 records that it has entered the barrier. Only then does it enqueue the notification. Before releasing the barrier, it requires notification completion, no completion for export-1, and no start for export-2. After release, both exports must finish. The captured monotonic events preserve that ordering.
The driver records notification enqueue, start and end separately. Queue wait is start minus enqueue; handler execution is end minus start. The runner independently checks both calculations and nonnegative values. There is no before/after workload, representative CSV, database contention or real notification delivery here, so the recorded durations must not be advertised as application latency or a Horizon performance benchmark.
Both mutations run the changed program or configuration, not a mocked queue. Removing the notification supervisor leaves no worker for its queue. Dispatching the notification onto exports puts it behind held work. Both must fail notification_before_release after export-1 has reached its barrier; a startup crash does not count as the expected result. Exact mounted-source hashes, runtime versions and exit codes are checked.
Keep timeout policy separate from the isolation result
The fixture supervisor timeout is eight seconds and Redis retry_after is twelve. Horizon’s Laravel 12 documentation says the supervisor timeout should exceed any job-level timeout and remain a few seconds below retry_after, to avoid overlapping processing. This experiment does not inject a worker timeout, demonstrate retry safety or test auto-balancing scale-down. Its normal export barrier is released before its own bounded wait expires.
I recommend treating reserved workers as a control over where work waits, not a guarantee of downstream capacity. These supervisors still share Redis and any database or external service the real jobs use. More workers can increase pressure on those dependencies. A real export also needs a durable operation identity and a verified policy for interruption after writing a file but before acknowledging completion; no file effect or duplicate-delivery recovery is implemented here.
Reverb delivery needs a current, authorized status path
Reverb’s allowed_origins setting rejects requests from origins outside its configured list. Laravel private broadcast channels separately authorize whether the authenticated user may subscribe. An allowed origin is therefore not evidence that a user owns a particular export. A real implementation needs both the appropriate connection policy and application authorization.
I recommend fetching the export’s current authorized status, then resuming listening for updates. The application should not decide that an export is unfinished merely because a browser did not observe its completion message. Keep download authorization on the request that retrieves the file too. These are design recommendations, not browser, channel-authorization or WebSocket tests from this fixture.
Also trace how the broadcast event reaches Reverb. Laravel queues ordinary ShouldBroadcast events; a broadcast job waiting on an overloaded queue is upstream of the WebSocket server. The fixture’s notification stand-in does not broadcast an event, so it proves queue progress rather than end-to-end message delivery.
Octane changes which objects outlive a request
Laravel 12’s Octane guidance warns about a request injected into a singleton resolved during application boot. Consider request A for account Cedar captured by that singleton. Later, request B for Harbour becomes current, but the singleton still holds the reference to A. Looking up data through that stored request can therefore use stale input. This is a sourced object-lifetime walkthrough, not an executed Octane cross-account test.
I prefer passing the specific information a service needs into a method at request time instead of storing the whole request. Derive that information from the current authorized context. The Octane documentation also describes non-singleton bindings and current-request resolvers; a closure that captures the original request variable would not become current merely because it is a closure.
Profile the actual HTTP endpoint before evaluating a persistent HTTP worker. This Redis fixture says nothing about Octane throughput, request isolation or memory growth, and it does not establish Reverb reliability. Its useful result is narrower and observable: a notification worker can progress while export workers are occupied, provided dispatch and supervisor configuration preserve that separation.
Reproduce the examples
Download the locked Laravel/Horizon application, exact configuration fragment, bounded Docker runner and two deliberate failure checks. From the extracted root run python3 frontend/content-quality/fixtures/horizon/verify.py results.json with Docker available. Synthetic queue progress only; no real export, WebSocket or Octane test.
Download the source and tests (.zip)
