Skip to content

spatie/laravel-data: validating request input with data objects

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.

The CreatePostData class: a constructor with typed properties and validation attributes

Before: a form request and an array

This is what the endpoint looks like the classic way:

PHP
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:

PHP
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:

PHP
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

PHP
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:

PHP
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:

JSON
{
    "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:

JSON
{
    "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:

PHP
$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:

PHP
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:

PHP
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.

Product, company and trademark names mentioned here belong to their respective owners and are used descriptively only. No affiliation with them, and no endorsement by them, is implied.