Respect Validation

バリデーションライブラリPHPFluent Interfaceチェイナブルバリデーターカスタムルール例外処理

ライブラリ

Respect Validation

概要

Respect Validationは、「PHPで最も素晴らしいバリデーションエンジン」として知られる高機能なバリデーションライブラリです。Fluent Interfaceによる直感的なメソッドチェーン記法で複雑なバリデーションルールを簡潔に表現でき、150以上の豊富な組み込みバリデーションルール、詳細なエラーメッセージ制御、カスタムルールの簡単な作成などを提供します。2025年現在、バージョン2.4系が最新で、PHP 8.1以上をサポート。小規模なフォームバリデーションから大規模なAPIデータ検証まで、あらゆる規模のプロジェクトで活用されているPHPコミュニティの定番ライブラリです。

詳細

Respect Validation 2.4.xは、2025年現在のPHP 8.1以上に対応した最新版で、PHPエコシステムにおいて最も人気の高いバリデーションライブラリの一つです。その最大の特徴は、v::numericVal()->positive()->between(1, 255)->isValid($input)のような流れるような記法で複雑なバリデーションルールを構築できるFluent Interfaceです。ライブラリには150以上の多様な組み込みルールが含まれており、基本的なデータ型検証から高度なビジネスロジックまで幅広くカバー。カスタムバリデーターの作成も簡単で、独自のビジネス要件に合わせた柔軟な拡張が可能です。メソッドチェーンによる組み合わせで、複雑な条件も可読性高く表現できます。

主な特徴

  • Fluent Interface: メソッドチェーンによる直感的なバリデーションルール構築
  • 150以上のルール: 豊富な組み込みバリデーターで多様な検証ニーズに対応
  • チェイナブルバリデーター: 複数の条件を連鎖させて複雑なロジックを表現可能
  • カスタムルール作成: 独自のビジネスロジックに特化したバリデーターを簡単に実装
  • 詳細なエラー制御: バリデーション結果の粒度調整と詳細なエラーレポート
  • 例外ベースのハンドリング: 構造化された例外処理でエラー情報を管理

メリット・デメリット

メリット

  • 非常に直感的で読みやすいFluent Interface記法
  • 豊富な組み込みルールによる高い即戦力性
  • カスタムバリデーターの作成が簡単
  • 詳細なエラーメッセージとコンテキスト情報
  • 軽量でパフォーマンスが良い
  • アクティブなコミュニティと継続的な開発
  • フレームワークに依存しないスタンドアロン設計

デメリット

  • 大量のデータに対してはパフォーマンスが低下する可能性
  • 複雑なネストしたオブジェクト検証には追加のコードが必要
  • 国際化機能が限定的(カスタム実装が必要)
  • バリデーションルールの静的解析が困難
  • エラーメッセージのカスタマイズに学習コストがある
  • デバッグ時にチェーンの途中でどこが失敗したかわかりにくい場合がある

参考ページ

書き方の例

インストールと基本セットアップ

# Composerでインストール
composer require respect/validation

# 特定のバージョンを指定する場合
composer require respect/validation:^2.4

# 追加の機能が必要な場合(メール検証の強化)
composer require egulias/email-validator

# 電話番号検証の強化
composer require giggsey/libphonenumber-for-php-lite

基本的なバリデーション実装

<?php

use Respect\Validation\Validator as v;
use Respect\Validation\Exceptions\ValidationException;

// シンプルな値のバリデーション
$ageValidator = v::intVal()->positive()->between(1, 120);

// 基本的な検証
if ($ageValidator->isValid(25)) {
    echo "有効な年齢です";
}

// 詳細なエラーメッセージを取得
try {
    $ageValidator->assert(-5);
} catch (ValidationException $e) {
    echo $e->getMessage();
    // "-5" must be greater than 1
}

// 複数の条件を組み合わせた複雑なバリデーション
$emailValidator = v::stringType()
    ->notEmpty()
    ->email()
    ->domain(v::in(['gmail.com', 'yahoo.com', 'example.com']));

// ユーザー登録データの検証例
$userData = [
    'username' => 'johndoe123',
    'email' => '[email protected]',
    'age' => 28,
    'website' => 'https://johndoe.com',
    'tags' => ['developer', 'php', 'javascript']
];

$userValidator = v::key('username', v::stringType()->alnum()->length(3, 30))
    ->key('email', v::stringType()->email())
    ->key('age', v::intVal()->between(18, 100))
    ->key('website', v::optional(v::url()))
    ->key('tags', v::arrayType()->each(v::stringType()->notEmpty()));

try {
    $userValidator->assert($userData);
    echo "ユーザーデータは有効です";
} catch (ValidationException $e) {
    echo "バリデーションエラー: " . $e->getMessage();
}

// より詳細なエラー情報の取得
try {
    $userValidator->assert($userData);
} catch (ValidationException $e) {
    // 全てのエラーメッセージを取得
    foreach ($e->getMessages() as $message) {
        echo $message . "\n";
    }
    
    // 構造化されたエラー情報
    print_r($e->getMessages());
}

Fluent Interfaceの高度な使用例

<?php

use Respect\Validation\Validator as v;

// パスワード強度の検証
$passwordValidator = v::stringType()
    ->notEmpty()
    ->length(8, 50)
    ->regex('/[A-Z]/')         // 大文字を含む
    ->regex('/[a-z]/')         // 小文字を含む
    ->regex('/[0-9]/')         // 数字を含む
    ->regex('/[^A-Za-z0-9]/')  // 特殊文字を含む
    ->not(v::contains('password'))
    ->not(v::contains('123456'));

// 日本の郵便番号バリデーション
$zipCodeValidator = v::stringType()
    ->notEmpty()
    ->regex('/^\d{3}-?\d{4}$/')
    ->callback(function($value) {
        // ハイフンを除去して長さをチェック
        $digits = preg_replace('/[^0-9]/', '', $value);
        return strlen($digits) === 7;
    });

// クレジットカード番号の検証
$creditCardValidator = v::stringType()
    ->notEmpty()
    ->regex('/^[0-9\s-]+$/')
    ->callback(function($value) {
        // Luhnアルゴリズムによる検証
        $number = preg_replace('/[^0-9]/', '', $value);
        return luhnCheck($number);
    });

// 条件付きバリデーション
$productValidator = v::key('name', v::stringType()->notEmpty())
    ->key('price', v::numericVal()->positive())
    ->key('category', v::stringType()->in(['electronics', 'books', 'clothing']))
    ->key('discount_price', v::optional(
        v::when(
            v::key('category', v::equals('electronics')),
            v::numericVal()->positive()->callback(function($value, $context) {
                // 割引価格が元の価格より安いかチェック
                return $value < $context['price'];
            }),
            v::nullType()
        )
    ));

// ファイルアップロードのバリデーション
$fileValidator = v::arrayType()
    ->key('name', v::stringType()->notEmpty())
    ->key('type', v::stringType()->in(['image/jpeg', 'image/png', 'image/gif']))
    ->key('size', v::intVal()->between(1, 5242880)) // 最大5MB
    ->key('error', v::equals(UPLOAD_ERR_OK));

// ネストしたデータ構造の検証
$orderValidator = v::arrayType()
    ->key('customer', v::arrayType()
        ->key('name', v::stringType()->notEmpty())
        ->key('email', v::email())
        ->key('phone', v::stringType()->regex('/^[0-9\-\+\(\)\s]+$/'))
    )
    ->key('items', v::arrayType()->each(
        v::arrayType()
            ->key('product_id', v::intVal()->positive())
            ->key('quantity', v::intVal()->positive())
            ->key('price', v::numericVal()->positive())
    ))
    ->key('total', v::numericVal()->positive())
    ->callback(function($order) {
        // 合計金額の整合性チェック
        $calculatedTotal = array_sum(array_map(function($item) {
            return $item['quantity'] * $item['price'];
        }, $order['items']));
        
        return abs($calculatedTotal - $order['total']) < 0.01;
    });

function luhnCheck($number): bool
{
    $sum = 0;
    $alternate = false;
    
    for ($i = strlen($number) - 1; $i >= 0; $i--) {
        $digit = intval($number[$i]);
        
        if ($alternate) {
            $digit *= 2;
            if ($digit > 9) {
                $digit = ($digit % 10) + 1;
            }
        }
        
        $sum += $digit;
        $alternate = !$alternate;
    }
    
    return ($sum % 10) === 0;
}

カスタムルールの作成

<?php

namespace App\Validation\Rules;

use Respect\Validation\Rules\AbstractRule;

/**
 * 日本の電話番号バリデーションルール
 */
class JapanesePhoneNumber extends AbstractRule
{
    private bool $allowMobile;
    private bool $allowLandline;

    public function __construct(bool $allowMobile = true, bool $allowLandline = true)
    {
        $this->allowMobile = $allowMobile;
        $this->allowLandline = $allowLandline;
    }

    public function validate($input): bool
    {
        if (!is_string($input)) {
            return false;
        }

        // ハイフンとスペースを除去
        $cleanNumber = preg_replace('/[\s\-\(\)]/', '', $input);

        if ($this->allowMobile && $this->isMobileNumber($cleanNumber)) {
            return true;
        }

        if ($this->allowLandline && $this->isLandlineNumber($cleanNumber)) {
            return true;
        }

        return false;
    }

    private function isMobileNumber(string $number): bool
    {
        // 携帯電話: 090, 080, 070で始まる11桁
        return preg_match('/^0[789]0\d{8}$/', $number) === 1;
    }

    private function isLandlineNumber(string $number): bool
    {
        // 固定電話: 地域番号で始まる10桁または11桁
        return preg_match('/^0\d{9,10}$/', $number) === 1 &&
               !preg_match('/^0[789]0/', $number); // 携帯番号を除外
    }
}

// カスタムルールの例外クラス
namespace App\Validation\Exceptions;

use Respect\Validation\Exceptions\ValidationException;

class JapanesePhoneNumberException extends ValidationException
{
    protected $defaultTemplates = [
        self::MODE_DEFAULT => [
            self::STANDARD => '{{name}} は有効な日本の電話番号である必要があります',
        ],
        self::MODE_NEGATIVE => [
            self::STANDARD => '{{name}} は日本の電話番号であってはいけません',
        ],
    ];
}

// カスタムルールの使用例
use Respect\Validation\Validator as v;
use App\Validation\Rules\JapanesePhoneNumber;

// カスタムルールをチェーンに組み込み
$contactValidator = v::key('name', v::stringType()->notEmpty())
    ->key('phone', v::stringType()->setName('電話番号')->addRule(new JapanesePhoneNumber()))
    ->key('mobile_only', v::optional(
        v::stringType()->setName('携帯電話番号')
            ->addRule(new JapanesePhoneNumber(true, false))
    ));

// より複雑なカスタムルール: 営業時間の検証
class BusinessHours extends AbstractRule
{
    private array $allowedDays;
    private string $startTime;
    private string $endTime;

    public function __construct(
        array $allowedDays = [1, 2, 3, 4, 5], // 月-金
        string $startTime = '09:00',
        string $endTime = '18:00'
    ) {
        $this->allowedDays = $allowedDays;
        $this->startTime = $startTime;
        $this->endTime = $endTime;
    }

    public function validate($input): bool
    {
        if (!$input instanceof \DateTimeInterface) {
            return false;
        }

        $dayOfWeek = (int)$input->format('N'); // 1=月曜日, 7=日曜日
        $time = $input->format('H:i');

        return in_array($dayOfWeek, $this->allowedDays) &&
               $time >= $this->startTime &&
               $time <= $this->endTime;
    }
}

// 営業時間バリデーターの使用
$appointmentValidator = v::key('datetime', 
    v::dateTime()->addRule(new BusinessHours([1, 2, 3, 4, 5], '09:00', '17:00'))
);

例外処理とエラーハンドリング

<?php

use Respect\Validation\Validator as v;
use Respect\Validation\Exceptions\ValidationException;
use Respect\Validation\Exceptions\NestedValidationException;

class ValidationService
{
    /**
     * 基本的な例外ハンドリング
     */
    public function validateUserRegistration(array $data): array
    {
        $validator = v::key('username', v::stringType()->alnum()->length(3, 20))
            ->key('email', v::email())
            ->key('password', v::stringType()->length(8, 50))
            ->key('age', v::intVal()->between(13, 120));

        try {
            $validator->assert($data);
            return ['success' => true, 'errors' => []];
        } catch (NestedValidationException $e) {
            return [
                'success' => false,
                'errors' => $this->formatNestedErrors($e)
            ];
        }
    }

    /**
     * ネストした例外の詳細なエラーフォーマット
     */
    private function formatNestedErrors(NestedValidationException $e): array
    {
        $errors = [];
        
        // 各フィールドのエラーを個別に処理
        foreach ($e->getMessages() as $field => $messages) {
            if (is_array($messages)) {
                $errors[$field] = array_values($messages);
            } else {
                $errors[$field] = [$messages];
            }
        }

        return $errors;
    }

    /**
     * カスタムエラーメッセージの適用
     */
    public function validateWithCustomMessages(array $data): array
    {
        $validator = v::key('email', v::email()->setName('メールアドレス'))
            ->key('age', v::intVal()->between(18, 65)->setName('年齢'))
            ->key('website', v::optional(v::url()->setName('ウェブサイト')));

        try {
            $validator->assert($data);
            return ['success' => true];
        } catch (NestedValidationException $e) {
            // カスタムメッセージテンプレートを適用
            $customMessages = [
                'email' => [
                    'email' => 'メールアドレスの形式が正しくありません'
                ],
                'age' => [
                    'intVal' => '年齢は整数で入力してください',
                    'between' => '年齢は18歳から65歳の間で入力してください'
                ],
                'website' => [
                    'url' => 'ウェブサイトは有効なURL形式で入力してください'
                ]
            ];

            return [
                'success' => false,
                'errors' => $this->applyCustomMessages($e, $customMessages)
            ];
        }
    }

    private function applyCustomMessages(NestedValidationException $e, array $customMessages): array
    {
        $errors = [];
        
        foreach ($e->getChildren() as $child) {
            $field = $child->getName();
            
            if ($child instanceof NestedValidationException) {
                foreach ($child->getChildren() as $fieldError) {
                    $ruleName = $this->getRuleName($fieldError);
                    
                    if (isset($customMessages[$field][$ruleName])) {
                        $errors[$field][] = $customMessages[$field][$ruleName];
                    } else {
                        $errors[$field][] = $fieldError->getMessage();
                    }
                }
            } else {
                $errors[$field][] = $child->getMessage();
            }
        }

        return $errors;
    }

    private function getRuleName(ValidationException $e): string
    {
        // バリデーション例外からルール名を抽出
        $className = get_class($e);
        $ruleName = str_replace(['Respect\\Validation\\Exceptions\\', 'Exception'], '', $className);
        return lcfirst($ruleName);
    }

    /**
     * 段階的バリデーション(一部のフィールドのみ検証)
     */
    public function validateStep(array $data, string $step): array
    {
        $validators = [
            'basic' => v::key('name', v::stringType()->notEmpty())
                      ->key('email', v::email()),
            
            'personal' => v::key('age', v::intVal()->between(18, 120))
                         ->key('phone', v::stringType()->regex('/^[0-9\-\+\(\)\s]+$/')),
            
            'preferences' => v::key('newsletter', v::boolType())
                           ->key('notifications', v::arrayType())
        ];

        if (!isset($validators[$step])) {
            return ['success' => false, 'errors' => ['step' => ['無効なステップです']]];
        }

        try {
            $validators[$step]->assert($data);
            return ['success' => true, 'errors' => []];
        } catch (NestedValidationException $e) {
            return [
                'success' => false,
                'errors' => $this->formatNestedErrors($e)
            ];
        }
    }

    /**
     * バリデーション結果のサマリー生成
     */
    public function generateValidationSummary(array $data, array $rules): array
    {
        $summary = [
            'total_fields' => count($data),
            'validated_fields' => 0,
            'failed_fields' => 0,
            'errors' => [],
            'warnings' => []
        ];

        foreach ($rules as $field => $validator) {
            try {
                if (isset($data[$field])) {
                    $validator->assert($data[$field]);
                    $summary['validated_fields']++;
                } else {
                    $summary['warnings'][] = "フィールド '$field' が見つかりません";
                }
            } catch (ValidationException $e) {
                $summary['failed_fields']++;
                $summary['errors'][$field] = $e->getMessage();
            }
        }

        $summary['success_rate'] = round(
            ($summary['validated_fields'] / $summary['total_fields']) * 100, 2
        );

        return $summary;
    }
}

// 使用例
$validationService = new ValidationService();

$userData = [
    'username' => 'john123',
    'email' => 'invalid-email',
    'password' => '123', // 短すぎる
    'age' => 25
];

$result = $validationService->validateUserRegistration($userData);

if (!$result['success']) {
    foreach ($result['errors'] as $field => $errors) {
        echo "フィールド '$field' のエラー:\n";
        foreach ($errors as $error) {
            echo "  - $error\n";
        }
    }
}

実用的なベストプラクティス

<?php

use Respect\Validation\Validator as v;
use Respect\Validation\Exceptions\ValidationException;

/**
 * 実用的なバリデーションパターン集
 */
class ValidationPatterns
{
    /**
     * 日本のフォーマットに特化したバリデーター
     */
    public static function getJapaneseValidators(): array
    {
        return [
            'zip_code' => v::regex('/^\d{3}-?\d{4}$/'),
            'phone' => v::regex('/^0\d{1,4}-?\d{1,4}-?\d{4}$/'),
            'mobile' => v::regex('/^0[789]0-?\d{4}-?\d{4}$/'),
            'katakana' => v::regex('/^[ァ-ヶー]+$/u'),
            'hiragana' => v::regex('/^[ひ-ゞー]+$/u'),
            'kanji' => v::regex('/^[一-龯]+$/u'),
            'full_width_alnum' => v::regex('/^[0-9A-Za-z]+$/u'),
        ];
    }

    /**
     * API リクエストバリデーション
     */
    public static function validateApiRequest(array $data, string $endpoint): array
    {
        $validators = [
            'users' => [
                'POST' => v::key('name', v::stringType()->length(1, 100))
                         ->key('email', v::email())
                         ->key('role', v::in(['admin', 'user', 'moderator'])),
                'PUT' => v::key('id', v::intVal()->positive())
                        ->key('name', v::optional(v::stringType()->length(1, 100)))
                        ->key('email', v::optional(v::email()))
            ],
            'products' => [
                'POST' => v::key('name', v::stringType()->notEmpty())
                         ->key('price', v::numericVal()->positive())
                         ->key('category_id', v::intVal()->positive()),
                'PUT' => v::key('id', v::intVal()->positive())
                        ->keyOptional('name', v::stringType()->notEmpty())
                        ->keyOptional('price', v::numericVal()->positive())
            ]
        ];

        $method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
        
        if (!isset($validators[$endpoint][$method])) {
            return [
                'valid' => false,
                'errors' => ['endpoint' => "Unsupported endpoint: $endpoint $method"]
            ];
        }

        try {
            $validators[$endpoint][$method]->assert($data);
            return ['valid' => true, 'errors' => []];
        } catch (ValidationException $e) {
            return [
                'valid' => false,
                'errors' => $e->getMessages()
            ];
        }
    }

    /**
     * ファイルアップロードバリデーション
     */
    public static function validateFileUpload(array $file, array $options = []): array
    {
        $defaults = [
            'max_size' => 5242880, // 5MB
            'allowed_types' => ['image/jpeg', 'image/png', 'image/gif'],
            'required' => true
        ];
        
        $config = array_merge($defaults, $options);

        if ($config['required'] && (!isset($file['tmp_name']) || empty($file['tmp_name']))) {
            return ['valid' => false, 'errors' => ['ファイルが選択されていません']];
        }

        if (!$config['required'] && empty($file['tmp_name'])) {
            return ['valid' => true, 'errors' => []];
        }

        $errors = [];

        // ファイルサイズチェック
        if ($file['size'] > $config['max_size']) {
            $maxMB = round($config['max_size'] / 1024 / 1024, 1);
            $errors[] = "ファイルサイズは{$maxMB}MB以下にしてください";
        }

        // MIMEタイプチェック
        $finfo = finfo_open(FILEINFO_MIME_TYPE);
        $mimeType = finfo_file($finfo, $file['tmp_name']);
        finfo_close($finfo);

        if (!in_array($mimeType, $config['allowed_types'])) {
            $allowedStr = implode(', ', $config['allowed_types']);
            $errors[] = "許可されていないファイル形式です。許可形式: $allowedStr";
        }

        // ファイル名チェック
        if (!v::stringType()->notEmpty()->length(1, 255)->isValid($file['name'])) {
            $errors[] = "ファイル名が無効です";
        }

        // アップロードエラーチェック
        if ($file['error'] !== UPLOAD_ERR_OK) {
            $errors[] = "ファイルアップロードエラー: " . $this->getUploadErrorMessage($file['error']);
        }

        return [
            'valid' => empty($errors),
            'errors' => $errors,
            'file_info' => [
                'mime_type' => $mimeType,
                'size' => $file['size'],
                'name' => $file['name']
            ]
        ];
    }

    private static function getUploadErrorMessage(int $error): string
    {
        $messages = [
            UPLOAD_ERR_INI_SIZE => 'ファイルサイズが上限を超えています',
            UPLOAD_ERR_FORM_SIZE => 'フォームのファイルサイズ上限を超えています',
            UPLOAD_ERR_PARTIAL => 'ファイルが部分的にしかアップロードされませんでした',
            UPLOAD_ERR_NO_FILE => 'ファイルがアップロードされませんでした',
            UPLOAD_ERR_NO_TMP_DIR => '一時ディレクトリがありません',
            UPLOAD_ERR_CANT_WRITE => 'ディスクへの書き込みに失敗しました',
            UPLOAD_ERR_EXTENSION => 'アップロードが拡張機能により停止されました'
        ];

        return $messages[$error] ?? '不明なエラー';
    }

    /**
     * パフォーマンスを考慮したバリデーション
     */
    public static function validateLargeDataset(array $items, callable $validator): array
    {
        $results = [
            'processed' => 0,
            'valid' => 0,
            'invalid' => 0,
            'errors' => [],
            'performance' => [
                'start_time' => microtime(true),
                'memory_start' => memory_get_usage()
            ]
        ];

        foreach ($items as $index => $item) {
            try {
                if ($validator($item)) {
                    $results['valid']++;
                } else {
                    $results['invalid']++;
                    $results['errors'][$index] = 'バリデーション失敗';
                }
            } catch (Exception $e) {
                $results['invalid']++;
                $results['errors'][$index] = $e->getMessage();
            }
            
            $results['processed']++;

            // メモリ使用量監視
            if ($results['processed'] % 1000 === 0) {
                $currentMemory = memory_get_usage();
                if ($currentMemory > 128 * 1024 * 1024) { // 128MB制限
                    $results['warnings'][] = "メモリ使用量が多すぎます: " . round($currentMemory / 1024 / 1024, 2) . "MB";
                    break;
                }
            }
        }

        $results['performance']['end_time'] = microtime(true);
        $results['performance']['duration'] = $results['performance']['end_time'] - $results['performance']['start_time'];
        $results['performance']['memory_peak'] = memory_get_peak_usage();
        $results['performance']['items_per_second'] = round($results['processed'] / $results['performance']['duration'], 2);

        return $results;
    }
}

// 使用例
$patterns = new ValidationPatterns();

// 日本語バリデーションの例
$japaneseValidators = ValidationPatterns::getJapaneseValidators();
$zipCode = '123-4567';
if ($japaneseValidators['zip_code']->isValid($zipCode)) {
    echo "有効な郵便番号です\n";
}

// APIリクエストバリデーション
$apiData = [
    'name' => 'John Doe',
    'email' => '[email protected]',
    'role' => 'admin'
];

$apiResult = ValidationPatterns::validateApiRequest($apiData, 'users');
if ($apiResult['valid']) {
    echo "APIリクエストは有効です\n";
}

// ファイルアップロードバリデーション
$uploadedFile = [
    'name' => 'photo.jpg',
    'type' => 'image/jpeg',
    'tmp_name' => '/tmp/phpXXXXXX',
    'error' => UPLOAD_ERR_OK,
    'size' => 1048576 // 1MB
];

$fileResult = ValidationPatterns::validateFileUpload($uploadedFile, [
    'max_size' => 2097152, // 2MB
    'allowed_types' => ['image/jpeg', 'image/png']
]);

if ($fileResult['valid']) {
    echo "ファイルは有効です\n";
} else {
    foreach ($fileResult['errors'] as $error) {
        echo "エラー: $error\n";
    }
}