Skip to content

Laravel: Doppelte Formularübermittlungen verhindern

Ein deaktivierter Button schützt dein Laravel-Formular nur im Browser. Dieses Tutorial zeigt, wie du doppelte Datensätze mit einem Übermittlungstoken, einem Unique Index und einem gezielten Feature-Test zuverlässig verhinderst. Der Ansatz funktioniert auch bei Doppelklicks, erneuten Requests und mehreren geöffneten Tabs.

Moritz Klaßen

Moritz Klaßen

Entwickler & Gründer von Klassen AI

5 Min. Lesezeit

Doppelte Formularübermittlungen entstehen auch dann, wenn der Absenden-Button im Browser deaktiviert wird: etwa durch parallele Requests, erneutes Senden nach einer langsamen Antwort oder mehrere geöffnete Tabs. Der Schutz muss deshalb auf dem Server liegen.

Dieses Tutorial vergibt für jede fachliche Übermittlung ein eigenes Token. Ein Unique Index akzeptiert dieses Token nur einmal. Wiederholte Requests erzeugen dadurch weder einen zweiten Datensatz noch eine zweite Benachrichtigung.

Was der Ansatz schützt

Das Beispiel speichert Anfragen in contact_requests. Es lässt sich auf Bestellungen, Buchungen, Supportanfragen und interne Freigaben übertragen, wenn ein Formularaufruf genau eine fachliche Aktion repräsentiert.

Ein CSRF-Token reicht dafür nicht aus. Es schützt vor Anfragen fremder Websites, darf aber bei einer legitimen Übermittlung mehrfach gesendet werden. Das Übermittlungstoken ist außerdem kein Berechtigungsnachweis und ersetzt keine Authentifizierung oder Autorisierung.

Ein Token beim Rendern des Formulars erzeugen

Erzeuge beim Anzeigen des Formulars eine UUID und übermittle sie in einem versteckten Feld. Nach einem Validierungsfehler verwendet old() denselben Wert, damit die korrigierte Eingabe weiterhin zur selben Übermittlung gehört.

<form method="POST" action="{{ route('contact.store') }}">
    @csrf

    <input
        type="hidden"
        name="submission_token"
        value="{{ old('submission_token', (string) \Illuminate\Support\Str::uuid()) }}"
    >

    <input type="email" name="email" value="{{ old('email') }}" required>
    <textarea name="message" required>{{ old('message') }}</textarea>
    <button type="submit">Anfrage senden</button>
</form>

Die UUID muss nicht geheim sein. Sie dient ausschließlich dazu, Requests derselben Übermittlung zuzuordnen. Ein per JavaScript deaktivierter Button kann die Bedienung verbessern, ist aber nur eine zusätzliche Schutzschicht.

Das Token in der Datenbank eindeutig absichern

Die Datenbank ist die maßgebliche Instanz für parallele Requests. Eine vorherige Abfrage nach einem vorhandenen Datensatz genügt nicht, weil zwei Requests diese Abfrage gleichzeitig bestehen können.

Für eine bestehende Tabelle kann zunächst eine nullable Spalte ergänzt werden. Neue Übermittlungen erhalten das Token über die Anwendung.

<?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::table('contact_requests', function (Blueprint $table) {
            $table->uuid('submission_token')->nullable();
            $table->unique('submission_token');
        });
    }

    public function down(): void
    {
        Schema::table('contact_requests', function (Blueprint $table) {
            $table->dropUnique(['submission_token']);
            $table->dropColumn('submission_token');
        });
    }
};

Bei einer neuen Tabelle sollte die Spalte direkt verpflichtend sein. In einer bestehenden Anwendung ist ein gestufter Rollout sinnvoll: Spalte ergänzen, alle schreibenden Stellen anpassen und erst danach eine Pflichtspalte einführen. Hinweise zum kontrollierten Ausliefern solcher Änderungen enthält der Artikel über das Testen und Deployen von Laravel-Datenbankmigrationen.

Stelle außerdem sicher, dass submission_token beim Speichern tatsächlich gesetzt wird. Bei Mass Assignment muss das Attribut in $fillable stehen oder einzeln zugewiesen werden.

Wiederholte Requests im Controller behandeln

Der erste Request legt den Datensatz an. Ein weiterer Request mit demselben Token verletzt den Unique Index. Der Controller lädt dann den vorhandenen Datensatz und löst keine Nebenwirkung erneut aus.

<?php

namespace App\Http\Controllers;

use App\Jobs\SendContactRequestNotification;
use App\Models\ContactRequest;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class ContactRequestController extends Controller
{
    public function store(Request $request): RedirectResponse
    {
        $data = $request->validate([
            'submission_token' => ['required', 'uuid'],
            'email' => ['required', 'email'],
            'message' => ['required', 'string', 'max:5000'],
        ]);

        $created = false;

        try {
            $contactRequest = DB::transaction(function () use ($data) {
                return ContactRequest::query()->create([
                    'submission_token' => $data['submission_token'],
                    'email' => $data['email'],
                    'message' => $data['message'],
                ]);
            });

            $created = true;
        } catch (UniqueConstraintViolationException $exception) {
            $contactRequest = ContactRequest::query()
                ->where('submission_token', $data['submission_token'])
                ->first();

            if (! $contactRequest) {
                throw $exception;
            }
        }

        if ($created) {
            SendContactRequestNotification::dispatch($contactRequest->id);
        }

        return to_route('contact.thanks');
    }
}

Die Abfrage im Catch-Block ist notwendig, weil die Tabelle weitere Unique Indizes haben kann. Wird ein anderer Index verletzt, darf der Fehler nicht als doppelte Übermittlung behandelt werden.

Nebenwirkungen und Weiterleitung

Das Flag $created verhindert doppelte E-Mails, Webhooks oder Queue-Jobs. Für kritische Prozesse reicht das allein nicht immer aus: Scheitert das Dispatching nach dem Datenbank-Commit, darf ein erneuter Formularversand keinen zweiten Datensatz erzeugen, stößt den Job aber auch nicht erneut an.

Bei Zahlungen, verbindlichen Buchungen oder externen Provisionierungen ist daher ein transaktionales Outbox-Verfahren sinnvoll. Dabei wird der ausstehende Auftrag zusammen mit dem fachlichen Datensatz gespeichert und später von einem separaten Prozess verarbeitet. Bei fehlgeschlagenen Jobs hilft die Anleitung zur Analyse fehlgeschlagener Laravel Queue-Jobs.

Leite nach einem erfolgreichen POST auf eine GET-Seite weiter. Dieses Post/Redirect/Get-Muster verhindert, dass das Aktualisieren der Bestätigungsseite den POST erneut sendet. Es ersetzt den Unique Index nicht, weil parallele Requests bereits vor der Weiterleitung eintreffen können.

Den Ablauf als Feature-Test absichern

Der entscheidende Test sendet dieselben Daten zweimal. Erwartet werden genau ein Datensatz und genau ein Queue-Job.

<?php

namespace Tests\Feature;

use App\Jobs\SendContactRequestNotification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Tests\TestCase;

class ContactRequestTest extends TestCase
{
    use RefreshDatabase;

    public function test_same_submission_is_stored_and_dispatched_once(): void
    {
        Queue::fake();

        $data = [
            'submission_token' => (string) Str::uuid(),
            'email' => 'kontakt@example.test',
            'message' => 'Bitte um Rückruf.',
        ];

        $this->post(route('contact.store'), $data)
            ->assertRedirect(route('contact.thanks'));

        $this->post(route('contact.store'), $data)
            ->assertRedirect(route('contact.thanks'));

        $this->assertDatabaseCount('contact_requests', 1);
        Queue::assertPushed(SendContactRequestNotification::class, 1);
    }
}

Ergänze einen Test mit zwei unterschiedlichen Tokens und gleichen Formulardaten. Dann müssen zwei Datensätze entstehen. Ein weiterer Test sollte ein ungültiges Token mit einem Validierungsfehler zurückweisen.

Wann eine separate Idempotenz-Tabelle besser passt

Das Token im Zieldatensatz passt, wenn ein Request einen neuen Datensatz erzeugt. Aktualisiert eine Aktion dagegen bestehende Daten oder mehrere Objekte, etwa beim Freigeben einer Rechnung, ist eine separate Tabelle wie idempotency_requests oft klarer.

Sie speichert mindestens Token, Aktion und Bearbeitungsstatus. Der Unique Index kann auf dem Token oder auf einer Kombination aus Nutzer, Aktion und Token liegen. Das gleiche Prinzip eignet sich für APIs mit einem Idempotency-Key im Header.

Die belastbare Reihenfolge

  1. Beim Anzeigen des Formulars ein Übermittlungstoken erzeugen.
  2. Token mitsenden und sein Format validieren.
  3. Token zusammen mit dem fachlichen Datensatz speichern.
  4. Die Spalte per Unique Index absichern.
  5. Wiederholungen als bereits ausgeführte Aktion behandeln.
  6. Nebenwirkungen nur beim ersten erfolgreichen Anlegen auslösen.
  7. Nach dem POST auf eine GET-Seite weiterleiten.
  8. Denselben Request zweimal testen und genau ein Ergebnis erwarten.

Damit hängt die fachliche Korrektheit nicht vom Verhalten des Browsers ab. Wiederholte Requests bleiben möglich, führen in der Laravel-Anwendung aber nur einmal zur gewünschten Aktion.

Häufige Fragen

Kurz beantwortet, damit du schneller einschätzen kannst, was für dein Projekt wichtig ist.

Reicht es, den Absenden-Button per JavaScript zu deaktivieren?

Nein. Das verbessert die Bedienung, verhindert aber keine parallelen Requests, Wiederholungen durch Clients oder manipulierte Anfragen. Die verlässliche Sicherung muss serverseitig erfolgen.

Verhindert Laravels CSRF-Schutz doppelte Formularübermittlungen?

Nein. Ein gültiger CSRF-Token kann innerhalb seiner Gültigkeit mehrfach verwendet werden. Für doppelte fachliche Aktionen brauchst du ein separates Übermittlungstoken mit einer eindeutigen Datenbankregel.

Warum genügt eine Prüfung mit exists() vor dem Speichern nicht?

Zwei parallele Requests können beide feststellen, dass noch kein Datensatz existiert, und danach jeweils einen anlegen. Erst ein Unique Index entscheidet diese Konkurrenz atomar in der Datenbank.

Wann ist eine separate Idempotenz-Tabelle sinnvoll?

Sie ist sinnvoll, wenn ein Request keinen einzelnen neuen Datensatz erzeugt, sondern bestehende Daten aktualisiert, mehrere Objekte verändert oder als API-Aufruf ein wiederverwendbares Ergebnis benötigt.