diff --git a/src/Validator/Constraints/EuropeanVatNumber.php b/src/Validator/Constraints/EuropeanVatNumber.php
new file mode 100644
index 0000000..d44ad18
--- /dev/null
+++ b/src/Validator/Constraints/EuropeanVatNumber.php
@@ -0,0 +1,14 @@
+ 'U[0-9]{8}',
+ 'BE' => '[01][0-9]{9}',
+ 'BG' => '[0-9]{9,10}',
+ 'CY' => '[0-9]{8}[A-Z]',
+ 'CZ' => '[0-9]{8,10}',
+ 'DE' => '[0-9]{9}',
+ 'DK' => '[0-9]{8}',
+ 'EE' => '[0-9]{9}',
+ 'EL' => '[0-9]{9}',
+ 'ES' => '[0-9A-Z][0-9]{7}[0-9A-Z]',
+ 'FI' => '[0-9]{8}',
+ 'FR' => '[0-9A-Z]{2}[0-9]{9}',
+ 'HR' => '[0-9]{11}',
+ 'HU' => '[0-9]{8}',
+ 'IE' => '([0-9]{7}[A-Z]{1,2}|[0-9][A-Z+*][0-9]{5}[A-Z])',
+ 'IT' => '[0-9]{11}',
+ 'LT' => '([0-9]{9}|[0-9]{12})',
+ 'LU' => '[0-9]{8}',
+ 'LV' => '[0-9]{11}',
+ 'MT' => '[0-9]{8}',
+ 'NL' => '[0-9]{9}B[0-9]{2}',
+ 'PL' => '[0-9]{10}',
+ 'PT' => '[0-9]{9}',
+ 'RO' => '[0-9]{2,10}',
+ 'SE' => '[0-9]{12}',
+ 'SI' => '[0-9]{8}',
+ 'SK' => '[0-9]{10}',
+ ];
+
+ public function validate(mixed $value, Constraint $constraint): void
+ {
+ if (!$constraint instanceof EuropeanVatNumber) {
+ throw new UnexpectedTypeException($constraint, EuropeanVatNumber::class);
+ }
+
+ if (null === $value || '' === $value) {
+ return;
+ }
+
+ if (!is_string($value)) {
+ throw new UnexpectedValueException($value, 'string');
+ }
+
+ if ($this->hasValidFormat(strtoupper(str_replace(' ', '', $value)))) {
+ return;
+ }
+
+ $this->context->buildViolation($constraint::MESSAGE)
+ ->setParameter('{{ value }}', $this->formatValue($value))
+ ->setCode($constraint::INVALID_FORMAT_ERROR)
+ ->addViolation();
+ }
+
+ private function hasValidFormat(string $normalized): bool
+ {
+ if (strlen($normalized) < 2) {
+ return false;
+ }
+
+ $pattern = self::PATTERNS[substr($normalized, 0, 2)] ?? null;
+
+ if (null === $pattern) {
+ return false;
+ }
+
+ // \z (not $) so a trailing newline never slips through the format check.
+ return 1 === preg_match('#^(?:' . $pattern . ')\z#', substr($normalized, 2));
+ }
+}
diff --git a/tests/Validator/Constraints/EuropeanVatNumberValidatorTest.php b/tests/Validator/Constraints/EuropeanVatNumberValidatorTest.php
new file mode 100644
index 0000000..10bfd41
--- /dev/null
+++ b/tests/Validator/Constraints/EuropeanVatNumberValidatorTest.php
@@ -0,0 +1,60 @@
+
+ */
+class EuropeanVatNumberValidatorTest extends ConstraintValidatorTestCase
+{
+ protected function getConstraint(): Constraint
+ {
+ return new EuropeanVatNumber();
+ }
+
+ public function createValidator(): ConstraintValidator
+ {
+ return new EuropeanVatNumberValidator();
+ }
+
+ public static function providerValidValues(): iterable
+ {
+ yield 'null value' => [null];
+ yield 'empty string' => [''];
+ yield 'Belgian number starting with 0' => ['BE0123456789'];
+ yield 'Belgian number starting with 1' => ['BE1123456789'];
+ yield 'German number' => ['DE123456789'];
+ yield 'French number' => ['FR12345678901'];
+ yield 'Greek number with the official EL prefix' => ['EL123456789'];
+ yield 'Croatian number' => ['HR12345678901'];
+ yield 'Dutch number' => ['NL123456789B12'];
+ yield 'Cypriot number' => ['CY10259033P'];
+ yield 'Irish number new style with one letter' => ['IE6433435F'];
+ yield 'Irish number new style with two letters' => ['IE3628739UA'];
+ yield 'Irish number old style' => ['IE8Z49289F'];
+ yield 'lowercase input is normalized' => ['be0123456789'];
+ yield 'inner spaces are normalized' => ['BE 0123 456 789'];
+ }
+
+ public static function providerInvalidValues(): iterable
+ {
+ $code = EuropeanVatNumber::INVALID_FORMAT_ERROR;
+ $message = EuropeanVatNumber::MESSAGE;
+
+ yield 'GB prefix (not an EU member)' => ['GB123456789', $code, $message];
+ yield 'unknown prefix' => ['XX123456789', $code, $message];
+ yield 'single character' => ['B', $code, $message];
+ yield 'wrong body length' => ['BE123', $code, $message];
+ yield 'Cypriot number without its trailing letter' => ['CY123456789', $code, $message];
+ yield 'trailing newline' => ["DE123456789\n", $code, $message];
+ yield 'Greek number with the GR country code' => ['GR123456789', $code, $message];
+ }
+}
diff --git a/translations/validators.en.xlf b/translations/validators.en.xlf
index dbe4c59..2ff7956 100644
--- a/translations/validators.en.xlf
+++ b/translations/validators.en.xlf
@@ -58,6 +58,10 @@
The value {{ value }} is not a valid RNA identifier.
The value {{ value }} is not a valid RNA identifier.
+
+ The intra-EU VAT number {{ value }} is not valid.
+ The intra-EU VAT number {{ value }} is not valid.
+