Symfony Validator

バリデーションライブラリPHPSymfony制約カスタムバリデーターバリデーショングループ

ライブラリ

Symfony Validator

概要

Symfony Validatorは、PHPオブジェクトをバリデーションルールのセット(制約)に対して検証するための強力なコンポーネントです。Symfonyフレームワークの一部として開発されていますが、スタンドアロンでも使用可能で、アノテーション、属性、YAML、XMLによる制約定義をサポート。複雑なビジネスルールの実装、ネストしたオブジェクトの検証、グループベースのバリデーションなど、エンタープライズレベルの要件に対応する包括的な機能を提供します。2025年現在、PHPエコシステムにおける最も成熟したバリデーションソリューションの一つです。

詳細

Symfony Validator 7.xは2025年現在の最新版で、PHPコミュニティで広く採用されているエンタープライズグレードのバリデーションコンポーネントです。70以上の組み込み制約を提供し、データ型、フォーマット、ビジネスルール、コレクション検証など幅広いニーズに対応。PHP 8の属性(Attributes)を完全サポートし、モダンなコード記述を可能にします。Doctrine ORMとの深い統合、国際化対応、カスタム制約の簡単な作成、バリデーショングループによる条件付き検証など、大規模アプリケーション開発に必要な機能を網羅しています。

主な特徴

  • 70以上の組み込み制約: NotBlank、Email、Url、Uuid、Isbn、Ibanなど豊富な制約セット
  • PHP 8属性サポート: モダンな属性構文による直感的な制約定義
  • バリデーショングループ: コンテキストに応じた柔軟なバリデーション適用
  • ネストした検証: オブジェクトグラフ全体の深い検証が可能
  • カスタム制約: ビジネスロジックに特化した独自制約の簡単な実装
  • 国際化対応: 多言語エラーメッセージとロケール固有の検証
  • パフォーマンス最適化: 遅延読み込みと効率的な検証処理
  • フレームワーク統合: Symfony、Laravel、他のPSRフレームワークとの連携

メリット・デメリット

メリット

  • Symfonyエコシステムとの完璧な統合による開発効率の向上
  • スタンドアロンでも使用可能な柔軟性とポータビリティ
  • 豊富な組み込み制約による即座の生産性と開発速度向上
  • PHP属性による直感的でクリーンな制約定義
  • 強力なグループベースのバリデーションによる複雑な業務要件への対応
  • 優れたドキュメントとアクティブなコミュニティサポート
  • 国際化対応による多言語アプリケーション開発の容易さ
  • テスト容易性とモック可能な設計

デメリット

  • 学習曲線が比較的急(多くの概念と機能を理解する必要)
  • 小規模プロジェクトではオーバーキルになる可能性
  • 制約の組み合わせが複雑になりやすく、保守性に注意が必要
  • パフォーマンスオーバーヘッド(特に深いオブジェクトグラフ)
  • 他のフレームワークとの統合には追加作業が必要
  • メモリ使用量が比較的大きく、リソース制約環境では注意が必要

参考ページ

書き方の例

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

# 基本インストール
composer require symfony/validator

# 属性サポート(推奨)
composer require doctrine/annotations

# 翻訳機能が必要な場合
composer require symfony/translation

# Doctrine ORM統合(オプション)
composer require symfony/validator doctrine/orm

# パフォーマンス向上のためのキャッシュ(本番環境推奨)
composer require symfony/cache

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

<?php

use Symfony\Component\Validator\Validation;
use Symfony\Component\Validator\Constraints as Assert;

// バリデーターサービスの作成
$validator = Validation::createValidator();

// シンプルな値のバリデーション
$emailConstraint = new Assert\Email([
    'message' => '有効なメールアドレスを入力してください。',
    'mode' => 'strict'
]);

$errors = $validator->validate('invalid-email', $emailConstraint);

if (count($errors) > 0) {
    foreach ($errors as $error) {
        echo $error->getMessage() . "\n";
    }
}

// 配列データの包括的なバリデーション
$userData = [
    'username' => 'user123',
    'email' => '[email protected]',
    'age' => 25,
    'website' => 'https://example.com',
    'tags' => ['php', 'symfony'],
    'profile' => [
        'firstName' => '太郎',
        'lastName' => '田中',
        'bio' => 'PHPエンジニアです'
    ]
];

$constraint = new Assert\Collection([
    'username' => [
        new Assert\NotBlank(['message' => 'ユーザー名は必須です']),
        new Assert\Length([
            'min' => 3,
            'max' => 20,
            'minMessage' => 'ユーザー名は{{ limit }}文字以上で入力してください',
            'maxMessage' => 'ユーザー名は{{ limit }}文字以内で入力してください'
        ]),
        new Assert\Regex([
            'pattern' => '/^[a-zA-Z0-9_]+$/',
            'message' => 'ユーザー名は英数字とアンダースコアのみ使用できます'
        ])
    ],
    'email' => [
        new Assert\NotBlank(['message' => 'メールアドレスは必須です']),
        new Assert\Email([
            'message' => '有効なメールアドレスを入力してください',
            'mode' => 'strict'
        ])
    ],
    'age' => [
        new Assert\NotBlank(),
        new Assert\Type('integer'),
        new Assert\Range([
            'min' => 18,
            'max' => 120,
            'notInRangeMessage' => '年齢は{{ min }}歳から{{ max }}歳の間で入力してください'
        ])
    ],
    'website' => [
        new Assert\Url(['message' => '有効なURLを入力してください']),
        new Assert\Optional()
    ],
    'tags' => [
        new Assert\Type('array'),
        new Assert\Count([
            'min' => 1,
            'max' => 5,
            'minMessage' => '最低{{ limit }}個のタグが必要です',
            'maxMessage' => 'タグは最大{{ limit }}個までです'
        ]),
        new Assert\All([
            new Assert\NotBlank(),
            new Assert\Length(['max' => 20])
        ])
    ],
    'profile' => new Assert\Collection([
        'firstName' => [
            new Assert\NotBlank(['message' => '名前は必須です']),
            new Assert\Length(['max' => 50])
        ],
        'lastName' => [
            new Assert\NotBlank(['message' => '苗字は必須です']),
            new Assert\Length(['max' => 50])
        ],
        'bio' => [
            new Assert\Length([
                'max' => 500,
                'maxMessage' => '自己紹介は{{ limit }}文字以内で入力してください'
            ]),
            new Assert\Optional()
        ]
    ])
]);

$violations = $validator->validate($userData, $constraint);

if (count($violations) > 0) {
    foreach ($violations as $violation) {
        echo sprintf(
            "フィールド: %s\nエラー: %s\n値: %s\n\n",
            $violation->getPropertyPath(),
            $violation->getMessage(),
            $violation->getInvalidValue()
        );
    }
}

PHP属性を使用したエンティティクラスの検証

<?php

namespace App\Entity;

use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;

class User
{
    #[Assert\NotBlank(message: 'ユーザー名は必須です')]
    #[Assert\Length(
        min: 3,
        max: 50,
        minMessage: 'ユーザー名は{{ limit }}文字以上で入力してください',
        maxMessage: 'ユーザー名は{{ limit }}文字以内で入力してください'
    )]
    #[Assert\Regex(
        pattern: '/^[a-zA-Z0-9_\p{Hiragana}\p{Katakana}\p{Han}]+$/u',
        message: 'ユーザー名は英数字、アンダースコア、日本語のみ使用できます'
    )]
    private string $username;

    #[Assert\NotBlank(message: 'メールアドレスは必須です')]
    #[Assert\Email(
        message: '有効なメールアドレスを入力してください',
        mode: 'strict'
    )]
    private string $email;

    #[Assert\NotBlank(message: 'パスワードは必須です')]
    #[Assert\Length(
        min: 8,
        minMessage: 'パスワードは{{ limit }}文字以上で入力してください'
    )]
    #[Assert\PasswordStrength(
        minScore: Assert\PasswordStrength::STRENGTH_MEDIUM,
        message: 'パスワードが弱すぎます。英数字記号を組み合わせた強力なパスワードを選択してください'
    )]
    private string $password;

    #[Assert\NotBlank(message: '生年月日は必須です')]
    #[Assert\Date(message: '有効な日付を入力してください')]
    #[Assert\LessThan(
        'today',
        message: '生年月日は今日より前の日付を入力してください'
    )]
    private string $birthDate;

    #[Assert\Choice(
        choices: ['male', 'female', 'other', 'prefer_not_to_say'],
        message: '有効な性別を選択してください'
    )]
    private ?string $gender = null;

    #[Assert\Valid]
    private ?Address $address = null;

    #[Assert\All([
        new Assert\NotBlank(message: 'タグは空にできません'),
        new Assert\Length(max: 20, maxMessage: 'タグは{{ limit }}文字以内で入力してください'),
        new Assert\Regex(
            pattern: '/^[\p{Hiragana}\p{Katakana}\p{Han}a-zA-Z0-9]+$/u',
            message: 'タグは日本語、英数字のみ使用できます'
        )
    ])]
    #[Assert\Count(
        min: 1,
        max: 5,
        minMessage: '少なくとも{{ limit }}個のタグが必要です',
        maxMessage: 'タグは最大{{ limit }}個までです'
    )]
    private array $tags = [];

    #[Assert\File(
        maxSize: '2M',
        mimeTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
        mimeTypesMessage: 'プロフィール画像はJPEG、PNG、GIF、WebP形式のみアップロード可能です'
    )]
    private ?\SplFileInfo $profileImage = null;

    #[Assert\Callback]
    public function validateAgeConsistency(ExecutionContextInterface $context): void
    {
        if ($this->birthDate) {
            $birthDate = new \DateTime($this->birthDate);
            $today = new \DateTime();
            $age = $today->diff($birthDate)->y;

            if ($age < 13) {
                $context->buildViolation('13歳未満のユーザーは登録できません')
                    ->atPath('birthDate')
                    ->addViolation();
            }

            if ($age > 120) {
                $context->buildViolation('有効な生年月日を入力してください')
                    ->atPath('birthDate')
                    ->addViolation();
            }
        }
    }

    // ゲッター/セッター
    public function getUsername(): string
    {
        return $this->username;
    }

    public function setUsername(string $username): self
    {
        $this->username = $username;
        return $this;
    }

    public function getEmail(): string
    {
        return $this->email;
    }

    public function setEmail(string $email): self
    {
        $this->email = $email;
        return $this;
    }

    // その他のプロパティのゲッター/セッター...
}

class Address
{
    #[Assert\NotBlank(message: '郵便番号は必須です')]
    #[Assert\Regex(
        pattern: '/^\d{3}-?\d{4}$/',
        message: '有効な郵便番号を入力してください(例:123-4567)'
    )]
    private string $postalCode;

    #[Assert\NotBlank(message: '都道府県は必須です')]
    #[Assert\Choice(
        choices: [
            '北海道', '青森県', '岩手県', '宮城県', '秋田県', '山形県', '福島県',
            '茨城県', '栃木県', '群馬県', '埼玉県', '千葉県', '東京都', '神奈川県',
            '新潟県', '富山県', '石川県', '福井県', '山梨県', '長野県', '岐阜県',
            '静岡県', '愛知県', '三重県', '滋賀県', '京都府', '大阪府', '兵庫県',
            '奈良県', '和歌山県', '鳥取県', '島根県', '岡山県', '広島県', '山口県',
            '徳島県', '香川県', '愛媛県', '高知県', '福岡県', '佐賀県', '長崎県',
            '熊本県', '大分県', '宮崎県', '鹿児島県', '沖縄県'
        ],
        message: '有効な都道府県を選択してください'
    )]
    private string $prefecture;

    #[Assert\NotBlank(message: '市区町村は必須です')]
    #[Assert\Length(max: 100, maxMessage: '市区町村は{{ limit }}文字以内で入力してください')]
    private string $city;

    #[Assert\NotBlank(message: '番地は必須です')]
    #[Assert\Length(max: 100, maxMessage: '番地は{{ limit }}文字以内で入力してください')]
    private string $street;

    #[Assert\Length(max: 100, maxMessage: '建物名は{{ limit }}文字以内で入力してください')]
    private ?string $building = null;

    // ゲッター/セッター...
}

日本特有のカスタム制約の作成

<?php

namespace App\Validator\Constraints;

use Symfony\Component\Validator\Constraint;

/**
 * 日本の電話番号バリデーション制約
 */
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD)]
class JapanesePhoneNumber extends Constraint
{
    public string $message = '有効な日本の電話番号を入力してください(例:090-1234-5678)';
    public bool $allowHyphen = true;
    public bool $mobileOnly = false;
    public bool $allowInternational = false;

    public function __construct(
        ?array $options = null,
        ?string $message = null,
        ?bool $allowHyphen = null,
        ?bool $mobileOnly = null,
        ?bool $allowInternational = null,
        ?array $groups = null,
        mixed $payload = null
    ) {
        parent::__construct($options ?? [], $groups, $payload);

        $this->message = $message ?? $this->message;
        $this->allowHyphen = $allowHyphen ?? $this->allowHyphen;
        $this->mobileOnly = $mobileOnly ?? $this->mobileOnly;
        $this->allowInternational = $allowInternational ?? $this->allowInternational;
    }
}

namespace App\Validator\Constraints;

use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;

class JapanesePhoneNumberValidator extends ConstraintValidator
{
    private array $mobilePatterns = [
        '/^0[789]0\d{8}$/',           // 携帯(ハイフンなし)
        '/^0[789]0-\d{4}-\d{4}$/',   // 携帯(ハイフンあり)
        '/^\+81[789]0\d{8}$/',       // 国際形式(+81)
    ];

    private array $landlinePatterns = [
        '/^0\d{9}$/',                  // 固定電話(ハイフンなし)10桁
        '/^0\d{8}$/',                  // 固定電話(ハイフンなし)9桁
        '/^0\d{1,4}-\d{1,4}-\d{4}$/',  // 固定電話(ハイフンあり)
        '/^\+81\d{9,10}$/',            // 国際形式固定電話
    ];

    public function validate($value, Constraint $constraint): void
    {
        if (!$constraint instanceof JapanesePhoneNumber) {
            throw new UnexpectedTypeException($constraint, JapanesePhoneNumber::class);
        }

        if (null === $value || '' === $value) {
            return;
        }

        if (!is_string($value)) {
            throw new UnexpectedValueException($value, 'string');
        }

        // 国際形式チェック
        if (!$constraint->allowInternational && str_starts_with($value, '+81')) {
            $this->context->buildViolation('国際形式の電話番号は許可されていません')
                ->addViolation();
            return;
        }

        // ハイフン除去(許可されていない場合)
        $cleanValue = $value;
        if (!$constraint->allowHyphen) {
            $cleanValue = str_replace(['-', ' '], '', $value);
        }

        $patterns = $constraint->mobileOnly 
            ? $this->mobilePatterns 
            : array_merge($this->mobilePatterns, $this->landlinePatterns);

        foreach ($patterns as $pattern) {
            if (preg_match($pattern, $cleanValue)) {
                return;
            }
        }

        $this->context->buildViolation($constraint->message)
            ->setParameter('{{ value }}', $this->formatValue($value))
            ->addViolation();
    }
}

/**
 * 日本の企業コード(法人番号)バリデーション制約
 */
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD)]
class JapaneseCorporateNumber extends Constraint
{
    public string $message = '有効な法人番号を入力してください(13桁の数字)';

    public function __construct(
        ?array $options = null,
        ?string $message = null,
        ?array $groups = null,
        mixed $payload = null
    ) {
        parent::__construct($options ?? [], $groups, $payload);
        $this->message = $message ?? $this->message;
    }
}

class JapaneseCorporateNumberValidator extends ConstraintValidator
{
    public function validate($value, Constraint $constraint): void
    {
        if (!$constraint instanceof JapaneseCorporateNumber) {
            throw new UnexpectedTypeException($constraint, JapaneseCorporateNumber::class);
        }

        if (null === $value || '' === $value) {
            return;
        }

        if (!is_string($value)) {
            throw new UnexpectedValueException($value, 'string');
        }

        // ハイフンを除去
        $cleanValue = str_replace('-', '', $value);

        // 13桁の数字かチェック
        if (!preg_match('/^\d{13}$/', $cleanValue)) {
            $this->context->buildViolation($constraint->message)
                ->addViolation();
            return;
        }

        // チェックデジット検証
        if (!$this->validateCheckDigit($cleanValue)) {
            $this->context->buildViolation('法人番号のチェックデジットが無効です')
                ->addViolation();
        }
    }

    private function validateCheckDigit(string $number): bool
    {
        $weights = [1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2];
        $sum = 0;

        for ($i = 0; $i < 12; $i++) {
            $product = (int)$number[$i] * $weights[$i];
            $sum += $product >= 10 ? $product - 9 : $product;
        }

        $checkDigit = (10 - ($sum % 10)) % 10;
        return $checkDigit === (int)$number[12];
    }
}

バリデーショングループの高度な活用

<?php

namespace App\Entity;

use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;

class ECommerceProduct
{
    #[Assert\NotBlank(groups: ['Default', 'create', 'update'])]
    #[Assert\Length(
        min: 3, 
        max: 100, 
        groups: ['Default', 'create', 'update'],
        minMessage: '商品名は{{ limit }}文字以上で入力してください',
        maxMessage: '商品名は{{ limit }}文字以内で入力してください'
    )]
    private string $name;

    #[Assert\NotBlank(groups: ['Default', 'create'])]
    #[Assert\Positive(groups: ['Default', 'create', 'update', 'pricing'])]
    #[Assert\LessThan(
        value: 10000000,
        groups: ['Default', 'create', 'update', 'pricing'],
        message: '価格は1000万円未満で設定してください'
    )]
    private float $price;

    #[Assert\NotBlank(groups: ['create'])]
    #[Assert\Regex(
        pattern: '/^[A-Z]{2,3}-\d{4,6}$/',
        groups: ['create'],
        message: 'SKUは正しい形式で入力してください(例:AB-1234)'
    )]
    private string $sku;

    #[Assert\GreaterThanOrEqual(
        value: 0,
        groups: ['Default', 'inventory', 'publish'],
        message: '在庫数は0以上である必要があります'
    )]
    #[Assert\LessThan(
        value: 999999,
        groups: ['inventory'],
        message: '在庫数は99万個未満で設定してください'
    )]
    private int $stock = 0;

    #[Assert\When(
        expression: 'this.isPublished() == true',
        constraints: [
            new Assert\NotBlank(message: '公開商品には説明が必要です'),
            new Assert\Length(
                min: 50, 
                minMessage: '商品説明は{{ limit }}文字以上必要です'
            )
        ],
        groups: ['publish']
    )]
    #[Assert\Length(
        max: 2000,
        groups: ['Default'],
        maxMessage: '商品説明は{{ limit }}文字以内で入力してください'
    )]
    private ?string $description = null;

    #[Assert\Valid(groups: ['Default', 'create', 'update'])]
    #[Assert\Count(
        min: 1, 
        groups: ['publish'], 
        minMessage: '公開商品には最低1つのカテゴリーが必要です'
    )]
    private array $categories = [];

    #[Assert\All([
        new Assert\Url(message: '有効なURL形式で入力してください'),
        new Assert\Regex(
            pattern: '/\.(jpg|jpeg|png|gif|webp)$/i',
            message: '画像ファイルのURLを入力してください'
        )
    ])]
    #[Assert\Count(
        min: 1,
        max: 10,
        groups: ['publish'],
        minMessage: '公開商品には最低{{ limit }}枚の画像が必要です',
        maxMessage: '画像は最大{{ limit }}枚まで登録できます'
    )]
    private array $imageUrls = [];

    private bool $published = false;

    #[Assert\Range(
        min: 0,
        max: 100,
        groups: ['discount'],
        notInRangeMessage: '割引率は{{ min }}%から{{ max }}%の間で設定してください'
    )]
    private ?float $discountPercentage = null;

    #[Assert\DateTime(groups: ['schedule'])]
    #[Assert\GreaterThan(
        'now',
        groups: ['schedule'],
        message: '公開予定日は現在より後の日時を設定してください'
    )]
    private ?\DateTimeInterface $scheduledPublishDate = null;

    // 複雑なビジネスルールのコールバック検証
    #[Assert\Callback(groups: ['pricing'])]
    public function validatePricing(ExecutionContextInterface $context): void
    {
        // 最低価格チェック
        if ($this->price > 0 && $this->price < 100) {
            $context->buildViolation('100円未満の商品は登録できません')
                ->atPath('price')
                ->addViolation();
        }

        // 割引価格チェック
        if ($this->discountPercentage && $this->discountPercentage > 0) {
            $discountedPrice = $this->price * (1 - $this->discountPercentage / 100);
            if ($discountedPrice < 50) {
                $context->buildViolation('割引後価格は50円以上である必要があります')
                    ->atPath('discountPercentage')
                    ->addViolation();
            }
        }
    }

    #[Assert\Callback(groups: ['publish'])]
    public function validatePublishRequirements(ExecutionContextInterface $context): void
    {
        if ($this->published) {
            // 在庫チェック
            if ($this->stock <= 0) {
                $context->buildViolation('在庫がない商品は公開できません')
                    ->atPath('stock')
                    ->addViolation();
            }

            // 価格設定チェック
            if (!$this->price || $this->price <= 0) {
                $context->buildViolation('価格が設定されていない商品は公開できません')
                    ->atPath('price')
                    ->addViolation();
            }

            // カテゴリーチェック
            if (empty($this->categories)) {
                $context->buildViolation('カテゴリーが設定されていない商品は公開できません')
                    ->atPath('categories')
                    ->addViolation();
            }
        }
    }

    public function isPublished(): bool
    {
        return $this->published;
    }

    // その他のゲッター/セッター...
}

// バリデーショングループの実際の使用例
use Symfony\Component\Validator\Validation;

$product = new ECommerceProduct();
$product->setName('テスト商品');
$product->setPrice(50); // 100円未満
$product->setSku('invalid-sku');
$product->setStock(-1); // 負の在庫
$product->setPublished(true);
// 説明なし、カテゴリーなし、画像なし

$validator = Validation::createValidatorBuilder()
    ->enableAttributeMapping()
    ->getValidator();

// 段階的バリデーション

// 1. 基本情報の検証(作成時)
echo "=== 作成時の検証 ===\n";
$violations = $validator->validate($product, null, ['create']);
foreach ($violations as $violation) {
    echo "• {$violation->getPropertyPath()}: {$violation->getMessage()}\n";
}

// 2. 価格設定の検証
echo "\n=== 価格設定の検証 ===\n";
$violations = $validator->validate($product, null, ['pricing']);
foreach ($violations as $violation) {
    echo "• {$violation->getPropertyPath()}: {$violation->getMessage()}\n";
}

// 3. 在庫管理の検証
echo "\n=== 在庫管理の検証 ===\n";
$violations = $validator->validate($product, null, ['inventory']);
foreach ($violations as $violation) {
    echo "• {$violation->getPropertyPath()}: {$violation->getMessage()}\n";
}

// 4. 公開前の検証
echo "\n=== 公開前の検証 ===\n";
$violations = $validator->validate($product, null, ['publish']);
foreach ($violations as $violation) {
    echo "• {$violation->getPropertyPath()}: {$violation->getMessage()}\n";
}

// 5. 複数グループの総合検証
echo "\n=== 総合検証 ===\n";
$violations = $validator->validate($product, null, ['create', 'pricing', 'inventory', 'publish']);
echo "総エラー数: " . count($violations) . "\n";

国際化対応とエラーハンドリング

<?php

namespace App\Service;

use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Symfony\Contracts\Translation\TranslatorInterface;

class InternationalValidationService
{
    public function __construct(
        private ValidatorInterface $validator,
        private TranslatorInterface $translator
    ) {}

    /**
     * ロケールに応じたバリデーションとエラーメッセージの生成
     */
    public function validateWithLocale(object $entity, string $locale = 'ja', array $groups = null): array
    {
        // ロケールを設定
        $this->translator->setLocale($locale);
        
        $violations = $this->validator->validate($entity, null, $groups);
        
        if (count($violations) === 0) {
            return ['valid' => true, 'errors' => []];
        }

        return [
            'valid' => false,
            'errors' => $this->formatViolationsWithLocale($violations, $locale),
            'summary' => $this->generateErrorSummary($violations, $locale)
        ];
    }

    /**
     * バリデーションエラーの構造化とローカライゼーション
     */
    private function formatViolationsWithLocale(
        ConstraintViolationListInterface $violations, 
        string $locale
    ): array {
        $errors = [];
        $errorCounts = [];
        
        foreach ($violations as $violation) {
            $propertyPath = $violation->getPropertyPath();
            $constraintClass = get_class($violation->getConstraint());
            
            if (!isset($errors[$propertyPath])) {
                $errors[$propertyPath] = [];
                $errorCounts[$propertyPath] = 0;
            }
            
            $errorCounts[$propertyPath]++;
            
            $errors[$propertyPath][] = [
                'message' => $violation->getMessage(),
                'code' => $violation->getCode(),
                'invalidValue' => $this->formatInvalidValue($violation->getInvalidValue()),
                'constraint' => $this->getConstraintFriendlyName($constraintClass, $locale),
                'severity' => $this->getErrorSeverity($constraintClass),
                'suggestions' => $this->generateSuggestions($violation, $locale)
            ];
        }
        
        // エラーを重要度でソート
        foreach ($errors as $property => &$propertyErrors) {
            usort($propertyErrors, function($a, $b) {
                $severityOrder = ['critical' => 0, 'high' => 1, 'medium' => 2, 'low' => 3];
                return $severityOrder[$a['severity']] <=> $severityOrder[$b['severity']];
            });
        }
        
        return [
            'fields' => $errors,
            'counts' => $errorCounts,
            'total' => array_sum($errorCounts)
        ];
    }

    /**
     * エラーサマリーの生成
     */
    private function generateErrorSummary(
        ConstraintViolationListInterface $violations, 
        string $locale
    ): array {
        $summary = [
            'total_errors' => count($violations),
            'error_types' => [],
            'critical_issues' => [],
            'recommendations' => []
        ];

        $constraintCounts = [];
        
        foreach ($violations as $violation) {
            $constraintClass = get_class($violation->getConstraint());
            $constraintName = $this->getConstraintFriendlyName($constraintClass, $locale);
            
            if (!isset($constraintCounts[$constraintName])) {
                $constraintCounts[$constraintName] = 0;
            }
            $constraintCounts[$constraintName]++;
            
            // クリティカルな問題の特定
            if ($this->isCriticalConstraint($constraintClass)) {
                $summary['critical_issues'][] = [
                    'field' => $violation->getPropertyPath(),
                    'issue' => $violation->getMessage()
                ];
            }
        }
        
        $summary['error_types'] = $constraintCounts;
        $summary['recommendations'] = $this->generateRecommendations($violations, $locale);
        
        return $summary;
    }

    /**
     * 制約の分かりやすい名前を取得
     */
    private function getConstraintFriendlyName(string $constraintClass, string $locale): string
    {
        $constraintMap = [
            'ja' => [
                'NotBlank' => '必須入力',
                'Email' => 'メール形式',
                'Length' => '文字数制限',
                'Range' => '数値範囲',
                'Regex' => '書式制限',
                'Type' => '型制限',
                'Url' => 'URL形式',
                'Date' => '日付形式',
                'Choice' => '選択制限',
                'File' => 'ファイル制限'
            ],
            'en' => [
                'NotBlank' => 'Required Field',
                'Email' => 'Email Format',
                'Length' => 'Length Constraint',
                'Range' => 'Range Constraint',
                'Regex' => 'Format Constraint',
                'Type' => 'Type Constraint',
                'Url' => 'URL Format',
                'Date' => 'Date Format',
                'Choice' => 'Choice Constraint',
                'File' => 'File Constraint'
            ]
        ];

        $shortName = substr($constraintClass, strrpos($constraintClass, '\\') + 1);
        return $constraintMap[$locale][$shortName] ?? $shortName;
    }

    /**
     * エラーの重要度を判定
     */
    private function getErrorSeverity(string $constraintClass): string
    {
        $criticalConstraints = ['NotBlank', 'NotNull', 'Type'];
        $highConstraints = ['Email', 'Url', 'Regex', 'Range'];
        $mediumConstraints = ['Length', 'Choice', 'Date'];
        
        $shortName = substr($constraintClass, strrpos($constraintClass, '\\') + 1);
        
        if (in_array($shortName, $criticalConstraints)) {
            return 'critical';
        } elseif (in_array($shortName, $highConstraints)) {
            return 'high';
        } elseif (in_array($shortName, $mediumConstraints)) {
            return 'medium';
        }
        
        return 'low';
    }

    /**
     * 改善提案の生成
     */
    private function generateSuggestions($violation, string $locale): array
    {
        $constraintClass = get_class($violation->getConstraint());
        $shortName = substr($constraintClass, strrpos($constraintClass, '\\') + 1);
        $invalidValue = $violation->getInvalidValue();
        
        $suggestions = [];
        
        switch ($shortName) {
            case 'Email':
                if (is_string($invalidValue)) {
                    if (strpos($invalidValue, '@') === false) {
                        $suggestions[] = $locale === 'ja' 
                            ? '@マークを含めてください' 
                            : 'Include @ symbol';
                    }
                    if (strpos($invalidValue, '.') === false) {
                        $suggestions[] = $locale === 'ja' 
                            ? 'ドメインを含めてください(例:example.com)' 
                            : 'Include domain (e.g., example.com)';
                    }
                }
                break;
                
            case 'Length':
                $constraint = $violation->getConstraint();
                if (is_string($invalidValue)) {
                    $currentLength = mb_strlen($invalidValue);
                    if (isset($constraint->min) && $currentLength < $constraint->min) {
                        $needed = $constraint->min - $currentLength;
                        $suggestions[] = $locale === 'ja' 
                            ? "あと{$needed}文字以上入力してください" 
                            : "Add at least {$needed} more characters";
                    }
                    if (isset($constraint->max) && $currentLength > $constraint->max) {
                        $excess = $currentLength - $constraint->max;
                        $suggestions[] = $locale === 'ja' 
                            ? "{$excess}文字削除してください" 
                            : "Remove {$excess} characters";
                    }
                }
                break;
                
            case 'Regex':
                $suggestions[] = $locale === 'ja' 
                    ? '指定された書式に従って入力してください' 
                    : 'Follow the specified format';
                break;
        }
        
        return $suggestions;
    }

    private function isCriticalConstraint(string $constraintClass): bool
    {
        $critical = ['NotBlank', 'NotNull', 'Type'];
        $shortName = substr($constraintClass, strrpos($constraintClass, '\\') + 1);
        return in_array($shortName, $critical);
    }

    private function generateRecommendations($violations, string $locale): array
    {
        $recommendations = [];
        
        if ($locale === 'ja') {
            $recommendations = [
                '必須項目の入力を完了してください',
                'フォーマットエラーを修正してください',
                '入力値の範囲を確認してください'
            ];
        } else {
            $recommendations = [
                'Complete all required fields',
                'Fix format errors',
                'Check input value ranges'
            ];
        }
        
        return $recommendations;
    }

    private function formatInvalidValue($value): string
    {
        if (is_null($value)) {
            return 'null';
        }
        if (is_bool($value)) {
            return $value ? 'true' : 'false';
        }
        if (is_array($value)) {
            return 'array(' . count($value) . ' items)';
        }
        if (is_object($value)) {
            return 'object(' . get_class($value) . ')';
        }
        
        return (string) $value;
    }
}

// 使用例
$user = new User();
$user->setUsername('u'); // 短すぎる
$user->setEmail('invalid-email'); // 無効なメール
$user->setPassword('weak'); // 弱いパスワード

$validationService = new InternationalValidationService($validator, $translator);

// 日本語でのバリデーション
$result = $validationService->validateWithLocale($user, 'ja');
print_r($result);

// 英語でのバリデーション
$result = $validationService->validateWithLocale($user, 'en');
print_r($result);

パフォーマンス最適化とベストプラクティス

<?php

namespace App\Service;

use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Component\Cache\Adapter\AdapterInterface;
use Symfony\Component\Validator\Validation;
use Symfony\Component\Validator\ConstraintViolationListInterface;

class OptimizedValidationService
{
    private array $cachedMetadata = [];
    
    public function __construct(
        private ValidatorInterface $validator,
        private AdapterInterface $cache
    ) {}

    /**
     * バリデーション結果のキャッシュと最適化
     */
    public function validateWithCache(
        object $entity, 
        array $groups = null, 
        int $cacheTtl = 300
    ): array {
        $cacheKey = $this->generateCacheKey($entity, $groups);
        $cacheItem = $this->cache->getItem($cacheKey);

        if ($cacheItem->isHit() && $this->isEntityUnchanged($entity, $cacheItem->get()['hash'])) {
            return $cacheItem->get()['result'];
        }

        $violations = $this->validator->validate($entity, null, $groups);
        $result = $this->formatViolations($violations);
        
        $entityHash = $this->generateEntityHash($entity);
        $cacheData = [
            'result' => $result,
            'hash' => $entityHash,
            'timestamp' => time()
        ];

        $cacheItem->set($cacheData);
        $cacheItem->expiresAfter($cacheTtl);
        $this->cache->save($cacheItem);

        return $result;
    }

    /**
     * 部分的なバリデーション(変更されたフィールドのみ)
     */
    public function validateModifiedFields(
        object $entity, 
        array $modifiedFields, 
        array $groups = null
    ): array {
        $metadata = $this->getMetadataWithCache($entity);
        $errors = [];

        foreach ($modifiedFields as $field) {
            if ($metadata->hasPropertyMetadata($field)) {
                $value = $this->getPropertyValue($entity, $field);
                $constraints = $this->getFieldConstraints($metadata, $field, $groups);
                
                if (!empty($constraints)) {
                    $fieldViolations = $this->validator->validate($value, $constraints);
                    
                    if (count($fieldViolations) > 0) {
                        $errors[$field] = $this->formatViolations($fieldViolations);
                    }
                }
            }
        }

        return $errors;
    }

    /**
     * 段階的バリデーション(早期終了)
     */
    public function validateProgressively(
        object $entity, 
        array $groupSequence, 
        bool $stopOnFirstError = false
    ): array {
        $allErrors = [];
        $allViolations = 0;

        foreach ($groupSequence as $groups) {
            $violations = $this->validator->validate($entity, null, $groups);
            
            if (count($violations) > 0) {
                $errors = $this->formatViolations($violations);
                $allErrors[implode(',', (array)$groups)] = $errors;
                $allViolations += count($violations);
                
                if ($stopOnFirstError) {
                    break;
                }
            }
        }

        return [
            'errors' => $allErrors,
            'total_violations' => $allViolations,
            'has_errors' => $allViolations > 0
        ];
    }

    /**
     * 非同期バリデーション(大量データ処理)
     */
    public function validateBatch(array $entities, array $groups = null): \Generator
    {
        $batchSize = 100;
        $batches = array_chunk($entities, $batchSize);
        
        foreach ($batches as $batchIndex => $batch) {
            $batchResults = [];
            
            foreach ($batch as $index => $entity) {
                $violations = $this->validator->validate($entity, null, $groups);
                $batchResults[$index] = [
                    'entity' => $entity,
                    'valid' => count($violations) === 0,
                    'errors' => count($violations) > 0 ? $this->formatViolations($violations) : [],
                    'violation_count' => count($violations)
                ];
            }
            
            yield [
                'batch_index' => $batchIndex,
                'batch_size' => count($batch),
                'results' => $batchResults,
                'summary' => $this->generateBatchSummary($batchResults)
            ];
            
            // メモリ効率のための休憩
            if ($batchIndex % 10 === 0) {
                gc_collect_cycles();
            }
        }
    }

    /**
     * 条件付きバリデーション
     */
    public function validateConditionally(
        object $entity, 
        callable $condition, 
        array $groups = null
    ): array {
        if (!$condition($entity)) {
            return ['valid' => true, 'skipped' => true, 'reason' => 'Condition not met'];
        }

        $violations = $this->validator->validate($entity, null, $groups);
        
        return [
            'valid' => count($violations) === 0,
            'skipped' => false,
            'errors' => count($violations) > 0 ? $this->formatViolations($violations) : []
        ];
    }

    private function getMetadataWithCache(object $entity)
    {
        $class = get_class($entity);
        
        if (!isset($this->cachedMetadata[$class])) {
            $this->cachedMetadata[$class] = $this->validator->getMetadataFor($entity);
        }
        
        return $this->cachedMetadata[$class];
    }

    private function generateCacheKey(object $entity, ?array $groups): string
    {
        return sprintf(
            'validation_%s_%s',
            str_replace('\\', '_', get_class($entity)),
            md5(serialize($groups ?? []))
        );
    }

    private function generateEntityHash(object $entity): string
    {
        // セキュアなハッシュ生成(機密データを除外)
        $reflection = new \ReflectionClass($entity);
        $data = [];
        
        foreach ($reflection->getProperties() as $property) {
            $property->setAccessible(true);
            $value = $property->getValue($entity);
            
            // パスワードなどの機密データをハッシュから除外
            if (!in_array($property->getName(), ['password', 'token', 'secret'])) {
                $data[$property->getName()] = $value;
            }
        }
        
        return md5(serialize($data));
    }

    private function isEntityUnchanged(object $entity, string $cachedHash): bool
    {
        return $this->generateEntityHash($entity) === $cachedHash;
    }

    private function getPropertyValue(object $entity, string $property)
    {
        $reflection = new \ReflectionClass($entity);
        
        if ($reflection->hasProperty($property)) {
            $prop = $reflection->getProperty($property);
            $prop->setAccessible(true);
            return $prop->getValue($entity);
        }
        
        // Getterメソッドの試行
        $getter = 'get' . ucfirst($property);
        if (method_exists($entity, $getter)) {
            return $entity->$getter();
        }
        
        return null;
    }

    private function getFieldConstraints($metadata, string $field, ?array $groups): array
    {
        $constraints = [];
        
        if ($metadata->hasPropertyMetadata($field)) {
            $propertyMetadata = $metadata->getPropertyMetadata($field);
            
            foreach ($propertyMetadata as $fieldMetadata) {
                foreach ($fieldMetadata->getConstraints() as $constraint) {
                    if ($this->constraintAppliesToGroups($constraint, $groups)) {
                        $constraints[] = $constraint;
                    }
                }
            }
        }
        
        return $constraints;
    }

    private function constraintAppliesToGroups($constraint, ?array $groups): bool
    {
        if ($groups === null) {
            return true;
        }
        
        $constraintGroups = $constraint->groups ?? ['Default'];
        return !empty(array_intersect($constraintGroups, $groups));
    }

    private function formatViolations(ConstraintViolationListInterface $violations): array
    {
        $errors = [];
        
        foreach ($violations as $violation) {
            $errors[] = [
                'property' => $violation->getPropertyPath(),
                'message' => $violation->getMessage(),
                'code' => $violation->getCode(),
                'invalid_value' => $violation->getInvalidValue()
            ];
        }
        
        return $errors;
    }

    private function generateBatchSummary(array $batchResults): array
    {
        $totalEntities = count($batchResults);
        $validEntities = 0;
        $totalViolations = 0;
        
        foreach ($batchResults as $result) {
            if ($result['valid']) {
                $validEntities++;
            }
            $totalViolations += $result['violation_count'];
        }
        
        return [
            'total_entities' => $totalEntities,
            'valid_entities' => $validEntities,
            'invalid_entities' => $totalEntities - $validEntities,
            'total_violations' => $totalViolations,
            'success_rate' => $totalEntities > 0 ? ($validEntities / $totalEntities) * 100 : 0
        ];
    }
}

// 実践的な使用例
class ECommerceValidationWorkflow
{
    public function __construct(
        private OptimizedValidationService $validationService
    ) {}

    /**
     * 商品登録の段階的バリデーション
     */
    public function validateProductRegistration(ECommerceProduct $product): array
    {
        // 段階的検証(早期終了あり)
        $groupSequence = [
            ['basic'],      // 基本情報
            ['pricing'],    // 価格設定
            ['inventory'],  // 在庫管理
            ['publish']     // 公開要件
        ];

        return $this->validationService->validateProgressively(
            $product, 
            $groupSequence, 
            true // 最初のエラーで停止
        );
    }

    /**
     * 大量商品データの一括検証
     */
    public function validateProductCatalog(array $products): array
    {
        $results = [];
        
        foreach ($this->validationService->validateBatch($products, ['create']) as $batch) {
            $results[] = $batch;
            
            // 進捗表示
            echo sprintf(
                "バッチ %d 処理完了: %d/%d 商品が有効 (成功率: %.1f%%)\n",
                $batch['batch_index'] + 1,
                $batch['summary']['valid_entities'],
                $batch['summary']['total_entities'],
                $batch['summary']['success_rate']
            );
        }
        
        return $results;
    }

    /**
     * リアルタイム入力検証(Ajax対応)
     */
    public function validateFieldUpdate(
        ECommerceProduct $product, 
        string $field, 
        $newValue
    ): array {
        // フィールド値を更新
        $setter = 'set' . ucfirst($field);
        if (method_exists($product, $setter)) {
            $product->$setter($newValue);
        }

        // 変更されたフィールドのみを検証
        return $this->validationService->validateModifiedFields(
            $product, 
            [$field], 
            ['Default']
        );
    }
}

このSymfony Validatorの包括的なドキュメントは、日本の開発者が実際のプロジェクトで直面する様々なバリデーション要件に対応できるよう、実践的な例とベストプラクティスを含めて作成しました。特に日本特有の要件(電話番号、住所、法人番号など)や、エンタープライズレベルの複雑なバリデーションシナリオに対応する内容となっています。