Skip to content
Tanvir Ahamed

Full-Stack Development

What changed in Laravel 13.36: DB::olap() and fluent rule builders

Adopt DB::olap() for large PlanetScale and Vitess queries and replace string validation rules with fluent Rule builders in Laravel 13.36.

By Tanvir Ahamed, published . 7 min read.

Researched and written with AI assistance; sources are linked below.

この記事を日本語で読む

Running a multi-year aggregation or CSV export against a PlanetScale or Vitess database fails when the query scans or returns more than 100,000 rows, while string-based validation rules in FormRequest classes break whenever a target parameter value contains a comma. Laravel v13.36.0, released in October 2026, addresses both problems with two additions: DB::olap() for switching Vitess connections into OLAP mode safely, and fluent Rule builders for every remaining built-in validation rule. After walking through how both features work under the hood, you will be able to wrap analytical queries without leaking session state and migrate brittle string validation rules to type-safe builders.

Why Vitess and PlanetScale cap OLTP queries at 100,000 rows

PlanetScale databases run on Vitess, which defaults every connection to the OLTP (Online Transaction Processing) workload mode. In OLTP mode, PlanetScale enforces two hard guardrails on every query: a single statement can return, update, or delete at most 100,000 rows, and the result set cannot exceed 64 MiB (Laravel News).

These limits protect transactional throughput from runaway queries, but they create a discrepancy between local development and production. A reporting query or backfill that runs cleanly against a local MySQL or MariaDB instance will fail on PlanetScale as soon as the dataset grows past 100,000 rows.

Vitess provides a second session workload mode called OLAP (Online Analytical Processing), which removes both the 100,000-row cap and the 64 MiB result set limit (Laravel News). Previously, switching to OLAP mode from Laravel required executing raw session statements around your query:

<?php
 
use App\Models\Order;
use Illuminate\Support\Facades\DB;
 
// Problematic manual approach prior to Laravel 13.36
DB::statement("set workload = 'olap'");
 
$revenue = Order::query()
    ->selectRaw("customer_id, date_format(created_at, '%Y-%m') as month, sum(total) as revenue")
    ->where('created_at', '>=', now()->subYears(3))
    ->groupBy('customer_id', 'month')
    ->get();
 
DB::statement("set workload = 'oltp'");

Managing the session mode manually introduces three failure modes:

  1. Read replica routing: Calling DB::statement("set workload = 'olap'") modifies the write connection. If your database configuration uses read and write connections, subsequent SELECT queries run against the read connection, which is still in oltp mode (Laravel News).
  2. Leaked session state on exceptions: Because workload is a session-level setting, any exception thrown during the query skips the trailing set workload = 'oltp' statement. In long-lived processes such as queue workers or Laravel Octane, the connection remains stuck in olap mode for subsequent jobs or requests (Laravel News).
  3. Local environment failures: Standard MySQL and MariaDB servers do not recognise the set workload statement, forcing you to wrap the raw statement in environment checks so local development and CI tests do not crash (Laravel News).

Running large analytical queries with DB::olap()

Contributed by Jack Bayliss in pull request #61932 (GitHub release notes), Laravel 13.36 adds isVitess() and olap() directly to Illuminate\Database\MySqlConnection.

First, isVitess() inspects the database server version string and returns true when the version contains Vitess or PlanetScale (Laravel Database documentation). You can call it directly if you ever need to branch on the underlying engine:

<?php
 
use Illuminate\Support\Facades\DB;
 
$isVitess = DB::connection('mysql')->isVitess();

When you pass a closure to DB::olap(), MySqlConnection uses isVitess() to decide whether to switch workloads, and wraps the callback in a try / finally block:

public function olap(Closure $callback)
{
    if (! $this->isVitess()) {
        return $callback($this);
    }
 
    $this->setWorkload('olap');
 
    try {
        return $callback($this);
    } finally {
        $this->setWorkload('oltp');
    }
}

Under the hood, MySqlConnection::setWorkload() issues the set workload statement on both the write PDO connection and the read PDO connection, initializing the read connection first if it is still a lazy Closure (MySqlConnection.php, Laravel News). That guarantees SELECT queries routed to a read replica execute in OLAP mode, and the finally block guarantees both connections return to OLTP mode even if the query throws an exception. On standard MySQL or MariaDB in local development or CI, isVitess() returns false and DB::olap() executes the closure directly as a no-op (Laravel Database documentation).

Here is how to wrap a multi-year reporting query with DB::olap() in an export action:

<?php
 
namespace App\Actions;
 
use App\Models\Order;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
 
class ExportThreeYearRevenueAction
{
    public function execute(): Collection
    {
        return DB::olap(fn () => Order::query()
            ->selectRaw("customer_id, date_format(created_at, '%Y-%m') as month, sum(total) as revenue")
            ->where('created_at', '>=', now()->subYears(3))
            ->groupBy('customer_id', 'month')
            ->get()
        );
    }
}

When to use DB::olap() versus chunking or dedicated analytics connections

While DB::olap() removes the 100,000-row and 64 MiB ceilings, it should not be used as a default wrapper for routine application queries. PlanetScale's OLTP limits exist to prevent long-running queries from exhausting database CPU and memory (Laravel News). In addition, PlanetScale warns that OLAP queries can fail with errors if the underlying connection is interrupted while the query is running (Laravel News).

I'd reserve DB::olap() for three specific workloads (Laravel News):

  • Occasional CSV or data exports triggered by staff or background jobs.
  • One-off data backfills during deployments or maintenance tasks.
  • Internal admin or reporting queries that must scan or return more than 100,000 rows in a single statement.

For regular application traffic, stick to standard OLTP mode and paginate or chunk large queries, or replicate data to a dedicated analytics database when heavy reporting runs frequently (Laravel News).

Replacing string validation rules with fluent Rule builders in Laravel 13.36

Prior to Laravel 13.36, Illuminate\Validation\Rule offered fluent builders for a subset of rules (in, notIn, unique, exists, enum, date, email, file, imageFile, dimensions, numeric, array, contains, anyOf, can, prohibitedIf, requiredIf, and excludeIf), leaving the rest of your FormRequest arrays reliant on colon-and-comma string syntax (Laravel News).

String rules have two drawbacks: they lack IDE autocomplete and static analysis support, and Laravel's string parser splits rule parameters on commas. If a rule parameter itself contains a comma, such as checking 'accepted_if:plan,Team, Annual', the validator splits 'Team, Annual' into two separate parameters ('Team' and 'Annual'), causing validation to never match 'Team, Annual' (Laravel News).

Contributed by Eric (@ericmp33) in pull request #61833 (GitHub release notes, Laravel News), Laravel 13.36 adds fluent Rule:: methods for every remaining built-in validation rule. The new methods fall into three groups.

Parameterless rule methods

Nineteen new methods take no required parameters and return the underlying validation rule string directly (with Rule::boolean() accepting an optional $strict flag) (Laravel News, HowToShipIt):

  • Rule::accepted() returns 'accepted'
  • Rule::activeUrl() returns 'active_url'
  • Rule::bail() returns 'bail'
  • Rule::base64() returns 'base64'
  • Rule::boolean(bool $strict = false) returns 'boolean', or 'boolean:strict' when passed true so only true and false pass
  • Rule::declined() returns 'declined'
  • Rule::exclude() returns 'exclude'
  • Rule::filled() returns 'filled'
  • Rule::hexColor() returns 'hex_color'
  • Rule::json() returns 'json'
  • Rule::list() returns 'list'
  • Rule::macAddress() returns 'mac_address'
  • Rule::missing() returns 'missing'
  • Rule::nullable() returns 'nullable'
  • Rule::present() returns 'present'
  • Rule::prohibited() returns 'prohibited'
  • Rule::required() returns 'required'
  • Rule::sometimes() returns 'sometimes'
  • Rule::ulid() returns 'ulid'

Parameterized rules for fields, keys, and values

Rules that accept field names, array keys, patterns, or comparison values now return objects implementing the ParameterizedRule contract (Laravel News). These methods accept variadic arguments or arrays:

  • Conditional requirement rules: Rule::requiredWith(...), Rule::requiredWithout(...), Rule::requiredWithoutAll(...), Rule::requiredIfAccepted($field), and Rule::requiredArrayKeys(...) (Laravel News, HowToShipIt).
  • Acceptance and prohibition rules: Rule::acceptedIf($field, $value), Rule::declinedIf($field, $value), Rule::prohibitedIfAccepted($field), Rule::prohibitedIfDeclined($field), and Rule::prohibits(...) (Laravel News).
  • Presence, missing, and exclusion rules: Rule::presentIf($field, $value), Rule::presentUnless($field, $value), Rule::presentWith(...), Rule::presentWithAll(...), Rule::missingIf($field, $value), Rule::missingUnless($field, $value), Rule::missingWith(...), Rule::missingWithAll(...), Rule::excludeWith($field), and Rule::excludeWithout($field) (Laravel News).
  • Pattern, array, and timezone rules: Rule::regex($pattern), Rule::notRegex($pattern), Rule::inArray($otherField), and Rule::timezone(...) (for example, Rule::timezone(['per_country', 'US']) replaces 'timezone:per_country,US') (Laravel News, HowToShipIt).

Because ParameterizedRule exposes a toArray() method that returns the rule name and its parameters as a structured array, the validator consumes those parameters directly without splitting on colons or commas (Laravel News). Commas, quotes, pipes, and backslashes in values or array keys are preserved intact. Non-string scalar parameters such as true, false, and null are converted to 'true', 'false', and 'null' automatically (Rule::presentIf('is_company', true) produces present_if:is_company,true), and casting a builder to a string for logging wraps any parameter containing a comma or double quote in double quotes (Laravel News).

Rules with chainable options

Six rules return dedicated builder objects with chainable methods, alongside a new equals() method on the existing Rule::date() builder (Laravel News):

  • Rule::url()->protocols(['https']) produces 'url:https'.
  • Rule::uuid()->version(4) or Rule::uuid()->version(7) produces 'uuid:4' or 'uuid:7', and version() also accepts 'nil' and 'max'.
  • Rule::ip()->version(6) produces 'ipv6' (accepting 4 or 6, and throwing an InvalidArgumentException for any other number).
  • Rule::distinct()->strict()->ignoreCase() produces 'distinct:strict,ignore_case'.
  • Rule::confirmed()->customField('repeat_pass') produces 'confirmed:repeat_pass' (or 'confirmed' when called without arguments).
  • Rule::currentPassword()->guard('admin') produces 'current_password:admin'.
  • Rule::date()->equals('2026-12-31') or Rule::date()->equals(today()) adds a date_equals rule.

Migrating existing FormRequest classes and testing the upgrade

To upgrade an existing Laravel 13 project to 13.36 or newer, raise the constraint or update the framework via Composer (HowToShipIt):

composer require laravel/framework:^13.36

You do not need to rewrite every FormRequest at once. String rules remain supported and can be mixed with both existing and new Rule:: builders in the same rules() array (Laravel News). Here is a CheckoutRequest class that combines the new parameterless, parameterized, and chainable builders while safely handling a plan name containing a comma:

<?php
 
namespace App\Http\Requests;
 
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
 
class CheckoutRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'plan' => [Rule::required(), Rule::in(['starter', 'Team, Annual'])],
            'terms' => [Rule::acceptedIf('plan', 'Team, Annual')],
            'company_name' => [Rule::requiredIfAccepted('is_business')],
            'vat_number' => [Rule::nullable(), Rule::requiredWith('company_name')],
            'billing' => [Rule::required(), Rule::requiredArrayKeys('street', 'city', 'postcode')],
            'coupon_code' => [Rule::nullable(), Rule::prohibits('gift_card')],
            'website' => [Rule::nullable(), Rule::url()->protocols(['https'])],
            'emails.*' => [Rule::distinct()->ignoreCase(), 'email'],
        ];
    }
}

I prefer starting the migration with rules that accept user-facing strings, regex patterns, or array keys, such as Rule::acceptedIf(), Rule::regex(), and Rule::requiredArrayKeys(), because those are the rules where commas and pipes in string syntax cause silent validation bugs. Next, check your codebase for raw set workload statements and replace them with DB::olap() closures so read replicas and exception handling are covered automatically.

Sources

  • Laravel
  • PHP
  • PlanetScale
  • MySQL
  • Validation