Welcome to HowToShipIt — practical how-to guides for developers: code, AI tools, and servers, explained step by step.

Laravel 13.36 Validation Rules: Fluent Rule:: Builders for Everything (2026)

Laravel 13.36’s headline change is validation.

You have written 'required_without:phone' a hundred times. You have also, at least once, typed 'require_without:phone' and only discovered it when a real request hit your endpoint — because validation strings only fail at runtime. Your IDE cannot autocomplete them, your refactoring tools cannot see them, and a comma in the wrong place silently changes what your rule means.

Laravel 13.36 validation rules fix this properly. Released 13 October 2026 — and the current 13.x line at the time of writing — the release gives every remaining string-syntax rule a fluent Rule:: builder via PR #61833: Rule::requiredWithout('phone') replaces 'required_without:phone', with IDE autocompletion, parameters carried as objects instead of comma-joined strings, and each builder compiling down to the classic string format the validator has always understood. The release also ships DB::olap() for PlanetScale, opt-in sticky-connection flushing for queue workers, and synchronous-by-default Mercure broadcasting. Here is the before-and-after guide.

Get on 13.36 first

The builders are a minor-release feature, so there is no need to wait for Laravel 14. If your composer.json requires laravel/framework: ^13.0, this is the whole upgrade:

composer update laravel/framework
php artisan --version   # Laravel Framework 13.36.0

To also raise the floor in composer.json so every future install gets at least 13.36, run composer require laravel/framework:^13.36 instead. One gotcha worth knowing: composer update takes package names only, so composer update laravel/framework:^13.36 is not valid syntax — Composer will reject it outright.

Why string rules were the problem

Three specific things go wrong with strings, and 13.36’s builders fix all three.

1. Typos fail at runtime. 'require_without:mobile' looks correct in your editor. It compiles, your test suite passes — until a request actually exercises that field, at which point the validator chokes on a rule it has never heard of. Builders are method calls: a typo is an error your editor underlines before you even save.

2. No discovery. You cannot ask your IDE “which conditional rules take a field list?” — you have to already know the answer. With builders you type Rule:: and read the list: requiredWith, requiredWithoutAll, prohibitedIfAccepted… the entire conditional family, sitting there in autocomplete.

3. Commas are load-bearing. String rules are parsed by splitting parameters on commas. Write 'missing_if:status,on hold, needs review' and your intended value "on hold, needs review" is silently split into two parameters — the rule no longer means what you wrote. The new parameterized builders carry their parameters as an array on a rule object (the new Illuminate\Contracts\Validation\ParameterizedRule contract, which extends Stringable), so values containing commas survive intact:

use Illuminate\Validation\Rule;

// "on hold, needs review" arrives as ONE value, not two parameters
'status_note' => [Rule::missingIf('status', 'on hold, needs review')],

Laravel 13.36 validation rules: the complete builder set

The new builders fall into two families, and it helps to know which is which.

The no-parameter builders

Nineteen methods return the plain rule string directly — drop-in replacements you can mix with strings in any rules array:

Rule::accepted()    Rule::activeUrl()   Rule::base64()
Rule::bail()        Rule::boolean()     Rule::declined()
Rule::exclude()     Rule::filled()      Rule::hexColor()
Rule::json()        Rule::list()        Rule::macAddress()
Rule::missing()     Rule::nullable()    Rule::present()
Rule::prohibited()  Rule::required()    Rule::sometimes()
Rule::ulid()

One nuance: Rule::boolean() takes an optional $strict flag — Rule::boolean(true) compiles to boolean:strict. Otherwise these are one-to-one: Rule::sometimes() is exactly 'sometimes', just discoverable and safe to refactor.

The parameterized builders

Everything else returns a ParameterizedRule object — usable anywhere a rule goes, but carrying its parameters as an array and casting to the classic string format when compiled. The conditional family is where these shine:

use Illuminate\Validation\Rule;

'phone'       => [Rule::requiredWithout('mobile')],
'backup_code' => [Rule::requiredWithoutAll(['email', 'sms'])],
'vat_number'  => [Rule::requiredIfAccepted('business_account')],
'coupon_code' => [Rule::prohibitedIfAccepted('free_trial')],
'invite_code' => [Rule::prohibitedIfDeclined('beta_opt_in')],
'legacy_id'   => [Rule::prohibits(['modern_id'])],
'nickname'    => [Rule::presentUnless('anonymous', true)],
'archived_at' => [Rule::excludeWithout('archived')],

And the rest of the parameterized set:

Rule::acceptedIf($field, $value)      Rule::declinedIf($field, $value)
Rule::missingIf($field, $value)       Rule::missingUnless($field, $value)
Rule::presentIf($field, $value)
Rule::missingWith($fields)            Rule::missingWithAll($fields)
Rule::presentWith($fields)            Rule::presentWithAll($fields)
Rule::excludeWith($anotherField)      Rule::excludeWithout($anotherField)
Rule::regex($pattern)                 Rule::notRegex($pattern)
Rule::inArray($otherField)            Rule::inArrayKeys($keys)
Rule::requiredArrayKeys($keys)        Rule::timezone($arguments = null)

The fluent chains

A few builders return dedicated rule objects with their own chainable configuration:

use Illuminate\Validation\Rule;

'website'     => [Rule::nullable(), Rule::url()->protocols(['https'])],
'api_key'     => [Rule::uuid()->version(7)],
'username'    => [Rule::distinct()->strict()],   // strict comparison
'password'    => ['required', 'string', 'min:12', Rule::confirmed()->customField('password_verification')],
'server_ip'   => [Rule::ip()->version(6)],
'launch_date' => [Rule::date()->equals('2026-10-16')],   // renders the date_equals rule
'db_password' => [Rule::currentPassword()->guard('admin')],

Note the last two carefully if you are copying from memory: Rule::date() returns the Date rule object (there is also Rule::dateTime() for the same object), and equals() accepts a DateTimeInterface or a string. Rule::ip()->version() only accepts 4 or 6 and throws an InvalidArgumentException otherwise.

Before and after: a real Form Request

Here is a signup request, first the way you wrote it last month, then the 13.36 way:

// BEFORE
public function rules(): array
{
    return [
        'name'        => 'required|string|max:255',
        'email'       => 'required|email|unique:users,email',
        'phone'       => 'required_without:mobile|regex:/^\+?[0-9\s-]{7,20}$/',
        'mobile'      => 'nullable|regex:/^\+?[0-9\s-]{7,20}$/',
        'password'    => 'required|string|min:12|confirmed',
        'website'     => 'nullable|url',
        'tags'        => 'sometimes|array',
        'tags.*'      => 'string|distinct',
        'coupon_code' => 'prohibited_if:free_trial,accepted',
        'status_note' => 'missing_if:status,on hold, needs review', // broken: the comma splits the value
    ];
}
// AFTER
use Illuminate\Validation\Rule;

public function rules(): array
{
    return [
        'name'        => [Rule::required(), 'string', 'max:255'],
        'email'       => ['required', 'email', 'unique:users,email'],
        'phone'       => [Rule::requiredWithout('mobile'), Rule::regex('/^\+?[0-9\s-]{7,20}$/')],
        'mobile'      => [Rule::nullable(), Rule::regex('/^\+?[0-9\s-]{7,20}$/')],
        'password'    => ['required', 'string', 'min:12', Rule::confirmed()],
        'website'     => [Rule::nullable(), Rule::url()->protocols(['https'])],
        'tags'        => [Rule::sometimes(), 'array'],
        'tags.*'      => ['string', Rule::distinct()->strict()],
        'coupon_code' => [Rule::prohibitedIfAccepted('free_trial')],
        'status_note' => [Rule::missingIf('status', 'on hold, needs review')], // value survives intact
    ];
}

Nothing about the validator itself changed — builders compile to the same strings the engine has always understood, so old and new syntax mix freely in the same array. If these rules guard an API rather than a Blade form, pair them with the Laravel Sanctum setup guide for the token-auth side. And you do not have to rewrite everything on day one: migrate the rules you touch, especially the conditional ones where strings are most fragile, and leave the rest.

What the builders don’t change (and the migration order that works)

It is worth being precise about what 13.36 did and did not do, because the release notes can read bigger than the change. The validator itself is untouched: a builder compiles to the exact string rule the engine has always understood, so required_without is still required_without underneath. That has two practical consequences.

First, custom validation messages keep their string keys. If you have 'phone.required_without' => 'Tell us how to reach you.' in your language files or your Form Request’s messages() method, it keeps working with Rule::requiredWithout('mobile') — the message key is the rule name, and the rule name did not change.

Second, this release completes a set, it does not start one. Builders you may already use — Rule::unique(), Rule::in(), Rule::exists(), Rule::enum(), Rule::file(), Rule::dimensions(), and the closure-based conditionals like Rule::requiredIf($callback) — all predate 13.36. PR #61833 filled in every rule that previously had only string syntax, which is why the conditional family (requiredWith, prohibitedIfAccepted, missingUnless…) dominates the new list: those were the stragglers.

The migration order that works in practice: start with the conditional rules, because that is where strings are most fragile — nested field references, comma-containing values, and the typo class of bug that only shows up in production. Leave simple, stable rules ('required|string|max:255') alone until you touch that file for another reason. Builders and strings mix freely in the same array, so there is no big-bang rewrite to schedule, and no test needs to change: the compiled rules are identical to the strings you had before.

Also in 13.36: DB::olap(), sticky queue connections, and Mercure

The validation builders are the headline, but three smaller additions round out the release — the first matters a lot if you run PlanetScale.

DB::olap() for PlanetScale. The method lives on Illuminate\Database\MySqlConnection and is reached through the DB facade. It wraps a callback in the OLAP workload:

use Illuminate\Support\Facades\DB;

$report = DB::olap(fn () => DB::table('events')
    ->selectRaw('date(created_at) as day, count(*) as total')
    ->groupBy('day')
    ->get());

On Vitess/PlanetScale — detected automatically via the new isVitess() check against the PDO server version — the connection sets workload = 'olap' for the duration of the callback and restores 'oltp' afterwards. On plain MySQL it is a safe no-op: the callback just runs. Why it matters: the default OLTP workload caps result sets (PlanetScale’s vtgate caps at around 100,000 rows), so a large monthly aggregation could come back silently truncated. OLAP mode lifts that cap for exactly the query that needs it.

Opt-in sticky-connection flushing for queue jobs. If your workers use read/write splitting with sticky connections, a job can currently read data an earlier job in the same worker process wrote — convenient, until a later job trusts sticky state it should never have seen. 13.36 adds an explicit opt-in in your AppServiceProvider:

use Illuminate\Queue\Worker;

public function boot(): void
{
    Worker::$flushStickyConnections = true;
}

It is off by default for a reason: some workloads legitimately depend on reading what an earlier job wrote. Turn it on and sticky state resets before each job, so every job after the first goes back to the read replica unless it writes first. Review your jobs before flipping it — the Supervisor queue workers guide covers the worker lifecycle this setting plugs into.

Mercure publishes are synchronous by default. One correction to keep the timeline straight: the Mercure broadcaster itself landed in 13.32, not 13.36. What 13.36 changes is that events published only to Mercure connections are now sent synchronously — they behave like ShouldBroadcastNow — because queueing them added latency without benefit for most setups. Set MERCURE_ASYNC=true in your environment to queue them again, and note that async mode needs MERCURE_URL set, since workers publish over HTTP. The same release also fixes Artisan crashing when BROADCAST_CONNECTION=mercure is set without a URL and FrankenPHP’s mercure_publish() is unavailable.

Should you upgrade now?

If you are already on 13.x, yes. 13.36.0 is a minor release, minors do not break things, and the builders are purely additive — no rule you have today changes meaning. Date-stamp this one mentally as the October 2026 release that finally made Laravel’s validation catalogue fully fluent. The sensible migration path is opportunistic: start with the conditional rules (required_without, prohibited_if, and friends), where strings are most fragile and the comma-splitting trap is real, and let the rest migrate as you touch those files. Your IDE will thank you; your on-call rotation will too.

Further Reading & References

Verified 16 October 2026. Every link below was opened and confirmed during research.

Leave a Comment