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.
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
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).
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;
– 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).
category_id steht in products, nicht umgekehrt. Häufiger Anfängerfehler.-- 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');
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
<?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
rootnehmen, 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.
$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.$conn. Manche Tutorials nennen es $pdo oder $db – muss überall im Projekt gleich sein.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
<?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
- require_once('db.php') – DB-Verbindung laden.
$connist danach verfügbar. - session_start() – Session starten, damit wir nach erfolgreichem Register den User direkt einloggen können.
- REQUEST_METHOD === 'POST' – Formular wurde abgeschickt. Beim ersten Aufruf (GET) wird nur das leere Formular gezeigt.
- trim() + Null-Coalescing (??) – Whitespace weg, und wenn das Feld gar nicht gesendet wurde, kommt leerer String statt Notice.
- Validierung mit if/elseif – bricht bei erstem Fehler ab und setzt
$fehler. - Prepared Statement für Username-Check – niemals
WHERE username = '$username'schreiben, das wäre SQL Injection. - password_hash(..., PASSWORD_DEFAULT) – erzeugt bcrypt-Hash. Aktuell ist DEFAULT = bcrypt, kann in Zukunft wechseln, deshalb Spalte
VARCHAR(255). - lastInsertId() – die ID des gerade eingefügten Users, wird in Session gespeichert damit sofort eingeloggt.
- header('Location: ...') + exit – Redirect.
exitnicht vergessen, sonst läuft das Script weiter.
password_hash() nutzt bcrypt mit automatischem Salt und Cost-Faktor.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
<?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
- User-Datensatz per Prepared Statement holen (Prevention SQL Injection).
- 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.
- Bei Erfolg: alle relevanten Userdaten in die Session (id, username, email, rolle). Die
rollebraucht die navbar für den Admin-Menüpunkt. - Bei Fehler: bewusst unspezifische Meldung "Benutzername ODER Passwort falsch". Man verrät nicht, ob der Username existiert (sonst User Enumeration).
SELECT * FROM users?" → Nur die Spalten holen, die man wirklich braucht. Weniger Datentransfer, weniger versehentlicher Leak sensibler Daten.logout.php – Session beenden
Sehr kurz: alle Session-Variablen löschen, Session zerstören, zurück zur Startseite.
<?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.
setcookie(session_name(), '', time() - 3600, '/') vor dem destroy. Für die zertifizierung-Übung reicht die einfache Variante oben.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
<?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_idaus 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.
(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'] ?? 0gibt 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.
number_format($product['price'], 2, ',', '.') Zahl schöner formatieren (7,90 statt 7.9). Für die Übung nicht nötig, aber prüferfreundlich.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
<?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=abcschickt → wird zu 0. Wenn jemand?id=5; DROP TABLE usersschickt → 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.
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
<?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.
header('Location: ...'); exit;. Nie stumm sein und nie zufällig HTML rausgeben.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
<?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.
implode(',', $ids) in der Query gefährlich ist und warum die array_fill-Variante sicher ist.cart_remove.php – aus Warenkorb entfernen
Wieder eine reine Aktions-Datei. POST rein, Produkt aus Session löschen, zurück zum Warenkorb.
Der Code
<?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.
cart_decrease.php).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
<?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 wechselndelete_id→ löschenadd_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
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.
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
<?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 leeren –
unset($_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.
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.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
xNULL ist (etwa weilorder_itemsleer), 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;
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.
$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.
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.
| Anforderung | Wo umgesetzt |
|---|---|
| Datenbank-Design als Skizze + Zeitschätzung | Kapitel 1, Skizze auf Papier machen |
| Produktliste mit Bild und Bezeichnung | index.php |
| Detailansicht mit allen Eigenschaften | productdetail.php |
| Admin: sicherer Login | login.php + Rollen-Check |
| Admin: Produkte hinzufügen, bearbeiten, freigeben, löschen | statistiken.php (bearbeiten evtl. noch ergänzen) |
| Validierung: keine Fehleingaben, Artikelcode unique | statistiken.php + UNIQUE-Constraint in DB |
| Datenanbindung via Datenbank | durchgehend via PDO |
| Einfaches, sicheres UI | navbar.php + separate Seiten |
| Bestellvorgang mit Login + Warenkorb | cart_*, warenkorb.php, checkout.php |
| Rechnungserstellung | checkout.php (drucken via Strg+P) |
| Statistik: Top 5 meistbestellt | SQL-Cheatsheet #11 |
| Statistik: 5 am seltensten bestellt | SQL-Cheatsheet #12 |
| Statistik: Bestellverlauf 4 Wochen | SQL-Cheatsheet #13 |
| Nur über Admin-Bereich zugänglich | Rollen-Check in statistiken.php |
| Auf beliebigen Geräten verwendbar | Meta-Viewport-Tag, ggf. Media Queries |
| Code kommentiert und strukturiert | durchgehend |
– 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)
– 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)