stas-plov/dto-validator-bundle
stas-plov/dto-validator-bundle adds DTO-based validation to PHP applications, letting you define rules for data transfer objects and keep request/input handling clean and reusable. A lightweight bundle for structured validation workflows.
Requirements (as of v1.1.4): PHP >=8.1 (lowered from >=8.2 in this release), plus whatever container/validator abstractions the bundle pulls in transitively — check composer why after install, since a "Bundle" package will typically drag in Symfony Validator/DI components even in a Laravel app.
Install and wire up:
composer require stasplov/dto-validator-bundle
php artisan vendor:publish --tag=dto-validator-config # if the package ships config
php artisan config:clear
Register the package service provider in bootstrap/providers.php (Laravel 11+) or config/app.php if the package doesn't rely on auto-discovery. Then verify the binding resolves:
php artisan tinker --execute="dump(app()->make(StasPlov\DtoValidator\Validator::class));"
Where to look first, in order:
src/Attribute, src/Constraint) — this is the vocabulary you'll actually write in DTOs.v1.1.4.First use case — replace a hand-rolled FormRequest + array-shuffling block:
final class CreateOrderDto
{
#[Required]
#[Positive]
public int $quantity;
#[Required]
#[Email]
public string $customerEmail;
#[Optional]
#[Length(min: 3, max: 64)]
public ?string $coupon = null;
}
public function store(Request $request, DtoValidator $validator)
{
$dto = $validator->validate(CreateOrderDto::class, $request->all());
return OrderResource::make(CreateOrder::fromDto($dto));
}
The win is that the DTO is now the single contract — reused by HTTP controllers, queued jobs, and console commands, with no Request dependency.
Request → DTO → Action. Keep controllers thin: hydrate and validate the DTO in the controller (or a dedicated FormRequest that only calls $validator->validate(...)), then pass the typed DTO into a single-purpose action. The action never touches $request, which makes it trivially testable and reusable from a job or command.
Validation groups for create vs. update. Define scenario groups on the DTO and select them per entry point:
$validator->validate(UpdateUserDto::class, $payload, groups: ['update']);
This is the cleanest way to express "email required on create, optional on update" without duplicating DTOs.
Custom constraints as first-class attributes. Any business rule that appears in two DTOs belongs in an attribute class plus a validator. Register it once and it's available everywhere — this is the package's main extension point for domain rules like #[UniqueSku] or #[WithinCreditLimit].
Nested DTOs for payload trees. Model OrderDto containing LineItemDto[] rather than flattening. Validate the parent once and let cascade handle children; flattening by hand is where most of the bugs live.
Mapping boundary. Give each DTO an explicit toArray() or toModel() rather than splatting it into Model::create($dto). DTOs and Eloquent models have different lifecycles (see gotcha on mass assignment), and the explicit mapper is where you decide what's persistable versus what's transport-only.
Testing. Unit-test DTO validation with plain arrays — no HTTP layer, no database. Reserve feature tests for the controller wiring. A data provider with [validPayload, invalidPayload, expectedViolationPath] triples covers a DTO thoroughly and fast.
Coexistence with FormRequest. A pragmatic split that works well: keep FormRequest for authorization (authorize()) and HTTP-shaped concerns (file uploads, headers), and hand the validated array to the DTO validator for the domain rules. Don't duplicate the same rule in both layers.
The PHP floor dropped, but confidence didn't go up. v1.1.4 changes only the composer.json constraint from >=8.2 to >=8.1. That widens installability; it does not prove runtime testing on 8.1. If you're on 8.1, pin deliberately, run your own suite, and watch for use of 8.2-only features (readonly classes, true/false/null standalone types, DNF types) that the constraint no longer blocks. On 8.2+ nothing changes — this is a no-op upgrade.
Two validators, two error shapes. Laravel's ValidationException produces {message, errors: {field: [..]}}; the bundle typically produces its own violation list. Standardise early — write one exception handler that converts bundle violations into a ValidationException (or your API's error envelope) so clients never see two shapes depending on which layer rejected the request.
HTTP input is always strings. JSON bodies give you real types; form posts and query strings do not. Decide whether the DTO validator coerces ("5" → 5) or rejects, and be consistent. Silent coercion hides client bugs; strict typing breaks ordinary HTML forms. Most teams want coercion at the HTTP boundary and strictness inside.
Nullable is not optional. ?string $coupon = null with a #[Required] attribute will still fail — "key absent" and "key present but null" are different states in nearly every validator implementation. Test both explicitly.
Nested validation depth and cycles. Deep payloads can be expensive, and a DTO that references itself will recurse. Cap depth in config and prefer DTOs that terminate.
Config and metadata caching. Laravel's config:cache and route:cache freeze things; if the package compiles attribute metadata, you need a cache-clear in your deploy script. Symptom of forgetting: validation rules change locally but not in production after a deploy.
Don't cast DTOs on Eloquent models. protected $casts = ['payload' => OrderDto::class] looks neat and then breaks on serialization, dirty-checking, and queued model restoration. Map explicitly at the boundary.
Mass assignment will bite you. Model::create($dto->toArray()) on a DTO carrying fields like id, status, or is_admin is a privilege-escalation vector if $fillable is permissive. Keep DTO-to-model mapping an explicit allowlist.
Symfony components in a Laravel app. Expect symfony/validator, symfony/dependency-injection, or similar in the tree. Two consequences: keep the lockfile committed and upgrade the bundle in a branch, because a transitive Symfony minor can move exception classes underneath you; and don't expect Laravel-native features like Rule::unique or exists to work as attributes — wrap them in custom constraints that use the DB connection directly.
Extension points worth knowing. Custom constraint attribute + matching validator (add a service tag/registration if the package uses a registry); overriding the violation formatter for your API envelope; and — if the bundle exposes service definitions — decorating the validator service rather than forking the package.
Debugging order when a rule "isn't firing": confirm the attribute is on the property (not the constructor parameter only), confirm the class was passed to the validator rather than an already-hydrated instance, confirm the group name matches the call site, then clear caches. Nine times out of ten it's the group or the cache.
Upgrade note for this release. Treat v1.1.3 → v1.1.4 as safe for anyone already on PHP 8.2+. If you're on 8.1, this is the release that finally lets you install — test it rather than assuming parity, and check the package's CI matrix to see whether 8.1 is actually in it.
How can I help you explore Laravel packages today?