Custom Validation Rules in Laravel

When writing any app, validating user inputs is absolutely essential to ensure everything works they way it should. It's most obvious for functional data (like a user account's email address), but sometimes the business case also requires some additional checks.

The app I'm currently working on allows companies from all over the world to register and pay for a service. However, for legal reasons this service may only be offered to EU companies, not to EU individuals. Therefor it is essential for the client to ensure that no private person based in EU can sign up without owning an actual business.

In order to make sure of this, multiple checks have to be run:

  1. If the company signing up is registered outside of the EU, the VAT ID should be optional and no further checks should be run.

  2. If it is a EU company, the VAT ID needs to be provided.

  3. A VAT ID provided needs to follow the official syntax

  4. Furthermore, the provided VAT ID needs to be valid and belong to a company with the exact name that was provided.

As companies can be added and edited in multiple places in this application, the logic for these checks should obviously be easily reusable.

Laravel Validation Rules

The Laravel framework comes with very powerful validation features which cover a lot of the most common use cases. We could even use the existing validation rules to check for the first point on our list above, by using Rule::requiredIf() and checking the provided country.

For the other two items we need custom functionality however, so it makes sense to bundle all of this logic together in a new, custom validation rule. So let's create one by running php artisan make:rule VatID.

app/Rules/VatID.php
<?php namespace App\Rules; use Closure; use Illuminate\Contracts\Validation\ValidationRule; class VatID implements ValidationRule { /** * Run the validation rule. * * @param \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail */ public function validate(string $attribute, mixed $value, Closure $fail): void { // } }
The blank custom validation rule as generated by the Laravel framework.

Before we start to add the actual functionality, the new validation rule needs a bit more data to work with. Per default it is only passed the variable that is being checked, so in our case the VAT ID itself.

But we also need to pass it the company name as well as the country provided by the user for our additional checks. To do so, we simply provide the entire $input array with all user data to the rule class when applying it inside a validator where we actually want to verify the ID.

app/Actions/CreateCompany.php
<?php namespace App\Actions; use App\Rules\VatID; ... class CreateCompany { public function create(array $input) { Validator::make($input, [ ... 'vat_id' => [new VatID($input)], ... ])->validateWithBag('createCompany'); ... } ... }
The action class where our new validation rule is being used.

Running the basic checks

Most of the checks we need to run on the provided data a fairly straight forward, so I chose to bundle them all directly inside the validation rule class. If any check fails, we end the execution by returning early from the function as there is no point of running further checks on incomplete or incorrect data.

Essentially, this rule for now goes through four steps:

  1. If no country or no name are provided, we ignore the VAT ID for now. We simply don't have the data required to run our checks yet. The user will get errors about those two required fields and we can run this check again once they've been filled.

  2. It checks the selected country in our database to see if it is part European Union (EU). If it is not, we don't need to validate the VAT ID and can end the check.

  3. If the country is part of the EU but the user failed to provide a VAT ID, we send back an error message and end the check.

  4. If a VAT ID was provided we run a simple Regex test to ensure it has a valid format and the alert the user otherwise.

app/Rules/VatID.php
class VatID implements ValidationRule { private $data; public function __construct(array $data) { $this->data = $data; } public function validate(string $attribute, mixed $value, Closure $fail): void { $country_id = $this->data['country_id']; $name = $this->data['name']; if (!$country_id || !$name) { return; } if (!$this->countryShouldValidate($country_id)) { return; } if (!$value) { $fail('A VAT ID is required for this country.'); return; } if (!$this->formatIsValid($value)) { $fail('The VAT ID is not in a valid format.'); return; } } protected function countryShouldValidate(int $country_id): bool { $country = (new Countries())->getOne($country_id); return $country['is_eu']; } protected function formatIsValid(string $vat_id): bool { $pattern = '/^((AT)(U\d{8})|(BE)(0\d{9})|(BG)(\d{9,10})|(CY)(\d{8}[LX])|(CZ)(\d{8,10})|(DE)(\d{9})|(DK)(\d{8})|(EE)(\d{9})|(EL|GR)(\d{9})|(ES)([\dA-Z]\d{7}[\dA-Z])|(FI)(\d{8})|(FR)([\dA-Z]{2}\d{9})|(HU)(\d{8})|(IE)(\d{7}[A-Z]{2})|(IT)(\d{11})|(LT)(\d{9}|\d{12})|(LU)(\d{8})|(LV)(\d{11})|(MT)(\d{8})|(NL)(\d{9}(B\d{2}|BO2))|(PL)(\d{10})|(PT)(\d{9})|(RO)(\d{2,10})|(SE)(\d{12})|(SI)(\d{8})|(SK)(\d{10}))$/'; return preg_match($pattern, $vat_id); } }
All the basic checks inside our new validation rule.

Only if all of these checks pass do we actually verify if the VAT ID is valid and if the company name matches up. The reason is simple: to do so we need to talk to an external API, which takes time, and we'd rather not have the user wait a couple of seconds for the error message when we already knew the data wasn't valid.

Checking the Validity using an API

To make sure the VAT ID is actually valid, we need to check the EU database for a matching entry. There are a bunch of paid services for this, but luckily the EU also offers a free REST or SOAP service to check against. I decided to go with SOAP this time as it required less information to be sent in the request.

To keep the code tidy, this code will live in its own action class. In there, we use the native PHP SOAP client, send our VAT ID to the endpoint, and check whether the results indicates a valid ID and a matching company name.

app/Actions/ValidateVatID.php
<?php namespace App\Actions; use SoapClient; use Exception; class ValidateVatID { public function handle(string $vat_id, string $company_name) { $client = new SoapClient("http://ec.europa.eu/taxation_customs/vies/checkVatService.wsdl"); $country_code = substr($vat_id, 0, 2); $vat_number = substr($vat_id, 2); try { $result = $client->checkVat([ 'countryCode' => $country_code, 'vatNumber' => $vat_number ]); // check for valid id if (!$result->valid) { return false; } // check for matching name if ($result->name !== $company_name) { return false; } return true; } catch (Exception $e) { // we should probably fail more gracefully here return false; } } }
The new action class that verifies VAT IDs against the government API.

With the new action class in place we can simply add one final check to our rule. It hands over the VAT ID and company name to the action and then send an error message back to the user if the API call found any issues.

app/Rukes/VatID.php
<?php namespace App\Rules; use App\Actions\ValidateVatID; use Closure; use Illuminate\Contracts\Validation\ValidationRule; use Webpatser\Countries\Countries; class VatID implements ValidationRule { private $data; public function __construct(array $data) { $this->data = $data; } public function validate(string $attribute, mixed $value, Closure $fail): void { $country_id = $this->data['country_id']; $name = $this->data['name']; if (!$country_id || !$name) { return; } if (!$this->countryShouldValidate($country_id)) { return; } if (!$value) { $fail('A VAT ID is required for this country.'); return; } if (!$this->formatIsValid($value)) { $fail('The VAT ID is not in a valid format.'); return; } if (!$this->companyNameMatches($value, $name)) { $fail('The VAT ID is not valid or does not match the company name provided.'); } } protected function countryShouldValidate(int $country_id): bool { $country = (new Countries())->getOne($country_id); return $country['eea']; } protected function formatIsValid(string $vat_id): bool { $pattern = '/^((AT)(U\d{8})|(BE)(0\d{9})|(BG)(\d{9,10})|(CY)(\d{8}[LX])|(CZ)(\d{8,10})|(DE)(\d{9})|(DK)(\d{8})|(EE)(\d{9})|(EL|GR)(\d{9})|(ES)([\dA-Z]\d{7}[\dA-Z])|(FI)(\d{8})|(FR)([\dA-Z]{2}\d{9})|(HU)(\d{8})|(IE)(\d{7}[A-Z]{2})|(IT)(\d{11})|(LT)(\d{9}|\d{12})|(LU)(\d{8})|(LV)(\d{11})|(MT)(\d{8})|(NL)(\d{9}(B\d{2}|BO2))|(PL)(\d{10})|(PT)(\d{9})|(RO)(\d{2,10})|(SE)(\d{12})|(SI)(\d{8})|(SK)(\d{10}))$/'; return preg_match($pattern, $vat_id); } protected function companyNameMatches(string $vat_id, string $company_name): bool { $validateVatID = new ValidateVatID(); return $validateVatID->handle($vat_id, $company_name); } }
The complete validation rule class.

With these checks in place, we can be sure that no EU customer signs up without a valid VAT ID, keeping my client out of trouble.

More Posts