Legacy-Datenbankzugriffe auf PDO migrieren
AI generated
<?php
8.4
PHP · Legacy · PDO · Datenbank-Migration
Legacy-Datenbankzugriffe auf PDO migrieren
Von mysql_ und prozeduralem mysqli zu Prepared Statements

Verstreute mysql_-Aufrufe und String-verkettete SQL-Abfragen sind eines der häufigsten und gefährlichsten Erbstücke alter PHP-Projekte. Dieser Artikel zeigt, wie man solche Legacy-Datenbankzugriffe systematisch durch eine einheitliche PDO-Schicht mit Prepared Statements ersetzt, ohne den Betrieb zu unterbrechen.

19 Min. Lesezeit PDO · Prepared Statements · Adapter · SQL-Injection PHP 8.x · Legacy-Datenbankcode

1. Warum Legacy-Datenbankzugriffe so gefährlich sind

Legacy-Datenbankzugriffe über mysql_*-Funktionen oder prozedurales mysqli mit direkt in SQL-Strings eingebetteten Variablen gehören zu den häufigsten und gefährlichsten Mustern in alten PHP-Projekten. Das Problem ist nicht nur, dass mysql_* seit PHP 7.0 vollständig entfernt ist, sondern dass String-verkettete Abfragen ohne Prepared Statements praktisch immer eine SQL-Injection-Lücke bedeuten, sobald Benutzereingaben ungefiltert in die Abfrage gelangen.

Der typische Fund in einem Legacy-Audit sieht so aus: eine Funktion, die eine Benutzer-ID per $_GET['id'] entgegennimmt und direkt in einen SQL-String einfügt, ohne jegliche Escaping- oder Bindungslogik. Diese Muster sind über Jahre gewachsen, oft von mehreren Entwicklergenerationen kopiert, und finden sich typischerweise in Dutzenden bis Hunderten Stellen im Code verteilt, was eine punktuelle Behebung unpraktikabel macht.

Die Migration auf PDO löst zwei Probleme gleichzeitig: Sie stellt die technische Voraussetzung für einen PHP-Versionssprung her, weil mysql_* nicht mehr existiert, und sie beseitigt strukturell das SQL-Injection-Risiko, sofern konsequent Prepared Statements statt String-Verkettung verwendet werden. Beide Ziele erfordern denselben Migrationsweg, weshalb sich dieser Artikel auf eine gemeinsame Strategie konzentriert.

2. Bestandsaufnahme: alle Zugriffsmuster im Code finden

Bevor eine einzige Zeile Code geändert wird, braucht die Migration eine vollständige Bestandsaufnahme aller Datenbankzugriffsmuster im Projekt. Ein einfaches Grep nach mysql_query, mysql_connect und mysqli_query liefert einen ersten Überblick, deckt aber nicht die noch gefährlicheren Fälle ab, in denen Variablen direkt in SQL-Strings interpoliert werden, selbst wenn bereits mysqli im objektorientierten Stil verwendet wird.

Das folgende Skript sucht systematisch nach SQL-Strings mit direkter Variableninterpolation, einem der zuverlässigsten Indikatoren für eine SQL-Injection-Lücke, unabhängig davon, welche Datenbank-API im Einsatz ist.


#!/usr/bin/env bash
# find-sql-injection-risks.sh — locate string-interpolated SQL queries
set -euo pipefail

# Matches SELECT/INSERT/UPDATE/DELETE strings containing a $variable
# directly inside the quotes — a strong signal of unescaped user input
grep -rnE '"(SELECT|INSERT|UPDATE|DELETE)[^"]*\$[a-zA-Z_]' \
  --include='*.php' \
  app/code/ \
  | tee /tmp/sql-injection-candidates.txt

echo "Found $(wc -l < /tmp/sql-injection-candidates.txt) candidate locations"

Diese Liste ist der Ausgangspunkt für die gesamte Migration. Jeder Treffer wird nach Risiko bewertet, etwa danach, ob die interpolierte Variable aus Benutzereingaben stammt oder aus einer intern kontrollierten Quelle, und in eine priorisierte Reihenfolge gebracht, die im achten Abschnitt dieses Artikels vertieft wird.

3. Eine Adapterschicht als sicheren Übergang bauen

Eine Migration, die alle Zugriffsstellen gleichzeitig umbaut, ist bei mehreren hundert Fundstellen nicht praktikabel. Der bewährte Ansatz ist eine dünne Adapterklasse, die die neue PDO-Verbindung kapselt und eine API bereitstellt, die der alten mysql_*-Nutzung ähnelt genug ist, um bestehenden Code mit minimalen Änderungen zu migrieren, aber intern bereits vollständig auf Prepared Statements setzt.


<?php

declare(strict_types=1);

// Adapter layer: familiar call shape, safe PDO implementation underneath
final class Database
{
    private \PDO $connection;

    public function __construct(string $dsn, string $user, string $password)
    {
        $this->connection = new \PDO($dsn, $user, $password, [
            \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
            \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
            \PDO::ATTR_EMULATE_PREPARES => false,
        ]);
    }

    /**
     * @param array<string, mixed> $params
     * @return array<int, array<string, mixed>>
     */
    public function fetchAll(string $sql, array $params = []): array
    {
        $stmt = $this->connection->prepare($sql);
        $stmt->execute($params);
        return $stmt->fetchAll();
    }

    /** @param array<string, mixed> $params */
    public function fetchOne(string $sql, array $params = []): ?array
    {
        $stmt = $this->connection->prepare($sql);
        $stmt->execute($params);
        $row = $stmt->fetch();
        return $row === false ? null : $row;
    }

    /** @param array<string, mixed> $params */
    public function execute(string $sql, array $params = []): int
    {
        $stmt = $this->connection->prepare($sql);
        $stmt->execute($params);
        return $stmt->rowCount();
    }
}

// Legacy call site — old: mysql_query("SELECT * FROM users WHERE id = $id")
// New: named placeholders replace direct string interpolation entirely
$db = new Database('mysql:host=localhost;dbname=shop;charset=utf8mb4', 'app_user', getenv('DB_PASSWORD') ?: '');
$user = $db->fetchOne('SELECT * FROM users WHERE id = :id', ['id' => $id]);

Der entscheidende Vorteil dieser Adapterschicht: Jede Methode erzwingt strukturell die Nutzung von Platzhaltern statt String-Verkettung, weil $sql und $params getrennte Parameter sind. Ein Entwickler kann in dieser API kaum noch versehentlich eine unsichere Abfrage schreiben, ohne die Methode bewusst zu umgehen.

4. Prepared Statements statt String-Verkettung

Der Kern der gesamten Migration ist der Übergang von String-verketteten SQL-Abfragen zu Prepared Statements mit gebundenen Parametern. Der Unterschied ist nicht nur syntaktisch, sondern fundamental: Bei einer Prepared Statement wird die SQL-Struktur getrennt vom Datenbanktreiber kompiliert, bevor Werte eingesetzt werden, sodass ein Wert niemals als Teil der SQL-Syntax interpretiert werden kann, unabhängig von seinem Inhalt.

Benannte Platzhalter wie :id statt positionsbasierter ?-Platzhalter erhöhen zusätzlich die Lesbarkeit bei Abfragen mit vielen Parametern, weil die Zuordnung zwischen Platzhalter und Wert im Code explizit sichtbar bleibt, statt sich aus der Reihenfolge der Werte im Array zu ergeben. Bei der Migration von Legacy-Code ist es sinnvoll, systematisch auf benannte Platzhalter zu setzen, auch wenn positionale Platzhalter funktional gleichwertig sind, weil sie bei späteren Änderungen an der Abfrage weniger fehleranfällig sind.

5. Fetch-Modi und Rückgabetypen vereinheitlichen

Ein oft übersehener Aspekt der Migration betrifft die Konsistenz der Rückgabetypen. mysql_fetch_array() lieferte historisch sowohl numerisch indizierte als auch assoziative Schlüssel im selben Array, was in Legacy-Code zu einer wilden Mischung aus $row[0] und $row['name'] im selben Projekt führte. PDO erlaubt, den Fetch-Modus explizit und projektweit einheitlich festzulegen, etwa ausschließlich PDO::FETCH_ASSOC, was Code vorhersehbarer und leichter überprüfbar macht.

Bei der Migration empfiehlt es sich, diesen einheitlichen Fetch-Modus bereits als Standardeinstellung in der Verbindungskonfiguration zu setzen, wie im Adapterbeispiel aus Abschnitt drei gezeigt, statt ihn bei jedem einzelnen Aufruf explizit anzugeben. Das reduziert die Menge an Code, die während der Migration angepasst werden muss, und verhindert, dass neue, noch nicht migrierte Aufrufer versehentlich einen inkonsistenten Fetch-Modus verwenden.

6. Transaktionen korrekt nachrüsten

Viele Legacy-Systeme mit mysql_*-Zugriffen verzichten komplett auf Transaktionen, weil die alte API sie zwar technisch unterstützte, aber selten konsequent genutzt wurde. Bei der Migration auf PDO ist es sinnvoll, gleichzeitig zu prüfen, welche mehrstufigen Datenbankoperationen tatsächlich atomar ablaufen müssen, etwa eine Bestellung, die sowohl eine Zeile in der Bestelltabelle als auch mehrere Zeilen in der Positionstabelle anlegt.

PDO bietet mit beginTransaction(), commit() und rollBack() eine einfache, konsistente Transaktions-API über alle unterstützten Datenbanktreiber hinweg. Ein bewährtes Muster ist, kritische mehrstufige Operationen in eine Methode zu kapseln, die im Fehlerfall automatisch zurückrollt, statt die Transaktionslogik über den aufrufenden Code zu verteilen, wo sie leicht vergessen werden kann.


<?php

declare(strict_types=1);

// Multi-step operation wrapped in a transaction with automatic rollback
final class OrderCreator
{
    public function __construct(private readonly \PDO $connection)
    {
    }

    /** @param array<int, array{price: float, quantity: int}> $lines */
    public function create(int $customerId, array $lines): int
    {
        $this->connection->beginTransaction();

        try {
            $stmt = $this->connection->prepare(
                'INSERT INTO orders (customer_id, status) VALUES (:customer_id, :status)'
            );
            $stmt->execute(['customer_id' => $customerId, 'status' => 'new']);
            $orderId = (int) $this->connection->lastInsertId();

            $lineStmt = $this->connection->prepare(
                'INSERT INTO order_lines (order_id, price, quantity) VALUES (:order_id, :price, :quantity)'
            );
            foreach ($lines as $line) {
                $lineStmt->execute([
                    'order_id' => $orderId,
                    'price' => $line['price'],
                    'quantity' => $line['quantity'],
                ]);
            }

            $this->connection->commit();
            return $orderId;
        } catch (\PDOException $e) {
            $this->connection->rollBack();
            throw $e;
        }
    }
}

7. Fehlerbehandlung: von Rückgabewerten zu Exceptions

mysql_query() gab bei einem Fehler false zurück, das Skript musste anschließend jeden Rückgabewert explizit prüfen, um Fehler zu erkennen, ein Muster, das in der Praxis regelmäßig vergessen wurde. PDO löst dieses Problem strukturell, wenn PDO::ATTR_ERRMODE auf PDO::ERRMODE_EXCEPTION gesetzt wird, wie im Adapterbeispiel gezeigt: Ein Datenbankfehler wirft dann automatisch eine PDOException, statt einen leicht übersehbaren Rückgabewert zu liefern.

Diese Umstellung erfordert, bestehenden Code zu überprüfen, der bisher explizit auf false-Rückgabewerte geprüft hat, weil solche Prüfungen nach der Migration nie mehr greifen, der Fehler aber trotzdem geworfen wird, wenn er nicht abgefangen wird. Ein zentraler try-catch-Block an einer sinnvollen Stelle der Anwendung, etwa im Controller oder Command-Handler, fängt unerwartete PDOException-Instanzen ab und verhindert, dass Datenbankfehler unkontrolliert Details wie Tabellennamen oder Verbindungsdaten an den Benutzer durchreichen.


<?php

declare(strict_types=1);

// Central boundary: catches PDOException, never leaks internals to users
final class ErrorHandlingController
{
    public function __construct(private readonly OrderCreator $orderCreator)
    {
    }

    public function handle(int $customerId, array $lines): array
    {
        try {
            $orderId = $this->orderCreator->create($customerId, $lines);
            return ['success' => true, 'order_id' => $orderId];
        } catch (\PDOException $e) {
            // Log the real error internally, return a generic message
            error_log('Order creation failed: ' . $e->getMessage());
            return ['success' => false, 'error' => 'Order could not be created'];
        }
    }
}

8. Priorisierung: welche Abfragen zuerst migrieren

Bei Hunderten zu migrierenden Fundstellen aus der Bestandsaufnahme in Abschnitt zwei braucht die Migration eine Reihenfolge. Der wichtigste Faktor ist nicht die Häufigkeit der Nutzung, sondern die Herkunft der interpolierten Variable: Abfragen, die direkt Benutzereingaben aus $_GET, $_POST oder $_COOKIE verarbeiten, stellen ein akutes Sicherheitsrisiko dar und sollten vor allen anderen migriert werden, unabhängig von ihrer sonstigen Komplexität.

Abfragen mit intern kontrollierten Werten, etwa aus Konfigurationsdateien oder festen Konstanten, haben ein geringeres unmittelbares Risiko und können in der zweiten Priorisierungswelle folgen. Eine dritte Kategorie, Abfragen ohne jegliche Variableninterpolation, benötigt aus Sicherheitssicht keine dringende Migration, sollte aber aus Kompatibilitätsgründen dennoch vor dem nächsten PHP-Versionssprung umgestellt werden, weil mysql_* schlicht nicht mehr existiert.


#!/usr/bin/env bash
# categorize-sql-findings.sh — tag each candidate by variable origin
set -euo pipefail

while IFS=: read -r file line content; do
  if echo "$content" | grep -qE '\$_(GET|POST|COOKIE|REQUEST)'; then
    echo "CRITICAL  $file:$line"
  elif echo "$content" | grep -qE '\$_(SESSION|SERVER)'; then
    echo "MEDIUM    $file:$line"
  else
    echo "LOW       $file:$line"
  fi
done < /tmp/sql-injection-candidates.txt | sort

9. mysql_/mysqli-prozedural vs. PDO im Vergleich

Die folgende Tabelle vergleicht die wichtigsten Unterschiede zwischen den alten Datenbankzugriffsmustern und der PDO-Zielarchitektur.

Kriterium mysql_ / mysqli prozedural PDO mit Prepared Statements
SQL-Injection-Schutz Manuelles Escaping, leicht vergessen Strukturell durch getrennte Parameterbindung
Datenbankunterstützung Nur MySQL, mysql_ seit PHP 7 entfernt MySQL, PostgreSQL, SQLite über ein Interface
Fehlerbehandlung Rückgabewert false, leicht übersehen Exceptions über ERRMODE_EXCEPTION
Transaktionen Vorhanden, aber selten konsequent genutzt Einheitliche API, leicht in Methoden kapselbar

Die Tabelle macht deutlich, dass der Umstieg auf PDO nicht nur eine formale Notwendigkeit für neuere PHP-Versionen ist, sondern strukturelle Sicherheits- und Wartbarkeitsvorteile mit sich bringt, die weit über die reine Kompatibilität hinausgehen.

Mironsoft

PHP-Legacy-Modernisierung und Magento-Entwicklung

Noch mysql_ oder ungesicherte SQL-Strings im Einsatz?

Wir identifizieren alle SQL-Injection-Risiken in eurer Codebasis, bauen eine sichere PDO-Adapterschicht und migrieren bestehende Abfragen priorisiert nach tatsächlichem Risiko.

Sicherheits-Audit

Alle string-interpolierten SQL-Abfragen im Projekt systematisch finden

PDO-Adapter

Adapterschicht mit Prepared Statements und einheitlichen Fetch-Modi

Priorisierte Migration

Kritische, benutzereingabegetriebene Abfragen zuerst absichern

10. Zusammenfassung

Legacy-Datenbankzugriffe über mysql_* oder prozedurales mysqli mit String-verketteten SQL-Abfragen gehören zu den dringendsten Modernisierungsaufgaben in alten PHP-Projekten, weil sie sowohl ein technisches Kompatibilitätsproblem als auch ein akutes Sicherheitsrisiko darstellen. Eine dünne PDO-Adapterschicht, die Prepared Statements strukturell erzwingt, ermöglicht eine schrittweise Migration ohne Big-Bang-Umbau, indem bestehende Aufrufer nach und nach auf die neue, sichere API umgestellt werden.

Die Priorisierung sollte sich am tatsächlichen Risiko orientieren, nicht an der Nutzungshäufigkeit: Abfragen mit direkten Benutzereingaben zuerst, intern kontrollierte Werte danach. Einheitliche Fetch-Modi, konsequente Exception-basierte Fehlerbehandlung und sauber gekapselte Transaktionen runden die Migration ab und liefern am Ende eine Datenbankschicht, die sowohl auf aktuellen PHP-Versionen läuft als auch strukturell gegen SQL-Injection abgesichert ist.

Legacy-Datenbankzugriffe auf PDO migrieren — Das Wichtigste auf einen Blick

Bestandsaufnahme

Grep nach mysql_query und string-interpolierten SQL-Statements liefert die vollständige Fundliste.

Adapterschicht

Eine dünne PDO-Klasse erzwingt strukturell Prepared Statements und ermöglicht schrittweise Migration.

Priorisierung

Abfragen mit direkten Benutzereingaben aus $_GET oder $_POST zuerst migrieren.

Fehlerbehandlung

ERRMODE_EXCEPTION ersetzt leicht übersehbare false-Rückgabewerte durch echte Exceptions.

11. FAQ: Legacy-Datenbankzugriffe auf PDO migrieren

1Warum ist mysql_query() riskant?
Kein eingebautes Parameterbinding, manuelles Escaping wurde regelmäßig vergessen und führte zu SQL-Injection.
2Alles gleichzeitig migrieren?
Nein, eine PDO-Adapterschicht erlaubt schrittweise, nach Risiko priorisierte Migration.
3Benannte vs. positionelle Platzhalter?
Benannte Platzhalter bleiben bei Abfrageänderungen robuster, positionelle sind gleichwertig aber fehleranfälliger.
4Wie finde ich alle Risiken?
Grep nach SQL-Schlüsselwörtern mit Variableninterpolation liefert eine verlässliche erste Fundliste.
5Warum ERRMODE_EXCEPTION setzen?
Verwandelt Datenbankfehler in Exceptions, die nicht ignoriert werden können, statt stiller Rückgabewerte.
6Verlangsamen Prepared Statements?
Vernachlässigbar, besonders ohne Emulation. Sicherheitsgewinn überwiegt deutlich.
7Gleichzeitig Transaktionen einführen?
Nicht zwingend sofort, aber lohnend bei tatsächlich atomaren mehrstufigen Operationen.
8Gemischte Fetch-Modi?
Einheitlichen Fetch-Modus als Standard in der Verbindungskonfiguration festlegen.
9Unterstützt PDO andere Datenbanken?
Ja, MySQL, PostgreSQL, SQLite und mehr über ein einheitliches Interface.
10Wie priorisiere ich bei vielen Fundstellen?
Nach Herkunft der Variable: Benutzereingaben zuerst, interne Werte danach, unkritische Abfragen zuletzt.