PHP · MySQL · HTML/CSS – Schritt für Schritt
Tutorial

Webshop bauen mit PHP, MySQL & HTML/CSS

Dieses Tutorial baut einen kompletten Webshop – ohne Framework, ohne Composer, ohne Libraries. Nur PHP (mit PDO), MySQL/MariaDB und pures HTML/CSS.

Ziel ist: nach Durcharbeit hast du einen funktionierenden Shop mit Registrierung, Login, Produktliste mit Kategorien, Warenkorb, Admin-Bereich mit Produktverwaltung und Statistiken. Fokus liegt auf Funktion und Sicherheit, nicht auf Aussehen.

Was am Ende steht

  • Registrierung & Login mit gehashten Passwörtern (bcrypt)
  • Rollen-System (user / admin)
  • Produktliste mit Kategoriefilter
  • Produktdetailseite
  • Session-basierter Warenkorb
  • Admin-Bereich: Produkte anlegen, freigeben/sperren, löschen
  • Rechnung/Bestellabschluss (druckbar)
  • Statistiken für Admins

Voraussetzungen

Ein LAMP-Stack (Linux/Apache/MySQL/PHP) oder XAMPP. In diesem Tutorial läuft alles unter einem Projektordner namens webshop_otto. Datenbank heißt genauso.

Dateistruktur am Ende
webshop_otto/
  db.php               Verbindung zur DB (überall included)
  register.php         Neue User anlegen
  login.php            Einloggen
  logout.php           Session beenden
  navbar.php           Navigation (überall included)
  index.php            Produktliste (mit Kategoriefilter)
  productdetail.php    Detailseite eines Produkts
  cart_add.php         Produkt in Warenkorb legen
  warenkorb.php        Warenkorb anzeigen
  cart_remove.php      Produkt aus Warenkorb entfernen
  statistiken.php      Admin: Stats + Produktverwaltung
  checkout.php         Bestellung abschließen + Rechnung
  styles.css           Design
Baureihenfolge = Kapitelreihenfolge. Erst DB, dann Verbindung, dann Auth, dann Shop-Frontend, dann Warenkorb, dann Admin. Nach jedem File einmal testen, bevor du weitergehst.
Kapitel 1

Datenbank-Schema

Fünf Tabellen: users, categories, products, orders, order_items. Skizze zuerst auf Papier – Beziehungen: eine Kategorie hat viele Produkte (1:n), ein User hat viele Bestellungen (1:n), eine Bestellung hat viele Positionen (1:n), jede Position gehört zu einem Produkt (n:1).

Schema anlegen (in phpMyAdmin oder MySQL-Konsole)
CREATE DATABASE webshop_otto CHARACTER SET utf8mb4;
USE webshop_otto;

CREATE TABLE users (
    id            INT AUTO_INCREMENT PRIMARY KEY,
    username      VARCHAR(50)  NOT NULL UNIQUE,
    email         VARCHAR(100) NOT NULL,
    password_hash VARCHAR(255) NOT NULL,
    rolle         VARCHAR(20)  NOT NULL DEFAULT 'user',
    created_at    DATETIME     DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;

CREATE TABLE categories (
    id   INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(50) NOT NULL
) ENGINE=InnoDB;

CREATE TABLE products (
    id          INT AUTO_INCREMENT PRIMARY KEY,
    artikelcode VARCHAR(20)   NOT NULL UNIQUE,
    name        VARCHAR(100)  NOT NULL,
    description TEXT,
    price       DECIMAL(10,2) NOT NULL,
    picture     VARCHAR(255),
    freigeben   TINYINT(1)    NOT NULL DEFAULT 0,
    category_id INT,
    FOREIGN KEY (category_id) REFERENCES categories(id)
) ENGINE=InnoDB;

CREATE TABLE orders (
    id          INT AUTO_INCREMENT PRIMARY KEY,
    user_id     INT NOT NULL,
    bestellt_am DATETIME DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id)
) ENGINE=InnoDB;

CREATE TABLE order_items (
    id         INT AUTO_INCREMENT PRIMARY KEY,
    order_id   INT NOT NULL,
    product_id INT NOT NULL,
    menge      INT NOT NULL,
    preis      DECIMAL(10,2) NOT NULL,
    FOREIGN KEY (order_id)   REFERENCES orders(id),
    FOREIGN KEY (product_id) REFERENCES products(id)
) ENGINE=InnoDB;
Warum so?
DECIMAL(10,2) für Preise, nie FLOAT (Rundungsfehler bei Geld).
preis in order_items gespeichert – weil sich der Produktpreis später ändern kann, die alte Rechnung aber nicht.
artikelcode UNIQUE – so kann die DB selbst schon doppelte Codes verhindern.
InnoDB – nur InnoDB unterstützt Foreign Keys und Transaktionen (MyISAM nicht).
Achtung Foreign Key Direction: Der FK gehört auf die "n"-Seite (die "viele" Seite). Also category_id steht in products, nicht umgekehrt. Häufiger Anfängerfehler.
Erster Admin-User + Testdaten
-- passwort_hash für 'test1234' — bei dir mit password_hash() eigenen erzeugen
INSERT INTO users (username, email, password_hash, rolle)
VALUES ('admin', 'admin@shop.local',
        '$2y$10$exampleReplaceThisWithRealHash', 'admin');

INSERT INTO categories (name) VALUES ('Elektronik'), ('Bücher'), ('Sonstiges');
Kapitel 2

db.php – Datenbankverbindung

Diese Datei stellt die Verbindung zur Datenbank her und wird von jeder anderen PHP-Datei ganz oben mit require_once('db.php') eingebunden. So gibt es nur eine einzige Stelle, an der die DB-Zugangsdaten stehen.

Der Code

db.php
<?php
$servername = "localhost"; 
$username = "root"; // am besten hier einen eigenen benutzer anlegen 
$password = "passwort"; 
$dbname = "webshop_otto";

try {
    $conn = new PDO("mysql:host=$servername;dbname=$dbname;charset=utf8mb4", $username, $password);
    $conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
  
} catch(PDOException $e) {
    die("Connection failed: " . $e->getMessage());
}
?>

Was passiert hier zeilenweise

  • $servername, $username, $password, $dbname – die Zugangsdaten in Variablen. Für die zertifizierung nicht root nehmen, sondern einen eigenen DB-User mit weniger Rechten anlegen (Least-Privilege-Prinzip).
  • new PDO(...) – baut die Verbindung auf. Der DSN-String enthält Host, DB-Name und wichtig: charset=utf8mb4 (sonst Probleme mit Umlauten und Emojis).
  • setAttribute ERRMODE_EXCEPTION – ohne das würden PDO-Fehler still verschluckt. So werfen sie Exceptions und man sieht sofort, was kaputt ist.
  • try/catch – wenn die Verbindung fehlschlägt (falsches Passwort, DB down), fängt catch den Fehler ab und beendet das Script mit einer Meldung.
Für die echte Produktion: das $e->getMessage() im die() zeigt technische Details öffentlich (DB-Name, User) – Sicherheitsproblem. Besser: error_log($e->getMessage()) und dem User nur "Serverfehler" zeigen. Für zertifizierung/Übung ist die Version oben okay, weil man den Fehler ja beim Entwickeln sehen will.
Merke: das Objekt heißt hier $conn. Manche Tutorials nennen es $pdo oder $db – muss überall im Projekt gleich sein.
Kapitel 3

register.php – Neuen User anlegen

Ablauf: Formular anzeigen → beim Absenden Eingaben validieren → prüfen ob Username schon existiert → Passwort hashen → in DB einfügen → direkt einloggen und weiterleiten.

Der Code

register.php
<?php
require_once('db.php');
session_start();

$fehler = '';

// nur wenn formular abgeschickt wurde
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $username = trim($_POST['username'] ?? '');
    $email = trim($_POST['email'] ?? '');
    $password = $_POST['password'] ?? '';
    $password_repeat = $_POST['password_repeat'] ?? '';

    // validierung
    if ($username === '' || $email === '' || $password === '') {
        $fehler = 'Bitte alle Felder ausfüllen.';
    } elseif ($password !== $password_repeat) {
        $fehler = 'Passwörter stimmen nicht überein.';
    } elseif (strlen($password) < 6) {
        $fehler = 'Passwort muss mindestens 6 Zeichen haben.';
    } else {
        // check ob username schon existiert
        $stmt = $conn->prepare("SELECT id FROM users WHERE username = ?");
        $stmt->execute([$username]);
        if ($stmt->fetch()) {
            $fehler = 'Benutzername ist bereits vergeben.';
        } else {
            // passwort hashen und user einfügen
            $hash = password_hash($password, PASSWORD_DEFAULT);
            $stmt = $conn->prepare("INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?)");
            $stmt->execute([$username, $email, $hash]);

            // direkt einloggen und weiterleiten
            $_SESSION['user_id'] = $conn->lastInsertId();
            $_SESSION['username'] = $username;
            header('Location: index.php');
            exit;
        }
    }
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Registrieren</title>
    <link rel="stylesheet" href="styles.css">
</head>
<body>
<?php include('navbar.php'); ?>

<h1>Registrieren</h1>

<?php if ($fehler !== ''): ?>
    <p style="color: red;"><?= htmlspecialchars($fehler, ENT_QUOTES, 'UTF-8') ?></p>
<?php endif; ?>

<form action="register.php" method="POST">
    <label>Benutzername:<br>
        <input type="text" name="username" required>
    </label><br><br>

    <label>E-Mail:<br>
        <input type="email" name="email" required>
    </label><br><br>

    <label>Passwort:<br>
        <input type="password" name="password" required>
    </label><br><br>

    <label>Passwort wiederholen:<br>
        <input type="password" name="password_repeat" required>
    </label><br><br>

    <button type="submit">Konto erstellen</button>
</form>
</body>
</html>

Was passiert Schritt für Schritt

  1. require_once('db.php') – DB-Verbindung laden. $conn ist danach verfügbar.
  2. session_start() – Session starten, damit wir nach erfolgreichem Register den User direkt einloggen können.
  3. REQUEST_METHOD === 'POST' – Formular wurde abgeschickt. Beim ersten Aufruf (GET) wird nur das leere Formular gezeigt.
  4. trim() + Null-Coalescing (??) – Whitespace weg, und wenn das Feld gar nicht gesendet wurde, kommt leerer String statt Notice.
  5. Validierung mit if/elseif – bricht bei erstem Fehler ab und setzt $fehler.
  6. Prepared Statement für Username-Check – niemals WHERE username = '$username' schreiben, das wäre SQL Injection.
  7. password_hash(..., PASSWORD_DEFAULT) – erzeugt bcrypt-Hash. Aktuell ist DEFAULT = bcrypt, kann in Zukunft wechseln, deshalb Spalte VARCHAR(255).
  8. lastInsertId() – die ID des gerade eingefügten Users, wird in Session gespeichert damit sofort eingeloggt.
  9. header('Location: ...') + exit – Redirect. exit nicht vergessen, sonst läuft das Script weiter.
Prüfer-Frage: "Warum nicht md5 oder sha1 für Passwörter?" → Weil die zu schnell zu berechnen sind (GPU knackt Milliarden pro Sekunde) und keinen Salt haben. password_hash() nutzt bcrypt mit automatischem Salt und Cost-Faktor.
htmlspecialchars() bei jeder Ausgabe – auch wenn hier "nur" der Fehler-Text ausgegeben wird, ist die Angewohnheit wichtig. Jede Variable, die ins HTML fließt, wird escaped. Schutz gegen XSS.
Kapitel 4

login.php – Einloggen

Sehr ähnlich zu register: Formular → beim Absenden User aus DB holen → Passwort mit password_verify() prüfen → bei Erfolg Session setzen und weiterleiten.

Der Code

login.php
<?php
require_once('db.php');
session_start();

$fehler = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $username = trim($_POST['username'] ?? '');
    $password = $_POST['password'] ?? '';

    if ($username === '' || $password === '') {
        $fehler = 'Bitte alle Felder ausfüllen.';
    } else {
        // user aus db holen
        $stmt = $conn->prepare("SELECT id, username, email, password_hash, rolle FROM users WHERE username = ?");
        $stmt->execute([$username]);
        $user = $stmt->fetch();

        // passwort prüfen
        if ($user && password_verify($password, $user['password_hash'])) {
            $_SESSION['user_id'] = $user['id'];
            $_SESSION['username'] = $user['username'];
            $_SESSION['email'] = $user['email'];
            $_SESSION['rolle'] = $user['rolle'];
            header('Location: index.php');
            exit;
        } else {
            $fehler = 'Benutzername oder Passwort falsch!';
        }
    }
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
    <meta charset="UTF-8">
    <title>Login</title>
    <link rel="stylesheet" href="styles.css">
</head>
<body>
<?php include('navbar.php'); ?>

<h1>Login</h1>

<?php if ($fehler !== ''): ?>
    <p style="color: red;"><?= htmlspecialchars($fehler, ENT_QUOTES, 'UTF-8') ?></p>
<?php endif; ?>

<form action="login.php" method="POST">
    <label>Benutzername:<br>
        <input type="text" name="username" required>
    </label><br><br>

    <label>Passwort:<br>
        <input type="password" name="password" required>
    </label><br><br>

    <button type="submit">Einloggen</button>
</form>
</body>
</html>

Was passiert Schritt für Schritt

  1. User-Datensatz per Prepared Statement holen (Prevention SQL Injection).
  2. password_verify(input, hash) – vergleicht das eingetippte Passwort mit dem Hash aus der DB. Das Passwort wird nicht entschlüsselt (Hashes sind Einweg), sondern bcrypt hasht die Eingabe mit dem gleichen Salt und vergleicht.
  3. Bei Erfolg: alle relevanten Userdaten in die Session (id, username, email, rolle). Die rolle braucht die navbar für den Admin-Menüpunkt.
  4. Bei Fehler: bewusst unspezifische Meldung "Benutzername ODER Passwort falsch". Man verrät nicht, ob der Username existiert (sonst User Enumeration).
Prüfer-Frage: "Warum kein SELECT * FROM users?" → Nur die Spalten holen, die man wirklich braucht. Weniger Datentransfer, weniger versehentlicher Leak sensibler Daten.
Kapitel 5

logout.php – Session beenden

Sehr kurz: alle Session-Variablen löschen, Session zerstören, zurück zur Startseite.

logout.php
<?php
session_start();
session_unset();      // alle session-leer machen
session_destroy();    // session zerstören
header('Location: index.php');
exit;
?>

Warum beides – unset UND destroy?

  • session_unset() – leert das $_SESSION-Array im aktuellen Script.
  • session_destroy() – löscht die Session-Datei auf dem Server.

Streng genommen reicht session_destroy(), aber beides zusammen ist idiotensicher – nach dem Script ist nichts mehr da, weder im Speicher noch auf der Platte.

Session-Cookie mit löschen (für die 110%-Version): zusätzlich setcookie(session_name(), '', time() - 3600, '/') vor dem destroy. Für die zertifizierung-Übung reicht die einfache Variante oben.
Kapitel 7

index.php – Produktliste

Die Startseite. Zeigt entweder alle freigegebenen Produkte oder – wenn ein cat_id-Parameter in der URL steht – nur die einer Kategorie.

Der Code

index.php
<?php 
// DB muss immmer als erstes geladen werden 
require_once('db.php'); 

// jz kann ich conn verwenden für sql kein muss da ich es nur als array key benutze und nicht sql query 
$cat_id = (int)($_GET['cat_id'] ?? 0);

if ($cat_id > 0) {
    // gefiltert nach kategorie
    $stmt = $conn->prepare("SELECT id, name, price, description, picture 
                            FROM products 
                            WHERE freigeben = 1 AND category_id = ?");
    $stmt->execute([$cat_id]);
} else {
    // alle produkte
    $stmt = $conn->query("SELECT id, name, price, description, picture 
                          FROM products 
                          WHERE freigeben = 1");
}
$products = $stmt->fetchAll(PDO::FETCH_ASSOC);
?>
<!DOCTYPE html>
<html lang="de">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Webshop</title>
    <link rel="stylesheet" href="styles.css">
</head>
<body>
 <?php include('navbar.php'); ?>

    <h1>Produkte:</h1>

        <?php foreach ($products as $product): ?>
            <div>
                <img src="<?= htmlspecialchars($product['picture'], ENT_QUOTES, 'UTF-8') ?>" alt="<?= htmlspecialchars($product['name'], ENT_QUOTES, 'UTF-8') ?>">
                <br>
                <?= htmlspecialchars($product['name'], ENT_QUOTES, 'UTF-8') ?>
                (<?= htmlspecialchars($product['price'], ENT_QUOTES, 'UTF-8') ?> €)
                <br>
                 <?= htmlspecialchars($product['description'], ENT_QUOTES, 'UTF-8') ?>
                 <a href="productdetail.php?id=<?= htmlspecialchars($product['id'], ENT_QUOTES, 'UTF-8') ?>">Details</a>
                 <br><br><br>
            </div>
        <?php endforeach; ?>
</body>
</html>

Der wichtigste Unterschied: prepare() vs. query()

Dieser Code zeigt beide Varianten in einem File – nicht zufällig, sondern weil es der zentrale PDO-Lernpunkt ist:

  • prepare() + execute() – wenn Userinput in die Query fließt (hier $cat_id aus der URL). Schützt vor SQL Injection.
  • query() – wenn die Query keine Variablen enthält. Kürzer, ein Aufruf statt zwei. Beim ungefilterten Pfad ist genau das der Fall.
Prüfer-Frage: "Warum reicht nicht einfach (int) um SQL Injection zu verhindern?" → Für Integer schon, aber Prepared Statements sind die generelle Lösung, die auch für Strings, Datumsangaben etc. sicher ist. Immer den gleichen sicheren Weg gehen, egal welcher Datentyp.

Weitere Details

  • Null-Coalescing bei $_GET$_GET['cat_id'] ?? 0 gibt 0 wenn Parameter fehlt (Startseite ohne Filter).
  • WHERE freigeben = 1 – gesperrte Produkte kommen nie in die Liste. Bug-Vermeidung: nicht "vergessen" wenn du eine zweite Produktliste baust.
  • htmlspecialchars() bei JEDER Ausgabe – auch bei Preis und Bildpfad. Nicht raten was "sicher" ist – einheitlich escapen.
Kleiner Verbesserungspunkt: beim Preis würde number_format($product['price'], 2, ',', '.') Zahl schöner formatieren (7,90 statt 7.9). Für die Übung nicht nötig, aber prüferfreundlich.
Kapitel 8

productdetail.php – Detailseite

Wird per ?id=xxx aufgerufen. Zeigt Details eines Produkts und hat das Formular "In den Warenkorb". Wenn die ID nicht existiert oder das Produkt gesperrt ist: Redirect zur Startseite.

Der Code

productdetail.php
<?php
require_once('db.php');
session_start();

// id aus url holen und in int casten (sicherheit)
$id = (int)($_GET['id'] ?? 0);

// produkt aus db holen (prepared statement wegen user-input aus url!)
$stmt = $conn->prepare("SELECT id, name, price, description, picture, artikelcode 
                        FROM products 
                        WHERE id = ? AND freigeben = 1");
$stmt->execute([$id]);
$product = $stmt->fetch(PDO::FETCH_ASSOC);

// wenn kein produkt gefunden → zurück zur startseite
if (!$product) {
    header('Location: index.php');
    exit;
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
    <meta charset="UTF-8">
    <title><?= htmlspecialchars($product['name'], ENT_QUOTES, 'UTF-8') ?> - Webshop</title>
    <link rel="stylesheet" href="styles.css">
</head>
<body>
<?php include('navbar.php'); ?>
<div>
<h1><?= htmlspecialchars($product['name'], ENT_QUOTES, 'UTF-8') ?></h1>

<img src="<?= htmlspecialchars($product['picture'], ENT_QUOTES, 'UTF-8') ?>" 
     alt="<?= htmlspecialchars($product['name'], ENT_QUOTES, 'UTF-8') ?>" 
     style="max-width: 300px;">

<p><strong>Artikelnummer:</strong> <?= htmlspecialchars($product['artikelcode'], ENT_QUOTES, 'UTF-8') ?></p>
<p><strong>Preis:</strong> <?= htmlspecialchars($product['price'], ENT_QUOTES, 'UTF-8') ?> €</p>
<p><strong>Beschreibung:</strong><br>
   <?= htmlspecialchars($product['description'], ENT_QUOTES, 'UTF-8') ?>
</p>
<form action="cart_add.php" method="POST">
    <input type="hidden" name="produkt_id" value="<?= (int)$product['id'] ?>">
    <label>Menge:
        <input type="number" name="menge" value="1" min="1" max="99">
    </label>
    <button type="submit">In den Warenkorb</button>
</form>
</div>
<a href="index.php">← Zurück zur Übersicht</a>
</body>
</html>

Wichtige Details

  • (int)($_GET['id'] ?? 0) – der Klassiker: wenn Parameter fehlt → 0, dann findet die Query nichts, dann Redirect. Wenn jemand ?id=abc schickt → wird zu 0. Wenn jemand ?id=5; DROP TABLE users schickt → wird zu 5, der Rest ist weg.
  • WHERE id = ? AND freigeben = 1 – gesperrte Produkte lassen sich auch nicht per direkter URL aufrufen. Konsistenz zu index.php.
  • Redirect bei !$product – kein 404, keine leere Seite. User landet auf der Übersicht.
  • hidden input mit (int) – der Wert im HTML wird sowieso vom Server generiert, aber Cast ist Absicherung falls jemand das HTML manipuliert bevor er's absendet.
Kapitel 9

cart_add.php – Produkt in Warenkorb

Kein HTML – reine "Aktions-Datei". Bekommt POST vom Detailseite-Formular, prüft die Daten, legt das Produkt in die Session, redirected zum Warenkorb.

Der Code

cart_add.php
<?php
session_start();
require_once('db.php');

// nur post erlauben
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    header('Location: index.php');
    exit;
}

$produkt_id = (int)($_POST['produkt_id'] ?? 0);
$menge = (int)($_POST['menge'] ?? 1);

// validierung
if ($produkt_id <= 0 || $menge <= 0) {
    header('Location: index.php');
    exit;
}

// prüfen ob produkt überhaupt existiert und freigegeben ist
$stmt = $conn->prepare("SELECT id FROM products WHERE id = ? AND freigeben = 1");
$stmt->execute([$produkt_id]);
if (!$stmt->fetch()) {
    header('Location: index.php');
    exit;
}

// warenkorb initialisieren falls noch nicht existiert
if (!isset($_SESSION['warenkorb'])) {
    $_SESSION['warenkorb'] = [];
}

// wenn produkt schon im warenkorb menge addieren, sonst neu setzen
if (isset($_SESSION['warenkorb'][$produkt_id])) {
    $_SESSION['warenkorb'][$produkt_id] += $menge;
} else {
    $_SESSION['warenkorb'][$produkt_id] = $menge;
}

// zurück zum warenkorb
header('Location: warenkorb.php');
exit;
?>

Warum so aufgebaut

  • Nur POST erlauben – so kann kein präparierter GET-Link etwas in Warenkörbe schmuggeln. Klassisches "Actions ändern nur bei POST"-Muster.
  • (int)-Casts überall – User-Input aus dem Formular. Selbst wenn das hidden-Feld manipuliert wurde: nach dem Cast ist es garantiert ein Integer.
  • DB-Check ob Produkt existiert und freigegeben – wichtig! Ohne diesen Check könnte man beliebige Produkt-IDs in den Warenkorb legen, sogar gelöschte oder gesperrte.
  • Warenkorb-Struktur: assoziatives Array$_SESSION['warenkorb'][produkt_id] = menge. Kompakt, kein Duplicate-Handling nötig, Menge einfach addierbar.
Merke: "Aktions-Dateien" (add, remove, delete etc.) enden immer mit header('Location: ...'); exit;. Nie stumm sein und nie zufällig HTML rausgeben.
Kapitel 10

warenkorb.php – Warenkorb anzeigen

Hier passiert das interessantere PDO-Konstrukt: eine dynamische IN-Query. Weil man in Prepared Statements keine Arrays als Parameter binden kann, muss man die Fragezeichen manuell bauen.

Der Code

warenkorb.php
<?php
require_once('db.php');
session_start();

$warenkorb = $_SESSION['warenkorb'] ?? [];
$produkte_im_korb = [];
$gesamtsumme = 0;

// produktdaten aus db holen (falls warenkorb nicht leer)
if (!empty($warenkorb)) {
    // liste von ids
    $ids = array_keys($warenkorb);

    // für jede id ein ? erzeugen und mit komma verbinden: "?,?,?"
    // grund: prepared statements akzeptieren keine arrays als parameter,
    // jedes element braucht sein eigenes ?
    $placeholders = implode(',', array_fill(0, count($ids), '?'));
    
    $stmt = $conn->prepare("SELECT id, name, price, picture 
                            FROM products 
                            WHERE id IN ($placeholders)");
    $stmt->execute($ids);
    $produkte = $stmt->fetchAll(PDO::FETCH_ASSOC);
    
    foreach ($produkte as $produkt) {
        $menge = $warenkorb[$produkt['id']];
        $zwischensumme = $produkt['price'] * $menge;
        $gesamtsumme += $zwischensumme;
        
        $produkte_im_korb[] = [
            'id' => $produkt['id'],
            'name' => $produkt['name'],
            'price' => $produkt['price'],
            'picture' => $produkt['picture'],
            'menge' => $menge,
            'zwischensumme' => $zwischensumme,
        ];
    }
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
    <meta charset="UTF-8">
    <title>Warenkorb</title>
    <link rel="stylesheet" href="styles.css">
</head>
<body>
<?php include('navbar.php'); ?>

<h1>Warenkorb</h1>

<?php if (empty($produkte_im_korb)): ?>
    <p>Dein Warenkorb ist leer.</p>
    <a href="index.php">Zurück zur Übersicht</a>
<?php else: ?>
    <table border="1" cellpadding="8">
        <tr>
            <th>Produkt</th><th>Preis</th><th>Menge</th>
            <th>Zwischensumme</th><th>Aktion</th>
        </tr>
        <?php foreach ($produkte_im_korb as $item): ?>
            <tr>
                <td><?= htmlspecialchars($item['name'], ENT_QUOTES, 'UTF-8') ?></td>
                <td><?= number_format($item['price'], 2, ',', '.') ?> €</td>
                <td><?= (int)$item['menge'] ?></td>
                <td><?= number_format($item['zwischensumme'], 2, ',', '.') ?> €</td>
                <td>
                    <form action="cart_remove.php" method="POST" style="display:inline;">
                        <input type="hidden" name="produkt_id" value="<?= (int)$item['id'] ?>">
                        <button type="submit">Entfernen</button>
                    </form>
                </td>
            </tr>
        <?php endforeach; ?>
        <tr>
            <td colspan="3"><strong>Gesamtsumme:</strong></td>
            <td colspan="2"><strong><?= number_format($gesamtsumme, 2, ',', '.') ?> €</strong></td>
        </tr>
    </table>
    <br>
    <a href="index.php">Weiter einkaufen</a>
    <a href="checkout.php">Zur Kasse</a>
<?php endif; ?>

</body>
</html>

Der Kern: dynamische IN-Query erklärt

Wenn im Warenkorb z.B. die Produkt-IDs 3, 7 und 12 stecken, will man SELECT ... WHERE id IN (3, 7, 12). Naiver Ansatz:

// FALSCH – SQL Injection!
$sql = "SELECT ... WHERE id IN (" . implode(',', $ids) . ")";
$conn->query($sql);

Warum falsch? Weil $ids aus der Session kommt, und wer weiß was da drin steht – wenn irgendwo im Code mal eine unbedachte User-Eingabe reingerät, ist die Query offen.

Richtige Lösung: für jede ID ein ? erzeugen, dann mit den Werten binden.

// bei 3 ids: "?,?,?"
$placeholders = implode(',', array_fill(0, count($ids), '?'));

// eingebaut in query
$stmt = $conn->prepare("SELECT ... WHERE id IN ($placeholders)");
$stmt->execute($ids);   // ids als array übergeben
  • array_fill(0, count($ids), '?') – erzeugt ein Array mit so vielen '?'-Einträgen wie IDs.
  • implode(',', ...) – klebt sie mit Komma zusammen.
  • execute($ids) – die Werte werden gebunden, sind garantiert safe.
Prüfer-Frage-Alarm: das ist ein Lieblings-Thema. Sei bereit zu erklären, warum ein simples implode(',', $ids) in der Query gefährlich ist und warum die array_fill-Variante sicher ist.
Zwei Schleifen absichtlich getrennt: erst DB-Ergebnisse holen, dann in einer zweiten Schleife das Anzeige-Array bauen (mit Zwischensumme). Getrennte Verantwortung – DB-Aggregation im PHP, nicht in SQL.
Kapitel 11

cart_remove.php – aus Warenkorb entfernen

Wieder eine reine Aktions-Datei. POST rein, Produkt aus Session löschen, zurück zum Warenkorb.

Der Code

cart_remove.php
<?php
session_start();

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $produkt_id = (int)($_POST['produkt_id'] ?? 0);
    if (isset($_SESSION['warenkorb'][$produkt_id])) {
        unset($_SESSION['warenkorb'][$produkt_id]);
    }
}

header('Location: warenkorb.php');
exit;
?>

Warum so minimal

  • Kein DB-Zugriff nötig – wir arbeiten nur mit dem Session-Array. Deshalb auch kein require_once('db.php').
  • unset() – löscht den Schlüssel komplett aus dem Array.
  • isset()-Check davor – Sicherheit. Falls jemand eine ID sendet, die gar nicht im Warenkorb ist, passiert einfach nichts.
  • Redirect am Ende ohne if – egal was passiert ist, User landet auf warenkorb.php.
Mögliche Erweiterung: statt komplett entfernen, Menge um 1 reduzieren – dann bräuchte man ein zweites Feld oder eine zweite Aktions-Datei (cart_decrease.php).
Kapitel 12

statistiken.php – Admin-Bereich

Diese Seite ist nur für Admins zugänglich und macht drei Dinge gleichzeitig: sie zeigt Statistiken, sie erlaubt Produkte anzulegen, und sie erlaubt bestehende Produkte freizugeben, zu sperren oder zu löschen. Der Reihe nach.

Der komplette Code

statistiken.php
<?php
require_once('db.php');
session_start();

// admin-check
if (!isset($_SESSION['user_id']) || ($_SESSION['rolle'] ?? '') !== 'admin') {
    header('Location: index.php');
    exit;
}

$fehler = '';
$erfolg = '';

// ===== POST-HANDLER =====
if ($_SERVER['REQUEST_METHOD'] === 'POST') {

    // FREIGABE WECHSELN
    if (isset($_POST['toggle_id'])) {
        $id = (int)$_POST['toggle_id'];
        $stmt = $conn->prepare("UPDATE products SET freigeben = 1 - freigeben WHERE id = ?");
        $stmt->execute([$id]);
    }

    // LÖSCHEN
    elseif (isset($_POST['delete_id'])) {
        $id = (int)$_POST['delete_id'];
        try {
            $stmt = $conn->prepare("DELETE FROM products WHERE id = ?");
            $stmt->execute([$id]);
        } catch (PDOException $e) {
            // fk-constraint: produkt schon in order_items
            $fehler = 'Produkt kann nicht gelöscht werden (bereits bestellt). Sperre es stattdessen.';
        }
    }

    // HINZUFÜGEN
    elseif (isset($_POST['add_name'])) {
        $name        = trim($_POST['add_name'] ?? '');
        $artikelcode = trim($_POST['add_artikelcode'] ?? '');
        $price       = (float)str_replace(',', '.', $_POST['add_price'] ?? '0');
        $category_id = (int)($_POST['add_category'] ?? 0);
        $description = trim($_POST['add_description'] ?? '');
        $picture     = trim($_POST['add_picture'] ?? '');

        // validierung
        if ($name === '' || $artikelcode === '') {
            $fehler = 'Name und Artikelcode sind pflicht.';
        } elseif ($price <= 0) {
            $fehler = 'Preis muss größer als 0 sein.';
        } elseif ($category_id <= 0) {
            $fehler = 'Kategorie auswählen.';
        } else {
            // artikelcode-unique-check (zertifizierung-anforderung!)
            $stmt = $conn->prepare("SELECT id FROM products WHERE artikelcode = ?");
            $stmt->execute([$artikelcode]);
            if ($stmt->fetch()) {
                $fehler = 'Artikelcode existiert bereits.';
            } else {
                $stmt = $conn->prepare(
                    "INSERT INTO products 
                     (name, artikelcode, price, category_id, description, picture, freigeben) 
                     VALUES (?, ?, ?, ?, ?, ?, 0)"
                );
                $stmt->execute([$name, $artikelcode, $price, $category_id, $description, $picture]);
                $erfolg = 'Produkt angelegt (noch gesperrt, muss freigegeben werden).';
            }
        }
    }
}

// ===== DATEN LADEN =====
$anzahl_user         = $conn->query("SELECT COUNT(*) FROM users")->fetchColumn();
$anzahl_produkte     = $conn->query("SELECT COUNT(*) FROM products")->fetchColumn();
$anzahl_bestellungen = $conn->query("SELECT COUNT(*) FROM orders")->fetchColumn();
$umsatz              = $conn->query("SELECT COALESCE(SUM(menge * preis), 0) FROM order_items")->fetchColumn();

$kategorien    = $conn->query("SELECT id, name FROM categories ORDER BY name")->fetchAll(PDO::FETCH_ASSOC);
$alle_produkte = $conn->query(
    "SELECT p.id, p.name, p.artikelcode, p.price, p.freigeben, c.name AS kategorie_name
     FROM products p
     LEFT JOIN categories c ON c.id = p.category_id
     ORDER BY p.id DESC"
)->fetchAll(PDO::FETCH_ASSOC);
?>
<!DOCTYPE html>
<html lang="de">
<head>
    <meta charset="UTF-8">
    <title>Statistiken & Admin</title>
    <link rel="stylesheet" href="styles.css">
</head>
<body>
<?php include('navbar.php'); ?>

<h1>Statistiken</h1>

<p>Registrierte User: <strong><?= (int)$anzahl_user ?></strong></p>
<p>Produkte: <strong><?= (int)$anzahl_produkte ?></strong></p>
<p>Bestellungen: <strong><?= (int)$anzahl_bestellungen ?></strong></p>
<p>Umsatz: <strong><?= number_format($umsatz, 2, ',', '.') ?> €</strong></p>

<hr>
<h1>Produktverwaltung</h1>

<?php if ($fehler !== ''): ?>
    <p style="color:red;"><?= htmlspecialchars($fehler, ENT_QUOTES, 'UTF-8') ?></p>
<?php endif; ?>
<?php if ($erfolg !== ''): ?>
    <p style="color:green;"><?= htmlspecialchars($erfolg, ENT_QUOTES, 'UTF-8') ?></p>
<?php endif; ?>

<h2>Neues Produkt anlegen</h2>
<form action="statistiken.php" method="POST">
    <label>Name:<br><input type="text" name="add_name" required></label><br><br>
    <label>Artikelcode:<br><input type="text" name="add_artikelcode" required></label><br><br>
    <label>Preis (€):<br><input type="number" name="add_price" step="0.01" min="0.01" required></label><br><br>
    <label>Kategorie:<br>
        <select name="add_category" required>
            <option value="">-- bitte wählen --</option>
            <?php foreach ($kategorien as $kat): ?>
                <option value="<?= (int)$kat['id'] ?>">
                    <?= htmlspecialchars($kat['name'], ENT_QUOTES, 'UTF-8') ?>
                </option>
            <?php endforeach; ?>
        </select>
    </label><br><br>
    <label>Beschreibung:<br><textarea name="add_description" rows="3" cols="40"></textarea></label><br><br>
    <label>Bildpfad (z.B. bilder/xyz.jpg):<br><input type="text" name="add_picture"></label><br><br>
    <button type="submit">Produkt anlegen</button>
</form>

<h2>Alle Produkte</h2>
<table border="1" cellpadding="6">
    <tr>
        <th>ID</th><th>Artikelcode</th><th>Name</th><th>Kategorie</th>
        <th>Preis</th><th>Status</th><th>Aktionen</th>
    </tr>
    <?php foreach ($alle_produkte as $p): ?>
        <tr>
            <td><?= (int)$p['id'] ?></td>
            <td><?= htmlspecialchars($p['artikelcode'], ENT_QUOTES, 'UTF-8') ?></td>
            <td><?= htmlspecialchars($p['name'], ENT_QUOTES, 'UTF-8') ?></td>
            <td><?= htmlspecialchars($p['kategorie_name'] ?? '-', ENT_QUOTES, 'UTF-8') ?></td>
            <td><?= number_format($p['price'], 2, ',', '.') ?> €</td>
            <td>
                <?= $p['freigeben'] ? '<span style="color:green;">freigegeben</span>'
                                    : '<span style="color:red;">gesperrt</span>' ?>
            </td>
            <td>
                <form action="statistiken.php" method="POST" style="display:inline;">
                    <input type="hidden" name="toggle_id" value="<?= (int)$p['id'] ?>">
                    <button type="submit">
                        <?= $p['freigeben'] ? 'Sperren' : 'Freigeben' ?>
                    </button>
                </form>
                <form action="statistiken.php" method="POST" style="display:inline;"
                      onsubmit="return confirm('Wirklich löschen?');">
                    <input type="hidden" name="delete_id" value="<?= (int)$p['id'] ?>">
                    <button type="submit">Löschen</button>
                </form>
            </td>
        </tr>
    <?php endforeach; ?>
</table>

</body>
</html>

Wichtige Konzepte in diesem File

1. Admin-Check gleich am Anfang

if (!isset($_SESSION['user_id']) || ($_SESSION['rolle'] ?? '') !== 'admin') {
    header('Location: index.php');
    exit;
}

Wer nicht eingeloggt ist ODER nicht die Rolle "admin" hat → sofort raus, noch bevor irgendwas anderes passiert. Kein Rendering, kein DB-Zugriff für Unbefugte. Das ist die eigentliche Sicherheit – das Verstecken in der navbar ist nur Deko.

2. Ein File, drei Aktionen – unterschieden durch hidden inputs

Es gibt keinen ?action=delete-Parameter. Stattdessen unterscheiden die Formulare durch welches hidden field mitgeschickt wird:

  • toggle_id → Freigabe wechseln
  • delete_id → löschen
  • add_name → neues Produkt anlegen

Das elseif-Konstrukt stellt sicher, dass immer nur eine Aktion greift.

3. Toggle-Trick: freigeben = 1 - freigeben

UPDATE products SET freigeben = 1 - freigeben WHERE id = ?

Aus 1 wird 0 (1 - 1 = 0), aus 0 wird 1 (1 - 0 = 1). Ein einziges Statement für beide Richtungen, kein IF drumherum. Klein aber elegant.

4. Artikelcode-Unique-Check

Doppelt abgesichert: die DB hat UNIQUE auf der Spalte, und PHP prüft davor mit SELECT. Die DB würde einen Fehler werfen – aber der Check davor liefert eine schöne Fehlermeldung statt Exception. Das ist die ausdrückliche Anforderung aus der zertifizierung-Angabe: "es darf nicht möglich sein, dass mehrere Produkte denselben Artikelcode haben".

5. Löschen wirft FK-Fehler wenn Produkt schon bestellt

Wichtig zu verstehen: ein Produkt, das in order_items steckt, kann nicht gelöscht werden – der Foreign Key verhindert es. Genau deshalb steht der DELETE in einem try/catch. Fällt die Query auf die Nase, fängt der Catch das ab und schlägt vor: Produkt sperren statt löschen (freigeben = 0 setzen). Das ist auch in echten Shops so – historische Bestellungen sollen ihre Produkte behalten, sonst wird die Rechnung unlesbar.

6. Zwei separate forms statt einem mit mehreren buttons

Jede Aktion (Toggle, Löschen) ist ein eigenes <form>, damit das onsubmit="return confirm(...)" beim Löschen nur beim Löschen greift und nicht auch beim Toggeln nachfragt.

7. Neue Produkte starten immer als "gesperrt"

Der INSERT setzt hart freigeben = 0. Der Admin muss aktiv freigeben – so kann kein halbfertiger Datensatz versehentlich im Shop landen.

Was diese Seite laut zertifizierung-Angabe noch nicht kann: "Statistik über die 5 meistbestellten, 5 seltensten und den Bestellverlauf über 4 Wochen". Die vier Zahlen oben sind ein guter Anfang, aber die drei Ranking-Queries fehlen noch. Snippets dazu im nächsten Kapitel (SQL-Cheatsheet).
Kapitel 13

checkout.php – Bestellung & Rechnung

Minimal-Lösung: eine Seite, die den Warenkorb als druckbare Rechnung anzeigt und in die DB einträgt. Statt eines echten Zahlungsmoduls wird die Rechnung mit Strg+P ausgedruckt (oder als PDF gespeichert).

Der Code

checkout.php
<?php
require_once('db.php');
session_start();

// login pflicht
if (!isset($_SESSION['user_id'])) {
    header('Location: login.php');
    exit;
}

$warenkorb = $_SESSION['warenkorb'] ?? [];
if (empty($warenkorb)) {
    header('Location: warenkorb.php');
    exit;
}

// produktdaten holen (gleiche technik wie warenkorb.php)
$ids = array_keys($warenkorb);
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $conn->prepare("SELECT id, name, price FROM products WHERE id IN ($placeholders)");
$stmt->execute($ids);
$produkte = $stmt->fetchAll(PDO::FETCH_ASSOC);

$gesamt = 0;
foreach ($produkte as $p) {
    $gesamt += $p['price'] * $warenkorb[$p['id']];
}

// bestellung in db speichern (nur beim ersten aufruf!)
if (!isset($_SESSION['letzte_bestellung_id'])) {
    $conn->beginTransaction();
    try {
        // bestellung anlegen
        $stmt = $conn->prepare("INSERT INTO orders (user_id) VALUES (?)");
        $stmt->execute([$_SESSION['user_id']]);
        $bestell_id = $conn->lastInsertId();

        // positionen anlegen
        $pos = $conn->prepare("INSERT INTO order_items (order_id, product_id, menge, preis) VALUES (?, ?, ?, ?)");
        foreach ($produkte as $p) {
            $pos->execute([$bestell_id, $p['id'], $warenkorb[$p['id']], $p['price']]);
        }

        $conn->commit();
        $_SESSION['letzte_bestellung_id'] = $bestell_id;
        unset($_SESSION['warenkorb']);
    } catch (Exception $e) {
        $conn->rollBack();
        die('Bestellung fehlgeschlagen.');
    }
}

$bestell_id = $_SESSION['letzte_bestellung_id'];
?>
<!DOCTYPE html>
<html lang="de">
<head>
    <meta charset="UTF-8">
    <title>Rechnung Nr. <?= (int)$bestell_id ?></title>
    <link rel="stylesheet" href="styles.css">
    <style>
        /* nur beim drucken: navbar und buttons ausblenden */
        @media print {
            ul, .no-print { display: none; }
        }
    </style>
</head>
<body>
<?php include('navbar.php'); ?>

<h1>Rechnung Nr. <?= (int)$bestell_id ?></h1>
<p>Kunde: <?= htmlspecialchars($_SESSION['username'], ENT_QUOTES, 'UTF-8') ?></p>
<p>Datum: <?= date('d.m.Y H:i') ?></p>

<table border="1" cellpadding="8">
    <tr><th>Produkt</th><th>Menge</th><th>Einzelpreis</th><th>Summe</th></tr>
    <?php foreach ($produkte as $p): $m = $warenkorb[$p['id']] ?? 1; ?>
        <tr>
            <td><?= htmlspecialchars($p['name'], ENT_QUOTES, 'UTF-8') ?></td>
            <td><?= (int)$m ?></td>
            <td><?= number_format($p['price'], 2, ',', '.') ?> €</td>
            <td><?= number_format($p['price'] * $m, 2, ',', '.') ?> €</td>
        </tr>
    <?php endforeach; ?>
    <tr>
        <td colspan="3"><strong>Gesamt:</strong></td>
        <td><strong><?= number_format($gesamt, 2, ',', '.') ?> €</strong></td>
    </tr>
</table>

<br>
<div class="no-print">
    <button onclick="window.print()">Rechnung drucken (Strg+P)</button>
    <a href="index.php">Zurück zum Shop</a>
</div>

</body>
</html>

Was hier passiert

  • Login-Pflicht – bestellen ohne Account geht nicht.
  • Warenkorb leer? → Redirect – keine Rechnung über nichts.
  • Transaction (beginTransaction / commit / rollBack) – die Bestellung und ihre Positionen sind eine logische Einheit. Wenn beim Einfügen einer Position was schiefgeht, wird die halbfertige Bestellung wieder zurückgerollt. Atomicity.
  • Session-Merker letzte_bestellung_id – verhindert doppelte Bestellungen bei F5 auf der Rechnungsseite. Ist die Bestellung einmal eingetragen, wird beim erneuten Aufruf nur noch angezeigt.
  • Warenkorb leerenunset($_SESSION['warenkorb']) nach erfolgreicher Bestellung.
  • @media print – CSS-Regel, die beim Drucken die navbar und die Buttons ausblendet. So sieht die gedruckte Seite nach echter Rechnung aus.
Prüfer-Frage garantiert: "Was ist eine Transaktion und warum brauchst du sie hier?" → Weil eine Bestellung ohne Positionen sinnlos ist. Entweder werden beide Inserts durchgeführt oder keiner. Rollback bei Fehlern.
Für Voll-DSGVO-konform: zusätzliche Felder wie Rechnungs- und Lieferadresse in einer Tabelle rechnungen speichern und beim Bestellen abfragen. Für die Minimal-Version reicht der Username. Wenn der Prüfer nach Rechnungsadresse fragt: sag, dass sie beim Registrieren erfasst wird – dann fehlt in users noch eine Adress-Spalte.
Referenz

SQL-Cheatsheet – jede Query erklärt

Hier sind alle SQL-Queries aus dem Projekt, einzeln erklärt. Zusätzlich die drei Ranking-Queries, die die zertifizierung-Angabe explizit verlangt.

1. SELECT mit WHERE + Prepared Statement

SELECT id, name, price, description, picture 
FROM products 
WHERE freigeben = 1 AND category_id = ?

Holt alle Spalten aus products, aber nur freigegebene (freigeben = 1) UND aus einer bestimmten Kategorie. Das ? ist ein Platzhalter fürs Prepared Statement. Verwendet in index.php.

2. SELECT ohne Filter (query statt prepare)

SELECT id, name, price, description, picture 
FROM products 
WHERE freigeben = 1

Gleiches Prinzip, aber ohne Parameter. Deshalb kein Prepared Statement nötig – $conn->query(...) reicht. Verwendet in index.php für den ungefilterten Pfad.

3. SELECT mit LIKE / mehreren Bedingungen

SELECT id, name, price, description, picture, artikelcode 
FROM products 
WHERE id = ? AND freigeben = 1

Findet ein einzelnes Produkt anhand seiner ID – aber nur, wenn es freigegeben ist. AND verknüpft beide Bedingungen. Verwendet in productdetail.php.

4. INSERT mit Prepared Statement

INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?)

Legt einen neuen User an. Die drei ? werden per execute([$username, $email, $hash]) gefüllt. Reihenfolge muss stimmen. Danach liefert $conn->lastInsertId() die neue ID.

5. UPDATE mit Toggle-Trick

UPDATE products SET freigeben = 1 - freigeben WHERE id = ?

Wechselt den Freigabe-Status: aus 1 wird 0, aus 0 wird 1 (weil 1 - 1 = 0 und 1 - 0 = 1). Ein einziges Statement für beide Richtungen. Verwendet im Admin-Bereich.

6. DELETE mit Prepared Statement

DELETE FROM products WHERE id = ?

Löscht ein Produkt. Wichtig: kann eine Exception werfen, wenn das Produkt bereits in order_items referenziert wird (Foreign Key Constraint). Deshalb immer in try/catch.

7. IN-Query mit dynamischen Platzhaltern

SELECT id, name, price, picture 
FROM products 
WHERE id IN (?,?,?)

Holt mehrere Produkte auf einmal. Anzahl der ? wird zur Laufzeit mit array_fill() und implode() erzeugt. Verwendet in warenkorb.php.

8. JOIN – Tabellen verknüpfen

SELECT p.id, p.name, p.artikelcode, p.price, p.freigeben, c.name AS kategorie_name
FROM products p
LEFT JOIN categories c ON c.id = p.category_id
ORDER BY p.id DESC

Verknüpft die Produkte mit ihrer Kategorie. LEFT JOIN (statt INNER JOIN) sorgt dafür, dass auch Produkte OHNE Kategorie noch angezeigt werden (kategorie_name ist dann NULL). p und c sind Aliase, um die Query kürzer zu machen. AS kategorie_name benennt die Spalte im Ergebnis um. Verwendet im Admin-Bereich.

9. Aggregat-Funktionen (COUNT, SUM, COALESCE)

SELECT COUNT(*) FROM users;
SELECT COUNT(*) FROM products;
SELECT COUNT(*) FROM orders;
SELECT COALESCE(SUM(menge * preis), 0) FROM order_items;
  • COUNT(*) – zählt Zeilen.
  • SUM(menge * preis) – Summe über alle Zeilen, wobei zuerst Menge mal Preis pro Zeile ausgerechnet wird.
  • COALESCE(x, 0) – wenn x NULL ist (etwa weil order_items leer), wird stattdessen 0 zurückgegeben. Verhindert PHP-Fehler bei leeren Tabellen.
  • fetchColumn() holt nur die erste Spalte der ersten Zeile – perfekt für einzelne Werte.

10. INSERT + Transaktion (für Bestellung)

-- 1. bestellung anlegen
INSERT INTO orders (user_id) VALUES (?)

-- 2. für jedes produkt eine position
INSERT INTO order_items (order_id, product_id, menge, preis) VALUES (?, ?, ?, ?)

Muss atomar passieren, deshalb in PHP:

$conn->beginTransaction();
try {
    // beide INSERTs
    $conn->commit();
} catch (Exception $e) {
    $conn->rollBack();
}

11. Top 5 meistbestellte Produkte (zertifizierung-Anforderung f)

SELECT p.name, SUM(oi.menge) AS verkauft
FROM order_items oi
JOIN products p ON p.id = oi.product_id
GROUP BY p.id, p.name
ORDER BY verkauft DESC
LIMIT 5;
  • JOIN – Positionen mit Produkten verknüpfen (wir wollen den Namen sehen, nicht nur die ID).
  • SUM(oi.menge) – Mengen aller Bestellpositionen eines Produkts addieren.
  • GROUP BY p.id, p.name – nach Produkt gruppieren, sonst wäre SUM sinnlos.
  • ORDER BY verkauft DESC – meistverkaufte zuerst.
  • LIMIT 5 – nur die Top 5.

12. Flop 5 (5 am seltensten bestellten Produkte)

SELECT p.name, COALESCE(SUM(oi.menge), 0) AS verkauft
FROM products p
LEFT JOIN order_items oi ON oi.product_id = p.id
GROUP BY p.id, p.name
ORDER BY verkauft ASC
LIMIT 5;
Klassischer Fehler: INNER JOIN statt LEFT JOIN nehmen. Dann fehlen die Produkte, die NIE bestellt wurden – genau die aber sind die eigentlichen Flops. LEFT JOIN + COALESCE macht 0 aus fehlenden Werten.

13. Bestellverlauf letzte 4 Wochen (zertifizierung-Anforderung f)

SELECT DATE(bestellt_am) AS tag,
       COUNT(*)           AS anzahl_bestellungen
FROM orders
WHERE bestellt_am >= NOW() - INTERVAL 28 DAY
GROUP BY DATE(bestellt_am)
ORDER BY tag;
  • DATE(bestellt_am) – ignoriert die Uhrzeit, gruppiert pro Tag.
  • NOW() - INTERVAL 28 DAY – der Zeitpunkt vor 28 Tagen (= 4 Wochen).
  • GROUP BY DATE(bestellt_am) – ein Ergebnis pro Tag.
  • ORDER BY tag – chronologisch von alt nach neu.
Einbau in statistiken.php: die drei Queries oben mit $conn->query(...)->fetchAll(PDO::FETCH_ASSOC) ausführen und dann als Tabellen ausgeben. Muster ist genau wie bei $alle_produkte.

14. WHERE mit LIKE für Suche (nützlich als Bonus)

SELECT * FROM products WHERE name LIKE ?

Aufruf: $stmt->execute(['%'.$suche.'%']). Die Prozentzeichen sind Wildcards (beliebige Zeichen davor/danach). Auch im Prepared Statement escaped, deshalb sicher.

Referenz

zertifizierung-Checkliste

Auszug aus einer typischen Webshop-Prüfarbeit. Was von der zertifizierung-Angabe schon durch dieses Tutorial abgedeckt ist – und was noch offen bleibt.

AnforderungWo umgesetzt
Datenbank-Design als Skizze + ZeitschätzungKapitel 1, Skizze auf Papier machen
Produktliste mit Bild und Bezeichnungindex.php
Detailansicht mit allen Eigenschaftenproductdetail.php
Admin: sicherer Loginlogin.php + Rollen-Check
Admin: Produkte hinzufügen, bearbeiten, freigeben, löschenstatistiken.php (bearbeiten evtl. noch ergänzen)
Validierung: keine Fehleingaben, Artikelcode uniquestatistiken.php + UNIQUE-Constraint in DB
Datenanbindung via Datenbankdurchgehend via PDO
Einfaches, sicheres UInavbar.php + separate Seiten
Bestellvorgang mit Login + Warenkorbcart_*, warenkorb.php, checkout.php
Rechnungserstellungcheckout.php (drucken via Strg+P)
Statistik: Top 5 meistbestelltSQL-Cheatsheet #11
Statistik: 5 am seltensten bestelltSQL-Cheatsheet #12
Statistik: Bestellverlauf 4 WochenSQL-Cheatsheet #13
Nur über Admin-Bereich zugänglichRollen-Check in statistiken.php
Auf beliebigen Geräten verwendbarMeta-Viewport-Tag, ggf. Media Queries
Code kommentiert und strukturiertdurchgehend
Was noch offen ist:
– E-Mail-Versand der Rechnung (mit mail(), falls SMTP konfiguriert)
– Bearbeitungs-Formular für bestehende Produkte (analog zum Anlegen)
– Getrennte Rechnungs- und Lieferadresse (extra Tabelle oder Felder in users)
– Zahlung "per Rechnung / Kreditkarte" (im Realprojekt komplex, für zertifizierung reicht Radio-Button ohne Funktion)
Häufigste Prüfer-Fragen zu diesem Projekt:
– Warum Prepared Statements? (SQL Injection)
– Warum password_hash() und nicht md5? (Salt, Geschwindigkeit)
– Warum htmlspecialchars()? (XSS)
– Was ist eine Transaktion? (Atomicity beim Bestellen)
– Warum InnoDB? (FK + Transaktionen)
– Unterschied query() vs. prepare()? (mit vs. ohne User-Input)
– Was passiert bei 1 - freigeben? (Toggle-Trick)
– Warum FK auf der "n"-Seite? (Datenbank-Design-Grundlage)
– Was macht session_regenerate_id()? (Session Fixation Schutz – für Bonuspunkte einbauen)
webshoptutorial – ein freies Lern-Tutorial für PHP, MySQL & HTML/CSS.