Alexandre Gomes Gaigalas – February 22, 2026
The full example for this blog post is available on GitHub.
Validation logic often ends up distributed across request rules, controller checks, model helpers, and serializers. In that arrangement, constraints for the same field can diverge over time.
This example uses a single domain object as the source of truth for constraints and payload mapping, then integrates that object into Laravel request handling and persistence.
The objective is to keep one authoritative definition for:
PitchDraft is that authoritative definition.
PitchDraft declares constraints directly on typed constructor parameters with Respect attributes.
final readonly class PitchDraft
{
public function __construct(
#[Named('Speaker Name', new AllOf(new StringType, new Length(new Between(4, 60))))]
public string $speaker_name,
#[Named('Speaker Email', new Email)]
public string $speaker_email,
#[Named('Talk Title', new ShortCircuit(new Length(new Between(12, 90)), new Contains('Laravel')))]
public string $talk_title,
#[Named('Talk Duration', new AllOf(new IntVal, new Between(20, 45)))]
public int $talk_duration_minutes,
#[Named('Skill Level', new Templated('Skill level must be either {{haystack|list:or}}.', new In(['intermediate', 'advanced']))) ]
public string $skill_level,
) {}
}
This keeps rule composition explicit (AllOf, ShortCircuit, Between, Each) and localizes message customization (Named, Templated).
Form requests handle transport sanitation and coercion, then construct the DTO.
public function toPitchDraft(): PitchDraft
{
$sanitized = $this->validated();
return new PitchDraft(
speaker_name: trim((string) ($sanitized['speaker_name'] ?? '')),
speaker_email: trim((string) ($sanitized['speaker_email'] ?? '')),
talk_title: trim((string) ($sanitized['talk_title'] ?? '')),
talk_duration_minutes: $this->normalizeDuration($sanitized['talk_duration_minutes'] ?? null),
skill_level: trim((string) ($sanitized['skill_level'] ?? '')),
highlights: array_values(array_filter(array_map('trim', $sanitized['highlights'] ?? []))),
);
}
Request attributes remain focused on HTTP behavior, like #[RedirectToRoute].
Respect\Validation exceptions are converted to Laravel ValidationException once, in a small adapter.
final class RespectAttributeValidator
{
public function validate(object $dto): void
{
try {
v::attributes()->assert($dto);
} catch (RespectValidationException $exception) {
$messages = $exception->getMessages();
unset($messages['__root__']);
throw ValidationException::withMessages($messages);
}
}
}
This preserves standard Laravel response behavior for both web and API flows. If your project has multiple DTO classes, you can use this same adapter for them all.
PitchDraft::toAttributes() is reused for both persistence and API output.
Pitch::create($draft->toAttributes())$pitch->toDraft()->toAttributes()Using one mapping function avoids duplicated field lists and keeps write/read payloads aligned.
The approach introduces one integration component (RespectAttributeValidator) and one DTO-to-model reconstruction method (Pitch::toDraft()). In exchange, the validation contract is explicit, centralized, and reusable across multiple entry points.
This architecture keeps Laravel responsible for transport and persistence concerns, while domain validation remains defined in one DTO contract through Respect attributes. The result is lower duplication and a stricter single source of truth for validation logic.