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
}
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';
});
Here's Respect Validation:
v::key('account_type', v::equals('business'))
->key('vat_number', v::notEmpty());
// Wait, that's not even conditional...
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();
}
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' => [...]
]
];
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);
Conditions can reference nested paths too:
$dv->field('billing_address')
->when('user.type', '=', 'business')
->then->required;
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');
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');
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 (13)
Have you tried the Symfony validator component ?
That library can handle conditional validations.
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.
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:
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.
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
Whenconstraints as a part of theWhenfor 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
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.
I was more thinking about following code.
As you see the expressions are very short, there is no repetition and the flow is easy to follow.
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 = businesscondition per country(verbose but explicit), or hide it in a custom validation strategy (loses the
declarative flow).
Line-wise though, even with repetition:
~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.
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):
category = business✓country = US✓requiredfailsExample flow (category=business, country=US, vat="ABC123"):
requiredpassesregex('/^\d{9}$/')failsStill exploring the cleanest implementation - adds complexity but keeps the
pattern declarative.
Appreciate the concrete example.
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
I saw the library has custom validation strategies, so it is possible to do the same?
Yes — you can replicate
WhenRegexCollectionwith a custom validation strategy.A clean approach is a "regex by key" rule that receives the discriminant value:
Trade-off: Removes repetition, but you must pass the discriminant manually and the branching becomes a "black box" rule (vs explicit
when/thenbranches).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.
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.
Symfony validator avoided it by using a constraints array as an argument of the
Whenconstraint.Using the
groupandendGroupmethods 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.
I think there's a scope mismatch here.
Symfony Validator can validate many shapes (including collections), but it really shines when validating known object graphs (DTOs/entities) via metadata/attributes and constraint objects. That's why
this.countryis natural there: you're validating a typed model, not runtime input.DataVerify currently focuses on runtime-defined data structures: arrays, stdClass, objects without compile-time schemas. Validation rules are defined programmatically at execution time or registered as reusable schemas, but either way there's no
this.country— you're working with paths and keys, not object properties.That's why I lean toward a fluent, method-chained DSL instead of expression strings or
new-based constraint trees.And that's consistent with the post:
DTO/Entity validation (with reflection and attributes) is on the roadmap, but it won't replicate Symfony's 15+ years of features — different scope, lighter footprint. For now, if you validate typed domain models with complex nested conditionals, Symfony's approach is structurally cleaner.
DataVerify targets runtime-defined data where programmatic validation and lightweight integration matter more than declarative object metadata — but I'm open to exploring hybrid approaches as the library evolves.
I wasn't suggesting to replicate Symfony validator, but to do something like
Based on the type of the first argument of the
whenmethod the validation check is handled differently.This keeps the nesting away from the global API.
The Symfony validator works with objects because it makes a clear distinction between the validation and the data collection.
Because your library starts from less defined data types the library mixes those concerns.
It is not my intention to push your library towards Symfony validator. I'm just using that library as an example to keep your library as lightweight as possible.
Once you understand why decisions are made you see the genius in the simplicity.
I think this is where our perspectives diverge — and it's not about missing the elegance of Symfony's design.
On type-based dispatch
Dispatching on the type of the first argument (
stringvsAnd/Orobject) doesn't remove complexity, it moves it into an implicit polymorphic layer. You still build and evaluate a logical condition tree, only now it's hidden behind runtime type checks instead of being explicit in the DSL.That's a valid design, but it's not "simpler", it's simpler at the call site and more complex in the engine. DataVerify deliberately keeps that complexity explicit because implicit control flow is harder to reason about when rules are built dynamically or composed in schemas.
On "mixing concerns"
With runtime-defined data, path selection is part of the validation concern. Without a predefined schema, you cannot separate "data collection" from "validation metadata", that separation only exists when the structure is known upfront.
The path and rules must be defined together because the structure isn't known until execution. That's not poor separation of concerns, it's the constraint of the problem being solved.
On simplicity
The group syntax I'm exploring keeps complexity visible:
It has trade-offs (
endGroup()verbosity), but the control flow is explicit. Lightweight doesn't always mean hiding complexity, sometimes it means keeping it visible and debuggable.I get why Symfony's separation feels elegant, and for typed models it absolutely is. But that elegance comes from constraints DataVerify doesn't have. Different problem, different optimization.
This last comment feels like you let AI do the thinking for you.
The
OrandAndare a part of the DSL. The DSL is not only the fluent API pattern.The structure is known upfront with your library too how else can you create a schema?
The data collection can be done by an object mapper, that can be another library or a factory method like
Account::createFromCheckoutform($data)or anything in between.Show me where in my example the complexity is hidden?
The idea comes from grouping in SQL. But it can be found in PHP too with the logic operators.
By having two different types of groupings the context is even more obvious when reading the code. Even with the Symfony example the connection between the constraints is not explicit.
After all that is said, if I don't change your perspective that is OK. Not every debate needs to end in flipping to the other side. I just wanted to share my knowledge.