The Problem I Ran Into
I was building an AI image generation service. Users submit prompts, jobs go into
a Laravel queue, workers process them. Simple enough - until one power user
submitted 50 jobs at once.
Result? Everyone else waited. The queue was FIFO, and that one user owned it.
Queue: [A1][A2][A3]...[A50][B1][B2][C1]
User B and C wait for ALL 50 of User A's jobs to finish.
I needed fair distribution - not "first come first served", but "everyone gets
a turn". That's what laravel-balanced-queue does.
How It Works
The package splits the queue into partitions (one per user/tenant) and rotates
between them:
Partition A: [A1][A2][A3]...[A50]
Partition B: [B1][B2]
Partition C: [C1][C2]
Execution: A1 → B1 → C1 → A2 → B2 → C2 → A3 → ...
Three strategies available:
- round-robin (recommended) — strict A→B→C→A→B→C rotation
- random — stateless, great for high load
- smart — boosts smaller queues, prevents starvation
Plus concurrency limiting per partition - e.g., max 2 AI jobs per user
simultaneously, while other users keep running.
Setup in 4 Steps
Install:
composer require yangusik/laravel-balanced-queue
php artisan vendor:publish --tag=balanced-queue-config
Add connection to config/queue.php:
'balanced' => [
'driver' => 'balanced',
'connection' => 'default',
'queue' => 'default',
'retry_after' => 90,
],
Create a job:
use YanGusik\BalancedQueue\Jobs\BalancedDispatchable;
class GenerateAIImage implements ShouldQueue
{
use BalancedDispatchable; // Instead of standard Dispatchable
public function __construct(
public int $userId,
public string $prompt
) {}
public function handle(): void
{
// AI generation logic
}
}
Dispatch:
// $userId is auto-detected as partition key
GenerateAIImage::dispatch($userId, $prompt)
->onConnection('balanced')
->onQueue('ai-generation');
Run your workers as usual - no changes needed:
php artisan queue:work balanced --queue=ai-generation
Partition Keys
The trait auto-detects $userId, $user_id, $tenantId, $tenant_id as
partition keys. For custom logic:
public function getPartitionKey(): string
{
return "merchant:{$this->order->merchant_id}";
}
Or set it at dispatch time:
MyJob::dispatch($data)->onPartition($companyId)->onConnection('balanced');
Monitoring
Built-in live monitor:
php artisan balanced-queue:table --watch
+------------+--------+---------+--------+-----------+
| Partition | Status | Pending | Active | Processed |
+------------+--------+---------+--------+-----------+
| user:456 | ● | 15 | 2 | 45 |
| user:123 | ● | 8 | 2 | 120 |
| user:789 | ○ | 3 | 0 | 12 |
+------------+--------+---------+--------+-----------+
Total: 26 pending, 4 active - Strategy: round-robin | Max concurrent: 2
Also supports Prometheus + Grafana via built-in HTTP endpoints, and
Laravel Horizon for worker management (with some caveats - pending job
count won't show in Horizon's UI due to the different Redis key structure).
When to Use This
Perfect for multi-tenant apps where:
- Users submit batch jobs (AI generation, report exports, video processing)
- You need fair resource sharing across tenants
- Some users consistently submit more work than others
Not ideal if you need strict priority queues or real-time SLA guarantees per user
(though you can build that with a custom Strategy).
Links
- GitHub: https://github.com/YanGusik/laravel-balanced-queue
- Packagist:
composer require yangusik/laravel-balanced-queue
Feedback welcome - especially on the smart strategy and adaptive limiter,
those are areas I'm still tuning.
Top comments (0)