Progetto Guidato — Creare una Directory con PHP e MySQL

Dalla struttura vuota alla ricerca con recensioni, passo dopo passo · Marco Consiglio
Volta Institute · Napoli
A.A. 2026
0

Punto di partenza — cosa costruiamo

🎯 Obiettivo del progetto
Costruiremo una directory di attività locali: schede organizzate per categoria e città, ricercabili con filtri combinati, con un sistema di recensioni a stelle. A differenza del CMS, qui il front-end di ricerca resta pubblico; solo l'inserimento di nuove schede richiederà login.
📁 Struttura delle cartelle del progetto
directory/ ├── config.php // connessione al database (Passo 2) ├── cerca.php // ricerca pubblica con filtri (Passo 3-4) ├── scheda.php // dettaglio scheda + recensioni (Passo 6) ├── login.php // autenticazione (Passo 5) └── nuova_scheda.php // inserire una scheda (Passo 5)
💡 Notare che cerca.php nasce già al Passo 3 come pagina pubblica e ci resta per tutta la guida: a differenza di CMS/CRM/Forum, qui la sicurezza (Passo 5) protegge solo l'inserimento, non la consultazione.
1

Il database — creiamo tutte le tabelle

🗄️ Schema completo, nell'ordine corretto
Ordine: categorie_directory (indipendente) → schede (referenzia categorie) → recensioni (referenzia schede).
CREATE TABLE categorie_directory ( id INT AUTO_INCREMENT PRIMARY KEY, nome VARCHAR(100) NOT NULL ); CREATE TABLE schede ( id INT AUTO_INCREMENT PRIMARY KEY, nome VARCHAR(200) NOT NULL, descrizione TEXT, categoria_id INT, città VARCHAR(100), indirizzo VARCHAR(200), creata_il TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (categoria_id) REFERENCES categorie_directory(id) ); CREATE TABLE recensioni ( id INT AUTO_INCREMENT PRIMARY KEY, scheda_id INT, valutazione TINYINT CHECK (valutazione BETWEEN 1 AND 5), commento TEXT, creata_il TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (scheda_id) REFERENCES schede(id) );
💡 Campi strutturati (città, indirizzo separati, non un unico testo libero) fin dal Passo 1 — è ciò che renderà possibile filtrare per città al Passo 4, invece di dover analizzare testo non strutturato.
✅ Checkpoint — a questo punto hai
3 tabelle in catena, pronte a ricevere schede e recensioni.
2

Connessione e prima pagina di test

config.php
<?php $host = 'localhost'; $db = 'directory_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()); } ?>
cerca.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 cerca.php. Messaggio di conferma = si può proseguire.
3

Funzionalità core — elencare tutte le schede

🎯 Cosa costruiamo in questo passo
La versione più semplice: mostrare TUTTE le schede, senza ricerca né filtri né categoria mostrata (solo il nome grezzo). Niente form di inserimento ancora — le schede le creiamo direttamente nel database per ora, a scopo di test.
cerca.php (sostituisce la versione di test del Passo 2)
<?php require 'config.php'; $stmt = $pdo->query("SELECT id, nome, città FROM schede ORDER BY nome ASC"); $schede = $stmt->fetchAll(PDO::FETCH_ASSOC); ?> <h1>Tutte le schede</h1> <ul> <?php foreach ($schede as $s): ?> <li><?= htmlspecialchars($s['nome']) ?> — <?= htmlspecialchars($s['città'] ?? 'città non indicata') ?></li> <?php endforeach; ?> </ul>
✅ Checkpoint — a questo punto hai: inserisci manualmente 2-3 righe di test nella tabella schede (via un client MySQL) e verifica che compaiano in ordine alfabetico. Nessuna ricerca, nessun filtro, nessuna categoria mostrata ancora.
4

Aggiungere le relazioni — categoria e ricerca con filtri

🔗 Mostriamo la categoria e permettiamo la ricerca
Estendiamo cerca.php del Passo 3 aggiungendo JOIN per la categoria, e costruiamo una query dinamica che si adatta ai filtri effettivamente inviati dall'utente.
cerca.php (MODIFICA rispetto al Passo 3)
<?php require 'config.php'; $query = $_GET['q'] ?? ''; $città = $_GET['città'] ?? ''; // query costruita dinamicamente in base ai filtri presenti $sql = " SELECT s.id, s.nome, s.città, c.nome AS categoria FROM schede s LEFT JOIN categorie_directory c ON s.categoria_id = c.id WHERE 1=1 "; $parametri = []; if (!empty($query)) { $sql .= " AND s.nome LIKE :query"; $parametri['query'] = "%$query%"; } if (!empty($città)) { $sql .= " AND s.città = :città"; $parametri['città'] = $città; } $sql .= " ORDER BY s.nome ASC"; $stmt = $pdo->prepare($sql); $stmt->execute($parametri); $schede = $stmt->fetchAll(PDO::FETCH_ASSOC); ?> <form method="get"> <input type="text" name="q" placeholder="Cerca per nome" value="<?= htmlspecialchars($query) ?>"> <input type="text" name="città" placeholder="Città" value="<?= htmlspecialchars($città) ?>"> <button type="submit">Cerca</button> </form> <ul> <?php foreach ($schede as $s): ?> <li><a href="scheda.php?id=<?= $s['id'] ?>"><?= htmlspecialchars($s['nome']) ?></a> — <?= htmlspecialchars($s['categoria'] ?? 'senza categoria') ?>, <?= htmlspecialchars($s['città'] ?? 'n/d') ?></li> <?php endforeach; ?> </ul>
⚠️ Ogni pezzo aggiunto alla query dinamica ($sql .= "...") usa comunque un segnaposto (:query, :città) — mai concatenazione diretta dei valori, anche quando la struttura della query cresce condizionalmente.
✅ Checkpoint — a questo punto hai: puoi cercare per nome e/o città, in qualsiasi combinazione. WHERE 1=1 permette di aggiungere ogni filtro con AND senza doversi preoccupare se è il primo o no.
5

Permessi e sicurezza — proteggere solo l'inserimento

🎯 La ricerca resta pubblica, l'inserimento no
A differenza dei progetti precedenti, qui NON blocchiamo cerca.php: chiunque deve poter cercare. Solo nuova_scheda.php richiede login.
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']; header('Location: nuova_scheda.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>
nuova_scheda.php (nuovo file, protetto)
<?php session_start(); if (!isset($_SESSION['utente_id'])) { header('Location: login.php'); exit; } require 'config.php'; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $nome = $_POST['nome'] ?? ''; $categoriaId = (int) ($_POST['categoria_id'] ?? 0); $città = $_POST['città'] ?? ''; $stmt = $pdo->prepare("INSERT INTO schede (nome, categoria_id, città) VALUES (:nome, :categoria_id, :città)"); $stmt->execute(['nome' => $nome, 'categoria_id' => $categoriaId, 'città' => $città]); header('Location: cerca.php'); exit; } $categorie = $pdo->query("SELECT id, nome FROM categorie_directory")->fetchAll(PDO::FETCH_ASSOC); ?> <form method="post"> <input type="text" name="nome" placeholder="Nome attività" required> <select name="categoria_id"> <?php foreach ($categorie as $c): ?> <option value="<?= $c['id'] ?>"><?= htmlspecialchars($c['nome']) ?></option> <?php endforeach; ?> </select> <input type="text" name="città" placeholder="Città"> <button type="submit">Aggiungi scheda</button> </form>
💡 (int) su categoria_id: anche se il menu a tendina "dovrebbe" inviare sempre un numero, un utente potrebbe modificare manualmente l'URL — il cast esplicito garantisce che il valore usato sia effettivamente numerico.
✅ Checkpoint — a questo punto hai: cerca.php resta aperto a chiunque (nessuna modifica in questo passo); solo nuova_scheda.php richiede login, coerentemente con l'obiettivo dichiarato al Passo 0.
6

Funzionalità avanzate — recensioni e paginazione

scheda.php (nuovo file)
⭐ Dettaglio scheda con media recensioni
<?php require 'config.php'; $id = (int) ($_GET['id'] ?? 0); $stmt = $pdo->prepare(" SELECT s.nome, s.descrizione, AVG(r.valutazione) AS media, COUNT(r.id) AS num_recensioni FROM schede s LEFT JOIN recensioni r ON s.id = r.scheda_id WHERE s.id = :id GROUP BY s.nome, s.descrizione "); $stmt->execute(['id' => $id]); $scheda = $stmt->fetch(PDO::FETCH_ASSOC); if ($_SERVER['REQUEST_METHOD'] === 'POST') { $valutazione = (int) $_POST['valutazione']; $stmt = $pdo->prepare("INSERT INTO recensioni (scheda_id, valutazione, commento) VALUES (:sid, :val, :commento)"); $stmt->execute(['sid' => $id, 'val' => $valutazione, 'commento' => $_POST['commento'] ?? '']); header("Location: scheda.php?id=$id"); exit; } ?> <h1><?= htmlspecialchars($scheda['nome']) ?></h1> <p>Valutazione media: <?= $scheda['media'] ? round($scheda['media'], 1) : 'nessuna recensione' ?> (<?= $scheda['num_recensioni'] ?> recensioni)</p> <form method="post"> <select name="valutazione"> <option value="5">5 stelle</option><option value="4">4</option><option value="3">3</option><option value="2">2</option><option value="1">1</option> </select> <textarea name="commento" placeholder="Il tuo commento"></textarea> <button type="submit">Invia recensione</button> </form>
💡 Il vincolo CHECK (valutazione BETWEEN 1 AND 5) scritto nello schema al Passo 1 protegge il database anche se, per qualsiasi motivo, un valore fuori scala arrivasse fino qui — doppia sicurezza insieme al menu a tendina che limita già le opzioni lato interfaccia.
cerca.php (ESTENSIONE: paginazione)
<?php // aggiunta alla query del Passo 4, prima di eseguire $stmt->execute(): $paginaCorrente = (int) ($_GET['pagina'] ?? 1); $risultatiPerPagina = 20; $offset = ($paginaCorrente - 1) * $risultatiPerPagina; $sql .= " LIMIT :limit OFFSET :offset"; $stmt = $pdo->prepare($sql); foreach ($parametri as $chiave => $valore) { $stmt->bindValue($chiave, $valore); } $stmt->bindValue('limit', $risultatiPerPagina, PDO::PARAM_INT); $stmt->bindValue('offset', $offset, PDO::PARAM_INT); $stmt->execute();
⚠️ Con LIMIT/OFFSET non si può più usare il semplice execute($parametri) del Passo 4: servono bindValue() espliciti con PDO::PARAM_INT, perché questi due parametri richiedono tipizzazione esplicita come interi.
✅ Checkpoint — a questo punto hai: ricerca con filtri (Passo 4) ora combinata con paginazione, senza perdere nessuna funzionalità precedente — la stessa query dinamica, con due clausole aggiuntive in coda.
7

Integrazione finale — come tutto si tiene insieme

🧩 Il flusso completo, dall'inizio alla fine
PassoCosa abbiamo costruito
13 tabelle: categorie_directory → schede → recensioni, con CHECK su valutazione
2config.php: connessione condivisa
3cerca.php: elenco grezzo di tutte le schede, nessun filtro
4Query dinamica con filtri nome/città; JOIN per mostrare la categoria
5login.php + nuova_scheda.php protetto; cerca.php resta pubblico
6scheda.php con AVG/COUNT per la media recensioni; paginazione aggiunta a cerca.php
Nota una differenza rispetto a CMS/CRM/Forum: qui la sicurezza del Passo 5 non blocca la pagina principale di consultazione, ma solo l'inserimento — una scelta architetturale diversa, coerente con l'obiettivo dichiarato fin dal Passo 0 di avere una directory pubblicamente ricercabile.
🚀 Da qui in avanti
Estensioni naturali non trattate qui: filtro combinato per categoria (oltre a nome/città), ordinamento dei risultati per valutazione media, moderazione delle recensioni (riprendendo il pattern nascosta visto nel Forum).
🎓 Esercitazioni pratiche
1
Aggiungi il filtro per categoria
Estendi la query dinamica di cerca.php (Passo 4) con un terzo filtro opzionale categoria_id, seguendo lo stesso pattern if (!empty(...)) già usato per nome e città.
⏱ 25 min📦 1 file modificato💻 Individuale
2
Le 5 schede meglio recensite
Crea top5.php riusando il pattern JOIN+GROUP BY+HAVING del Passo 6: schede con almeno 3 recensioni, ordinate per media decrescente, limitate a 5 risultati.
⏱ 35 min📦 1 nuovo file💻 Individuale
3
Solo l'autore modifica la propria scheda
Aggiungi autore_id alla tabella schede (Passo 1) popolato da nuova_scheda.php (Passo 5), poi crea modifica_scheda.php che permette la modifica solo se l'utente loggato corrisponde all'autore.
⏱ 40 min📦 Migrazione SQL + 2 file💻 Individuale