Zum Inhalt springen

spatie/laravel-data: Input-Validierung mit Data-Objekten

Veröffentlicht am

In einem Laravel-Controller will ich mich nicht um die Validierung kümmern. Ich will, dass dort nur ankommt, was gültig ist. Der übliche Weg dahin führt über einen FormRequest und ein Array. Die Regeln stehen in der einen Klasse, die Typen habe ich im Kopf, und im Controller greife ich mit Strings auf Schlüssel zu, bei denen mir weder die IDE noch PHPStan hilft.

Mit spatie/laravel-data schreibe ich stattdessen eine Klasse, die den Input beschreibt. Der Controller bekommt genau diese Klasse als Parameter. Wenn die Methode läuft, ist der Input validiert, und die Werte haben die Typen, die in der Klasse stehen. Ein Request-Objekt brauche ich dafür nicht mehr.

Das Beispiel ist ein Endpunkt, der einen Blogbeitrag anlegt. Der Code ist für Version 4 von spatie/laravel-data geschrieben.

Die Klasse CreatePostData: Konstruktor mit typisierten Properties und Validierungs-Attributen

Vorher: FormRequest und ein Array

So sieht der Endpunkt klassisch aus:

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'],
        ];
    }
}

Das validiert sauber. Im Controller kommt danach aber ein Array an:

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'] ist ein String, $input['status'] auch. Dass readingMinutes eine Zahl ist, weiß die Validierung, der Typ im Code ist trotzdem mixed. Die Umwandlung schreibe ich von Hand, und wenn ich mich bei einem Schlüssel vertippe, merke ich das erst zur Laufzeit.

Die Data-Klasse

Dasselbe als Data-Objekt:

PHP
enum PostStatus: string
{
    case Draft     = 'draft';
    case Published = 'published';
}

final class CreatePostData extends Data
{
    public function __construct(
        // string: Pflichtfeld, muss ein String sein
        #[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: darf fehlen oder null sein
        #[Max(300)]
        public ?string $teaser,
    ) {
    }
}

Die Typen der Properties sind bereits Regeln. Alles, was sich aus dem Typ nicht ablesen lässt, kommt als Attribut dazu. Die Attribute liegen in Spatie\LaravelData\Attributes\Validation und heißen wie die Laravel-Regeln, die du schon kennst.

Der 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);
    }
}

Mehr ist es nicht. Laravel sieht am Type-Hint, dass eine Data-Klasse verlangt wird. Das Paket nimmt den aktuellen Request, validiert ihn, baut das Objekt und gibt es in die Methode. Schlägt die Validierung fehl, wird store() gar nicht erst aufgerufen.

$data->publishedAt ist ein CarbonImmutable, $data->status ein PostStatus und $data->readingMinutes ein int. Die IDE vervollständigt die Namen, und PHPStan kennt die Typen.

Welche Regeln daraus entstehen

Die Regeln lassen sich ausgeben, das ist beim Einstieg sehr hilfreich:

PHP
CreatePostData::getValidationRules([]);

Für die Klasse oben kommt das heraus:

Property Regeln
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

Aus dem Typ kommen required oder nullable, dazu string, numeric, boolean, array und bei einem Backed Enum die Enum-Regel. Der Rest stammt aus den Attributen.

Wenn der Input nicht stimmt

Ein Request mit fünf Fehlern:

JSON
{
    "headline": "Kurz",
    "content": "Noch kein Text.",
    "publishedAt": "14.03.2026",
    "status": "online",
    "readingMinutes": "3.5"
}

Die Antwort ist ein 422 im gewohnten 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."]
    }
}

Dahinter steckt die normale ValidationException. Ein Formular im Browser bekommt also wie immer den Redirect mit den Fehlern in der Session, ein API-Client das JSON. Wer das Format aus FormRequests kennt, muss im Frontend nichts ändern.

Aus Strings werden Typen

Nach der Validierung wandelt das Paket die Werte um. Für Datumswerte und Backed Enums bringt es Casts mit:

PHP
$data->publishedAt;    // CarbonImmutable, 2026-03-14 00:00:00
$data->status;         // PostStatus::Draft
$data->readingMinutes; // 6, auch wenn "6" als String kam
$data->teaser;         // null, wenn das Feld fehlt

Beim Datum gibt es zwei Dinge zu beachten.

Das Standardformat für Datumswerte ist ISO 8601 mit Uhrzeit und Zeitzone. Kommt bei dir nur ein Tag an, musst du das Format angeben, entweder global in config/data.php unter date_format oder wie oben pro Property mit WithCast.

Das Ausrufezeichen in '!Y-m-d' ist Absicht. Ohne es füllt PHP die Uhrzeit mit der aktuellen Zeit auf, und aus 2026-03-14 wird 2026-03-14 16:42:07. Mit ! steht dort Mitternacht. Die Validierung prüft weiter gegen Y-m-d, das ! gehört nur in den Cast.

Eigene Fehlermeldungen

Meldungen und Feldnamen überschreibst du mit zwei statischen Methoden in der Data-Klasse:

PHP
public static function messages(): array
{
    return [
        'headline.min' => 'Die Überschrift braucht mindestens :min Zeichen.',
    ];
}

public static function attributes(): array
{
    return [
        'readingMinutes' => 'Lesezeit',
    ];
}

Die Schlüssel sind dieselben wie bei einem FormRequest. Übersetzungsdateien unter lang/ greifen ebenfalls, weil am Ende der normale Laravel-Validator läuft.

Worauf du achten musst

Ein paar Stellen haben mich überrascht.

Ein int wird nur zu numeric. Ohne #[IntegerType] geht "3.5" durch die Validierung, und im Objekt steht danach eine 3. Deshalb steht das Attribut oben an readingMinutes.

Ein Datumstyp erzeugt keine Regel. CarbonImmutable $publishedAt allein heißt nur required. Kommt dann "morgen" an, scheitert erst der Cast, und der Client bekommt einen 500 statt eines 422. Jede Datums-Property braucht deshalb #[DateFormat] oder #[Date].

Validiert wird nur, was aus einem Request kommt. CreatePostData::from([...]) mit einem Array baut das Objekt ohne Prüfung. Für Arrays aus einer Queue, einem Import oder einem Test gibt es CreatePostData::validateAndCreate([...]).

Die Property-Namen sind die Feldnamen. Schickt der Client published_at, die Property soll aber publishedAt heißen, hilft #[MapInputName(SnakeCaseMapper::class)] an der Klasse.

Mit einem Test prüfen

Ob Regeln und Casts so greifen, wie du denkst, zeigt ein kurzer Test schneller als jedes Nachlesen:

PHP
public function testRejectsHeadlineThatIsTooShort(): void
{
    $response = $this->postJson('/api/posts', [
        'headline'       => 'Kurz',
        'content'        => str_repeat('x', 200),
        'publishedAt'    => '2026-03-14',
        'status'         => 'draft',
        'readingMinutes' => 6,
    ]);

    $response->assertStatus(422);
    $response->assertJsonValidationErrors(['headline']);
}

Dazu einer für den guten Fall, der prüft, dass ein 201 zurückkommt. Mit den beiden merkst du sofort, wenn jemand ein Attribut entfernt oder einen Typ ändert.

Was spatie/laravel-data nicht kann

Das Paket prüft die Form des Inputs: Typen, Längen, Formate, erlaubte Werte. Fachliche Regeln gehören nicht hinein. Ob ein Autor heute noch einen Beitrag veröffentlichen darf oder ob es die Überschrift schon gibt, entscheidet weiterhin dein Code hinter dem Controller.

Für mich reicht das völlig. Was ein Endpunkt annimmt, steht an einer Stelle, und im Controller arbeite ich mit Typen statt mit Array-Schlüsseln.

Genannte Produkt-, Firmen- und Markennamen gehören ihren jeweiligen Inhabern und werden hier allein beschreibend verwendet. Eine Verbindung zu ihnen oder eine Empfehlung durch sie besteht nicht.