Du schaust auf deinen persönlichen Blog, und irgendetwas stimmt nicht. Unter dem Teaser, wo ein Tag stehen sollte, steht eine einzelner # und dahinter nichts.
Genau zwei Dinge waren hier jemals vorgesehen. Entweder der Post hat einen Tag oder er hat keinen. Eine einsamer Hashtag stand nie zur Auswahl.


Der erste Reflex: Vielleicht liegt es am Template.
Aber das Template sieht völlig fein aus. Es prüft auf null, bevor es den Tag ausgibt:
@if ($post->tags !== null)
<p class="mt-3 text-xs text-neutral-400">
#{{ $post->tags }}
</p>
@endif
Das heißt: tags ist nicht null. Es ist irgendetwas — irgendetwas, das sich als nichts ausgibt. Also schaut man in die Datenbank.
Die einsame Raute ist nicht das Problem


Bestätigt: EMPTY. Nicht NULL. Empty.
Warum steht ein leerer String in einer nullable Spalte? Wie viele sind es noch? Und seit wann geht das schon so?
Das ist eine besonders fiese Sorte an Fehlern. Eine bei der alles scheinbar funktioniert, nie ein Fehler geworfen wird und keine Validierung je fehlgeschlägt — nullable ist mit '' vollkommen zufrieden.
Der Schreibvorgang lief durch, der Nutzer bekam seine Erfolgsmeldung, die Zeile wurde gespeichert. Die einsame Raute ist der Glücksfall dieser Geschichte. Sie ist der einzige Grund, warum es überhaupt jemandem aufgefallen ist.
Aber der Schaden ist bereits da. Sobald du mit der tags-Spalte in den Queries arbeitest, verhält sie sich nicht so, wie du es erwartest.
// Enthält jede Zeile mit einem leeren String
Post::whereNotNull('tags')->get();
Man könnte meinen, das liefert jeden Post, der einen Tag hat. Stattdessen bekommt man auch jede Zeile mit ''. Von hier aus breitet es sich aus: Ein unique-Index erlaubt beliebig viele null, weist aber das zweite '' ab. Und wer neu ins Projekt kommt, liest ?string $tags und nimmt berechtigterweise an, null sei der einzige leere Fall.
Wenn es dann jemandem auffällt, ist die Lösung keine Code-Änderung mehr, sondern eine Backfill-Migration auf Produktivdaten, denen man nicht mehr ganz traut. Das ist der eigentliche Preis dieses Bugs — und der Grund, ihn zu verstehen anstatt damit zu leben.
Die Suche nach der Ursache
Also geht man auf die Suche. Vielleicht passiert es ja schon beim Anlegen.
Man öffnet das Form-Objekt. Die Property ist nullable, typisiert und hat null als Default. Sieht gut aus:
public ?string $tags = null;
Man prüft Store- und Update-Methode. Sehen auch gut aus — unspektakulärer wird Laravel nicht:
$data = $this->validate();
// in update()
BlogPost::find($id)->update($data);
// in store()
BlogPost::create($data);
Nichts springt ins Auge. Nirgends ein verstecktes ?: '', kein Accessor, kein Cast.
Aber nichts, was ein einfaches dd nicht beantworten könnte:
$data = $this->validate();
dd($data['tags']);
Also geht man auf die Jagd. Ein neuer Post mit Tag — gespeichert, korrekt. Ein neuer Post mit leer gelassenem Tags-Feld — null, genau wie versprochen. Der Bug weigert sich aufzutauchen. Dann öffnet man einen Post, der bereits einen Tag hat, markiert ihn, löscht ihn und speichert:

Ein leerer String — überreicht von validate() höchstpersönlich.
Aber Moment: Was hat man denn getan? Ein Feld geleert. Mehr nicht. Und dieses Feld ist ?string, hat null als Default und wird als nullable validiert. Jede Schicht, die man selbst geschrieben hat, ist sich einig, dass hier null stehen müsste.
Ist das ein Bug? Ist das so gewollt? War das schon immer so? Also macht man das Kontrollexperiment: dasselbe Feld als ganz gewöhnlichen Form Request, durch einen Controller geschickt, und dort gedumpt.
null.
Gleiches Feld. Gleiche Regel. Gleiche leere Eingabe. Zwei verschiedene Werte. Spätestens hier kann man aufhören, den eigenen Code zu durchsuchen — der Unterschied liegt nicht darin.
Genau so ist es mir in einem echten Kundenprojekt ergangen, und es ist genau die Sorte unerwartetes Verhalten, die nirgends dokumentiert ist und von der man nur erfährt, wenn man aktiv und gezielt danach sucht.
Warum Laravel einen sonst rettet
In einem normalen Laravel-Request passiert das nie. Und genau deshalb ist es so irritierend.
Laravel bringt zwei globale Middlewares mit, die bei jedem HTTP-Request laufen: TrimStrings und ConvertEmptyStringsToNull. Zusammen sorgen sie dafür, dass ein abgeschicktes, aber leeres Textfeld als null im Controller ankommt, nicht als ''. Die meisten von uns haben darüber nie nachgedacht. Es ist schlicht das Wasser, in dem wir schwimmen.
Diese Annahme gilt in dem Moment nicht mehr, in dem der Request von Livewire kommt.
Warum Livewire das nicht tut
Der Reflex ist, das für einen Bug zu halten — Livewire vergisst eine Middleware. Tut es nicht. Livewire schaltet beide Middlewares ganz bewusst und namentlich ab.
Nachzulesen in Livewire\Mechanisms\HandleRequests\HandleRequests:
function skipRequestPayloadTamperingMiddleware()
{
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::skipWhen(function () {
return $this->isLivewireRequest();
});
\Illuminate\Foundation\Http\Middleware\TrimStrings::skipWhen(function () {
return $this->isLivewireRequest();
});
}
Der Methodenname sagt es unverblümt: Aus Livewires Sicht manipulieren diese Middlewares das Payload. Bemerkenswert ist, dass auch TrimStrings deaktiviert wird — ein Feld, das nur aus Leerzeichen besteht, bleibt also exakt das.
Diese Antwort steht seit 2020. Als Issue #823 das alte Verhalten zurückforderte, schloss Caleb Porzio es:

Sechs Jahre und drei Major-Versionen später steht der Code oben unverändert in Livewire 4.
Und sobald man aufhört, einen Livewire-Request als Formular-Absendung zu betrachten, ergibt es Sinn. Ein klassisches POST ist eine Einbahnstraße: Der Browser wirft dem Server Daten zu und vergisst sie — der Server darf sie auf dem Weg hinein also umdeuten. Ein Livewire-Request ist dagegen ein State-Sync. Im Eingabefeld steht '', also steht in der Property ''. Würde der Server das still zu null umschreiben, wären Server-State und Browser-State bei jedem Roundtrip uneinig. Livewires Aufgabe ist es, die Eingabe getreu zu spiegeln. Die Entscheidung, dass „leer“ eben „nichts“ bedeutet, ist Sache der Anwendung, nicht des Transports.
Das ist völlig in Ordnung. Es heißt nur, dass man sie irgendwo treffen muss.
Der Hook, zu dem alle zuerst greifen
Scrollt man im selben Issue weiter nach unten, findet man die Antwort, auf die sich die Community geeinigt hat — ein Lifecycle-Hook, hochgevotet und seitdem fleißig kopiert:

public function updated($name, $value)
{
if ($value == '') data_set($this, $name, null);
}
Es funktioniert, es sind vier Zeilen, und es ist genau der Schnipsel, den du in deine dritte Komponente einfügst, kurz bevor du anfängst, ihn zu verfluchen.
Der offensichtliche Preis ist die Wiederholung: Der Hook lebt in jeder Komponente mit einem Formular. Form-Objekte haben ihren eigenen updated()-Hook, eine Komponente mit zwei Formularen braucht ihn also zweimal. Jede neue Komponente ist eine weitere Gelegenheit, ihn zu vergessen — und wie im Abschnitt oben beschrieben passiert dieses Vergessen völlig lautlos. Du bekommst keinen Fehler. Du bekommst eine etwas inkonsistentere Datenbank.
Der subtilere Preis: Dieser Hook verändert den Component-State, und er hat keinerlei Ahnung, welche Properties überhaupt null sein dürfen. Er sieht einen Namen und einen Wert. Gib ihm eine typisierte Property, die nicht nullable ist:
public string $search = '';
Leert man dieses Feld, versucht der Hook data_set($this, 'search', null):
TypeError: Cannot assign null to property $search of type string
Es kommt schlimmer, denn == ist ein loser Vergleich. PHP 8 hat 0 == '' repariert — in PHP 7 true, heute false. false == '' hat es nicht repariert, das ist weiterhin true. Der Hook feuert also auch bei jeder abgewählten Checkbox:
public bool $active = false; // TypeError, wie oben
public ?bool $active = false; // wird stillschweigend zu null statt false
Ein Hook, der leere Strings normalisieren sollte, hat plötzlich eine Meinung zu Booleans. Und die zweite Zeile stürzt nicht einmal ab — sie schreibt false klammheimlich zu null um. Also exakt die Sorte Bug, um die es in diesem ganzen Artikel geht.
Der bessere Ort
Der naheliegende Ort ist die Stelle, die man auf dem Weg zur Datenbank ohnehin passiert: die Validierung.
Statt in jeder Speichern-Methode ein ?: null zu verteilen — oder jeder Komponente einen Lifecycle-Hook beizubringen — erweitert man Livewires Form einmalig und normalisiert die validierten Daten auf dem Weg nach draußen:
<?php
namespace App\Livewire\Forms;
use Livewire\Form as LivewireForm;
class Form extends LivewireForm
{
public function validate($rules = null, $messages = [], $attributes = []): array
{
$validated = parent::validate($rules, $messages, $attributes);
return collect($validated)
->dot()
->transform(fn (mixed $value): mixed => $value === '' ? null : $value)
->undot()
->toArray();
}
}
Das ist alles. === '' ist ein strikter Vergleich und kann damit ausschließlich auf einen String zutreffen — kein is_string()-Guard nötig, und nichts von dem false == ''-Ärger des Hooks weiter oben. Integers, Booleans und leere Arrays gehen unangetastet durch.
Das Paar dot() / undot() ist der Grund, warum das auch mit verschachtelten Daten funktioniert. dot() flacht ['address' => ['street' => '']] zu ['address.street' => ''] ab, die Closure sieht damit jedes Blatt — ganz ohne eigene Rekursion — und undot() stellt die Struktur wieder her.
Am Formular selbst ändert sich gar nichts:
<?php
namespace App\Livewire\Forms;
use Livewire\Attributes\Validate;
class PostForm extends Form
{
#[Validate('required|string|max:255')]
public string $title = '';
#[Validate('nullable|string|max:255')]
public ?string $tags = null;
}
Es gibt keine Feldliste, die synchron gehalten werden muss, und nichts, wofür man sich aktiv entscheiden müsste. Jedes String-Feld jedes Formulars, das von dieser Klasse erbt, ist abgedeckt — auch die, die nächstes Jahr jemand hinzufügt, der diesen Artikel nie gelesen hat. Trimmen kann man auf demselben Weg ergänzen, wenn man möchte: indem man die Transform-Closure entsprechend erweitert.
Leert man das Tags-Feld jetzt, liefert validate() ein null zurück. Und die Prüfung im Template tut endlich das, was sie immer behauptet hat.
Ein bestehendes Formular umzustellen, ist eine Zeile, ein simples Suchen und Ersetzen — nicht einmal der Klassenname wandert:
-use Livewire\Form;
+use App\Livewire\Forms\Form;
class PostForm extends Form
Das ist die ganze Migration. Kein Hook, an den man denken muss, nichts, was in die Komponente gehört, und nichts, was in die nächste Komponente gehört. Ein Formular erbt von der Basisklasse oder eben nicht — und das steht sichtbar in der extends-Zeile, statt versteckt in einer Lifecycle-Methode vier Dateien weiter.
Und man beachte, was diese Variante nicht tut: Sie weist niemals einer Property etwas zu. public string $search = '' bleibt ein string, eine abgewählte bool bleibt false, und die Komponente spiegelt weiterhin exakt den Browser — so, wie Livewire es vorsieht. Nur das Array auf dem Weg in die Datenbank bekommt eine Meinung zur Leere, und genau dorthin gehört sie.
Wo wir schon dabei sind: Das Template verdient einen zweiten Blick. !== null war nie wirklich die Frage „hat dieser Post einen Tag“ — es ist die Prüfung auf eine ganz bestimmte Spielart von Leere, und sie wettet stillschweigend darauf, dass nie eine andere auftaucht.
empty() geht diese Wette nicht ein:
@if (! empty($post->tags))
<p class="mt-3 text-xs text-neutral-400">
#{{ $post->tags }}
</p>
@endif
In neunundneunzig von hundert Fällen ist das die richtige Prüfung. Sie deckt null ab, '', und alles, was ein zukünftiges Refactoring dort ablegen möchte.
Die Prüfung verhindert, dass heute eine # gerendert wird. Die Basisklasse verhindert, dass morgen ein leerer String geschrieben wird.
Die Stelle, die dich erwischen wird
validate() normalisiert das zurückgegebene Array. Die Properties des Formulars fasst es nicht an.
Nach dem Aufruf ist $this->form->tags weiterhin ''. Die Komponente spiegelt immer noch getreu ein leeres Eingabefeld — was korrekt ist und zugleich eine Falle:
// Normalisiert. Leere Tags werden zu null.
$this->post->update($this->form->validate());
// NICHT normalisiert. Leere Tags bleiben ''.
$this->form->validate();
$this->post->update($this->form->all());
Die zweite Variante validiert, wirft die sauberen Daten weg und liest anschließend die rohen Properties direkt vom Formular. Sie sieht fast identisch aus und holt sich exakt den Bug zurück. Also: immer den Rückgabewert von validate() verwenden.
Lesson Learned
Jedes Mal, wenn ich ein neues Framework lerne, kommt mir der Gedanke nicht nur die Dokumentation, sondern auch die geschlossenen Issues im GitHub-Repo anzuschauen. Wer weiß, wieviel verstecktes Wissen dort liegt. Denn ein Teil dessen, schafft es nie in die Dokumentation — und es nicht zu kennen, kann dich trotzdem erwischen. Das hier ist ein gutes Beispiel dafür.