Building a Modern SaaS Application with Laravel - Part 3: Production Scaling, Queues & Observability - NextGenBeing
Back to discoveries
Part 3 of 3

Building a Modern SaaS Application with Laravel - Part 3: Production Scaling, Queues & Observability

Laravel SaaS queues and observability: in my Horizon test a separate queue cut a small tenant's wait from about 15 s to about 2 s. Tenant log IDs, limits, queue depth.

Comprehensive Tutorials 17 min read
Bekzod Erkinov

Bekzod Erkinov

Apr 25, 2026 • 159 views
Building a Modern SaaS Application with Laravel - Part 3: Production Scaling, Queues & Observability
Size:
Height:
17 min read 6,515 words Focus mode: Eye care:

Listen to Article

Loading...
0:00 / 0:00
0:00 0:00
Low High
0% 100%
⏸ Paused ▶️ Now playing... Ready to play ✓ Finished
Table of contents · 8 sections

Building a Modern SaaS Application with Laravel - Part 3: Tenant-Aware Queues on Horizon, Logs, Rate Limits and Queue Depth

In my Horizon test, a free-plan tenant's 300-job burst made a pro tenant's 5 jobs wait a median of about 15 s on a shared queue; a separate queue with its own worker cut that to about 2 s. A per-tenant rate limit also helped (about 3 s), but it made workers start 1,025 jobs for 305 real ones. Along the way, the post puts the tenant ID on every log line (including lines written inside jobs) and exposes queue depth.

The rule, for scanners: use a rate limit when you need to cap what a tenant can consume (a provider quota, a plan limit); use a separate queue with its own workers when you need to protect other tenants from a big one.

This is Part 3. Part 1 covers tenant identification and context, and Part 2 has a gotcha, "the auth middleware runs before your tenant middleware", in its own post. The code below doesn't depend on them: a two-tenant stand-in replaces their tenant model.

Lab setup: Laravel 12, Horizon and Redis in Docker

The lab is a php:8.4-cli container (plus pcntl and phpredis) next to a Redis 7.4 container, with a stock Laravel 12 install, Horizon, and the files in this post copied over it. The Dockerfile and configure.php are in the appendix. Versions in my container, as printed by the tools:

$ php artisan --version
Laravel Framework 12.69.3
$ composer show laravel/horizon | grep versions
versions : * v5.50.0
$ php -r 'echo PHP_VERSION, " ", phpversion("redis");'
8.4.26 6.3.0

(Horizon releases; the last number is phpredis.) Save each file below under overlay/ at the path in its first comment, as the appendix describes.

The log, cache and benchmark excerpts come from separate runs on different days (their timestamps read 2026-10-08 and 2026-10-10), so don't expect them to line up with each other.

Laravel Horizon tenant queues: jobs that carry the tenant

Laravel's Context facade does the carrying. Its docs say information added to the context is appended as metadata to log entries, and that when a job is dispatched the context is captured, then hydrated back while the job executes. The tenant only has to be identified once, in a middleware. Mine reads a header; in your app this is where the tenant resolution from Parts 1 and 2 plugs in.

<?php
// app/Support/CurrentTenant.php

namespace App\Support;

use Illuminate\Support\Facades\Context;

/** Stand-in for the tenant resolution built in Parts 1-2. */
class CurrentTenant
{
    public const PLANS = [
        'acme'   => ['plan' => 'free', 'jobs_per_minute' => 60],
        'globex' => ['plan' => 'pro',  'jobs_per_minute' => 600],
    ];

    public static function set(string $id): void
    {
        abort_unless(isset(self::PLANS[$id]), 404, 'unknown tenant');
        Context::add('tenant_id', $id);
        Context::add('plan', self::PLANS[$id]['plan']);
    }

    public static function id(): ?string
    {
        return Context::get('tenant_id');
    }

    public static function limit(string $id): int
    {
        return self::PLANS[$id]['jobs_per_minute'] ?? 10;   // unknown tenant: small bucket
    }

    /** Every cache key a tenant touches goes through here. */
    public static function key(string $key, ?string $tenant = null): string
    {
        $tenant ??= self::id() ?? throw new \LogicException('No tenant in context');
        return "t:{$tenant}:{$key}";
    }
}
<?php
// app/Http/Middleware/IdentifyTenant.php

namespace App\Http\Middleware;

use App\Support\CurrentTenant;
use Closure;
use Illuminate\Http\Request;

class IdentifyTenant
{
    public function handle(Request $request, Closure $next)
    {
        CurrentTenant::set($request->header('X-Tenant', ''));
        return $next($request);
    }
}

In your real app: replace the X-Tenant header with whatever resolves the tenant there (subdomain, authenticated user) and keep the Context::add calls. Jobs dispatched from the scheduler or an Artisan command run with no HTTP request, so nothing sets the tenant and the strict constructor below throws. Set it first, for example at the top of the command's handle():

CurrentTenant::set($this->argument('tenant'));   // or Context::add('tenant_id', $tenant->id)

acme is on the free plan (60 jobs/minute), globex on pro (600/minute). The middleware needs no registration; the route group below applies it by class name. The ?? 10 fallback in limit() is reachable, and the HTTP section shows how.

The job reads the ID back from Context when it's constructed, records when it was created, and tags itself so Horizon can filter by tenant. wait_ms is the time from construction to the start of handle(), and it's what the benchmark reports. Two switches let one codebase run all three benchmark setups. The job reads them with config(); env() is called only in this small config file, as the configuration docs advise:

<?php
// config/queue-tenant.php

return [
    // Setup B: cap each tenant with RateLimitedWithRedis.
    'limit' => (bool) env('TENANT_JOB_LIMIT', true),
    // Setup C: send free-plan jobs to their own "free" queue.
    'split' => (bool) env('TENANT_QUEUE_SPLIT', false),
];
<?php
// app/Jobs/GenerateReport.php

namespace App\Jobs;

use App\Support\CurrentTenant;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Middleware\RateLimitedWithRedis;
use Illuminate\Support\Facades\Log;

class GenerateReport implements ShouldQueue
{
    use Queueable;

    public int $tries = 0;          // rate-limit releases consume attempts (see gotcha below)
    public int $maxExceptions = 3;  // ...so cap real failures instead

    public string $tenantId;
    public float $dispatchedAt;

    public function __construct(public int $n, public int $workMs = 200)
    {
        $this->tenantId = CurrentTenant::id();   // typed string: null throws instead of running tenant-less
        $this->dispatchedAt = microtime(true);

        if (config('queue-tenant.split')) {      // setup C
            $this->onQueue(CurrentTenant::PLANS[$this->tenantId]['plan'] === 'free' ? 'free' : 'default');
        }
    }

    public function middleware(): array
    {
        if (! config('queue-tenant.limit')) {    // setup B turns this on
            return [];
        }
        return [(new RateLimitedWithRedis('tenant-jobs'))->releaseAfter(5)];
    }

    public function tags(): array { return ['tenant:'.$this->tenantId]; }

    public function handle(): void
    {
        usleep($this->workMs * 1000);
        Log::info('report.generated', [
            'n' => $this->n,
            'wait_ms' => (int) ((microtime(true) - $this->dispatchedAt) * 1000),
        ]);
    }
}

The routes. /reports is what the benchmark calls; /stats and /leaky-stats come back in the cache section, /metrics in the health section. The /metrics route below is lab only and has no authentication; the health section shows the token check to add before it leaves your machine:

<?php
// routes/web.php

use App\Http\Middleware\IdentifyTenant;
use App\Jobs\GenerateReport;
use App\Support\CurrentTenant;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Route;

Route::middleware([IdentifyTenant::class, 'throttle:tenant-api'])
    ->withoutMiddleware(ValidateCsrfToken::class)
    ->group(function () {
        Route::post('/reports', function () {
            $count = (int) request('count', 1);
            for ($i = 1; $i <= $count; $i++) {
                GenerateReport::dispatch($i, (int) request('work_ms', 200));
            }
            Log::info('reports.enqueued', ['count' => $count]);
            return ['enqueued' => $count, 'tenant' => CurrentTenant::id()];
        });

        Route::get('/stats', fn () => Cache::remember(
            CurrentTenant::key('stats'), 60,
            fn () => ['computed_for' => CurrentTenant::id(), 'at' => now()->toIso8601String()]
        ));

        Route::get('/leaky-stats', fn () => Cache::remember(
            'stats', 60, fn () => ['computed_for' => CurrentTenant::id()]
        ));
    });

// LAB ONLY, unauthenticated. Plain-text queue gauges, Prometheus exposition format.
Route::get('/metrics', function () {
    $lines = ['# TYPE queue_pending gauge'];
    foreach (['default', 'free'] as $q) {   // every queue you use, or it is invisible
        $lines[] = sprintf('queue_pending{queue="%s"} %d', $q, Redis::connection()->llen("queues:$q"));
        $lines[] = sprintf('queue_delayed{queue="%s"} %d', $q, Redis::connection()->zcard("queues:$q:delayed"));
    }
    return response(implode("\n", $lines)."\n", 200, ['Content-Type' => 'text/plain']);
});

And the service provider, which holds the two named limiters used below and the health listener used at the end:

<?php
// app/Providers/AppServiceProvider.php

namespace App\Providers;

use App\Support\CurrentTenant;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Events\DiagnosingHealth;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        // Queue side: one bucket per tenant, size set by plan.
        RateLimiter::for('tenant-jobs', fn (object $job) =>
            Limit::perMinute(CurrentTenant::limit($job->tenantId))->by('jobs:'.$job->tenantId));

        // HTTP side. In my run throttle executed before IdentifyTenant (see the HTTP section),
        // so this limiter reads the header itself instead of CurrentTenant::id().
        RateLimiter::for('tenant-api', function (Request $request) {
            $tenant = (string) $request->header('X-Tenant');
            return Limit::perMinute(CurrentTenant::limit($tenant))->by('api:'.$tenant);
        });

        // /up returns 500 if this throws.
        Event::listen(DiagnosingHealth::class, fn () => Redis::connection()->ping());
    }
}

If you are not using bench.sh, start Horizon and the web server like this (the second command is the built-in PHP server that bench.sh also uses; /app/public is the document root and the long path is the router script from the framework):

docker exec -d app sh -c 'cd /app && php artisan horizon > /tmp/horizon.out 2>&1'
docker exec -d app sh -c 'cd /app/public && php -S 0.0.0.0:8000 ../vendor/laravel/framework/src/Illuminate/Foundation/resources/server.php > /tmp/serve.log 2>&1'

Then, inside the container (docker exec -it app sh, then cd /app), dispatch two jobs as globex with curl -s -XPOST -H "X-Tenant: globex" "localhost:8000/reports?count=2&work_ms=100" (it printed {"enqueued":2,"tenant":"globex"} for me) and ask Horizon's job repository what it recorded. Horizon's dashboard is open in the local environment; elsewhere the viewHorizon gate in HorizonServiceProvider controls it, per the Horizon docs:

$ php artisan tinker --execute='foreach (app(Laravel\Horizon\Contracts\JobRepository::class)->getRecent() as $j) echo $j->name," status=",$j->status," tags=",implode(",", json_decode($j->payload)->tags ?? []),"\n";'
App\Jobs\GenerateReport status=completed tags=tenant:globex
App\Jobs\GenerateReport status=completed tags=tenant:globex

Laravel queue monitoring per tenant: tenant ID on every log line

The json channel that configure.php added is a Monolog StreamHandler to stderr with JsonFormatter, and nothing in it is tenant-specific. Here are the line the web request wrote and the first line the queue worker wrote about 0.7 s later in a different process, from one run with two globex jobs:

{"message":"reports.enqueued","context":{"count":2},"level":200,"level_name":"INFO","channel":"local","datetime":"2026-10-10T14:14:50.516577+00:00","extra":{"tenant_id":"globex","plan":"pro"}}
{"message":"report.generated","context":{"n":1,"wait_ms":726},"level":200,"level_name":"INFO","channel":"local","datetime":"2026-10-10T14:14:51.178716+00:00","extra":{"tenant_id":"globex","plan":"pro"}}

The Log::info inside the job passes no tenant, yet extra.tenant_id is there, so the job was handed the tenant at dispatch and hydrated it before handle() ran. grep '"tenant_id":"globex"' over the web and worker output now gives you one customer's timeline across both.

Per-tenant throughput from the same logs

The worker's report.generated lines are enough for a per-tenant counter, with no extra metrics code. With no jq in the container, this is grep, sed and uniq over a setup C run's output; 19 timestamp characters buckets by second, 16 by minute:

$ grep -h '"report.generated"' /tmp/h_C.out | sed -E 's/.*"datetime":"([0-9T:-]{19}).*"tenant_id":"([a-z]+)".*/\2 \1/' | sort | uniq -c | head -8
      5 acme 2026-10-08T01:19:20
      9 acme 2026-10-08T01:19:21
      9 acme 2026-10-08T01:19:22
      9 acme 2026-10-08T01:19:23
      9 acme 2026-10-08T01:19:24
      9 acme 2026-10-08T01:19:25
     10 acme 2026-10-08T01:19:26
      9 acme 2026-10-08T01:19:27

That is the single free worker draining Acme at about nine 100 ms jobs a second. Per minute, the same file gave 293 acme and 5 globex for 2026-10-08T01:19.

Laravel multi-tenant rate limiting vs a separate Horizon queue

acme (free plan) enqueues 300 jobs of 100 ms each. One second later, globex (pro) enqueues 5. A shared FIFO queue is setup A; B and C are below. configure.php already wrote the supervisors, and it reads HORIZON_WORKERS (worker count) and HORIZON_SLEEP (Horizon's poll interval; configure.php falls back to 3) from the environment.

In A and B the free supervisor sits idle, so the shared queue has 2 workers; A3 is A with HORIZON_WORKERS=3. In C the free queue gets its own worker and default keeps 2, so C has 3 workers in total. A3 is there to separate "more workers" from "separate queue".

bench.sh takes A, A3, B or C; HORIZON_SLEEP=1 sh bench.sh C sets the poll sleep. It restarts Horizon and the web server (PHP's built-in server, started directly), flushes Redis, sends the two bursts and waits 30 s after the second one. It also prints how many commands Redis processed from just before the first burst to the end, which matters for setup B below:

#!/bin/sh
# usage: sh bench.sh A|A3|B|C   (A3 = A with 3 workers; optional HORIZON_SLEEP=1 sh bench.sh C)
cd /app
case "$1" in
  A) export TENANT_JOB_LIMIT=0; unset TENANT_QUEUE_SPLIT ;;
  A3) export TENANT_JOB_LIMIT=0 HORIZON_WORKERS=3; unset TENANT_QUEUE_SPLIT ;;
  B) export TENANT_JOB_LIMIT=1; unset TENANT_QUEUE_SPLIT ;;
  C) export TENANT_JOB_LIMIT=0 TENANT_QUEUE_SPLIT=1 ;;
esac
php artisan horizon:terminate >/dev/null 2>&1; sleep 4
for p in /proc/[0-9]*; do   # stop the old web server
  if grep -qaE 'php.-[S]' $p/cmdline 2>/dev/null; then kill ${p#/proc/}; fi
done
sleep 1
(cd public && php -S 0.0.0.0:8000 ../vendor/laravel/framework/src/Illuminate/Foundation/resources/server.php >/tmp/serve.log 2>&1 &)
php -r '$r=new Redis;$r->connect(getenv("REDIS_HOST"));$r->flushAll();'
php artisan horizon > /tmp/h_${1}${HORIZON_SLEEP:+_s$HORIZON_SLEEP}.out 2>/dev/null &
sleep 8
cmds() { php -r '$r=new Redis;$r->connect(getenv("REDIS_HOST"));echo $r->info("stats")["total_commands_processed"];'; }
before=$(cmds)
curl -s -XPOST -H "X-Tenant: acme"   "localhost:8000/reports?count=300&work_ms=100" >/dev/null
sleep 1
curl -s -XPOST -H "X-Tenant: globex" "localhost:8000/reports?count=5&work_ms=100"  >/dev/null
sleep 30
echo "== setup $1"
php analyze.php /tmp/h_${1}${HORIZON_SLEEP:+_s$HORIZON_SLEEP}.out
echo "RUNNING lines: $(grep -c RUNNING /tmp/h_${1}${HORIZON_SLEEP:+_s$HORIZON_SLEEP}.out)"
echo "redis commands in window: $(( $(cmds) - before ))"
php artisan horizon:terminate >/dev/null 2>&1

analyze.php reads the worker's JSON log lines out of Horizon's output and prints min, median and max wait_ms per tenant; "done" is the number of report.generated lines in the window:

<?php
// analyze.php
$by = [];
foreach (file($argv[1]) as $l) {
    $l = trim($l);
    if (($l[0] ?? '') !== '{') continue;
    $j = json_decode($l, true);
    if (($j['message'] ?? '') !== 'report.generated') continue;
    $by[$j['extra']['tenant_id']][] = $j['context']['wait_ms'];
}
foreach ($by as $t => $w) {
    sort($w);
    printf("%-7s done=%3d  wait_ms min=%d median=%d max=%d\n", $t, count($w), $w[0], $w[intdiv(count($w),2)], end($w));
}

Output of one run of setup A3 (shared queue, 3 workers) from the batch behind the table:

== setup A3
acme    done=300  wait_ms min=556 median=6654 max=11713
globex  done=  5  wait_ms min=10695 median=10748 max=10828
RUNNING lines: 305
redis commands in window: 11430

Median wait_ms of Globex's 5 jobs per run, in seconds. Each run was a fresh Horizon with Redis flushed. A, A3, C and C with sleep=1 are three runs each; B is six runs (two batches):

Setup Workers Globex median wait, per run (s) Acme jobs done in window Job starts (RUNNING lines)
A. One shared queue, no limit 2 15.3 / 15.0 / 15.5 298 / 300 / 300 305 each
A3. One shared queue, no limit 3 10.7 / 10.9 / 12.5 300 each 305 each
B. Per-tenant rate limit (free = 60/min) 2 2.8 / 2.9 / 2.9 / 3.1 / 2.8 / 3.1 59 to 60 1,025 each
C. Separate free queue and supervisor 2 + 1 2.2 / 1.4 / 2.3 283 / 263 / 274 289 / 269 / 281
C, HORIZON_SLEEP=1 2 + 1 0.4 / 1.3 / 0.5 282 / 283 / 282 288 each

Caveat: one laptop, one Redis, one burst shape and a handful of runs, with only 5 Globex jobs per median, so this compares setups and says nothing about multi-server production. Read the gaps (about 15 s, 3 s, 2 s), not 0.4 s versus 1.4 s. C has three workers to A's and B's two and confines Acme to one, so part of its win is allocation; A3 shows what a third shared worker alone buys.

B: per-tenant rate limit

Laravel's RateLimitedWithRedis job middleware (the docs show releaseAfter only on RateLimited; in my vendor source RateLimitedWithRedis extends RateLimited and its handle() at line 53 uses the inherited $this->releaseAfter) plus a named limiter keyed by tenant: the tenant-jobs limiter in the service provider above. B held Acme to 59 or 60 jobs in the window, and Globex's wait dropped from about 15 s to 2.8 to 3.1 s in six runs. But a FIFO queue doesn't know a job is over its limit until a worker picks it up. The worker releases it back for 5 seconds and it comes round again, which is why B shows 1,025 job starts against A's 305. Use a limiter when you need a cap (an API quota with a downstream provider, a plan limit you sell). The docs say a rate-limited job is released back to the queue, which is the bouncing seen here.

The bouncing costs Redis traffic. bench.sh reports the commands Redis processed in the window (the total includes Horizon's own traffic): 11,385 to 11,395 in A and 11,422 to 11,430 in A3, against 27,971 to 32,307 in B.

Gotcha: released jobs burn attempts. The Horizon docs say that middleware such as RateLimited consumes attempts, and that without tries Horizon defaults to a single attempt. In my test I deleted $tries and $maxExceptions from the job, set TENANT_JOB_LIMIT=1, and had Acme enqueue 80 jobs (work_ms=50) against its limit of 60, then counted after 25 s. The default migrations already created the SQLite failed_jobs table:

report.generated lines: 60
failed_jobs rows: 20

The 20 over-limit jobs were released once, started a second attempt, and failed. With the job as printed above ($tries = 0, $maxExceptions = 3) the same test gave:

report.generated lines: 60
failed_jobs rows: 0

The same docs section says $tries = 0 allows unlimited attempts and $maxExceptions limits the exceptions. So an over-limit job can cycle for as long as the limit holds; size the limit and your queue depth together.

C: separate queues and supervisors

Put free-plan jobs on their own queue (the onQueue call in the constructor) and give that queue its own small pool (the supervisor-free entry); the Horizon docs describe this multiple-supervisor layout for allocating processes per queue. Globex's wait dropped to 1.4 to 2.3 s with the default sleep and 0.4 to 1.3 s with sleep=1, and nobody's jobs get bounced.

The cost: Acme's backlog drains through one worker, so only 263 to 283 of its 300 jobs finished in the window. The single free worker finished about nine 100 ms jobs a second in the log counts above, so the 300-job burst needs a bit over 30 s to drain. Globex's short wait in C is paid for by Acme's longer one.

Gotcha: a new supervisor needs its own defaults entry. I removed the supervisor-free block from defaults and kept it under environments. php artisan horizon then failed immediately in my run (v5.50.0):

ErrorException

Undefined array key "connection"

at vendor/laravel/horizon/src/SupervisorOptions.php:380

In my v5.50.0 vendor copy, line 380 of that file is return tap(new static($array['name'], $array['connection']), ... inside SupervisorOptions::fromArray, so a supervisor whose merged options lack connection fails like this.

Which one to use

  • Protect other tenants from a big one: use C, and size the pool with drain-rate arithmetic. Workers ÷ job duration is the drain rate; burst size ÷ drain rate is how long the big tenant's last job waits. In this lab, 1 worker ÷ 0.1 s = 10 jobs/s, so 300 jobs take 30 s. If 30 s is acceptable for the free plan, keep one worker; if not, raise maxProcesses on supervisor-free, which takes more of the machine. Try extra shared-queue workers (A3) instead only if they already bring the small tenant under its target wait; here they did not.
  • Cap a specific resource per tenant (calls to a third-party API, say): use B, with $tries and $maxExceptions set.
  • Both: a rate limit on a job that is also on its own queue. I did not run that combination, so I have no number for it; see the next steps.

Laravel multi-tenant rate limiting for HTTP, and tenant cache keys

The HTTP side is the tenant-api limiter, applied as throttle:tenant-api. Acme's limit is 60. From inside the container, I flushed Redis, sent one warm-up request, then 63 more to /stats (curl -H "X-Tenant: acme" localhost:8000/stats):

warm-up request: 200
     59 200
      4 429
HTTP/1.1 429 Too Many Requests
x-ratelimit-limit: 60
x-ratelimit-remaining: 0
retry-after: 58

That's 1 + 59 = 60 allowed and 4 blocked out of 64 requests. Globex, right after, got 200 with X-RateLimit-Limit: 600.

The limiter reads the X-Tenant header itself instead of CurrentTenant::id(). In my run throttle executed before IdentifyTenant even though I listed them in the other order, and my first attempt, which used CurrentTenant::id() in the limiter, threw a TypeError on every /reports call because the tenant was still null. Reading the header works whichever middleware runs first. I did not investigate why the order differed.

A side effect: an unknown tenant is throttled before it is rejected. Twelve requests with X-Tenant: nobody:

     10 404
      2 429

Ten 404s from IdentifyTenant, then 429s once the ?? 10 bucket in limit() ran out. That is why the fallback exists.

Cache is where tenants leak into each other quietly. /leaky-stats caches under 'stats'; /stats uses CurrentTenant::key('stats'), which returns t:{tenant}:stats:

leaky-stats as acme   -> {"computed_for":"acme"}
leaky-stats as globex -> {"computed_for":"acme"}      <- Globex sees Acme's data
stats as acme         -> {"computed_for":"acme","at":"2026-10-08T00:41:57+00:00"}
stats as globex       -> {"computed_for":"globex","at":"2026-10-08T00:41:57+00:00"}

The helper throws if there's no tenant in Context, so a forgotten tenant fails loudly rather than writing to a shared key. It's a convention, not a guarantee: grep for Cache:: in code review, or wrap the cache in a tenant-scoped class.

Laravel queue metrics endpoint and health check

The Laravel 12 skeleton registers /up (health: '/up' in bootstrap/app.php). It dispatches a DiagnosingHealth event, and an exception in a listener turned the response into a 500 in my run; the listener is the last line of the AppServiceProvider above. With the Redis container stopped, then restarted:

/up with redis up: 200
/up with redis down: 500
/up with redis back: 200

For queue depth, a plain-text endpoint in Prometheus exposition format is enough to start; it's the /metrics route above. It reads two Redis keys per queue with LLEN and ZCARD. The names come from RedisQueue in the framework source, which I fetched for this revision: getQueue() returns 'queues:'.($queue ?: $this->default), and delayedSize() runs zcard($this->getQueue($queue).':delayed'). I found these names in the source, not in the documentation, so recheck them after upgrades.

bench.sh runs FLUSHALL, so don't point this lab at a Redis other apps use.

Six seconds into Acme's 300-job burst, in setup B and then C (B's backlog is delayed jobs, C's is pending on free):

=== setup B                              === setup C
queue_pending{queue="default"} 0         queue_pending{queue="default"} 0
queue_delayed{queue="default"} 240       queue_delayed{queue="default"} 0
queue_pending{queue="free"} 0            queue_pending{queue="free"} 244
queue_delayed{queue="free"} 0            queue_delayed{queue="free"} 0

Lock /metrics down before it leaves your machine. One line at the top of the route closure, plus a token in config/services.php ('metrics' => ['token' => env('METRICS_TOKEN')],):

abort_unless(filled($token = config('services.metrics.token')) && hash_equals($token, (string) request()->bearerToken()), 403);

In my test with METRICS_TOKEN=s3cret, no header gave 403 and Authorization: Bearer s3cret gave 200; with METRICS_TOKEN empty both gave 403. The filled() check is there because an empty configured token would otherwise equal an empty bearer token.

Appendix: lab files (Dockerfile and configure.php)

  1. Make a folder with this Dockerfile and an empty overlay/ folder next to it:
FROM php:8.4-cli
RUN apt-get update && apt-get install -y --no-install-recommends git unzip libzip-dev libicu-dev \
 && docker-php-ext-install pcntl zip intl \
 && pecl install redis && docker-php-ext-enable redis \
 && rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
  1. Start Redis and the app container, and create the project in /app (do this once; create-project needs an empty directory):
docker network create lab
docker run -d --name redis --network lab redis:7.4-alpine
docker build -t saas-lab . && docker run -d --name app --network lab -e REDIS_HOST=redis saas-lab sleep infinity
docker exec app sh -c 'composer create-project laravel/laravel . "^12.0" && composer require laravel/horizon && php artisan horizon:install'
  1. Save the files from the rest of this post under overlay/ at the path in each file's first comment (for example overlay/app/Jobs/GenerateReport.php). routes/web.php and app/Providers/AppServiceProvider.php replace the stock files; configure.php, bench.sh and analyze.php go in the root of overlay/.
  2. Copy the overlay in and run configure.php (below):
docker cp overlay/. app:/app/
docker exec app sh -c 'cd /app && php configure.php'
  1. Run the first measurement: docker exec app sh -c 'cd /app && sh bench.sh A'.

configure.php is lab-only: it overwrites config/horizon.php and edits config/logging.php by string replacement, so don't run it on a real app. There, add the json channel to config/logging.php by hand, and add a supervisor-free entry (queue free, maxProcesses 1) to both defaults and your environment in config/horizon.php. In the lab it also sets .env to Redis for queue, cache and client, file sessions and the json log channel, and it reads HORIZON_WORKERS and HORIZON_SLEEP for the benchmark. Running it twice on a fresh install left one json channel.

<?php
// configure.php: run from /app (safe to run twice). Edits .env and config/logging.php,
// and writes config/horizon.php as "stock Horizon config + the lab's supervisors".
$env = file_get_contents('.env');
foreach (['QUEUE_CONNECTION' => 'redis', 'CACHE_STORE' => 'redis', 'SESSION_DRIVER' => 'file',
          'REDIS_CLIENT' => 'phpredis', 'REDIS_HOST' => 'redis', 'LOG_CHANNEL' => 'json'] as $k => $v) {
    $env = preg_replace("/^$k=.*$/m", "$k=$v", $env, -1, $n);
    if (! $n) $env .= "\n$k=$v";
}
file_put_contents('.env', $env);

$log = file_get_contents('config/logging.php');
if (! str_contains($log, "'json' => [")) {
    $log = str_replace("'channels' => [\n", "'channels' => [\n
        'json' => [
            'driver' => 'monolog',
            'handler' => Monolog\Handler\StreamHandler::class,
            'with' => ['stream' => 'php://stderr'],
            'formatter' => Monolog\Formatter\JsonFormatter::class,
            'level' => 'info',
        ],\n", $log);
    file_put_contents('config/logging.php', $log);
}

file_put_contents('config/horizon.php', <<<'PHP'
<?php
// Stock config shipped inside the package, with the lab's supervisors on top.
$config = require __DIR__.'/../vendor/laravel/horizon/config/horizon.php';

$shared = ['connection' => 'redis', 'balance' => 'auto', 'autoScalingStrategy' => 'time',
           'maxProcesses' => 1, 'memory' => 128, 'tries' => 1, 'timeout' => 60,
           'sleep' => (int) env('HORIZON_SLEEP', 3)];   // 3 is Horizon's default sleep

$config['defaults'] = [
    'supervisor-1' => $shared + ['queue' => ['default'], 'maxTime' => 0, 'maxJobs' => 0, 'nice' => 0],
    'supervisor-free' => $shared + ['queue' => ['free']],   // a new supervisor needs its own defaults entry
];
$config['environments']['local'] = [
    'supervisor-1' => ['maxProcesses' => (int) env('HORIZON_WORKERS', 2)],
    // Free-tier tenants get their own small pool: a burst there can't use more than 1 worker.
    'supervisor-free' => ['queue' => ['free'], 'maxProcesses' => 1],
];

return $config;
PHP);
echo "done\n";

What to do next

  1. Run the combination I did not measure. Put a rate limit on jobs that are also on their own queue: set TENANT_JOB_LIMIT=1 TENANT_QUEUE_SPLIT=1 in a new E) case of bench.sh (copy the C) line and change the limit to 1), run it, and compare Globex's median wait and the RUNNING count with C's 289 / 269 / 281 and B's 1,025.
  2. Operate Horizon. Run it under a process monitor and call php artisan horizon:terminate on each deploy, as the Horizon docs describe. The same docs say to set Supervisor's stopwaitsecs greater than your longest-running job (their example uses 3600) and to schedule horizon:snapshot every five minutes to populate the Metrics dashboard. I did not test either.
Bekzod Erkinov

Bekzod Erkinov

Author

Founder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.

Free guide

Get the AI-Assisted Developer's Field Guide

The workflow, prompts, and tools I use to ship faster with AI — free when you subscribe. Plus new deep-dives in your inbox. No spam, unsubscribe anytime.

Comments (0)

Please log in to leave a comment.

Log In

Related Articles

Don't miss the next deep dive

Get one well-researched tutorial in your inbox each week. No spam, unsubscribe anytime.