Progetto Guidato — Creare un Forum con PHP e MySQL

Dalla struttura vuota al forum moderato, passo dopo passo · Marco Consiglio
Volta Institute · Napoli
A.A. 2026
0

Punto di partenza — cosa costruiamo

🎯 Obiettivo del progetto
Costruiremo un forum minimo: utenti autenticati creano thread (discussioni) organizzati in categorie, altri utenti rispondono, e un moderatore può nascondere risposte o bloccare thread problematici. Costruiremo prima la parte pubblica di lettura/scrittura, poi la moderazione, infine le notifiche.
📁 Struttura delle cartelle del progetto
forum/ ├── config.php // connessione al database (Passo 2) ├── login.php // autenticazione (Passo 5) ├── thread.php // elenco thread di una categoria (Passo 3) ├── nuovo_thread.php // creare un thread (Passo 3) ├── visualizza.php // thread + risposte (Passo 4) ├── modera.php // azioni di moderazione (Passo 5) └── statistiche.php // thread più attivi (Passo 6)
💡 Nota che nuovo_thread.php e visualizza.php sono file distinti: creare e leggere sono due funzionalità core separate, che costruiremo l'una dopo l'altra.
1

Il database — creiamo tutte le tabelle

🗄️ Schema completo, nell'ordine corretto
Ordine: categorie_forum (indipendente) → thread (referenzia categorie e utenti) → risposte (referenzia thread) → notifiche (referenzia utenti, indipendente dalle altre tre).
CREATE TABLE categorie_forum ( id INT AUTO_INCREMENT PRIMARY KEY, nome VARCHAR(100) NOT NULL ); CREATE TABLE thread ( id INT AUTO_INCREMENT PRIMARY KEY, titolo VARCHAR(200) NOT NULL, categoria_id INT, autore_id INT, bloccato BOOLEAN DEFAULT FALSE, creato_il TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (categoria_id) REFERENCES categorie_forum(id), FOREIGN KEY (autore_id) REFERENCES utenti(id) ); CREATE TABLE risposte ( id INT AUTO_INCREMENT PRIMARY KEY, thread_id INT, autore_id INT, contenuto TEXT NOT NULL, nascosta BOOLEAN DEFAULT FALSE, creata_il TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (thread_id) REFERENCES thread(id) ); CREATE TABLE notifiche ( id INT AUTO_INCREMENT PRIMARY KEY, utente_id INT, messaggio VARCHAR(255), letta BOOLEAN DEFAULT FALSE, creata_il TIMESTAMP DEFAULT CURRENT_TIMESTAMP );
💡 bloccato e nascosta sono flag booleani già pronti fin da questo passo, anche se la loro logica di attivazione arriverà solo al Passo 5 — così lo schema non dovrà essere modificato più avanti nel percorso.
✅ Checkpoint — a questo punto hai
4 tabelle, di cui 3 in catena e una indipendente (notifiche), pronte a ricevere dati.
2

Connessione e prima pagina di test

config.php
<?php $host = 'localhost'; $db = 'forum_volta'; $user = 'root'; $password = getenv('DB_PASSWORD'); try { $pdo = new PDO("mysql:host=$host;dbname=$db;charset=utf8mb4", $user, $password); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); } catch (PDOException $e) { die("Connessione fallita: " . $e->getMessage()); } ?>
thread.php (versione di TEST, verrà sostituita al Passo 3)
<?php require 'config.php'; echo "Connessione riuscita al database $db!"; ?>
✅ Checkpoint — a questo punto hai: apri thread.php. Messaggio di conferma = si può proseguire.
3

Funzionalità core — creare thread ed elencarli

🎯 Cosa costruiamo in questo passo
La versione più semplice: un form crea un thread con solo il titolo, una pagina li elenca tutti. Categoria, autore, risposte e moderazione restano fuori per ora.
nuovo_thread.php
<?php require 'config.php'; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $titolo = $_POST['titolo'] ?? ''; $stmt = $pdo->prepare("INSERT INTO thread (titolo) VALUES (:titolo)"); $stmt->execute(['titolo' => $titolo]); header('Location: thread.php'); exit; } ?> <form method="post"> <input type="text" name="titolo" placeholder="Titolo del thread" required> <button type="submit">Crea thread</button> </form>
💡 categoria_id e autore_id restano NULL — li aggiungeremo al Passo 4, senza toccare il resto di questo file.
thread.php (sostituisce la versione di test del Passo 2)
<?php require 'config.php'; $stmt = $pdo->query("SELECT id, titolo FROM thread ORDER BY creato_il DESC"); $threadList = $stmt->fetchAll(PDO::FETCH_ASSOC); ?> <h1>Discussioni</h1> <a href="nuovo_thread.php">+ Nuovo thread</a> <ul> <?php foreach ($threadList as $t): ?> <li><a href="visualizza.php?id=<?= $t['id'] ?>"><?= htmlspecialchars($t['titolo']) ?></a></li> <?php endforeach; ?> </ul>
✅ Checkpoint — a questo punto hai: puoi creare thread e vederli elencati con link a visualizza.php?id=..., che al momento non esiste ancora — lo costruiamo al Passo 4.
4

Aggiungere le relazioni — categoria, autore, risposte

🔗 Ora colleghiamo tutto e mostriamo le risposte
visualizza.php (nuovo file)
🧵 Mostrare un thread con autore, categoria e risposte
<?php require 'config.php'; $id = (int) ($_GET['id'] ?? 0); $stmt = $pdo->prepare(" SELECT t.titolo, u.nome AS autore, c.nome AS categoria FROM thread t LEFT JOIN utenti u ON t.autore_id = u.id LEFT JOIN categorie_forum c ON t.categoria_id = c.id WHERE t.id = :id "); $stmt->execute(['id' => $id]); $thread = $stmt->fetch(PDO::FETCH_ASSOC); $stmtRisposte = $pdo->prepare(" SELECT r.contenuto, u.nome AS autore FROM risposte r LEFT JOIN utenti u ON r.autore_id = u.id WHERE r.thread_id = :id ORDER BY r.creata_il ASC "); $stmtRisposte->execute(['id' => $id]); $risposte = $stmtRisposte->fetchAll(PDO::FETCH_ASSOC); ?> <h1><?= htmlspecialchars($thread['titolo']) ?></h1> <p>Di <?= htmlspecialchars($thread['autore'] ?? 'Anonimo') ?> · <?= htmlspecialchars($thread['categoria'] ?? 'Senza categoria') ?></p> <?php foreach ($risposte as $r): ?> <p><strong><?= htmlspecialchars($r['autore'] ?? 'Anonimo') ?>:</strong> <?= nl2br(htmlspecialchars($r['contenuto'])) ?></p> <?php endforeach; ?>
💡 LEFT JOIN (non INNER) sia per autore/categoria del thread sia per autore delle risposte — coerente con il fatto che, al Passo 3, i thread sono stati creati senza autore né categoria assegnati.
visualizza.php (ESTENSIONE: form per rispondere)
<?php if ($_SERVER['REQUEST_METHOD'] === 'POST') { $contenuto = $_POST['contenuto'] ?? ''; $stmt = $pdo->prepare("INSERT INTO risposte (thread_id, contenuto) VALUES (:tid, :contenuto)"); $stmt->execute(['tid' => $id, 'contenuto' => $contenuto]); header("Location: visualizza.php?id=$id"); exit; } // ... il resto (recupero thread e risposte) resta come sopra ... ?> // ... markup del thread e delle risposte come sopra ... <form method="post"> <textarea name="contenuto" required></textarea> <button type="submit">Rispondi</button> </form>
✅ Checkpoint — a questo punto hai: un thread completo di risposte, tutte visibili (nessun filtro nascosta ancora applicato — arriva al Passo 5).
5

Permessi e sicurezza — login e moderazione

login.php
<?php session_start(); require 'config.php'; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $stmt = $pdo->prepare("SELECT * FROM utenti WHERE email = :email"); $stmt->execute(['email' => $_POST['email']]); $utente = $stmt->fetch(PDO::FETCH_ASSOC); if ($utente && password_verify($_POST['password'], $utente['password_hash'])) { $_SESSION['utente_id'] = $utente['id']; $_SESSION['ruolo'] = $utente['ruolo']; // 'admin', 'moderatore' o 'utente' header('Location: thread.php'); exit; } echo "Credenziali non valide"; } ?> <form method="post"> <input type="email" name="email" required> <input type="password" name="password" required> <button type="submit">Accedi</button> </form>
nuovo_thread.php e visualizza.php (MODIFICA: login + autore_id automatico)
<?php session_start(); if (!isset($_SESSION['utente_id'])) { header('Location: login.php'); exit; } require 'config.php'; // nuovo_thread.php: aggiungiamo autore_id all'INSERT già visto al Passo 3 $stmt = $pdo->prepare("INSERT INTO thread (titolo, autore_id) VALUES (:titolo, :autore_id)"); $stmt->execute(['titolo' => $titolo, 'autore_id' => $_SESSION['utente_id']]); // visualizza.php: stessa aggiunta nell'INSERT delle risposte visto al Passo 4 $stmt = $pdo->prepare("INSERT INTO risposte (thread_id, contenuto, autore_id) VALUES (:tid, :contenuto, :autore_id)"); $stmt->execute(['tid' => $id, 'contenuto' => $contenuto, 'autore_id' => $_SESSION['utente_id']]); ?>
modera.php (nuovo file)
🛡️ Nascondere risposte e bloccare thread
<?php session_start(); if (!isset($_SESSION['utente_id'])) { header('Location: login.php'); exit; } require 'config.php'; function puoModerare(string $ruolo): bool { return in_array($ruolo, ['admin', 'moderatore']); } if (!puoModerare($_SESSION['ruolo'])) { die("Permesso negato: solo admin o moderatori"); } if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (isset($_POST['nascondi_risposta'])) { $stmt = $pdo->prepare("UPDATE risposte SET nascosta = TRUE WHERE id = :id"); $stmt->execute(['id' => $_POST['nascondi_risposta']]); } if (isset($_POST['blocca_thread'])) { $stmt = $pdo->prepare("UPDATE thread SET bloccato = TRUE WHERE id = :id"); $stmt->execute(['id' => $_POST['blocca_thread']]); } } ?>
visualizza.php (MODIFICA: applicare il filtro nascosta)
<?php // query risposte del Passo 4, con l'aggiunta del filtro: $stmtRisposte = $pdo->prepare(" SELECT r.contenuto, u.nome AS autore FROM risposte r LEFT JOIN utenti u ON r.autore_id = u.id WHERE r.thread_id = :id AND r.nascosta = FALSE ORDER BY r.creata_il ASC "); ?>
✅ Checkpoint — a questo punto hai: il flag nascosta scritto nello schema al Passo 1, attivabile da modera.php, ora effettivamente rispettato dalla query che mostra le risposte — senza questo filtro, nascondere una risposta non avrebbe alcun effetto visibile.
6

Funzionalità avanzate — notifiche e statistiche

visualizza.php (ESTENSIONE: notificare l'autore del thread)
<?php // subito dopo l'INSERT della risposta (Passo 5), aggiungiamo: $stmtThread = $pdo->prepare("SELECT autore_id, titolo FROM thread WHERE id = :id"); $stmtThread->execute(['id' => $id]); $threadInfo = $stmtThread->fetch(PDO::FETCH_ASSOC); if ($threadInfo['autore_id'] !== null) { $stmtNotifica = $pdo->prepare("INSERT INTO notifiche (utente_id, messaggio) VALUES (:uid, :msg)"); $stmtNotifica->execute([ 'uid' => $threadInfo['autore_id'], 'msg' => "Nuova risposta al tuo thread: {$threadInfo['titolo']}" ]); } ?>
💡 Il controllo !== null evita di notificare un "autore" inesistente per i thread creati al Passo 3 prima che il login fosse obbligatorio.
statistiche.php (nuovo file)
📊 I thread con più risposte
<?php require 'config.php'; $stmt = $pdo->query(" SELECT t.titolo, COUNT(r.id) AS num_risposte FROM thread t LEFT JOIN risposte r ON t.id = r.thread_id GROUP BY t.titolo ORDER BY num_risposte DESC LIMIT 5 "); $popolari = $stmt->fetchAll(PDO::FETCH_ASSOC); ?>
⚠️ LEFT JOIN, non INNER: un thread appena creato senza ancora risposte (come quelli del Passo 3 prima di ricevere la prima risposta) deve comunque comparire con conteggio 0, non sparire dal risultato.
7

Integrazione finale — come tutto si tiene insieme

🧩 Il flusso completo, dall'inizio alla fine
PassoCosa abbiamo costruito
14 tabelle: categorie_forum, thread, risposte, notifiche
2config.php: connessione condivisa
3nuovo_thread.php + thread.php: creare e listare thread (senza relazioni)
4visualizza.php: JOIN per autore/categoria del thread e delle risposte, form per rispondere
5login.php; autore_id automatico; modera.php con controllo ruolo; filtro nascosta = FALSE applicato in visualizza.php
6Notifica automatica all'autore del thread; statistiche.php con LEFT JOIN + GROUP BY + COUNT
Il flag nascosta scritto al Passo 1 resta senza effetto pratico fino al Passo 5, dove sia l'azione di moderazione sia il filtro che la rispetta vengono introdotti insieme. Ogni passo aggiunge senza rompere ciò che già funzionava nei passi precedenti.
🚀 Da qui in avanti
Estensioni naturali non trattate qui: paginazione delle risposte per thread molto lunghi, ricerca full-text nei thread, sistema di reputazione utenti basato sulle risposte ricevute.
🎓 Esercitazioni pratiche
1
Impedisci risposte su thread bloccati
Modifica il form di risposta in visualizza.php (Passo 4) aggiungendo un controllo su $thread['bloccato'] (Passo 5): se true, nascondi il form o mostra un messaggio invece di permettere l'invio.
⏱ 25 min📦 1 file modificato💻 Individuale
2
Pagina "le mie notifiche"
Crea notifiche.php che mostra (con WHERE utente_id = :id AND letta = FALSE) le notifiche non lette dell'utente loggato, riusando la tabella creata al Passo 1 e popolata al Passo 6.
⏱ 30 min📦 1 nuovo file💻 Individuale
3
Ripubblica una risposta nascosta
Estendi modera.php del Passo 5 con un'azione simmetrica ripubblica_risposta che imposta nascosta = FALSE, riusando puoModerare() già scritta.
⏱ 20 min📦 1 file modificato💻 Individuale