Published on
In a Laravel controller I don't want to deal with validation. I want only valid input to arrive there. The usual way there goes through a form request and an array. The rules live in one class, the types live in my head, and in the controller I reach for keys by string, where neither the IDE nor PHPStan can help me.
With spatie/laravel-data I write a class that describes the input instead. The controller
gets exactly that class as a parameter. By the time the method runs, the input is validated
and the values have the types the class declares. I no longer need a request object for
that.
The example is an endpoint that creates a blog post. The code is written for version 4 of
spatie/laravel-data.
Before: a form request and an array
This is what the endpoint looks like the classic way:
final class CreatePostRequest extends FormRequest
{
public function rules(): array
{
return [
'headline' => ['required', 'string', 'min:10', 'max:120'],
'content' => ['required', 'string', 'min:200'],
'publishedAt' => ['required', 'date_format:Y-m-d'],
'status' => ['required', Rule::enum(PostStatus::class)],
'readingMinutes' => ['required', 'integer', 'between:1,60'],
'teaser' => ['nullable', 'string', 'max:300'],
];
}
}
That validates cleanly. But what arrives in the controller afterwards is an array:
public function store(CreatePostRequest $request): JsonResponse
{
$input = $request->validated();
$publishedAt = CarbonImmutable::createFromFormat('!Y-m-d', $input['publishedAt']);
$status = PostStatus::from($input['status']);
$minutes = (int) $input['readingMinutes'];
// ...
}
$input['publishedAt'] is a string, and so is $input['status']. The validation knows that
readingMinutes is a number, yet the type in the code is still mixed. I write the
conversion by hand, and if I mistype a key I only find out at runtime.
The data class
The same thing as a data object:
enum PostStatus: string
{
case Draft = 'draft';
case Published = 'published';
}
final class CreatePostData extends Data
{
public function __construct(
// string: required, has to be a string
#[Min(10), Max(120)]
public string $headline,
#[Min(200)]
public string $content,
#[DateFormat('Y-m-d'), WithCast(DateTimeInterfaceCast::class, format: '!Y-m-d')]
public CarbonImmutable $publishedAt,
public PostStatus $status,
#[IntegerType, Between(1, 60)]
public int $readingMinutes,
// ?string: may be missing or null
#[Max(300)]
public ?string $teaser,
) {
}
}
The property types are already rules. Anything the type cannot
express is added as an attribute. The attributes live in
Spatie\LaravelData\Attributes\Validation and are named like the Laravel rules you already
know.
The controller
final class PostController
{
public function store(CreatePostData $data): JsonResponse
{
$post = Post::create([
'headline' => $data->headline,
'content' => $data->content,
'published_at' => $data->publishedAt,
'status' => $data->status,
'reading_minutes' => $data->readingMinutes,
'teaser' => $data->teaser,
]);
return response()->json(['id' => $post->id], 201);
}
}
That is all there is to it. Laravel sees from the type hint that a data class is wanted. The
package takes the current request, validates it, builds the object and passes it into the
method. If validation fails, store() is never called.
$data->publishedAt is a CarbonImmutable, $data->status a PostStatus and
$data->readingMinutes an int. The IDE completes the names, and PHPStan knows the types.
Which rules come out of it
You can print the rules, which helps a lot when you are starting out:
CreatePostData::getValidationRules([]);
For the class above this is the result:
| Property | Rules |
|---|---|
headline |
required, string, min:10, max:120 |
content |
required, string, min:200 |
publishedAt |
required, date_format:Y-m-d |
status |
required, Enum(PostStatus) |
readingMinutes |
required, numeric, integer, between:1,60 |
teaser |
nullable, string, max:300 |
The type contributes required or nullable, plus string, numeric, boolean, array
and, for a backed enum, the enum rule. The rest comes from the attributes.
When the input is wrong
A request with five mistakes:
{
"headline": "Short",
"content": "No text yet.",
"publishedAt": "14.03.2026",
"status": "online",
"readingMinutes": "3.5"
}
The response is a 422 in the usual Laravel format:
{
"message": "The headline field must be at least 10 characters. (and 4 more errors)",
"errors": {
"headline": ["The headline field must be at least 10 characters."],
"content": ["The content field must be at least 200 characters."],
"publishedAt": ["The published at field must match the format Y-m-d."],
"status": ["The selected status is invalid."],
"readingMinutes": ["The reading minutes field must be an integer."]
}
}
Behind it is the normal ValidationException. So a form in the browser gets the redirect
with the errors in the session as always, and an API client gets the JSON. If you know the
format from form requests, nothing changes in the frontend.
Strings become types
After validation the package converts the values. It ships casts for dates and backed enums:
$data->publishedAt; // CarbonImmutable, 2026-03-14 00:00:00
$data->status; // PostStatus::Draft
$data->readingMinutes; // 6, even if "6" came in as a string
$data->teaser; // null if the field is missing
There are two things to watch with dates.
The default date format is ISO 8601 with time and time zone. If all you receive is a day,
you have to state the format, either globally in config/data.php under date_format or
per property with WithCast, as above.
The exclamation mark in '!Y-m-d' is deliberate. Without it PHP fills in the time with the
current time, and 2026-03-14 becomes 2026-03-14 16:42:07. With ! it is midnight. The
validation still checks against Y-m-d; the ! belongs in the cast only.
Your own error messages
You override messages and field names with two static methods in the data class:
public static function messages(): array
{
return [
'headline.min' => 'The headline needs at least :min characters.',
];
}
public static function attributes(): array
{
return [
'readingMinutes' => 'reading time',
];
}
The keys are the same as in a form request. Translation files under lang/ work too,
because in the end the normal Laravel validator runs.
What to watch out for
A few things surprised me.
An int only becomes numeric. Without #[IntegerType], "3.5" passes validation, and
the object then holds a 3. That is why the attribute sits on readingMinutes above.
A date type produces no rule. CarbonImmutable $publishedAt on its own only means
required. If "tomorrow" comes in, it is the cast that fails, and the client gets a 500
instead of a 422. So every date property needs #[DateFormat] or #[Date].
Only what comes from a request is validated. CreatePostData::from([...]) with an array
builds the object without any check. For arrays from a queue, an import or a test there is
CreatePostData::validateAndCreate([...]).
The property names are the field names. If the client sends published_at but the property
should be called publishedAt, #[MapInputName(SnakeCaseMapper::class)] on the class
helps.
Check it with a test
Whether rules and casts do what you think they do, a short test shows faster than any amount of reading:
public function testRejectsHeadlineThatIsTooShort(): void
{
$response = $this->postJson('/api/posts', [
'headline' => 'Short',
'content' => str_repeat('x', 200),
'publishedAt' => '2026-03-14',
'status' => 'draft',
'readingMinutes' => 6,
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['headline']);
}
Add one for the good case that checks a 201 comes back. With those two you notice right away when somebody removes an attribute or changes a type.
What spatie/laravel-data does not do
The package checks the shape of the input: types, lengths, formats, allowed values. Business rules do not belong in it. Whether an author may still publish a post today, or whether the headline already exists, is still decided by your code behind the controller.
For me that is plenty. What an endpoint accepts is written in one place, and in the controller I work with types instead of array keys.
