Wer in PHP mit Datenbanken arbeitet, kommt an einem Punkt nicht vorbei: Mehrere Schreibvorgänge müssen zusammen gelingen oder zusammen scheitern. Genau dieses Alles-oder-nichts liefert PDO mit seinen Transaktions-Methoden. Der folgende Überblick zeigt beginTransaction(), commit() und rollBack(), den passenden Try-Catch-Rahmen und die typischen Fallen von impliziten Commits bis zu Deadlocks.
Was ist eine PHP PDO Transaction?
In jeder Anwendung, die mehrere zusammenhängende Datenbankoperationen ausführt, taucht früher oder später die Frage auf: Was passiert, wenn das Skript zwischen dem ersten und dem dritten Schritt abstürzt? Eine PHP PDO Transaction ist genau dafür da. Sie bündelt mehrere Änderungen auf einer einzigen DB-Verbindung zu einer atomaren Einheit, die entweder vollständig durchläuft oder im Fehlerfall komplett zurückgerollt wird. Saubere Transaktionen sind damit der Grundpfeiler konsistenter Datenbearbeitung.

Bevor beginTransaction, commit und rollBack im Detail folgen, hilft ein kurzer Blick auf einen typischen Bestellprozess als Praxisbeispiel.
Stell dir einen Bestellprozess vor: Kunde wird angelegt, Bestellzeile gespeichert, Lagerbestand reduziert. Fällt die Lagerbuchung wegen einer Race Condition durch, soll auch die Bestellzeile nicht im System landen. Genau diese Konsistenz garantiert eine Transaktion. PDO bietet dafür die Methoden beginTransaction(), commit() und rollBack(). Wer sie sauber einsetzt, hat einen verlässlichen Schutz vor halb durchgeführten Operationen.
beginTransaction, commit und rollBack im Überblick
Standardmäßig arbeitet PDO im Auto-Commit-Modus: jede einzelne SQL-Anweisung wird sofort in der Datenbank persistiert. Ein Aufruf von beginTransaction() schaltet diesen Modus vorübergehend aus. Ab diesem Moment werden alle Änderungen erst durch commit() dauerhaft gespeichert oder durch rollBack() verworfen.
<?php
$pdo = new PDO('mysql:host=localhost;dbname=shop;charset=utf8mb4', 'user', 'pass');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->beginTransaction();
$pdo->exec("UPDATE konto SET saldo = saldo - 100 WHERE id = 1");
$pdo->exec("UPDATE konto SET saldo = saldo + 100 WHERE id = 2");
$pdo->commit();
/* erst hier sind beide Buchungen wirklich gespeichert */
Wichtig ist die Zeile mit ATTR_ERRMODE. Ohne den Exception-Modus würden viele Fehler still bleiben und die PHP PDO Transaction hätte kaum noch Wert. Mit Exception-Modus wirft jede fehlgeschlagene Operation eine PDOException, die im Catch-Block behandelt werden kann. Mehr Hintergrund zur Klasse selbst liefert das Tutorial zu PHP PDO und Prepared Statements.
Bestellprozess als realistisches Beispiel
Ein typischer Bestellprozess besteht aus mehreren INSERTs in unterschiedliche Tabellen. Die folgende PHP PDO Transaction legt eine Bestellung an, schreibt eine Position dazu und reduziert den Lagerbestand.
<?php
$pdo->beginTransaction();
$stmt = $pdo->prepare("INSERT INTO bestellung (kunde_id, summe) VALUES (?, ?)");
$stmt->execute([42, 89.90]);
$bestellId = (int) $pdo->lastInsertId();
$pos = $pdo->prepare("INSERT INTO bestell_position (bestell_id, artikel_id, menge) VALUES (?, ?, ?)");
$pos->execute([$bestellId, 1001, 2]);
$lager = $pdo->prepare("UPDATE lager SET bestand = bestand - ? WHERE artikel_id = ? AND bestand >= ?");
$lager->execute([2, 1001, 2]);
if ($lager->rowCount() === 0) {
/* nicht genug Bestand: alles zurueckrollen */
throw new RuntimeException('Lager leer');
}
$pdo->commit();
Sobald der Lagerbestand nicht ausreicht, sorgt das throw dafür, dass keiner der drei Schritte gespeichert wird. Im Catch-Block werden die bisherigen Inserts über den Try-Catch-Wrapper automatisch zurückgerollt und der DB-Stand bleibt sauber. Genau das ist der Sinn einer Transaktion: alles oder nichts.
Try-Catch und automatischer rollBack
Der saubere Umgang mit Fehlern ist der wichtigste Teil bei jeder PHP PDO Transaction. Best Practice ist ein Try-Catch-Block, der beim Auftreten einer Exception den rollBack() auslöst und die Exception danach erneut wirft, damit der Aufrufer den Fehler sieht. Verwandte Patterns finden sich in der Tutorial-Seite zur Ausnahmebehandlung in PHP.
<?php
try {
$pdo->beginTransaction();
$stmt = $pdo->prepare("INSERT INTO bestellung (kunde_id, summe) VALUES (?, ?)");
$stmt->execute([42, 89.90]);
/* weitere Schritte */
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
/* Exception nach oben weiterreichen */
}
Die Prüfung inTransaction() ist wichtig: fällt der Fehler bereits vor beginTransaction(), etwa beim Vorbereiten eines Statements, gibt es nichts zurückzurollen und ein blinder rollBack() würde selbst eine Exception werfen.
inTransaction und verschachtelte Aufrufe
In Service-Schichten ruft eine Methode oft eine andere auf, die ihrerseits eine Transaktion starten will. Ein verschachtelter beginTransaction()-Aufruf erzeugt allerdings eine Exception, weil PDO standardmäßig keine echten geschachtelten Transaktionen unterstützt. Mit inTransaction() lässt sich der Konflikt sauber abfangen.
<?php
function speicherePosition(PDO $pdo, int $bestellId, int $artikelId, int $menge): void {
$managed = false;
if (!$pdo->inTransaction()) {
$pdo->beginTransaction();
$managed = true;
}
$pos = $pdo->prepare("INSERT INTO bestell_position (bestell_id, artikel_id, menge) VALUES (?, ?, ?)");
$pos->execute([$bestellId, $artikelId, $menge]);
if ($managed) {
$pdo->commit();
}
}
Diese Methode passt sich an: hat der Aufrufer bereits eine Transaktion gestartet, beteiligt sie sich daran. Ist sie der erste Einstiegspunkt, öffnet und schließt sie die Transaktion selbst.
Stolperfalle DDL-Statements und implizite Commits
Eine der unangenehmsten Eigenheiten beim Arbeiten mit PHP PDO Transaction unter MySQL und MariaDB ist der implizite Commit bei DDL-Statements. Sobald innerhalb einer Transaktion ein CREATE TABLE, DROP TABLE, ALTER TABLE oder TRUNCATE ausgeführt wird, committed die Datenbank stillschweigend alle vorherigen Änderungen. Ein anschließender rollBack() greift dann ins Leere.
<?php
$pdo->beginTransaction();
$pdo->exec("INSERT INTO log (msg) VALUES ('Schritt 1')");
/* Achtung: dieser Befehl committed implizit! */
$pdo->exec("CREATE TEMPORARY TABLE t_tmp (id INT)");
$pdo->exec("INSERT INTO log (msg) VALUES ('Schritt 2')");
$pdo->rollBack();
/* Schritt 1 ist trotzdem in der Datenbank */
Praktisch heißt das: Schema-Änderungen niemals mitten in einer Datenbearbeitungs-Transaktion ausführen. Migrations-Skripte sollten ihre eigene Verbindung benutzen, damit eine spätere PHP PDO Transaction nicht von einem versteckten Commit überrascht wird.
Savepoints und teilweise Rollbacks
Manchmal soll nur ein Teil der Transaktion zurückgerollt werden, ohne die ganze Operation zu verlieren. Genau dafür gibt es Savepoints. PDO bildet sie nicht über eine eigene Methode ab, sondern über exec()-Aufrufe mit den passenden SQL-Befehlen.
<?php
$pdo->beginTransaction();
$pdo->exec("INSERT INTO log (msg) VALUES ('Vorbereitung ok')");
$pdo->exec("SAVEPOINT pruefung");
try {
$pdo->exec("UPDATE artikel SET preis = preis * 1.05 WHERE kategorie = 'X'");
/* heikler Schritt, der auch fehlschlagen darf */
} catch (Throwable $e) {
$pdo->exec("ROLLBACK TO SAVEPOINT pruefung");
/* alle Aenderungen seit dem Savepoint verworfen */
}
$pdo->commit();
Savepoints funktionieren in MySQL nur mit der InnoDB-Engine. MyISAM unterstützt sie nicht und ignoriert die SQL-Befehle stillschweigend. Das ist ein typischer Grund, warum ein Teil-Rollback in alten Projekten nicht greift.
Wrapper-Pattern für eine saubere Service-Schicht
In größeren Projekten lohnt es sich, das Begin-Commit-Rollback-Schema in eine zentrale Hilfsfunktion zu kapseln. Damit wird jede PHP PDO Transaction einheitlich gehandhabt und das Risiko für vergessene Rollbacks sinkt deutlich.
<?php
function transactional(PDO $pdo, callable $fn) {
$pdo->beginTransaction();
try {
$result = $fn($pdo);
$pdo->commit();
return $result;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
/* Aufruf */
transactional($pdo, function (PDO $pdo) {
$pdo->exec("UPDATE konto SET saldo = saldo - 100 WHERE id = 1");
$pdo->exec("UPDATE konto SET saldo = saldo + 100 WHERE id = 2");
});
Dieses Pattern hat mehrere Vorteile: der Code im Closure bleibt frei von Transaktionslogik, der Fehlerpfad ist immer gleich und Code-Reviews lassen sich auf die fachliche Logik konzentrieren. Wer eine Login-Funktion mit password_hash und password_verify baut, kann damit Aktualisierung des Last-Login-Datums und Passwort-Rehash-Erkennung in einem Block bündeln.
flowchart TD
A[beginTransaction] --> B[Operation 1]
B --> C[Operation 2]
C --> D{Fehler aufgetreten?}
D -->|Nein| E[commit]
D -->|Ja| F[rollBack]
ACID und Isolation Levels
Hinter jeder PHP PDO Transaction steht das ACID-Prinzip aus der Datenbanktheorie: Atomicity (alles oder nichts), Consistency (gueltige Zustände vor und nach der Transaktion), Isolation (parallele Transaktionen stören sich nicht) und Durability (committete Änderungen überleben einen Server-Absturz). Die Isolation lässt sich über vier Standard-Levels feinjustieren, die in der ANSI-SQL-Norm festgelegt sind.
| Level | Verhindert |
| READ UNCOMMITTED | Nichts; auch unbestätigte Daten sind sichtbar (Dirty Read) |
| READ COMMITTED | Dirty Read |
| REPEATABLE READ | Dirty Read, Non-Repeatable Read (MySQL/InnoDB Default) |
| SERIALIZABLE | Alle Anomalien inkl. Phantom Read |
Das Setzen des Isolation Levels passiert per SQL-Befehl direkt vor beginTransaction(). MySQL/InnoDB nutzt standardmäßig REPEATABLE READ, PostgreSQL READ COMMITTED.
<?php
$pdo->exec('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE');
$pdo->beginTransaction();
/* hier laufen Lesungen und Schreibungen mit maximaler Isolation */
$pdo->commit();
Höhere Isolation kostet Performance, weil die Datenbank mehr sperrt oder mehr Transaktionen zurückweist. Für Buchhaltung und Inventur lohnt sich SERIALIZABLE; für reine Lese-Reports reicht oft READ COMMITTED. Wer das Auto-Commit-Verhalten unabhängig von beginTransaction() umstellen will, kann zusätzlich PDO::ATTR_AUTOCOMMIT als Connection-Attribut setzen.
Deadlock-Retry-Pattern
Bei parallelen Transaktionen kann es zu Deadlocks kommen: zwei Transaktionen warten gegenseitig auf Locks, die der jeweils andere hält. MySQL/InnoDB erkennt das und bricht die teurere Transaktion mit dem SQLSTATE 40001 ab. Statt den Fehler bis zum Nutzer durchzureichen, wird in der Praxis ein paar Mal automatisch wiederholt.
<?php
function transactionalRetry(PDO $pdo, callable $fn, int $maxVersuche = 3) {
for ($versuch = 1; $versuch <= $maxVersuche; $versuch++) {
try {
$pdo->beginTransaction();
$result = $fn($pdo);
$pdo->commit();
return $result;
} catch (PDOException $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
/* SQLSTATE 40001 = serialization failure / deadlock */
if ($e->getCode() !== '40001' || $versuch === $maxVersuche) {
throw $e;
}
usleep(50000 * $versuch); /* kurzer Backoff */
}
}
}
Wichtig ist der Backoff zwischen den Versuchen, damit nicht beide Transaktionen direkt wieder ineinander laufen. Drei Versuche reichen für üblichen Datenverkehr; bei extrem hoher Konkurrenz lohnt es sich, die Reihenfolge der UPDATEs in den Service-Methoden zu vereinheitlichen, damit Deadlocks erst gar nicht entstehen.
Datenbank-spezifische Eigenheiten
Die bisherigen Beispiele setzen MySQL/InnoDB voraus. Für andere Engines und Datenbanken gibt es einige relevante Unterschiede, die beim Portieren leicht übersehen werden.
- MyISAM unterstützt überhaupt keine Transaktionen.
beginTransaction() und commit() werden ohne Fehler akzeptiert, wirken aber nicht. Ein rollBack() läuft ins Leere und die schon ausgeführten Inserts bleiben in der Tabelle stehen. Der Wechsel auf InnoDB ist Pflicht, sobald Konsistenz eine Rolle spielt. - PostgreSQL erlaubt im Gegensatz zu MySQL auch DDL-Statements wie
CREATE TABLE innerhalb einer Transaktion und rollt sie bei Bedarf zurück. Migrations-Skripte können dort daher wesentlich robuster gebaut werden. - SQLite kennt nur einen einzigen Schreiber pro Datenbank. Concurrent Writes führen schnell zu
SQLITE_BUSY-Fehlern, ein Retry-Pattern mit Backoff ist hier praktisch obligatorisch.
Wer ein Projekt zwischen Datenbanken portiert, sollte die Test-Suite für Transaktionsverhalten nicht der Default-Engine überlassen. Eine bewusste Wahl des Isolation Levels und ein knapper Hinweis im README, welche Engine vorausgesetzt wird, ersparen später Stunden an Debugging.
Fazit
Eine sauber geschriebene PHP PDO Transaction ist der einfachste Weg, mehrere Datenbankoperationen konsistent zu halten. Wer beginTransaction(), commit() und rollBack() immer im Try-Catch-Block kombiniert, vermeidet halb gespeicherte Bestellungen und blockierte Konten. Wichtig sind drei Punkte: Exception-Modus aktivieren, DDL-Statements aus Transaktionen heraushalten und in verschachtelten Aufrufen mit inTransaction() arbeiten. Mit einem kleinen Wrapper-Pattern wird die PHP PDO Transaction zur stillen Selbstverständlichkeit der Service-Schicht und schützt im Alltag vor genau den Datenfehlern, die später am teuersten zu beheben sind.