DEV Community

Cover image for Fluent Conditional Validation for PHP
FEREGOTTO Romain
FEREGOTTO Romain

Posted on

Fluent Conditional Validation for PHP

PHP Conditional Validation Without the Mess

Every PHP developer has written this at some point:

if ($data['account_type'] === 'business') {
    if (empty($data['company_name'])) {
        $errors[] = 'Company name is required';
    }
    if (empty($data['vat_number'])) {
        $errors[] = 'VAT number is required';
    }
    // ... and more
}
Enter fullscreen mode Exit fullscreen mode

It works. It's also a maintenance nightmare.

The Problem

Conditional validation — where field A is required only when field B has a specific value — is everywhere:

  • Registration: VAT number required only for business accounts
  • E-commerce: shipping address required only for physical delivery
  • Multi-step forms: fields depend on previous selections

Most PHP validation libraries handle simple cases well. But conditional logic? You end up with callbacks, closures, or falling back to manual if/else blocks.

Here's Valitron:

$v->rule('required', 'vat_number')->when(function($data) {
    return $data['account_type'] === 'business';
});
Enter fullscreen mode Exit fullscreen mode

Here's Respect Validation:

v::key('account_type', v::equals('business'))
    ->key('vat_number', v::notEmpty());
// Wait, that's not even conditional...
Enter fullscreen mode Exit fullscreen mode

The syntax gets verbose fast, especially with nested conditions.

A Different Approach

I wanted something that reads like the requirement:

"When account_type equals business, then vat_number is required"

So I built it:

$dv = new DataVerify($registrationData);

$dv
    ->field('email')->required->email
    ->field('account_type')->required->in(['personal', 'business'])
    ->field('country')->required->in(['FR', 'DE', 'ES'])

    ->field('vat_number')
        ->when('account_type', '=', 'business')
        ->and('country', '=', 'FR')
        ->then->required->regex('/^FR\d{11}$/')

    ->field('vat_number')
        ->when('account_type', '=', 'business')
        ->and('country', '=', 'DE')
        ->then->required->regex('/^DE\d{9}$/')

    ->field('vat_number')
        ->when('account_type', '=', 'business')
        ->and('country', '=', 'ES')
        ->then->required->regex('/^ES[A-Z0-9]\d{7}[A-Z0-9]$/');

if (!$dv->verify()) {
    $errors = $dv->getErrors();
}
Enter fullscreen mode Exit fullscreen mode

The when/and/then syntax maps directly to business rules. No callbacks, no closures, no mental gymnastics.

Nested Objects

Real-world data is rarely flat. API payloads have nested structures:

$data = [
    'user' => [
        'email' => 'john@example.com',
        'profile' => [
            'name' => 'John',
            'age' => 25
        ]
    ],
    'order' => [
        'items' => [...]
    ]
];
Enter fullscreen mode Exit fullscreen mode

Validation handles it naturally:

$dv->field('user')->required->object
    ->subfield('email')->required->email
    ->subfield('profile')->required->object
        ->subfield('name')->required->string
        ->subfield('age')->int->between(18, 120);
Enter fullscreen mode Exit fullscreen mode

Conditions can reference nested paths too:

$dv->field('billing_address')
    ->when('user.type', '=', 'business')
    ->then->required;
Enter fullscreen mode Exit fullscreen mode

Reusable Rules & Schemas

Define validation patterns once, reuse everywhere:

// Rules: reusable validation chains (field-agnostic)
DataVerify::registerRules('password_rules')
    ->minLength(12)->containsUpper->containsLower->containsNumber;

$dv->field('password')->required->rule('password_rules');
$dv->field('password_confirm')->required->rule('password_rules');
Enter fullscreen mode Exit fullscreen mode

For complete validation structures, use schemas:

// Schemas: full validation blueprints with fields + conditions
DataVerify::registerSchema('checkout')
    ->field('email')->required->email
    ->field('delivery_type')->required->in(['shipping', 'pickup'])
    ->field('shipping_address')
        ->when('delivery_type', '=', 'shipping')
        ->then->required->string;

// Apply in one line
$dv = new DataVerify($data);
$dv->schema('checkout');
Enter fullscreen mode Exit fullscreen mode

What It's Not

This isn't a Laravel replacement. If you're in a framework with built-in validation, use that.

This is for:

  • Legacy codebases without a framework
  • Microservices and APIs with custom validation needs
  • Projects where minimizing transitive dependencies matters (easier auditing, reduced supply chain risk)
  • Anyone tired of callback-based conditional validation

The Code

It's MIT licensed, zero dependencies, PHP 8.1+:

GitHub: github.com/gravity-zero/dataVerify

Install: composer require gravity/dataverify

Feedback welcome — I'm actively maintaining it and open to suggestions/contributers.


What's your current approach to conditional validation in PHP? Curious to hear how others handle this.

Top comments (9)

Collapse
 
xwero profile image
david duymelinck

Have you tried the Symfony validator component ?
That library can handle conditional validations.

Projects where zero dependencies matters

If that matters would your library be a no-go as well?

The one thing where I see a footgun, from the examples in the post, is setting the schema/rules and then using it. What if the schema or rule is changed somewhere else in the code?
Having a hard-coded/config validation schema is safer in my book.

Collapse
 
gravityzero profile image
FEREGOTTO Romain

Symfony Validator - Handles conditionals, but most PHP validation libs do.
The usual approaches are either callbacks or, in Symfony's case, inline expression
strings where typos or syntax errors can be hard to catch. I wanted method chaining
with IDE support - harder to mess up, follows standard conditional patterns:

->when('account_type', '=', 'business')
->and('country', '=', 'FR')
->then->required->regex('/^FR\d{11}$/')
Enter fullscreen mode Exit fullscreen mode

Not revolutionary, just trying to keep it lightweight and readable.

"Zero dependencies" - Bad wording on my part. Should've said "no transitive
dependencies". Avoiding npm-style trees where one lib pulls 45 packages who depends
either on N other packages. Easier audits, less supply chain risk. I'll update the post.

Schema mutability - Duplicate registration throws LogicException.
Pattern is documented: register once at bootstrap, consume everywhere. Tested
this specifically with FrankenPHP worker mode (3M+ requests) - no issues.

Thanks for the feedback.

Collapse
 
xwero profile image
david duymelinck

On the Symfony validator, expressions are not meant to be complex so syntax errors are not that hard to catch. For the typos, I don't think your library is in a better position to to handle them.
Also in the expression the properties are used instead of input keys, which makes the the validation less prone to input key changes.

It would bug me to repeat the field and account_type check over and over.
With the Symfony validator it is possible to add multiple When constraints as a part of the When for the account_type check.
It is more verbose but for me it is easier to follow.

What I'm also missing is custom messages.

To be clear, I like the library. I just playing the devil's advocate

Thread Thread
 
gravityzero profile image
FEREGOTTO Romain

I regularly use the devil's advocate approach myself, so appreciate it :)

Expression complexity - True for simple cases. But when you chain multiple
conditions (type == "business" and country == "FR" and vat_required == true),
both approaches have the same readability/error risk. Just different syntax.

Properties vs keys - That's the trade-off of not validating DTOs/Entities yet.
'this.getType()' in an ExpressionLanguage string has the same refactoring risk as
'account_type' in method arguments - no IDE autocomplete, no compile-time checks,
runtime errors only. Same problem, different syntax.

Repetition - If you have multiple fields depending on account_type = business,
yeah it gets verbose. But that's how you'd write it in standard conditionals too
(if/else, early returns). For reusable patterns, schemas and rules handle this.

Custom messages - Messages are currently field-level, not condition-level. You
can create custom rules with hardcoded messages (no i18n yet), but per-condition
messages without bloating the API - still figuring out the cleanest approach.

Good to know you see value in it. This kind of feedback helps understand where it
fits vs where it doesn't.

Thread Thread
 
xwero profile image
david duymelinck • Edited

I was more thinking about following code.

#[Assert\When(
        expression: 'this.category == "business"',
        constraints: [
            new Assert\NotBlank(message: 'VAT is required if the account category is business.'),
            new When(
                expression: 'this.country == "US"',
                constraints: [
                    new Regex([
                        'pattern' => '/^\d{9}$/',
                        'message' => 'US VAT must be 9 digits'
                    ])
                ]
            ),
            new When(
                expression: 'this.country == "UK"',
                constraints: [
                    new Regex([
                        'pattern' => '/^[A-Z]{2}\d{6}$/',
                        'message' => 'UK VAT must be 2 letters followed by 6 digits'
                    ])
                ]
            ),
            new When(
                expression: 'this.country == "DE"',
                constraints: [
                    new Regex([
                        'pattern' => '/^\d{11}$/',
                        'message' => 'German VAT must be 11 digits'
                    ])
                ]
            )
        ]
    )]
    public ?string $vat = null;
Enter fullscreen mode Exit fullscreen mode

As you see the expressions are very short, there is no repetition and the flow is easy to follow.

Thread Thread
 
gravityzero profile image
FEREGOTTO Romain

Fair point on the nested conditionals - that's a case where Symfony's approach is structurally cleaner.

Currently, I'd need to repeat the category = business condition per country
(verbose but explicit), or hide it in a custom validation strategy (loses the
declarative flow).

Line-wise though, even with repetition:

$dv->field('vat')
    ->when('category', '=', 'business')
    ->then->required->errorMessage('VAT required for business accounts')

    ->when('category', '=', 'business')
    ->and('country', '=', 'US')
    ->then->required->regex('/^\d{9}$/')->errorMessage('US VAT must be 9 digits')

    ->when('category', '=', 'business') 
    ->and('country', '=', 'UK')
    ->then->required->regex('/^[A-Z]{2}\d{6}$/')->errorMessage('UK VAT must be 2 letters + 6 digits')

    ->when('category', '=', 'business')
    ->and('country', '=', 'DE')
    ->then->required->regex('/^\d{11}$/')->errorMessage('German VAT must be 11 digits');
Enter fullscreen mode Exit fullscreen mode

~15 lines vs 32 - more compact despite the repetition. Obviously, the more you try to nest elements, the more repetitive it will become.

One approach I'm considering: conditional validation groups with inheritance.

$dv->field('vat')
    ->when('category', '=', 'business')

        ->group('vat.us')
            ->when('country', '=', 'US')
            ->then->alias('US VAT Number')->regex('/^\d{9}$/')
        ->endGroup()

        ->group('vat.uk')
            ->when('country', '=', 'UK')
            ->then->alias('UK VAT Number')->regex('/^[A-Z]{2}\d{6}$/')
        ->endGroup()

        ->group('vat.de')
            ->when('country', '=', 'DE')
            ->then->alias('German VAT Number')->regex('/^\d{11}$/')
        ->endGroup()

    ->then->required;
Enter fullscreen mode Exit fullscreen mode

Execution order: Parent condition first, then groups (each inheriting parent
validations). Group names as i18n keys for custom messages, aliases for field names.

Example flow (category=business, country=US, vat empty):

  1. Parent condition: category = business
  2. Group 'vat.us' matches: country = US
  3. Inherited validation: required fails
  4. Error: "The field vat is required" (or with custom errorMessage)

Example flow (category=business, country=US, vat="ABC123"):

  1. Parent condition + group match ✓
  2. Inherited validation: required passes
  3. Group validation: regex('/^\d{9}$/') fails
  4. Error: i18n message from 'vat.us' key → "US VAT must be 9 digits"
  5. Field displayed as: "US VAT Number" (from alias)

Still exploring the cleanest implementation - adds complexity but keeps the
pattern declarative.

Appreciate the concrete example.

Thread Thread
 
xwero profile image
david duymelinck • Edited

For the line count, I think it is a moot point. Just remove the line breaks and you end up with a smaller count.
The main goal is to avoid repetition.

To fully remove repetition I would create a custom constraint that accepts multiple regexes. And it could end up something like

[Assert\When(
    expression: 'this.category == "business"',
    constraints: [
        new Assert\NotBlank(message: 'VAT is required if the account category is business.'),
        new CustomAssert\WhenRegexCollection(
              value: 'this.country',
              regexes: [
                   'UK' => ['/^[A-Z]{2}\d{6}$/' => 'UK VAT must be 2 letters followed by 6 digits'],
                   'US' => ['/^\d{9}$/' => 'US VAT must be 9 digits' ],
                   'DE' => ['/^\d{11}$/' => 'German VAT must be 11 digits'],
              ]
        ),
    ]
)]
public ?string $vat = null;
Enter fullscreen mode Exit fullscreen mode

I saw the library has custom validation strategies, so it is possible to do the same?

Thread Thread
 
gravityzero profile image
FEREGOTTO Romain

Yes — you can replicate WhenRegexCollection with a custom validation strategy.

A clean approach is a "regex by key" rule that receives the discriminant value:

class RegexByKey extends ValidationStrategy {
    public function getName(): string { return 'regexByKey'; }

    protected function handler(mixed $value, ?string $key, array $patterns): bool {
        if ($key === null || !isset($patterns[$key])) return true;
        if (!is_string($value)) return false;
        return preg_match($patterns[$key], $value) === 1;
    }
}

// Usage
$dv->field('vat')
   ->when('category', '=', 'business')
   ->then->required
   ->regexByKey($data['country'] ?? null, [
       'FR' => '/^FR\d{11}$/',
       'UK' => '/^UK\d{9}$/',
   ]);
Enter fullscreen mode Exit fullscreen mode

Trade-off: Removes repetition, but you must pass the discriminant manually and the branching becomes a "black box" rule (vs explicit when/then branches).

That's why nested conditional groups with inheritance are the more declarative long-term solution: same DRY benefit, but branching stays visible in the DSL and group names can act as i18n keys for branch-specific messages.

Thread Thread
 
xwero profile image
david duymelinck

Removes repetition, but you must pass the discriminant manually

Now we are back to the point I mentioned before, that with the Symfony validator expression the object only needs to know their own properties to perform the validation.

nested conditional groups with inheritance are the more declarative long-term solution

Symfony validator avoided it by using a constraints array as an argument of the When constraint.

Using the group and endGroup methods is as annoying as repeating constraints, because it makes the code even longer.

I think you should think more in the direction of the Symfony solution, than a fluent API solution.