Zustandsautomaten: Statusabläufe in Software sauber modellieren
Ein Zustandsautomat legt für einen Geschäftsprozess fest, welche Status es gibt, welche Übergänge zwischen ihnen erlaubt sind und welche Rolle sie auslösen darf. Alles andere lehnt der Code ab, und jeder Übergang wird protokolliert.
Ein Antrag wird angelegt, eingereicht, geprüft und freigegeben oder abgelehnt. Solche Abläufe stecken in Urlaubsanträgen, Bestellfreigaben, Reklamationen und Prüfprotokollen. In der Datenbank steht dafür ein Feld „Status“. Wer darauf schreiben darf und von welchem Wert zu welchem, steht dann verstreut in Formularen, Controllern und Hintergrundjobs.
Ein Zustandsautomat bündelt diese Regeln an einer Stelle. Die Beispiele unten zeigen das mit PHP 8.4 und Laravel: ein Enum für die Status mit einer Tabelle der erlaubten Übergänge, eine Prüfung, die alles andere ablehnt, und ein Protokolleintrag je Übergang. Der Code ist ein eigenes, kleines Beispiel; wo und womit er ausgeführt wurde, steht am Ende im Quellenblock.
Das Prinzip
Erlaubte Übergänge als Tabelle im Code
Ein Statusablauf ist sauber modelliert, wenn sich der Status nur über einen benannten Übergang ändern lässt und jeder Übergang vorher geprüft wird. Das Statusfeld wird dann nie direkt gesetzt, sondern nur über eine Methode wie transitionTo, die drei Fragen beantwortet: Ist der Übergang vom aktuellen Status aus definiert? Darf die handelnde Rolle ihn auslösen? Wurde er protokolliert?
Der Ablauf im Beispiel steht in der Tabelle, links der aktuelle Status. Freigegeben ist ein Endzustand, aus dem kein Weg mehr führt. Ein abgelehnter Antrag geht zurück in den Entwurf und kann überarbeitet erneut eingereicht werden.
| Erlaubter nächster Status | Darf auslösen | |
|---|---|---|
| Entwurf | Eingereicht | Antragsteller |
| Eingereicht | In Prüfung | Prüfer |
| In Prüfung | Freigegeben | Freigeber |
| In Prüfung | Abgelehnt | Prüfer oder Freigeber |
| Abgelehnt | Entwurf | Antragsteller |
| Freigegeben | kein Übergang (Endzustand) | niemand |
Begriffe
Status, Übergang, Rolle: die drei Begriffe
Ein Status beschreibt, wo ein Vorgang gerade steht, und ist immer genau einer aus einer festen Liste. Ein Übergang ist der erlaubte Schritt von einem Status in einen anderen, mit einem Namen aus der Fachsprache: einreichen, prüfen, freigeben. Eine Rolle sagt, wer einen Übergang auslösen darf. Erst die drei zusammen beschreiben den Prozess; ein Statusfeld allein beschreibt nur den Ist-Zustand.
Dass diese Trennung kein neues Thema ist, zeigt eine Case Study aus dem Jahr 2012: ein Workflow-Portal mit handcodierter State-Logik. Aufgaben werden dort erzeugt, gehen durch definierte Status-Übergänge, landen bei der jeweils verantwortlichen Rolle und werden abgenommen oder zurückgewiesen. State-Machines, Übergänge, Rollen-Zuordnung und Audit-Trail entstanden als eigener Code, weil Engines wie Camunda damals im PHP-Umfeld nicht produktiv verfügbar waren. Geliefert wurde zum Festpreis in vier Raten (Setup, Kernlogik, Frontend-Ausbau, Übergabe), jede abgenommen, bevor die nächste begann.
Fehlerquellen
Warum freie Statusfelder Fehler erzeugen
Ein Statusfeld, das jeder Teil der Anwendung beliebig setzen kann, lässt Zustände zu, die der Prozess nicht kennt. Am Ablauf oben sieht das so aus:
Übersprungene Prüfung: Ein Formular für Administratoren setzt den Status von Entwurf direkt auf Freigegeben. Niemand hat geprüft, aber der Antrag sieht erledigt aus.
Falsche Rolle: Ein Prüfer klickt auf „Freigeben“, weil die Schaltfläche für ihn sichtbar ist. Die Oberfläche blendet sie zwar aus, ein direkter Aufruf der Schnittstelle prüft die Rolle aber nicht.
Doppelter Klick: Zwei Personen bearbeiten denselben Antrag gleichzeitig. Beide lesen „In Prüfung“, einer gibt frei, der andere lehnt ab, und der Antrag ist am Ende abgelehnt, obwohl die Freigabe zuerst kam.
Die ersten beiden Fehler fängt die Übergangstabelle ab, den dritten eine Sperre auf den Datensatz während des Übergangs. In einer Schnittstelle heißt das auch: Statt eines allgemeinen Endpunkts, der das Statusfeld ändert, gibt es je Übergang eine eigene Aktion, etwa POST /proposals/{id}/approve. Warum Schnittstellen den Begriffen des Fachgebiets folgen sollten, steht unter API-First: Warum die Schnittstelle vor dem Frontend entsteht.
Das Enum hält Status und erlaubte Übergänge zusammen. Die Methode transitions liefert je Status die möglichen Ziele mit den Rollen, die sie auslösen dürfen:
<?php
declare(strict_types=1);
namespace App\Domain;
enum ProposalState: string
{
case Draft = 'draft';
case Submitted = 'submitted';
case InReview = 'in_review';
case Approved = 'approved';
case Rejected = 'rejected';
/**
* Allowed transitions: target state => roles that may trigger it.
*
* @return array<string, list<string>>
*/
public function transitions(): array
{
return match ($this) {
self::Draft => [self::Submitted->value => ['applicant']],
self::Submitted => [self::InReview->value => ['reviewer']],
self::InReview => [
self::Approved->value => ['approver'],
self::Rejected->value => ['reviewer', 'approver'],
],
self::Rejected => [self::Draft->value => ['applicant']],
self::Approved => [],
};
}
}
Prüfung
Eine Stelle, die ungültige Übergänge ablehnt
Die Prüfung kennt nur zwei Gründe für eine Ablehnung: Der Übergang ist vom aktuellen Status aus nicht definiert, oder die Rolle darf ihn nicht auslösen. In beiden Fällen wirft sie eine eigene Ausnahme, die der Controller in eine verständliche Meldung übersetzt. Weil match in PHP ohne passenden Zweig einen UnhandledMatchError wirft, fällt ein neuer Status ohne Eintrag in der Tabelle beim ersten Aufruf auf, statt still durchzurutschen.
Die Ausnahme steht in einer eigenen Datei, damit das Autoloading nach PSR-4 sie auch findet, wenn ein Controller sie allein abfängt. Im Test liefen die fünf erlaubten Übergänge durch; abgelehnt wurden Entwurf nach Freigegeben, eine Freigabe durch den Prüfer, jeder Weg aus dem Endzustand und ein Übergang von Eingereicht auf Eingereicht.
<?php
declare(strict_types=1);
namespace App\Domain;
final class InvalidTransition extends \DomainException
{
}
<?php
declare(strict_types=1);
namespace App\Domain;
final class TransitionGuard
{
public static function check(ProposalState $from, ProposalState $to, string $role): void
{
$allowed = $from->transitions();
if (! array_key_exists($to->value, $allowed)) {
throw new InvalidTransition("Transition {$from->value} -> {$to->value} is not defined.");
}
if (! in_array($role, $allowed[$to->value], true)) {
throw new InvalidTransition("Role {$role} may not move {$from->value} -> {$to->value}.");
}
}
}
Nachvollziehbarkeit
Jeden Übergang protokollieren
Jeder erfolgreiche Übergang schreibt eine Zeile in eine eigene Tabelle: von welchem Status, in welchen, wer, in welcher Rolle, wann und mit welcher Begründung. Daraus entsteht ein Audit-Trail, der beantwortet, warum ein Antrag in seinem Status steht. Die Zeile entsteht in derselben Datenbanktransaktion wie die Statusänderung; scheitert eines von beiden, gilt keines.
Die Tabelle unten ist auf ein belastbares Protokoll ausgelegt. Die Spalten from_state und to_state speichern Text statt eines Enum-Casts, damit alte Einträge lesbar bleiben, wenn ein Status später umbenannt oder abgeschafft wird. Und der Fremdschlüssel auf den Antrag verhindert das Löschen, solange Protokolleinträge existieren. Das Modell für die Protokollzeilen wirft im Beispiel bei update und delete eine Ausnahme; das schützt vor Versehen im Anwendungscode, aber nicht vor direkten Datenbankzugriffen. Wer mehr braucht, gibt dem Datenbankbenutzer der Anwendung für diese Tabelle nur Lese- und Einfügerechte. Welche Ereignisse über Statuswechsel hinaus in ein Audit-Log gehören, etwa Anmeldungen, Rechteänderungen und Exporte, steht unter SaaS für Großkunden: SSO, Rollen und Audit-Log einplanen.
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('proposal_transitions', function (Blueprint $table) {
$table->id();
$table->foreignId('proposal_id')->constrained()->restrictOnDelete();
$table->string('from_state');
$table->string('to_state');
$table->foreignId('user_id')->constrained();
$table->string('role');
$table->text('reason')->nullable();
$table->timestamp('created_at')->useCurrent();
});
}
public function down(): void
{
Schema::dropIfExists('proposal_transitions');
}
};
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ProposalTransition extends Model
{
// Log rows are written once: no updated_at column, no updates, no deletes.
public const UPDATED_AT = null;
protected $fillable = ['from_state', 'to_state', 'user_id', 'role', 'reason'];
protected static function booted(): void
{
static::updating(fn () => throw new \LogicException('Transition log rows are immutable.'));
static::deleting(fn () => throw new \LogicException('Transition log rows cannot be deleted.'));
}
public function proposal(): BelongsTo
{
return $this->belongsTo(Proposal::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
Laravel
Umsetzung in Laravel
Im Modell verbindet die Methode transitionTo alle Teile. Eloquent wandelt das Statusfeld über einen Enum-Cast in ProposalState um, so wie es die Laravel-Dokumentation unter Enum Casting beschreibt. Innerhalb einer Transaktion liest die Methode den Antrag mit lockForUpdate neu ein, prüft den Übergang mit dem Status aus der Datenbank, speichert den neuen Status und schreibt die Protokollzeile. Die Sperre ist die Antwort auf den doppelten Klick: Der zweite Aufruf wartet, bis der erste fertig ist, und liest dann den schon geänderten Status. Laravel empfiehlt, solche Sperren in eine Transaktion zu legen. Proposal definiert kein $fillable; Eloquent lehnt deshalb jede Massenzuweisung ab, auch die des Statusfelds aus einem Formular. Für SQLite setzt Laravel lockForUpdate nicht in SQL um; die Wirkung der Sperre zeigt sich erst unter MySQL oder PostgreSQL.
Im Test lief der Ablauf Entwurf, Eingereicht, In Prüfung, Freigegeben mit drei Protokollzeilen durch. Die Freigabe durch einen Prüfer und der Schritt aus dem Endzustand zurück wurden abgelehnt, das Ändern und Löschen einer Protokollzeile und das Löschen eines Antrags mit Protokoll ebenso, und down() entfernte die Tabelle wieder.
Die Rolle steht im Beispiel als Textfeld am Benutzer. In größeren Anwendungen kommt sie aus einem Rechtesystem mit Policies; an der Prüfung ändert das nichts, sie bekommt nur einen anderen Wert. Zeigt eine Übersicht zu jedem Antrag den letzten Übergang, wird die Relation transitions mitgeladen, sonst entsteht pro Zeile eine weitere Abfrage; wie man solche Muster findet, steht unter Eloquent N+1 systematisch finden und beheben.
Laufen solche Abläufe über mehrere Systeme hinweg, vom Formular bis in die Buchhaltung, sammelt die Seite zur Automatisierung von Geschäftsprozessen die Ansätze dafür.
<?php
namespace App\Models;
use App\Domain\ProposalState;
use App\Domain\TransitionGuard;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\DB;
class Proposal extends Model
{
protected function casts(): array
{
return ['state' => ProposalState::class];
}
public function transitions(): HasMany
{
return $this->hasMany(ProposalTransition::class);
}
public function transitionTo(ProposalState $to, User $actor, ?string $reason = null): void
{
DB::transaction(function () use ($to, $actor, $reason) {
// Re-read the row under a lock so two parallel clicks cannot both pass the guard.
$locked = self::query()->lockForUpdate()->findOrFail($this->getKey());
$from = $locked->state;
TransitionGuard::check($from, $to, $actor->role);
$locked->state = $to;
$locked->save();
$locked->transitions()->create([
'from_state' => $from->value,
'to_state' => $to->value,
'user_id' => $actor->getKey(),
'role' => $actor->role,
'reason' => $reason,
]);
});
$this->refresh();
}
}
Kurz beantwortet
Häufige Fragen
-
State Machine ist der englische Begriff für Zustandsautomat: ein Modell mit einer festen Menge von Zuständen und festgelegten Übergängen zwischen ihnen. Zu jedem Zeitpunkt ist genau ein Zustand aktiv, und er wechselt nur über einen erlaubten Übergang. In Geschäftsanwendungen kommen meist Rollen und ein Protokoll dazu.
-
Eines ist spatie/laravel-model-states: Es bildet jeden Zustand als eigene Klasse ab und legt erlaubte Übergänge mit allowTransition fest. Am 02.10.2026 ist Version 2.14.2 vom 22.07.2026 aktuell, sie unterstützt Laravel 12 und 13 und verlangt PHP 8.4. Für wenige Status mit klarer Tabelle reicht ein Enum wie oben; das Paket spielt seine Stärken aus, wenn Zustände eigenes Verhalten mitbringen.
-
Neue Status und Übergänge lassen sich ergänzen, ohne bestehende Daten anzufassen. Wird ein Status entfernt oder umbenannt, braucht es eine Datenmigration für alle Vorgänge, die gerade in diesem Status stehen, und eine Entscheidung, wohin sie wechseln. Das Protokoll bleibt dabei lesbar, wenn es die Statuswerte als Text speichert.
Ihr Ablauf steckt in einem Statusfeld?
Schreiben Sie die Status Ihres Prozesses mit den Rollen auf, die sie ändern, und markieren Sie jede Stelle im Code, die das Statusfeld direkt setzt. Aus dieser Liste entsteht die Übergangstabelle; wie wir Geschäftsprozesse über mehrere Systeme hinweg in Software abbilden, steht auf der Seite zur Prozessautomatisierung.
Nachweise
Quellen und Stand
Stand: 02.10.2026. Der Code wurde unter macOS mit PHP 8.5 und illuminate/database aus Laravel 13 gegen eine SQLite-Datenbank im Arbeitsspeicher ausgeführt, mit 17 Prüffällen; die Sperre mit lockForUpdate wirkt erst unter MySQL oder PostgreSQL und wurde dort nicht getestet.
Artikel teilen