本文へスキップ
タンヴィル・アハメド

フルスタック開発

Laravel 13.36の変更点: DB::olap()とメソッドチェーン対応のRuleビルダー

Laravel 13.36で追加されたDB::olap()によるPlanetScaleやVitessの大規模クエリ実行と、文字列のバリデーションルールを置き換えるRuleビルダーを解説します。

著者:タンヴィル・アハメド・公開 ・約19分で読めます

AIの支援を受けて調査・執筆した記事です。出典へのリンクは以下に掲載しています。

Read this article in English

PlanetScaleやVitessのデータベースに対して複数年の集計やCSVエクスポートを実行すると、スキャン行数や取得行数が100,000行を超えた時点でクエリが失敗します。また、FormRequestクラスで文字列ベースのバリデーションルールを使っていると、対象のパラメーター値にカンマが含まれるだけでバリデーションが壊れてしまいます。2026年10月にリリースされたLaravel v13.36.0では、Vitessのコネクションを安全にOLAPモードへ切り替えるDB::olap()と、残っていたすべての組み込みバリデーションルールに対応するRuleビルダーという2つの新機能によって、この両方の問題が解決されました。この記事では両機能の内部実装を確認しながら、セッション状態をリークさせずに分析クエリをラップする方法と、壊れやすい文字列のバリデーションルールを型安全なビルダーへ移行する方法を解説します。

なぜVitessとPlanetScaleはOLTPクエリを100,000行に制限するのか

PlanetScaleのデータベースはVitess上で動作しており、すべてのコネクションはデフォルトでOLTP(Online Transaction Processing)ワークロードモードに設定されます。OLTPモードでは、PlanetScaleはすべてのクエリに対して2つの厳格なガードレールを適用します。1つのステートメントで取得・更新・削除できるのは最大100,000行までであり、結果セットのサイズも64 MiBを超えることはできません(Laravel News)。

これらの制限は暴走クエリからトランザクションのスループットを保護するためのものですが、ローカル開発環境と本番環境の間に差異を生む原因にもなります。ローカルのMySQLやMariaDBインスタンスでは問題なく動作するレポート用クエリやバックフィル処理でも、PlanetScale上ではデータセットが100,000行を超えた途端に失敗してしまいます。

VitessにはOLAP(Online Analytical Processing)という2つ目のセッションワークロードモードがあり、これを使うと100,000行の上限と64 MiBの結果セット制限の両方が解除されます(Laravel News)。これまでLaravelからOLAPモードに切り替えるには、クエリの前後で生のセッションステートメントを実行する必要がありました。

<?php
 
use App\Models\Order;
use Illuminate\Support\Facades\DB;
 
// 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'");

セッションモードを手動で管理すると、次の3つの障害パターンが発生します。

  1. リードレプリカへのルーティング: DB::statement("set workload = 'olap'")を呼び出すと、変更されるのは書き込み用コネクションです。データベース設定で読み取り用と書き込み用のコネクションを分けている場合、後続のSELECTクエリはoltpモードのままの読み取り用コネクションに対して実行されてしまいます(Laravel News)。
  2. 例外発生時のセッション状態のリーク: workloadはセッションレベルの設定であるため、クエリ実行中に例外がスローされると末尾のset workload = 'oltp'ステートメントがスキップされます。キューワーカーやLaravel Octaneのような常駐プロセスでは、コネクションがolapモードのまま残り続け、後続のジョブやリクエストに影響を及ぼします(Laravel News)。
  3. ローカル環境でのエラー: 標準のMySQLやMariaDBサーバーはset workloadステートメントを認識しないため、ローカル開発環境やCIテストがクラッシュしないよう、生のステートメントを環境判定でラップする必要があります(Laravel News)。

DB::olap()で大規模な分析クエリを実行する

Jack Baylissによるpull request #61932(GitHubリリースノート)で追加された機能として、Laravel 13.36ではIlluminate\Database\MySqlConnectionに直接isVitess()とolap()が実装されました。

まず、isVitess()はデータベースサーバーのバージョン文字列を検査し、バージョンにVitessまたはPlanetScaleが含まれている場合にtrueを返します(Laravel Databaseドキュメント)。基盤となるエンジンによって処理を分岐させる必要がある場合は、直接呼び出すこともできます。

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

DB::olap()にクロージャを渡すと、MySqlConnectionはisVitess()を使ってワークロードを切り替えるかどうかを判断し、コールバックをtry / finallyブロックでラップします。

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

内部では、MySqlConnection::setWorkload()が書き込み用PDOコネクションと読み取り用PDOコネクションの両方に対してset workloadステートメントを発行します。読み取り用コネクションがまだ遅延初期化用のClosureである場合は、先に初期化を行ってからステートメントを実行します(MySqlConnection.php、Laravel News)。これにより、リードレプリカにルーティングされるSELECTクエリも確実にOLAPモードで実行され、クエリが例外をスローした場合でもfinallyブロックによって両方のコネクションが確実にOLTPモードへ戻されます。ローカル開発環境やCIの標準的なMySQLやMariaDBでは、isVitess()がfalseを返すため、DB::olap()は何もせずそのままクロージャを実行します(Laravel Databaseドキュメント)。

エクスポート用のアクションクラスで、複数年にわたるレポートクエリをDB::olap()でラップする例は次のとおりです。

<?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()
        );
    }
}

DB::olap()とチャンク処理・分析専用コネクションの使い分け

DB::olap()は100,000行と64 MiBの上限を解除しますが、日常的なアプリケーションクエリのデフォルトラッパーとして使うべきではありません。PlanetScaleのOLTP制限は、長時間実行されるクエリがデータベースのCPUやメモリを枯渇させるのを防ぐために存在します(Laravel News)。さらにPlanetScaleは、クエリの実行中に基盤となるコネクションが切断された場合、OLAPクエリがエラーで失敗する可能性があると警告しています(Laravel News)。

私はDB::olap()の用途を次の3つのワークロードに限定しています(Laravel News)。

  • スタッフの操作やバックグラウンドジョブによって随時実行されるCSVやデータのエクスポート。
  • デプロイ時やメンテナンスタスクにおける単発のデータバックフィル。
  • 1つのステートメントで100,000行を超えるスキャンや取得が避けられない社内管理画面やレポート用のクエリ。

通常のアプリケーショントラフィックでは標準のOLTPモードを維持し、大規模なクエリはページネーションやチャンク処理を行うか、重いレポート処理が頻繁に実行される場合は分析専用のデータベースへデータをレプリケーションしてください(Laravel News)。

Laravel 13.36で文字列のバリデーションルールをRuleビルダーに置き換える

Laravel 13.36より前は、Illuminate\Validation\Ruleがメソッドチェーン可能なビルダーを提供していたのは一部のルール(in、notIn、unique、exists、enum、date、email、file、imageFile、dimensions、numeric、array、contains、anyOf、can、prohibitedIf、requiredIf、excludeIf)に限られており、FormRequestの配列の残りの部分はコロンとカンマを使った文字列構文に頼る必要がありました(Laravel News)。

文字列ルールには2つの欠点があります。IDEのオートコンプリートや静的解析が効かないこと、そしてLaravelの文字列パーサーがルールのパラメーターをカンマで分割してしまうことです。たとえば'accepted_if:plan,Team, Annual'のようにルールのパラメーター自体にカンマが含まれている場合、バリデーターは'Team, Annual'を2つの別々のパラメーター('Team'と'Annual')に分割してしまうため、'Team, Annual'には決してマッチしなくなります(Laravel News)。

Eric(@ericmp33)によるpull request #61833(GitHubリリースノート、Laravel News)により、Laravel 13.36では残っていたすべての組み込みバリデーションルールに対してRule::メソッドが追加されました。新しいメソッドは3つのグループに分けられます。

パラメーターなしのルールメソッド

新しく追加された19個のメソッドは必須パラメーターを取らず、対応するバリデーションルール文字列をそのまま返します(ただしRule::boolean()はオプションの$strictフラグを受け取ります)(Laravel News、HowToShipIt)。

  • Rule::accepted()は'accepted'を返します
  • Rule::activeUrl()は'active_url'を返します
  • Rule::bail()は'bail'を返します
  • Rule::base64()は'base64'を返します
  • Rule::boolean(bool $strict = false)は'boolean'を返し、trueを渡すとtrueとfalseのみを許可する'boolean:strict'を返します
  • Rule::declined()は'declined'を返します
  • Rule::exclude()は'exclude'を返します
  • Rule::filled()は'filled'を返します
  • Rule::hexColor()は'hex_color'を返します
  • Rule::json()は'json'を返します
  • Rule::list()は'list'を返します
  • Rule::macAddress()は'mac_address'を返します
  • Rule::missing()は'missing'を返します
  • Rule::nullable()は'nullable'を返します
  • Rule::present()は'present'を返します
  • Rule::prohibited()は'prohibited'を返します
  • Rule::required()は'required'を返します
  • Rule::sometimes()は'sometimes'を返します
  • Rule::ulid()は'ulid'を返します

フィールド・キー・値を受け取るパラメーター付きルール

フィールド名、配列キー、パターン、または比較値を受け取るルールは、ParameterizedRuleコントラクトを実装したオブジェクトを返すようになりました(Laravel News)。これらのメソッドは可変長引数または配列を受け取ります。

  • 条件付き必須ルール: Rule::requiredWith(...)、Rule::requiredWithout(...)、Rule::requiredWithoutAll(...)、Rule::requiredIfAccepted($field)、Rule::requiredArrayKeys(...)(Laravel News、HowToShipIt)。
  • 承認および禁止ルール: Rule::acceptedIf($field, $value)、Rule::declinedIf($field, $value)、Rule::prohibitedIfAccepted($field)、Rule::prohibitedIfDeclined($field)、Rule::prohibits(...)(Laravel News)。
  • 存在、欠落、除外ルール: 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)、Rule::excludeWithout($field)(Laravel News)。
  • パターン、配列、タイムゾーンルール: Rule::regex($pattern)、Rule::notRegex($pattern)、Rule::inArray($otherField)、Rule::timezone(...)(たとえば、Rule::timezone(['per_country', 'US'])は'timezone:per_country,US'を置き換えます)(Laravel News、HowToShipIt)。

ParameterizedRuleはルール名とそのパラメーターを構造化された配列として返すtoArray()メソッドを公開しているため、バリデーターはコロンやカンマで分割することなくそれらのパラメーターを直接処理します(Laravel News)。値や配列キーに含まれるカンマ、クォート、パイプ、バックスラッシュはそのまま保持されます。true、false、nullなどの文字列以外のスカラーパラメーターは自動的に'true'、'false'、'null'に変換され(Rule::presentIf('is_company', true)はpresent_if:is_company,trueを生成します)、ログ出力などのためにビルダーを文字列にキャストすると、カンマやダブルクォートを含むパラメーターはダブルクォートで囲まれます(Laravel News)。

オプションをチェーンできるルール

6つのルールがメソッドチェーン可能な専用ビルダーオブジェクトを返すようになったほか、既存のRule::date()ビルダーにも新しくequals()メソッドが追加されました(Laravel News)。

  • Rule::url()->protocols(['https'])は'url:https'を生成します。
  • Rule::uuid()->version(4)やRule::uuid()->version(7)は'uuid:4'や'uuid:7'を生成し、version()は'nil'や'max'も受け付けます。
  • Rule::ip()->version(6)は'ipv6'を生成します(4または6を受け付け、それ以外の数値にはInvalidArgumentExceptionをスローします)。
  • Rule::distinct()->strict()->ignoreCase()は'distinct:strict,ignore_case'を生成します。
  • Rule::confirmed()->customField('repeat_pass')は'confirmed:repeat_pass'を生成します(引数なしで呼び出した場合は'confirmed'になります)。
  • Rule::currentPassword()->guard('admin')は'current_password:admin'を生成します。
  • Rule::date()->equals('2026-12-31')やRule::date()->equals(today())はdate_equalsルールを追加します。

既存のFormRequestクラスの移行とアップグレードのテスト

既存のLaravel 13プロジェクトを13.36以降にアップグレードするには、バージョン制約を引き上げるか、Composer経由でフレームワークを更新します(HowToShipIt)。

composer require laravel/framework:^13.36

すべてのFormRequestを一度に書き換える必要はありません。文字列ルールも引き続きサポートされており、同じrules()配列の中で既存のRule::ビルダーや新しいビルダーと混在させることができます(Laravel News)。以下は、新しいパラメーターなしビルダー、パラメーター付きビルダー、チェーン可能なビルダーを組み合わせつつ、カンマを含むプラン名を安全に処理するCheckoutRequestクラスの例です。

<?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'],
        ];
    }
}

移行を始める際は、Rule::acceptedIf()、Rule::regex()、Rule::requiredArrayKeys()のように、ユーザー向けの文字列や正規表現パターン、配列キーを受け取るルールから着手するのが私のやり方です。これらは文字列構文におけるカンマやパイプによって、気づきにくいバリデーションのバグが発生しやすい箇所だからです。次に、コードベース内に生のset workloadステートメントがないか確認し、それらをDB::olap()のクロージャに置き換えることで、リードレプリカや例外処理を自動的にカバーできるようになります。

参考資料

  • Laravel
  • PHP
  • PlanetScale
  • MySQL
  • バリデーション

Next.jsの静的サイトをFirebase Hostingで公開する

このサイトの構成を解説します。Next.jsの静的エクスポートをFirebase Hostingで配信し、URL・キャッシュ・SEOメタデータの整合性をとる方法です。

フルスタック開発, 約5分で読めます