Why the same entity needs different rules depending on context, and how to solve that without duplicate classes
A User entity that requires a minimum-length password at registration but should simply ignore an empty password field during a later profile update puts many teams in front of the same question: does this need two separate entity classes, or is a single set of constraints with a bit of extra logic enough. Symfony's Validator component answers that with Validation Groups and Group Sequences, two mechanisms that validate the same class differently depending on context, without duplicating code or scattering validation logic across if-statements. This article walks through both tools using a continuous registration and profile-update example.
Table of Contents
- 1. Why an entity needs different rules depending on context
- 2. Validation Groups: grouping constraints deliberately
- 3. Group Sequences: base constraints first, expensive ones after
- 4. GroupSequenceProvider for dynamic sequences
- 5. A practical DTO example: registration vs. profile update
- 6. Expensive constraints only after base validation passes
- 7. Applying groups consistently in forms and API endpoints
- 8. Shaping error messages by context
- 9. Common pitfalls with groups and sequences
- 10. Summary
- 11. FAQ
1. Why an entity needs different rules depending on context
A User entity typically shows up in several, functionally quite different forms: at registration with required fields for email and password, during a profile update where the password field usually stays empty and isn't reset, and maybe in an admin form that requires extra internal fields like a customer number. If every constraint were attached to the class without any grouping, an empty password field during a profile update would either have to pass validation despite the same emptiness being a hard error at registration, or you'd end up with two nearly identical entity classes and all the downsides of code duplication that come with it.
Validation Groups solve this by attaching constraints not globally but to one or more named groups, with the calling code specifying which group is relevant at validation time. The same class carries every possible rule within it, but which of those rules actually apply is decided at runtime by the use case at hand. That's not just more elegant than duplication, it also guarantees that a change to a shared rule, say the email format, automatically applies across every context.
2. Validation Groups: grouping constraints deliberately
In practice, a constraint is assigned to one or more groups through its groups parameter, say #[Assert\NotBlank(groups: ['registration'])] for a field that's only required during registration. Constraints without an explicit group automatically fall into the Default group, which is used whenever validation runs without an explicit group. When actually validating, you pass the desired group as the second argument to $validator->validate($entity, null, ['registration']), and only that group's constraints (plus Default, where applicable) get evaluated.
A common gotcha is that the Default group, for entities referenced by their own class name, implicitly also includes a group named after the class itself, which can lead to unexpected behavior under inheritance. Because of that, it's usually cleaner to name the group explicitly on every security-relevant field rather than relying on implicit behavior. That way anyone reading the class later can immediately tell in which context a given constraint actually applies.
3. Group Sequences: base constraints first, expensive ones after
When a DTO carries both cheap constraints like NotBlank or Length and expensive ones that trigger a database query, say checking whether an email address is already taken, you want that expensive check to run only once the base constraints have already passed. Without any ordering, a completely empty submission would still trigger every constraint in parallel, including a pointless database query for a field that's already marked invalid anyway.
That's exactly what the #[GroupSequence] attribute is for: it defines an ordered list of groups processed one after another, where validation stops as soon as a group produces an error, and subsequent groups aren't even evaluated. The example below shows a registration DTO where the base constraints in the Default group run first, and the expensive email-uniqueness check only runs in the second group, strict, once the first group passed without errors.
<?php
declare(strict_types=1);
namespace App\Dto;
use App\Validator\Constraints as AppAssert;
use Symfony\Component\Validator\Attribute\GroupSequence;
use Symfony\Component\Validator\Constraints as Assert;
#[GroupSequence(['RegistrationDto', 'strict'])]
final class RegistrationDto
{
public function __construct(
#[Assert\NotBlank(message: 'Please provide an email address.')]
#[Assert\Email(message: 'The email format is invalid.')]
#[AppAssert\UniqueEmail(groups: ['strict'])]
public readonly string $email = '',
#[Assert\NotBlank(message: 'Please choose a password.')]
#[Assert\Length(min: 12, minMessage: 'The password must be at least 12 characters long.')]
#[Assert\PasswordStrength(minScore: Assert\PasswordStrength::STRENGTH_MEDIUM, groups: ['strict'])]
public readonly string $plainPassword = '',
) {
}
}
4. GroupSequenceProvider for dynamic sequences
The static #[GroupSequence] attribute works fine as long as the order of groups is known at compile time. Sometimes, though, the correct sequence depends on the state of the object itself, for example whether a user already has a confirmed account and is therefore subject to stricter rules than a freshly created, unconfirmed one. For that case Symfony offers GroupSequenceProviderInterface, which requires a getGroupSequence() method that decides at runtime, based on the current object state, which groups apply and in what order.
That flexibility comes at a cost: the code becomes harder to follow, since the actual validation order is no longer directly visible from the class's attributes but only emerges at runtime inside the provider method. In practice, GroupSequenceProviderInterface is worth reaching for only when the sequence genuinely depends on data state, while for the far more common case of different forms, the static attribute is entirely sufficient and stays easier to read.
5. A practical DTO example: registration vs. profile update
In the registration context from the earlier example, the password is a required field with a minimum length. During a profile update, on the other hand, an empty password field should mean the user wants to leave it unchanged, while a filled-in field still needs to meet the same strength requirements as at registration. Rather than building an entirely new DTO, this maps elegantly onto a combination of Assert\Length without NotBlank and a dedicated profile_update group that's explicitly requested from the controller.
The controller handling the profile update then calls the validator with $validator->validate($dto, null, ['profile_update']), while the registration controller keeps using the default groups. This clean separation by context, not by field, is the core idea behind the group pattern: a field carries several possible rules within it, but the calling code decides which of them actually count depending on the situation. That keeps the entity or DTO as the single source of truth, without scattering validation logic across controllers.
6. Expensive constraints only after base validation passes
Database queries, external API calls, or computationally heavy checks like a password-strength score should always sit in a later group of a Group Sequence, never in the first one. The reasoning is simple: if a required field is empty, the expensive check is pointless anyway, since its result gets overwritten by the already-present base error. On a high-traffic registration form, that difference can mean the gap between a trivial string check and hundreds of unnecessary database queries per minute whenever bots flood the form with empty or random values.
Another benefit of this ordering shows up in the error message itself: a user who submits a completely empty form immediately sees 'Please provide an email address' instead of a confusing combination of a required-field error and a uniqueness error at once. That keeps the interface not only faster but also clearer and less overwhelming for the end user, which can make a noticeable difference in drop-off rates on multi-step registration forms.
7. Applying groups consistently in forms and API endpoints
In the Symfony Form component, the group to use can be specified directly in the form options, say 'validation_groups' => ['profile_update'], so the form automatically checks the matching subset of constraints without the controller having to call the validator manually. For API endpoints that deserialize DTOs directly from the request body, say via Symfony's #[MapRequestPayload] attribute, the group can likewise be passed as an attribute parameter or through an explicit $validator->validate() call in the controller.
It's worth documenting this mapping between route and group in a central, easy-to-find spot, say directly as a comment above each controller endpoint, since otherwise it's easy to lose track of which endpoint expects which group. A test that checks for each endpoint that exactly the expected group is active, for instance by deliberately sending a request that would only slip through with the wrong group selected, reliably catches regressions before they reach production.
8. Shaping error messages by context
The same constraint can need different error messages in different contexts: at registration, 'This email is already registered, please log in' is a helpful message, while when changing an email address in a profile, wording like 'This address is already used by another account' fits better. Since constraints attach to one and the same field but get activated differently through different groups, you can also define separate constraint instances per group, each with its own message text.
In practice that means attaching two separate attributes with the same underlying validator, but different groups and messages, to the same field, say one for the Default group with registration-specific wording and one for profile_update with profile-specific wording. That looks like redundancy at first glance, but it ends up saving considerably more effort than shipping generic error messages that never really fit any context and end up generating support tickets.
9. Common pitfalls with groups and sequences
A classic mistake is forgetting that calling $validator->validate($entity) without a third argument implicitly checks only the Default group, which silently skips every constraint assigned to an explicit, different group, without any warning being raised. That leads to a newly added constraint in its own group appearing to have no effect at all, even though the code is syntactically perfectly correct, and the cause is usually found only after a lengthy debugging session in the calling code.
A second common pitfall involves #[GroupSequence] combined with inheritance: a child class that adds its own constraints but doesn't define its own GroupSequence does not automatically inherit the parent class's sequence the way you might expect, since Group Sequences aren't inherited across classes the way normal methods are. Anyone working with inheritance and groups at the same time should explicitly test which order actually applies for each concrete class, rather than relying on intuitive assumptions about inheritance.
| Mechanism | Purpose | When it makes sense | Call |
|---|---|---|---|
| Validation Groups | Toggle constraints on/off by context | Registration vs. profile update | validate($x, null, ['group']) |
#[GroupSequence] |
Fixed order, stop on first failing group | Expensive constraints after base checks | Attribute on the class |
GroupSequenceProviderInterface |
Dynamic order based on object state | Sequence depends on data values | getGroupSequence() |
Group Default |
Used automatically when none is specified | Base constraints with no context tie | Implicit on every call |
Mironsoft
Symfony architecture, clean domain logic, and legacy modernization
Symfony applications that stay maintainable two years down the line?
We review existing Symfony projects for bloated controllers, missing service abstractions, and untested core logic, then build an architecture that absorbs new features without getting more fragile with every release.
Architecture Review
Checking bundle structure, dependency injection, and service abstractions for maintainability.
Legacy Modernization
Incrementally migrating outdated Symfony versions without a full rewrite.
Testing and Quality Assurance
Setting up PHPUnit, PHPStan, and CI pipelines for lasting code quality.
10. Summary
Symfony Validation Groups: Key Takeaways
Validation Groups
One DTO, several rule sets, activated depending on the calling context.
Group Sequence
Base constraints first, expensive checks like DB queries only afterward.
GroupSequenceProvider
Only for a genuinely dynamic order that depends on object state.
Pitfall
validate() without a group checks only Default, other groups are silently skipped.