Validate phone numbers in Symfony
Phone number validation in Symfony that knows the national numbering plans: what people type is read in the plans you pick, a number no plan accepts gets Symfony's usual error, and what you store is its E.164 form. The reading is done by one file with no dependency, from the PHP guide; the rest is Symfony's own validation hook, a few lines long.
1. The file
Take Phone.php from the PHP guide, with the plans of the countries you serve (the link opens it for the United Kingdom, the United States and France, the plans the tests below use). It needs PHP 8.1 or later; no dependency. Its parse reads a number as typed and returns the number with its E.164 form, region and line type, or null when no plan accepts it.
composer require symfony/validator # a Symfony app already has it2. The Symfony side
src/Validator/PhoneNumber.php
The constraint, as Symfony's make:validator writes them.
<?php
declare(strict_types=1);
namespace App\Validator;
use Attribute;
use Symfony\Component\Validator\Attribute\HasNamedArguments;
use Symfony\Component\Validator\Constraint;
/** A phone number readable in the plans of Phone.php; national numbers are read in $defaultRegion. */
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD)]
final class PhoneNumber extends Constraint
{
public string $message = 'This value is not a valid phone number.';
#[HasNamedArguments]
public function __construct(public ?string $defaultRegion = null, ?array $groups = null, mixed $payload = null)
{
parent::__construct([], $groups, $payload);
}
}src/Validator/PhoneNumberValidator.php
The validator: the language guide's parse.
<?php
declare(strict_types=1);
namespace App\Validator;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class PhoneNumberValidator extends ConstraintValidator
{
public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof PhoneNumber) {
throw new UnexpectedTypeException($constraint, PhoneNumber::class);
}
if ($value === null || $value === '') {
return; // NotBlank's question
}
if (!is_string($value) || \Phone::parse($value, $constraint->defaultRegion) === null) {
$this->context->buildViolation($constraint->message)->addViolation();
}
}
}src/Dto/Signup.php
A DTO that uses it.
<?php
declare(strict_types=1);
namespace App\Dto;
use App\Validator\PhoneNumber;
use Symfony\Component\Validator\Constraints as Assert;
final class Signup
{
public function __construct(
#[Assert\NotBlank]
public string $name,
#[Assert\NotBlank, PhoneNumber(defaultRegion: 'GB')]
public string $phone,
) {
}
}3. The tests
tests/SignupTest.php: The tests, with PHPUnit and the Validator component.
<?php
declare(strict_types=1);
use App\Dto\Signup;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Validation;
use Symfony\Component\Validator\Validator\ValidatorInterface;
final class SignupTest extends TestCase
{
private function validator(): ValidatorInterface
{
return Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator();
}
public function testNationalNumberInTheDefaultRegion(): void
{
$this->assertCount(0, $this->validator()->validate(new Signup('Ada', '07400 123456')));
}
public function testInternationalNumber(): void
{
$this->assertCount(0, $this->validator()->validate(new Signup('Ada', '+1 201-555-0123')));
}
public function testOneViolationOnThePhone(): void
{
$violations = $this->validator()->validate(new Signup('Ada', '+44 7400 12345'));
$this->assertCount(1, $violations);
$this->assertSame('phone', $violations[0]->getPropertyPath());
$this->assertSame('This value is not a valid phone number.', $violations[0]->getMessage());
}
}php bin/phpunit # or vendor/bin/phpunitRun before publishing with symfony/validator 7.4.21 and the file generated for GB, US, FR, as 2026-10-25.
What is specific to Symfony
- Symfony finds
PhoneNumberValidatorby the constraint's class name plus "Validator"; in an app with autowiring both are registered with nothing more to write. - With
#[MapRequestPayload] Signup $signupin a controller, Symfony builds the DTO from the request and answers 422 with the violations when one fails. - The validator skips empty values so that
NotBlankdecides whether the field is required, as Symfony's own constraints do. Store\Phone::parse($signup->phone, 'GB')['e164']once it passes.