Progetto Guidato — Creare un CMS con PHP e MySQL

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

Punto di partenza — cosa costruiamo

🎯 Obiettivo del progetto
Costruiremo un CMS minimo ma completo: un pannello admin dove un utente autenticato può scrivere, modificare e pubblicare articoli, e un front-end pubblico che mostra solo gli articoli pubblicati. Lo costruiremo per passi: prima il database, poi la connessione, poi la funzione principale (scrivere/leggere articoli), poi le relazioni tra tabelle, poi la sicurezza, infine le funzionalità di rifinitura.
📁 Struttura delle cartelle del progetto
cms/ ├── config.php // connessione al database (Passo 2) ├── index.php // front-end pubblico: lista articoli (Passo 3) ├── articolo.php // front-end pubblico: singolo articolo (Passo 4) └── admin/ ├── login.php // autenticazione (Passo 5) ├── dashboard.php // elenco articoli da gestire (Passo 3) ├── nuovo.php // form nuovo articolo (Passo 3) └── modifica.php // form modifica articolo (Passo 6)
💡 Ogni file che vedremo nei prossimi passi userà esattamente questi nomi — costruiremo il progetto file per file, e ogni passo aggiungerà o modificherà uno di questi file, mai da zero.
1

Il database — creiamo tutte le tabelle

🗄️ Schema completo, nell'ordine corretto
Le tabelle vanno create in un ordine preciso: prima quelle "indipendenti" (utenti, categorie), poi quella che le referenzia entrambe (articoli) — MySQL richiede che una tabella referenziata da una FOREIGN KEY esista già al momento della creazione.
CREATE TABLE utenti ( id INT AUTO_INCREMENT PRIMARY KEY, nome VARCHAR(100) NOT NULL, email VARCHAR(150) UNIQUE NOT NULL, password_hash VARCHAR(255) NOT NULL, ruolo ENUM('admin', 'editor', 'autore') DEFAULT 'autore' ); CREATE TABLE categorie ( id INT AUTO_INCREMENT PRIMARY KEY, nome VARCHAR(100) NOT NULL ); -- Questa tabella referenzia le due sopra: va creata per ultima CREATE TABLE articoli ( id INT AUTO_INCREMENT PRIMARY KEY, titolo VARCHAR(200) NOT NULL, slug VARCHAR(220) UNIQUE NOT NULL, contenuto TEXT NOT NULL, stato ENUM('bozza', 'pubblicato', 'archiviato') DEFAULT 'bozza', autore_id INT, categoria_id INT, creato_il TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (autore_id) REFERENCES utenti(id), FOREIGN KEY (categoria_id) REFERENCES categorie(id) );
⚠️ Se provi a creare articoli PRIMA di utenti o categorie, MySQL restituisce un errore: non può creare una FOREIGN KEY verso una tabella che ancora non esiste.
✅ Checkpoint — a questo punto hai
Un database vuoto ma strutturato: 3 tabelle collegate correttamente, pronte a ricevere dati. Nessun file PHP ancora — è normale, arriva nel prossimo passo.
2

Connessione e prima pagina di test

config.php
🔌 Il file di connessione, riusato da ogni altra pagina
<?php $host = 'localhost'; $db = 'cms_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()); } ?>
💡 Questo file non stampa nulla — serve solo a creare $pdo, che ogni altra pagina del progetto includerà con require 'config.php'; per riusare la stessa connessione.
index.php (versione di TEST, verrà sostituita al Passo 4)
🧪 Prima verifica: la connessione funziona?
<?php require 'config.php'; echo "Connessione riuscita al database $db!"; ?>
✅ Checkpoint — a questo punto hai: apri index.php nel browser. Se vedi il messaggio di conferma, la connessione funziona e puoi passare al passo successivo. Se vedi l'errore di config.php, controlla nome database/utente/password prima di proseguire.
3

Funzionalità core — creare e leggere articoli

🎯 Cosa costruiamo in questo passo
Prima di preoccuparci di categorie, permessi o stati di pubblicazione, facciamo funzionare la cosa più semplice possibile: un form che salva un articolo, e una pagina che li elenca tutti. Le tabelle categorie e utenti esistono già ma per ora le ignoriamo.
admin/nuovo.php
✏️ Form per creare un nuovo articolo (versione minima)
<?php require '../config.php'; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $titolo = $_POST['titolo'] ?? ''; $contenuto = $_POST['contenuto'] ?? ''; $slug = strtolower(str_replace(' ', '-', $titolo)); $stmt = $pdo->prepare("INSERT INTO articoli (titolo, slug, contenuto) VALUES (:titolo, :slug, :contenuto)"); $stmt->execute(['titolo' => $titolo, 'slug' => $slug, 'contenuto' => $contenuto]); header('Location: dashboard.php'); exit; } ?> <form method="post"> <input type="text" name="titolo" placeholder="Titolo" required> <textarea name="contenuto" placeholder="Contenuto" required></textarea> <button type="submit">Salva articolo</button> </form>
💡 Nota che non compaiono ancora autore_id né categoria_id nell'INSERT — restano NULL per ora. Li aggiungeremo al Passo 4 quando introdurremo le relazioni, senza toccare il resto di questo file.
admin/dashboard.php
📋 Elencare gli articoli inseriti finora
<?php require '../config.php'; $stmt = $pdo->query("SELECT id, titolo, stato FROM articoli ORDER BY creato_il DESC"); $articoli = $stmt->fetchAll(PDO::FETCH_ASSOC); ?> <h1>I tuoi articoli</h1> <a href="nuovo.php">+ Nuovo articolo</a> <ul> <?php foreach ($articoli as $a): ?> <li><?= htmlspecialchars($a['titolo']) ?> (<?= $a['stato'] ?>)</li> <?php endforeach; ?> </ul>
✅ Checkpoint — a questo punto hai: puoi scrivere un articolo da nuovo.php e vederlo comparire in dashboard.php. Il CMS "funziona" nella sua forma più semplice, senza ancora categorie, permessi o front-end pubblico.
4

Aggiungere le relazioni — categorie e autore

🔗 Ora colleghiamo le tabelle che avevamo lasciato da parte
Modifichiamo nuovo.php del Passo 3 per includere anche categoria_id, e creiamo il front-end pubblico che userà JOIN per mostrare categoria e autore insieme all'articolo.
admin/nuovo.php (MODIFICA rispetto al Passo 3)
✏️ Aggiungere la selezione categoria al form esistente
<?php require '../config.php'; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $titolo = $_POST['titolo'] ?? ''; $contenuto = $_POST['contenuto'] ?? ''; $categoriaId = (int) ($_POST['categoria_id'] ?? 0); $slug = strtolower(str_replace(' ', '-', $titolo)); // query INSERT aggiornata con la nuova colonna: $stmt = $pdo->prepare("INSERT INTO articoli (titolo, slug, contenuto, categoria_id) VALUES (:titolo, :slug, :contenuto, :categoria_id)"); $stmt->execute(['titolo' => $titolo, 'slug' => $slug, 'contenuto' => $contenuto, 'categoria_id' => $categoriaId]); header('Location: dashboard.php'); exit; } // nuovo: recuperiamo le categorie per popolare il menu a tendina $categorie = $pdo->query("SELECT id, nome FROM categorie")->fetchAll(PDO::FETCH_ASSOC); ?> <form method="post"> <input type="text" name="titolo" placeholder="Titolo" required> <textarea name="contenuto" placeholder="Contenuto" required></textarea> <select name="categoria_id"> <?php foreach ($categorie as $c): ?> <option value="<?= $c['id'] ?>"><?= htmlspecialchars($c['nome']) ?></option> <?php endforeach; ?> </select> <button type="submit">Salva articolo</button> </form>
💡 Solo due parti sono cambiate rispetto al Passo 3: la lettura di categoria_id dal form, e la query SQL che ora include quella colonna. Il resto del file è identico.
index.php (front-end pubblico, sostituisce la versione di test del Passo 2)
🌐 Mostrare gli articoli pubblicati con autore e categoria
<?php require 'config.php'; $stmt = $pdo->query(" SELECT a.titolo, a.contenuto, u.nome AS autore, c.nome AS categoria FROM articoli a LEFT JOIN utenti u ON a.autore_id = u.id LEFT JOIN categorie c ON a.categoria_id = c.id WHERE a.stato = 'pubblicato' ORDER BY a.creato_il DESC "); $articoli = $stmt->fetchAll(PDO::FETCH_ASSOC); ?> <?php foreach ($articoli as $a): ?> <article> <h2><?= htmlspecialchars($a['titolo']) ?></h2> <p>Di <?= htmlspecialchars($a['autore'] ?? 'Anonimo') ?> · <?= htmlspecialchars($a['categoria'] ?? 'Senza categoria') ?></p> </article> <?php endforeach; ?>
✅ Checkpoint — a questo punto hai: LEFT JOIN (non INNER JOIN) perché un articolo potrebbe ancora non avere autore/categoria assegnati — così compare comunque, con "Anonimo"/"Senza categoria" invece di sparire dal risultato.
5

Permessi e sicurezza — proteggere il pannello

🎯 Ora che il CMS funziona, lo blindiamo
Finora chiunque poteva aprire admin/nuovo.php direttamente. Aggiungiamo login e controllo ruoli, innestandoli sui file già scritti.
admin/login.php
🔑 Autenticazione minima
<?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']; header('Location: dashboard.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>
admin/nuovo.php (MODIFICA rispetto al Passo 4: aggiungiamo il controllo accesso in cima)
<?php session_start(); if (!isset($_SESSION['utente_id'])) { header('Location: login.php'); exit; } require '../config.php'; if ($_SERVER['REQUEST_METHOD'] === 'POST') { // ... come nel Passo 4, ma ora aggiungiamo autore_id: ... $stmt = $pdo->prepare("INSERT INTO articoli (titolo, slug, contenuto, categoria_id, autore_id) VALUES (:titolo, :slug, :contenuto, :categoria_id, :autore_id)"); $stmt->execute([ 'titolo' => $titolo, 'slug' => $slug, 'contenuto' => $contenuto, 'categoria_id' => $categoriaId, 'autore_id' => $_SESSION['utente_id'] ]); // ... resto invariato ... } ?>
💡 Solo 5 righe aggiunte in cima al file, più una piccola modifica alla query INSERT — tutto il resto del Passo 4 resta com'era. Ora autore_id viene popolato automaticamente dall'utente loggato, non più lasciato NULL.
🛡️ Controllo dei permessi per ruolo
<?php function puoModificareArticolo(array $utente, array $articolo): bool { return match($utente['ruolo']) { 'admin', 'editor' => true, 'autore' => $articolo['autore_id'] === $utente['id'], default => false, }; } ?>
✅ Checkpoint — a questo punto hai: nessuno può più accedere al pannello senza login; ogni nuovo articolo registra automaticamente il suo vero autore; una funzione pronta per il Passo 6, dove la useremo nella pagina di modifica.
6

Funzionalità avanzate — modifica e pubblicazione

admin/modifica.php (nuovo file)
✏️ Modificare un articolo esistente, rispettando i permessi
<?php session_start(); if (!isset($_SESSION['utente_id'])) { header('Location: login.php'); exit; } require '../config.php'; require 'permessi.php'; // contiene puoModificareArticolo(), estratta dal Passo 5 $id = (int) ($_GET['id'] ?? 0); $stmt = $pdo->prepare("SELECT * FROM articoli WHERE id = :id"); $stmt->execute(['id' => $id]); $articolo = $stmt->fetch(PDO::FETCH_ASSOC); $utenteCorrente = ['id' => $_SESSION['utente_id'], 'ruolo' => $_SESSION['ruolo']]; if (!$articolo || !puoModificareArticolo($utenteCorrente, $articolo)) { die("Non hai i permessi per modificare questo articolo"); } if ($_SERVER['REQUEST_METHOD'] === 'POST') { $stmt = $pdo->prepare("UPDATE articoli SET titolo = :titolo, contenuto = :contenuto, stato = :stato WHERE id = :id"); $stmt->execute([ 'titolo' => $_POST['titolo'], 'contenuto' => $_POST['contenuto'], 'stato' => $_POST['stato'], 'id' => $id ]); header('Location: dashboard.php'); exit; } ?> <form method="post"> <input type="text" name="titolo" value="<?= htmlspecialchars($articolo['titolo']) ?>"> <textarea name="contenuto"><?= htmlspecialchars($articolo['contenuto']) ?></textarea> <select name="stato"> <option value="bozza">Bozza</option> <option value="pubblicato">Pubblicato</option> <option value="archiviato">Archiviato</option> </select> <button type="submit">Aggiorna</button> </form>
✅ Checkpoint — a questo punto hai: il flusso editoriale completo. Un articolo nasce come "bozza" (Passo 4), un editor o l'autore stesso lo modifica e cambia lo stato a "pubblicato" (qui) — solo allora compare nel front-end pubblico del Passo 4, grazie al filtro WHERE stato = 'pubblicato' già scritto.
7

Integrazione finale — come tutto si tiene insieme

🧩 Il flusso completo, dall'inizio alla fine
PassoCosa abbiamo costruito
13 tabelle collegate: utenti, categorie, articoli
2config.php: la connessione condivisa da tutti i file successivi
3nuovo.php + dashboard.php: creare e listare articoli (senza relazioni)
4Aggiunta categoria al form; index.php pubblico con LEFT JOIN su autore e categoria
5login.php + controllo sessione su nuovo.php; autore_id automatico; funzione permessi
6modifica.php: usa la funzione permessi del Passo 5 per decidere chi può modificare cosa, e permette di cambiare lo stato che il Passo 4 già filtra
Nota come ogni passo non sia isolato: il form del Passo 3 viene esteso al Passo 4, blindato al Passo 5, e riusato (tramite la stessa logica di permessi) al Passo 6. Il filtro stato = 'pubblicato' scritto al Passo 4 è ciò che rende utile lo stato che il Passo 6 permette di cambiare. Nessun pezzo di codice è stato scritto "a sé stante".
🚀 Da qui in avanti
Il CMS è funzionante end-to-end. Estensioni naturali (non trattate qui): pubblicazione di più categorie per articolo (tabella ponte, come in una relazione molti-a-molti), immagine in evidenza, ricerca full-text sugli articoli.
🎓 Esercitazioni pratiche
1
Aggiungi un campo "immagine_copertina"
Modifica la tabella articoli (Passo 1) aggiungendo una colonna immagine_copertina VARCHAR(255), poi aggiorna il form del Passo 4 e la query di index.php per includerla.
⏱ 30 min📦 Migrazione SQL + 2 file PHP modificati💻 Individuale
2
Pagina di dettaglio articolo pubblico
Crea articolo.php?slug=... che mostra un singolo articolo pubblicato per intero, riusando la stessa query JOIN vista al Passo 4 ma filtrata per slug invece che l'elenco completo.
⏱ 45 min📦 1 nuovo file PHP💻 Individuale
3
Estendi i permessi: solo l'admin elimina
Crea admin/elimina.php che usa puoModificareArticolo() del Passo 5 come base, ma aggiunge una regola più severa: solo il ruolo admin può eliminare (mai editor o autore, a differenza della modifica).
⏱ 25 min📦 1 nuovo file + 1 funzione💻 Individuale