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.
Vorher: FormRequest und ein Array
So sieht der Endpunkt klassisch aus:
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:
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:
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
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:
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:
{
"headline": "Kurz",
"content": "Noch kein Text.",
"publishedAt": "14.03.2026",
"status": "online",
"readingMinutes": "3.5"
}
Die Antwort ist ein 422 im gewohnten 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."]
}
}
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:
$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:
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:
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.
