Scaling Policies
Scaling Policies
Scaling policies are behaviour modifiers that run on a finished ScalingDecision, before and after the scaling action. They let you customise what the autoscaler does without replacing the scaling algorithm.
Quick Reference
| Policy | Effect | Use Case | Default |
|---|---|---|---|
| ConservativeScaleDownPolicy | Caps scale-down at max(1, ceil(currentWorkers × 0.25)) per cycle |
Prevent thrashing | ✅ Yes |
| AggressiveScaleDownPolicy | Forces the exact target when the queue is idle and the target is ≤ 1; otherwise passes through | Rapid stand-down | No |
| NoScaleDownPolicy | Blocks scale-down, except when the host is over capacity | Critical workloads | No |
| BreachNotificationPolicy | Logs SLA breach risk and high SLA utilisation, rate-limited | Monitoring | ✅ Yes |
All four implement Cbox\LaravelQueueAutoscale\Contracts\ScalingPolicy.
How Policies Work
Policies execute in two phases:
- Before Scaling (
beforeScaling): Can modify the scaling decision - After Scaling (
afterScaling): Can perform side effects (logging, alerts, etc.)
Execution Flow
Policies run after the engine has already produced a decision. They never run before the strategy, and they never see the queue metrics.
1. Strategy calculates a target
2. Host capacity clamps it
3. workers.min / workers.max clamp it
4. Failure fuse clamps it
5. ScalingDecision is built
6. PolicyExecutor::beforeScaling(decision) ← policies may replace the decision
7. Scaling action performed (spawn / terminate / nothing)
8. PolicyExecutor::afterScaling(finalDecision)
9. Events dispatched, carrying the post-policy decision
A policy that throws is caught by PolicyExecutor, logged to manager.log_channel, and skipped — scaling continues with the decision as it stood.
Policy Chaining
Multiple policies execute in order. Each policy receives the potentially modified decision from previous policies:
'policies' => [
ConservativeScaleDownPolicy::class, // Runs first
BreachNotificationPolicy::class, // Runs second (sees result of first)
],
Entries must be class strings. PolicyExecutor filters the array with is_string($policy) && class_exists($policy), so a policy instance or a closure placed here is silently dropped. Classes are resolved through app(), so constructor injection works — that is how NoScaleDownPolicy receives its CapacityCalculator.
Policy Deep Dive
ConservativeScaleDownPolicy
Caps how many workers a single cycle may remove at 25% of the current count, minimum 1.
$maxRemovable = max(1, (int) ceil($decision->currentWorkers * 0.25));
If the decision asks to remove more than that, the policy returns a replacement decision with targetWorkers = currentWorkers - $maxRemovable and a reason that records the clamp. If the decision asks to remove fewer, or is not a scale-down at all, it returns null and the decision passes through untouched.
The class docblock in
src/still says "1 worker per evaluation cycle". That is stale — read thebeforeScaling()body: the limit is proportional.
Configuration
use Cbox\LaravelQueueAutoscale\Policies\ConservativeScaleDownPolicy;
'policies' => [
ConservativeScaleDownPolicy::class,
],
How It Works
Without the policy:
Cycle 1: 10 workers → queue empties → scale to 2 workers (-8)
Cycle 2: New jobs arrive → scale to 9 workers (+7)
Cycle 3: Jobs complete → scale to 3 workers (-6)
Result: thrashing, wasted spawn cost
With the policy (strategy target 2 throughout):
Cycle 1: 10 workers, max removable = ceil(10 × 0.25) = 3 → 7
Cycle 2: 7 workers, max removable = ceil(7 × 0.25) = 2 → 5
Cycle 3: 5 workers, max removable = ceil(5 × 0.25) = 2 → 3
Cycle 4: 3 workers, removal of 1 is within the limit → 2
Result: geometric decay to the target, not a fixed -1 per cycle
The proportional shape means large pools shed workers quickly at first and slow down as they approach the floor.
When to Use
✅ Perfect for:
- High-volume queues with variable load
- Workloads with "bursty but persistent" patterns
- Preventing oscillation in unpredictable workloads
- General-purpose applications (default behavior)
❌ Not suitable for:
- Cost-sensitive background jobs (use AggressiveScaleDown instead)
- Truly idle queues needing rapid scale-down
- Workloads with clear on/off patterns
Real-World Example
Email Queue with Variable Load
use Cbox\LaravelQueueAutoscale\Configuration\Profiles\HighVolumeProfile;
use Cbox\LaravelQueueAutoscale\Policies\ConservativeScaleDownPolicy;
'queues' => [
'emails' => HighVolumeProfile::class, // profile handles scale-up
],
'policies' => [
ConservativeScaleDownPolicy::class,
],
Behaviour:
10:00 - Campaign sends 10,000 emails
→ Scales to 25 workers, processes them rapidly
10:30 - Campaign complete, queue empty, strategy target drops to 3
→ Without the policy: 25 → 3 in one cycle
→ With the policy: 25 → 19 (max removable = ceil(25 × 0.25) = 7)
10:30:05 - 19 → 15, then 15 → 12, then 12 → 9, then 9 → 7 ...
11:00 - New campaign starts (2,000 emails)
→ Some warm workers are still running
→ The spike is absorbed with less cold-start cost
Cost/Performance Trade-offs
Pros:
- Prevents expensive thrashing cycles
- Maintains some capacity for follow-up spikes
- Smoother resource utilization
- Better for cloud providers with per-minute billing
Cons:
- Slower cost reduction when truly idle
- May maintain excess workers longer than needed
- Not optimal for clear on/off workloads
AggressiveScaleDownPolicy
Overrides a conservative clamp so an idle queue can stand down in one cycle.
It is narrower than the name suggests. beforeScaling():
- Returns
nullimmediately unless the decision is a scale-down. - If the queue looks idle (
predictedPickupTimeisnullor0.0) and the target is already<= 1, it returns a replacement decision forcing that exact target (0 or 1). - Otherwise it returns
null— the decision passes through exactly as it arrived.
Case 3 is the important one: on its own, this policy changes nothing. Its purpose is to sit after ConservativeScaleDownPolicy in the chain so it can undo that policy's clamp for the idle case.
Configuration
use Cbox\LaravelQueueAutoscale\Policies\AggressiveScaleDownPolicy;
use Cbox\LaravelQueueAutoscale\Policies\ConservativeScaleDownPolicy;
'policies' => [
ConservativeScaleDownPolicy::class,
AggressiveScaleDownPolicy::class, // must come AFTER, to override
],
How It Works
Queue goes idle. Strategy target = 0 (workers.min = 0), current = 4.
With Conservative only:
max removable = ceil(4 × 0.25) = 1 → 3, then 2, then 1, then 0
With Conservative then Aggressive:
Conservative clamps 4 → 3
Aggressive sees an idle queue with target 0 (≤ 1) and forces 4 → 0
If the queue is not idle — predictedPickupTime is a real number — Aggressive does nothing, and Conservative's 25% clamp stands.
When to Use
✅ Perfect for:
- Background/maintenance queues
- Bursty workloads with clear idle periods
- Cost-sensitive applications
- Development/staging environments
- Scheduled batch jobs
❌ Not suitable for:
- Steady workloads
- Queues sensitive to cold start delays
- High-volume queues with persistent load
Real-World Example
Nightly Analytics Queue
'queues' => [
'analytics' => BackgroundProfile::class,
],
'policies' => [
AggressiveScaleDownPolicy::class,
],
Behavior:
22:00 - Nightly job dispatches 1,000 analytics tasks
→ Scales 0 → 5 workers
→ Processes tasks
23:30 - All tasks complete, queue empty
→ Strategy: scale to 0 workers (workers.min = 0)
→ Policy: Allows full scale-down
→ Result: 5 → 0 workers immediately
Cost savings:
Conservative: Maintains 1-2 workers until 00:00 = $1.50
Aggressive: Scales to 0 immediately = $0
Savings: $1.50/night = $45/month
Combining with ConservativeScaleDown
This ordering is the intended use, not a mistake:
'policies' => [
ConservativeScaleDownPolicy::class,
AggressiveScaleDownPolicy::class, // overrides the clamp for idle queues
],
The reverse order does nothing useful: Aggressive would run first, return null in almost every case, and Conservative would then clamp the result anyway.
If you want unrestricted scale-down in all cases, do not list either policy — an empty (or Conservative-free) policies array lets the strategy's target through unmodified, subject only to the strategy's own smoothing.
NoScaleDownPolicy
Prevents all scale-down to maintain constant capacity
Configuration
use Cbox\LaravelQueueAutoscale\Policies\NoScaleDownPolicy;
'policies' => [
NoScaleDownPolicy::class,
],
How It Works
Load spike: 5 → 20 workers (scale-up is untouched)
Load drops: 20 → 20 workers (scale-down replaced with a hold)
The policy intercepts a scale-down decision and replaces it with targetWorkers = currentWorkers.
One exception. Before blocking, it asks the CapacityCalculator whether the host can still support the current worker count:
$capacityResult = $this->capacity->calculateMaxWorkers(
$decision->currentWorkers,
ResourceEstimate::globalDefault(),
);
if ($decision->currentWorkers > $capacityResult->finalMaxWorkers) {
return null; // resource-forced scale-down is allowed through
}
So a host under CPU or memory pressure can still shed workers — the policy protects capacity, not stability.
It takes CapacityCalculator via constructor injection, which is why policies entries must be class strings resolved through the container.
When to Use
✅ Perfect for:
- Mission-critical queues with zero tolerance for delays
- Payment processing systems
- Real-time notification systems
- Queues with SLA contracts and penalties
- Workloads where cost < reliability
❌ Not suitable for:
- Cost-sensitive applications
- Variable workloads
- Background processing
- Any queue where over-provisioning is wasteful
Real-World Example
Payment Processing Queue
'queues' => [
'payments' => CriticalProfile::class,
],
'policies' => [
NoScaleDownPolicy::class,
BreachNotificationPolicy::class,
],
Behaviour:
10:00 - Sale starts, 1,000 payment requests/hour
→ Scales 5 → 35 workers, all within the 10s SLA
14:00 - Load drops to 200/hour
→ Without the policy: 35 → 10 workers
→ With the policy: stays at 35
18:00 - Load returns to 800/hour
→ Already at 35 workers, no cold start
Later - Host memory pressure pushes capacity below 35
→ The policy steps aside; the host sheds workers to stay stable
Trade-offs
- Scales up during spikes
- Never scales down under normal conditions
- Holds peak capacity indefinitely, so you pay for peak all day
- Still yields to host resource pressure
- Use only when the cost of a cold start outweighs the cost of idle workers
BreachNotificationPolicy
Logs and notifies about SLA compliance issues
Configuration
use Cbox\LaravelQueueAutoscale\Policies\BreachNotificationPolicy;
'policies' => [
BreachNotificationPolicy::class,
],
How It Works
beforeScaling() always returns null — this policy never modifies a decision. All of its behaviour is in afterScaling(), which checks two conditions and logs to manager.log_channel.
1. SLA breach risk ($decision->isSlaBreachRisk(), i.e. predictedPickupTime > slaTarget), logged at warning level:
[warning] SLA BREACH RISK DETECTED
{
"connection": "redis",
"queue": "emails",
"predicted_pickup_time": 35.2,
"sla_target": 30,
"current_workers": 5,
"target_workers": 8,
"reason": "..."
}
2. High SLA utilisation (predictedPickupTime / slaTarget >= 90%), logged at notice level:
[notice] High SLA utilization: 92.5%
{
"connection": "redis",
"queue": "payments",
"sla_utilization_percent": 92.5,
"predicted_pickup_time": 27.75,
"sla_target": 30,
"current_workers": 8,
"target_workers": 10
}
Both conditions are true on every evaluation cycle while they hold, so each is gated through AlertRateLimiter under its own key (breach_risk:{connection}:{queue} and high_util:{connection}:{queue}). The cooldown is queue-autoscale.alerting.cooldown_seconds, 300 seconds by default.
The policy logs nothing else — no "workers scaled successfully" line, no recovery line. For recovery, listen to the SlaRecovered event.
When to Use
✅ Perfect for:
- Production environments
- Queues with SLA requirements
- Systems requiring audit trails
- On-call rotation scenarios
- Performance monitoring
❌ Not suitable for:
- Development environments (noisy logs)
- Queues without SLA requirements
- When log volume is a concern
Real-World Example
Production Queue Monitoring
'queues' => [
'default' => BalancedProfile::class,
],
'policies' => [
ConservativeScaleDownPolicy::class,
BreachNotificationPolicy::class,
],
Log output:
[2026-08-04 10:30:15] local.NOTICE: High SLA utilization: 91.2%
{"connection":"redis","queue":"default","sla_utilization_percent":91.2,
"predicted_pickup_time":27.4,"sla_target":30,
"current_workers":8,"target_workers":10}
[2026-08-04 10:35:20] local.WARNING: SLA BREACH RISK DETECTED
{"connection":"redis","queue":"default","predicted_pickup_time":35.2,
"sla_target":30,"current_workers":10,"target_workers":12,
"reason":"..."}
Nothing appears again for either key until its 300-second cooldown expires.
Extending with Alerts
You can extend this policy for custom alerting:
namespace App\Policies;
use Cbox\LaravelQueueAutoscale\Policies\BreachNotificationPolicy as BasePolicy;
use Cbox\LaravelQueueAutoscale\Scaling\ScalingDecision;
use Illuminate\Support\Facades\Notification;
use App\Notifications\SlaBreachAlert;
readonly class CustomBreachNotificationPolicy extends BasePolicy
{
public function afterScaling(ScalingDecision $decision): void
{
// Call parent for the rate-limited logging
parent::afterScaling($decision);
// Add custom alerting
if ($decision->isSlaBreachRisk()) {
Notification::route('slack', config('alerts.slack_webhook'))
->notify(new SlaBreachAlert($decision));
}
}
}
BreachNotificationPolicy is declared readonly, so the subclass must be readonly too — PHP fatals otherwise. Note also that your afterScaling() override is not rate-limited: AlertRateLimiter gates the parent's log calls only. Inject your own limiter if the Slack notification should be throttled.
Remember to register the subclass instead of the base policy:
'policies' => [
ConservativeScaleDownPolicy::class,
\App\Policies\CustomBreachNotificationPolicy::class,
],
Policy Combinations
Recommended Combinations by Profile
Critical Profile
'policies' => [
NoScaleDownPolicy::class, // Maintain capacity
BreachNotificationPolicy::class, // Monitor compliance
],
Why:
- Critical workloads prioritize reliability over cost
- NoScaleDown prevents cold starts
- BreachNotification provides visibility
High-Volume Profile
'policies' => [
ConservativeScaleDownPolicy::class, // Prevent thrashing
BreachNotificationPolicy::class, // Monitor performance
],
Why:
- Steady workloads benefit from gradual scale-down
- Conservative prevents oscillation
- BreachNotification tracks SLA compliance
Balanced Profile (Default)
'policies' => [
ConservativeScaleDownPolicy::class, // Safe defaults
BreachNotificationPolicy::class, // Basic monitoring
],
Why:
- Safe defaults for unknown workloads
- Conservative prevents surprises
- BreachNotification provides baseline visibility
Bursty Profile
'policies' => [
ConservativeScaleDownPolicy::class, // 25%-per-cycle clamp
AggressiveScaleDownPolicy::class, // lifted once the queue is idle
BreachNotificationPolicy::class, // monitor spikes
],
Why:
- Bursty workloads have clear idle periods
- Aggressive must come after Conservative to override its clamp — on its own it does nothing
- BreachNotification tracks spike handling
Background Profile
'policies' => [
ConservativeScaleDownPolicy::class,
AggressiveScaleDownPolicy::class, // stand down fully once idle
],
Why:
- Background queues can afford to go cold between batches
- BreachNotification is omitted because a multi-minute SLA rarely warrants a log line
Policies are configured globally, not per queue.
queue-autoscale.policiesapplies to every queue and group. The headings above describe which combination suits an app whose queues are predominantly of that shape — to vary behaviour per queue, write a policy that inspects$decision->queue.
Custom Policies
Creating a Policy
Implement the ScalingPolicy interface:
namespace App\Policies;
use Cbox\LaravelQueueAutoscale\Contracts\ScalingPolicy;
use Cbox\LaravelQueueAutoscale\Scaling\ScalingDecision;
class MyCustomPolicy implements ScalingPolicy
{
public function beforeScaling(ScalingDecision $decision): ?ScalingDecision
{
// Modify decision or return null to allow it through
if ($this->shouldModify($decision)) {
return new ScalingDecision(
connection: $decision->connection,
queue: $decision->queue,
currentWorkers: $decision->currentWorkers,
targetWorkers: $this->calculateNewTarget($decision),
reason: 'MyCustomPolicy modified decision',
predictedPickupTime: $decision->predictedPickupTime,
slaTarget: $decision->slaTarget,
capacity: $decision->capacity, // carry these through
spawnCompensation: $decision->spawnCompensation, // or downstream sees null
);
}
return null; // Allow original decision
}
public function afterScaling(ScalingDecision $decision): void
{
// Perform side effects (logging, metrics, alerts)
}
}
Example: Time-Based Scaling Policy
namespace App\Policies;
use Cbox\LaravelQueueAutoscale\Contracts\ScalingPolicy;
use Cbox\LaravelQueueAutoscale\Scaling\ScalingDecision;
class BusinessHoursScalingPolicy implements ScalingPolicy
{
public function beforeScaling(ScalingDecision $decision): ?ScalingDecision
{
$hour = now()->hour;
$isBusinessHours = $hour >= 9 && $hour <= 17;
// During business hours, maintain a higher floor
if ($isBusinessHours && $decision->targetWorkers < 5) {
return new ScalingDecision(
connection: $decision->connection,
queue: $decision->queue,
currentWorkers: $decision->currentWorkers,
targetWorkers: 5,
reason: 'BusinessHoursScalingPolicy enforcing a floor of 5 workers (09:00-17:00)',
predictedPickupTime: $decision->predictedPickupTime,
slaTarget: $decision->slaTarget,
capacity: $decision->capacity,
spawnCompensation: $decision->spawnCompensation,
);
}
return null;
}
public function afterScaling(ScalingDecision $decision): void
{
// No action needed
}
}
Usage:
'policies' => [
\App\Policies\BusinessHoursScalingPolicy::class,
ConservativeScaleDownPolicy::class,
BreachNotificationPolicy::class,
],
A policy's target is not re-clamped.
workers.min,workers.max, the host capacity ceiling and the failure fuse are all applied before policies run. Whatever number the last policy returns is what the manager spawns or terminates to. Keep your policy's output inside the bounds you actually want — a floor aboveworkers.maxwill be honoured.
Example: Global Worker Cap
workers.max is per queue. If you need a cap across a specific queue regardless of its profile, a policy is the place to put it:
namespace App\Policies;
use Cbox\LaravelQueueAutoscale\Contracts\ScalingPolicy;
use Cbox\LaravelQueueAutoscale\Scaling\ScalingDecision;
use Illuminate\Support\Facades\Log;
class HardWorkerCapPolicy implements ScalingPolicy
{
public function __construct(
private readonly int $maxWorkers = 50,
) {}
public function beforeScaling(ScalingDecision $decision): ?ScalingDecision
{
if ($decision->targetWorkers <= $this->maxWorkers) {
return null;
}
Log::warning('HardWorkerCapPolicy capping workers', [
'connection' => $decision->connection,
'queue' => $decision->queue,
'requested' => $decision->targetWorkers,
'capped_to' => $this->maxWorkers,
]);
return new ScalingDecision(
connection: $decision->connection,
queue: $decision->queue,
currentWorkers: $decision->currentWorkers,
targetWorkers: $this->maxWorkers,
reason: sprintf(
'HardWorkerCapPolicy capped from %d to %d workers (original: %s)',
$decision->targetWorkers,
$this->maxWorkers,
$decision->reason,
),
predictedPickupTime: $decision->predictedPickupTime,
slaTarget: $decision->slaTarget,
capacity: $decision->capacity,
spawnCompensation: $decision->spawnCompensation,
);
}
public function afterScaling(ScalingDecision $decision): void {}
}
The constructor default is used only if the container can resolve it — app(HardWorkerCapPolicy::class) will autowire the scalar from its default value. Bind it explicitly in a service provider if you want a different cap:
$this->app->bind(
\App\Policies\HardWorkerCapPolicy::class,
fn () => new \App\Policies\HardWorkerCapPolicy(maxWorkers: 120),
);
Troubleshooting
Policies Not Executing
Check registration:
// config/queue-autoscale.php
'policies' => [
\Cbox\LaravelQueueAutoscale\Policies\ConservativeScaleDownPolicy::class,
// Fully qualified class name required
],
Check logs:
tail -f storage/logs/laravel.log | grep -i policy
Policy Conflicts
Redundant:
'policies' => [
NoScaleDownPolicy::class, // turns every scale-down into a hold
ConservativeScaleDownPolicy::class, // then sees a hold, and does nothing
],
NoScaleDownPolicy runs first and replaces the scale-down with targetWorkers = currentWorkers. Conservative then sees a decision that is no longer a scale-down and returns null. Not harmful, just pointless — drop the second entry.
Order-sensitive:
'policies' => [
ConservativeScaleDownPolicy::class,
AggressiveScaleDownPolicy::class, // only works in this order
],
Fine together:
'policies' => [
ConservativeScaleDownPolicy::class,
BreachNotificationPolicy::class, // never modifies anything
],
Policy Order Matters
Policies execute in order. Later policies see modifications from earlier policies:
'policies' => [
MyScaleUpPolicy::class, // Increases target by 2
ConservativeScaleDownPolicy::class, // Sees already-increased target
],
Performance Impact
Policies run synchronously inside the manager daemon, once per queue per evaluation cycle. Keep them fast:
- A slow policy delays the whole cycle for every queue behind it.
- Logging is synchronous — the shipped
BreachNotificationPolicygates its writes throughAlertRateLimiterfor exactly this reason. - An HTTP call inside
beforeScaling()orafterScaling()is a bad idea. Dispatch a job or fire an event and do the work elsewhere. - Exceptions are caught by
PolicyExecutorand logged; a throwing policy does not abort scaling, but it does mean its effect is silently missing.
Next Steps
- Workload Profiles - Choose the right profile
- Monitoring - Track policy effectiveness
- Event Handling - React to scaling events