PHP 8.6's clamp(): Delete Your min(max()) Sandwiches

PHP 8.6 clamp() bounds a value to a range in a single call. The exact semantics, the ValueErrors it throws, and the Laravel code it should replace today.

Steven Richardson
Steven Richardson
· 7 min read

Every Laravel codebase I inherit has min(max($perPage, 1), 100) in it somewhere, usually written three different ways across three controllers. One of them always has the bounds the wrong way round, and nobody catches it in review because both versions read identically at a glance.

PHP 8.6 adds clamp(). It is a small function, but it is the kind of small function that removes a whole category of silent bug.

What PHP 8.6's clamp() actually does#

The RFC landed with this signature:

clamp(mixed $value, mixed $min, mixed $max): mixed

Note mixed, not int|float. The first version of this RFC was numeric-only; the implementation that shipped accepts anything comparable so it follows PHP's normal comparison rules, the same way min() and max() do.

The behaviour itself is exactly what you'd guess:

clamp(2, min: 1, max: 3);   // 2  — in range, returned as-is
clamp(0, min: 1, max: 3);   // 1  — below the floor
clamp(6, min: 1, max: 3);   // 3  — above the ceiling

clamp(2.5, 1, 3);           // 2.5 — int bounds, float value
clamp(5, 10.0, 12);         // 10.0 — the bound is returned, with its own type
clamp(M_PI, -INF, INF);     // 3.141592653589793

Named arguments work, which matters more than it sounds. CSS uses clamp(min, value, max) and plenty of people have that order in their fingers, so you can force it:

clamp(min: 0, value: $angle, max: 90);

Internally the upper bound is tested first ($value > $max), then the lower ($value < $min), then the value falls through unchanged. That ordering is the whole specification for the weird type cases later on.

The errors clamp() throws that min(max()) never did#

This is the real reason to reach for it. min(max()) has no opinion about whether your bounds make sense:

// Bounds accidentally swapped
min(max(50, 100), 1);  // 1   — no error, just wrong
clamp(50, 100, 1);     // ValueError: clamp(): Argument #2 ($min) must be
                       // smaller than or equal to argument #3 ($max)

NAN is the other one. A denominator of zero upstream produces NAN, it flows into a bound, and min(max()) happily returns nonsense because every comparison against NAN is false:

clamp(4, NAN, 6);  // ValueError: clamp(): Argument #2 ($min) cannot be NAN
clamp(4, 6, NAN);  // ValueError: clamp(): Argument #3 ($max) cannot be NAN

NAN as the value is allowed and comes back untouched — clamp(NAN, 4, 6) returns NAN. That asymmetry is deliberate: bad bounds are a programming error, a bad value is data.

Worth a Pest test if you compute bounds from config or tenant settings:

it('rejects inverted plan limits', function () {
    expect(fn () => clamp(50, 100, 10))
        ->toThrow(ValueError::class);
});

Five places clamp() belongs in a Laravel app#

Pagination size. The canonical one:

// Before
$perPage = min(max((int) $request->input('per_page', 25), 1), 100);

// After
$perPage = clamp($request->integer('per_page', 25), 1, 100);

return UserResource::collection(User::paginate($perPage));

Progress percentages. A bad denominator should not render a 140% bar in a Livewire component or a Filament widget:

$percent = clamp(
    (int) round($completed / max($total, 1) * 100),
    0,
    100,
);

Per-tenant rate limits. Clamp a configurable budget to the plan's floor and ceiling before it reaches the limiter:

RateLimiter::for('api', function (Request $request) {
    $budget = clamp(
        $request->user()->settings->requests_per_minute,
        $request->user()->plan->min_rpm,
        $request->user()->plan->max_rpm,
    );

    return Limit::perMinute($budget)->by($request->user()->id);
});

Cache TTL floors. A config typo that sets a one-second TTL will quietly hammer your database all afternoon:

$ttl = clamp(config('reports.cache_ttl'), 60, 3600); // seconds
Cache::remember("report:{$id}", $ttl, fn () => $this->build($id));

Retry backoff. Capping exponential backoff is a clamp() in disguise, and it pairs nicely with the typed duration work covered in the broader PHP 8.6 upgrade checklist:

public function backoff(): array
{
    return collect(range(1, 5))
        ->map(fn (int $attempt) => clamp(2 ** $attempt, 5, 120))
        ->all();
}

The theme across all five: a small built-in replacing a hand-rolled expression, the same trade the partial function application and array_find/array_any additions make.

When clamping is the wrong answer#

Clamping user input hides the user's mistake. If someone requests per_page=10000, silently handing back 100 is defensible — it's a hint, not a contract. If someone submits a discount of 150% on an order form, silently storing 100 is a bug you'll be explaining to finance later.

The honest answer there is a 422:

$request->validate([
    'discount' => ['required', 'numeric', 'between:0,100'],
]);

My rule: clamp things the client didn't really choose (page sizes, internal budgets, derived percentages), validate things the client typed and cares about. If you want the rejection to happen before the user hits submit, Precognition gives you the same rules live in the form.

Gotchas and Edge Cases#

mixed means PHP's comparison rules apply, warts and all. Strings compare lexicographically, which is occasionally useful and occasionally a trap:

clamp('P', 'A', 'Z');     // "P"
clamp('AAA', 'AA', 'Z');  // "AAA"

Mix types and you inherit the historical coercion table. strict_types does not change this — it governs parameter types, and the parameter type here is mixed. If your bounds and value aren't the same type, compare them by hand.

Booleans are where max-first bites. Because $value > $max is tested before $value < $min:

clamp(true, false, false);  // false
clamp(false, true, 5);      // true

Neither is wrong given the rules, and neither is what anyone meant to write.

DateTimeImmutable works. Objects with overloaded comparison — DateTime, DateTimeImmutable, BCMath\Number — clamp correctly:

clamp(
    new DateTimeImmutable('2026-08-01'),
    new DateTimeImmutable('2026-08-15'),
    new DateTimeImmutable('2026-09-15'),
)->format('Y-m-d'); // 2026-08-15

Clamping a requested report date into a retention window is a genuinely nice use of this.

The name is now taken globally. clamp() is declared in the global namespace, so if your app or a dependency defines its own clamp() function, that's a fatal error on 8.6. Grep for it before you upgrade — it's the only BC concern in the RFC.

Shipping before 8.6. PHP 8.6 GA is scheduled for 19 November 2026, so most production apps are months away. A helper with matching error behaviour buys you the API now:

namespace App\Support;

final class Num
{
    public static function clamp(int|float $value, int|float $min, int|float $max): int|float
    {
        if (is_nan((float) $min) || is_nan((float) $max)) {
            throw new \ValueError('Num::clamp(): bounds cannot be NAN');
        }

        if ($min > $max) {
            throw new \ValueError('Num::clamp(): $min must be smaller than or equal to $max');
        }

        return match (true) {
            $value > $max => $max,
            $value < $min => $min,
            default => $value,
        };
    }
}

Keep it narrower than the real thing — int|float rather than mixed — and swapping to the native function later is a find-and-replace plus a Rector rule.

Wrapping Up#

Grep your app for min(max( and max(min( today. Every hit is either a clamp() waiting to happen or a validation rule you should have written instead, and the exercise usually finds at least one set of inverted bounds.

If you're planning the move to 8.6 properly, the upgrade checklist covers the deprecations and session default changes that actually need work, and readonly property defaults is the other small addition that earns its place in day-to-day code.

FAQ#

What does clamp() do in PHP 8.6?

clamp($value, $min, $max) constrains a value to an inclusive range. If the value sits between the two bounds it is returned unchanged; if it is above $max you get $max, and if it is below $min you get $min. It replaces the min(max($value, $min), $max) expression most codebases hand-roll, and unlike that expression it validates the bounds before using them.

What is the difference between clamp() and min()/max() in PHP?

The result is the same when your bounds are correct — clamp() is equivalent to max($min, min($max, $value)). The difference is error handling and readability. clamp() throws a ValueError if $min is greater than $max or if either bound is NAN, whereas the nested min/max version silently returns a wrong answer. It is also a single call whose argument order matches the sentence you'd say out loud, so the nesting mistake is impossible to make.

Does PHP clamp() work with floats?

Yes, and you can mix integers and floats freely. clamp(2.5, 1, 3) returns 2.5, and clamp(5, 10.0, 12) returns the float 10.0 — the bound is returned with its own type rather than being coerced to match the value. The only numeric restriction is NAN: it throws a ValueError as a bound, though it is returned as-is when passed as the value.

What happens if min is greater than max in PHP clamp()?

It throws a ValueError with the message "clamp(): Argument #2 ($min) must be smaller than or equal to argument #3 ($max)". This is the main safety win over min(max()), which treats inverted bounds as a perfectly valid calculation and returns a number that is wrong in a way nobody notices until a customer reports it.

Can I use clamp() in Laravel 13?

clamp() is a PHP function rather than a Laravel one, so it depends on your runtime, not your framework version. Laravel 13 requires PHP 8.3 or higher and runs fine on 8.6, so once your servers are on 8.6 the function is available everywhere — controllers, jobs, Blade, Livewire components. Until then a small Num::clamp() helper gives you the same API and the same ValueError behaviour.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.