Symfony Validator
ライブラリ
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属性による直感的でクリーンな制約定義
- 強力なグループベースのバリデーションによる複雑な業務要件への対応
- 優れたドキュメントとアクティブなコミュニティサポート
- 国際化対応による多言語アプリケーション開発の容易さ
- テスト容易性とモック可能な設計
デメリット
- 学習曲線が比較的急(多くの概念と機能を理解する必要)
- 小規模プロジェクトではオーバーキルになる可能性
- 制約の組み合わせが複雑になりやすく、保守性に注意が必要
- パフォーマンスオーバーヘッド(特に深いオブジェクトグラフ)
- 他のフレームワークとの統合には追加作業が必要
- メモリ使用量が比較的大きく、リソース制約環境では注意が必要
参考ページ
- Symfony Validator 公式ドキュメント
- Symfony Validator GitHub
- Symfony Validator API ドキュメント
- Symfony Validator 制約リファレンス
書き方の例
インストールと基本セットアップ
# 基本インストール
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の包括的なドキュメントは、日本の開発者が実際のプロジェクトで直面する様々なバリデーション要件に対応できるよう、実践的な例とベストプラクティスを含めて作成しました。特に日本特有の要件(電話番号、住所、法人番号など)や、エンタープライズレベルの複雑なバリデーションシナリオに対応する内容となっています。