Progetto Guidato — Incorporare un Page Builder stile Elementor in PHP

Dal blocco singolo alla pagina componibile a sezioni, passo dopo passo · Marco Consiglio
Volta Institute · Napoli
A.A. 2026
0

Punto di partenza — cosa costruiamo

🎯 Obiettivo del progetto
Un page builder stile Elementor non salva un unico blocco di HTML (come nel progetto sull'editor WYSIWYG), ma una struttura a blocchi component-based: una pagina è composta da sezioni (hero, testo, immagine, call-to-action) ordinabili e configurabili singolarmente, tipicamente salvate come JSON. Costruiremo una versione semplificata: alcuni tipi di blocco predefiniti, un pannello per aggiungerli e ordinarli, e un motore di rendering PHP che trasforma la struttura salvata in HTML finale.
📁 Struttura delle cartelle del progetto
page-builder/ ├── config.php // connessione al database (Passo 2) ├── editor.php // pannello di modifica pagina (Passo 3, poi drag&drop al Passo 6) ├── renderer.php // funzioni che trasformano blocchi JSON in HTML (Passo 4) ├── login.php // autenticazione (Passo 5) └── pagina.php // front-end pubblico che mostra la pagina renderizzata (Passo 4)
1

Il database — pagine con struttura a blocchi

🗄️ Schema minimo
CREATE TABLE pagine ( id INT AUTO_INCREMENT PRIMARY KEY, titolo VARCHAR(200) NOT NULL, slug VARCHAR(220) UNIQUE NOT NULL, blocchi JSON, pubblicata BOOLEAN DEFAULT FALSE, aggiornata_il TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );
💡 JSON è un tipo di colonna nativo in MySQL (dalla versione 5.7): a differenza di salvare la struttura come stringa in un TEXT, MySQL valida automaticamente che il contenuto sia JSON sintatticamente corretto, rifiutando un INSERT/UPDATE con JSON malformato.
💡 ON UPDATE CURRENT_TIMESTAMP aggiorna automaticamente questa colonna ad ogni modifica della riga — utile per sapere quando una pagina è stata modificata l'ultima volta, diverso da creato_il che invece resta fisso al momento dell'inserimento.
✅ Checkpoint — a questo punto hai
Una tabella pronta a contenere pagine con struttura a blocchi in formato JSON — nessun dato ancora.
2

Connessione e prima pagina di test

config.php
<?php $host = 'localhost'; $db = 'pagebuilder_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()); } ?>
editor.php (versione di TEST, verrà estesa al Passo 3)
<?php require 'config.php'; echo "Connessione riuscita al database $db!"; ?>
✅ Checkpoint — a questo punto hai: apri editor.php. Messaggio di conferma = si può proseguire.
3

Funzionalità core — salvare UN blocco singolo

🎯 Cosa costruiamo in questo passo
Prima di gestire più blocchi ordinabili, verifichiamo che il meccanismo base funzioni con UN solo blocco di tipo "testo", salvato come array PHP convertito in JSON.
editor.php (sostituisce la versione di test del Passo 2)
<?php require 'config.php'; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $titolo = $_POST['titolo'] ?? ''; $slug = strtolower(str_replace(' ', '-', $titolo)); // UN solo blocco per ora, come singolo elemento di un array $blocchi = [ ['tipo' => 'testo', 'contenuto' => $_POST['testo'] ?? ''] ]; $stmt = $pdo->prepare("INSERT INTO pagine (titolo, slug, blocchi) VALUES (:titolo, :slug, :blocchi)"); $stmt->execute([ 'titolo' => $titolo, 'slug' => $slug, 'blocchi' => json_encode($blocchi) ]); echo "Pagina salvata con id " . $pdo->lastInsertId(); } ?> <form method="post"> <input type="text" name="titolo" placeholder="Titolo pagina" required> <textarea name="testo" placeholder="Contenuto del blocco di testo"></textarea> <button type="submit">Salva pagina</button> </form>
💡 json_encode($blocchi) converte l'array PHP (un array con un solo elemento, per ora) in una stringa JSON — la stessa funzione già vista nella guida sul Chatbot AI per il corpo delle richieste API, qui usata per serializzare la struttura della pagina prima di salvarla.
✅ Checkpoint — a questo punto hai: una pagina salvata con un array di UN blocco. Verifica col client MySQL che la colonna blocchi contenga un JSON valido come [{"tipo":"testo","contenuto":"..."}].
4

Blocchi multipli e rendering — dal JSON all'HTML finale

🎨 Ora gestiamo più blocchi di tipo diverso
Estendiamo lo schema di dati (non il database, solo la struttura JSON) per supportare più blocchi di tipi diversi, e scriviamo il "motore di rendering" che trasforma ciascun blocco nel suo HTML corrispondente.
editor.php (MODIFICA rispetto al Passo 3: più blocchi)
<?php // invece di un singolo blocco fisso, costruiamo un array con più tipi: $blocchi = [ ['tipo' => 'titolo', 'contenuto' => $_POST['titolo_hero'] ?? ''], ['tipo' => 'testo', 'contenuto' => $_POST['testo'] ?? ''], ['tipo' => 'immagine', 'url' => $_POST['url_immagine'] ?? ''], ['tipo' => 'bottone', 'testo' => $_POST['testo_bottone'] ?? '', 'link' => $_POST['link_bottone'] ?? ''] ]; // il resto (json_encode, INSERT) resta identico al Passo 3 ?>
💡 Ogni tipo di blocco ha campi diversi (testo vs url vs testo+link): questo è ciò che rende un page builder "component-based" — ogni componente ha una propria struttura dati, tutte contenute nello stesso array grazie alla flessibilità del JSON.
renderer.php (nuovo file)
🖼️ Trasformare i blocchi salvati in HTML
<?php function renderizzaBlocco(array $blocco): string { return match($blocco['tipo']) { 'titolo' => "<h1>" . htmlspecialchars($blocco['contenuto']) . "</h1>", 'testo' => "<p>" . nl2br(htmlspecialchars($blocco['contenuto'])) . "</p>", 'immagine' => '<img src="' . htmlspecialchars($blocco['url']) . '">', 'bottone' => '<a class="btn" href="' . htmlspecialchars($blocco['link']) . '">' . htmlspecialchars($blocco['testo']) . '</a>', default => '', }; } function renderizzaPagina(array $blocchi): string { $html = ''; foreach ($blocchi as $blocco) { $html .= renderizzaBlocco($blocco); } return $html; } ?>
💡 match (già visto nelle guide PHP Basi e OOP) è perfetto qui: ogni tipo di blocco ha una trasformazione diversa e ben definita, con un default che ignora tipi sconosciuti invece di generare un errore.
pagina.php (nuovo file, front-end pubblico)
<?php require 'config.php'; require 'renderer.php'; $slug = $_GET['slug'] ?? ''; $stmt = $pdo->prepare("SELECT titolo, blocchi FROM pagine WHERE slug = :slug AND pubblicata = TRUE"); $stmt->execute(['slug' => $slug]); $pagina = $stmt->fetch(PDO::FETCH_ASSOC); if (!$pagina) { die("Pagina non trovata"); } $blocchi = json_decode($pagina['blocchi'], true); ?> <?= renderizzaPagina($blocchi) ?>
✅ Checkpoint — a questo punto hai: json_decode($pagina['blocchi'], true) converte la stringa JSON salvata di nuovo in array PHP; renderizzaPagina() lo trasforma in HTML finale — nota WHERE pubblicata = TRUE, che al Passo 3 non aveva ancora senso perché la colonna non veniva mai impostata a TRUE da nessun form.
5

Permessi e sicurezza — proteggere l'editor

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: editor.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>
editor.php (MODIFICA: login obbligatorio)
<?php session_start(); if (!isset($_SESSION['utente_id'])) { header('Location: login.php'); exit; } require 'config.php'; // resto invariato rispetto al Passo 4 ?>
⚠️ pagina.php resta pubblico (mostra solo pagine pubblicata = TRUE), mentre editor.php richiede login — lo stesso pattern già visto nel CMS: front-end pubblico, pannello di modifica protetto.
🛡️ Attenzione: renderizzaBlocco() applica già htmlspecialchars()
A differenza dell'editor WYSIWYG (dove serviva strip_tags() con whitelist per permettere formattazione), qui ogni campo testuale di un blocco (titolo, contenuto, testo del bottone) viene passato attraverso htmlspecialchars() nel Passo 4 — i blocchi non contengono HTML libero, solo testo semplice inserito nei campi del form, quindi lo stesso principio del Passo 3 dell'editor WYSIWYG si applica qui.
✅ Checkpoint — a questo punto hai: editor protetto da login; front-end pubblico che mostra solo pagine pubblicate; ogni campo di testo nei blocchi già sanificato con htmlspecialchars() dentro renderizzaBlocco().
6

Drag & drop lato browser — riordinare i blocchi

🎯 Dal form fisso a un'interfaccia riordinabile
Finora l'ordine dei blocchi era fisso (titolo, testo, immagine, bottone, sempre in quest'ordine). Aggiungiamo JavaScript per permettere di trascinare e riordinare i blocchi PRIMA di inviarli al server.
editor.php (ESTENSIONE: blocchi come lista riordinabile)
<ul id="lista-blocchi"> <li draggable="true" data-tipo="titolo">Blocco: Titolo</li> <li draggable="true" data-tipo="testo">Blocco: Testo</li> <li draggable="true" data-tipo="immagine">Blocco: Immagine</li> </ul> <input type="hidden" id="ordine-blocchi" name="ordine"> <script> const lista = document.getElementById('lista-blocchi'); let trascinato = null; lista.addEventListener('dragstart', (e) => { trascinato = e.target; }); lista.addEventListener('dragover', (e) => e.preventDefault()); lista.addEventListener('drop', (e) => { e.preventDefault(); if (e.target.tagName === 'LI' && e.target !== trascinato) { lista.insertBefore(trascinato, e.target); } aggiornaOrdine(); }); function aggiornaOrdine() { const tipi = [...lista.querySelectorAll('li')].map(el => el.dataset.tipo); document.getElementById('ordine-blocchi').value = JSON.stringify(tipi); } aggiornaOrdine(); // ordine iniziale, prima di qualsiasi trascinamento </script>
💡 Il drag & drop nativo del browser (draggable, eventi dragstart/dragover/drop) non richiede librerie esterne — al momento dell'invio del form, ordine-blocchi contiene un JSON con l'ordine scelto dall'utente, es. ["testo","titolo","immagine"].
editor.php (MODIFICA lato PHP: costruire i blocchi nell'ordine scelto)
<?php // invece dell'array fisso del Passo 4, costruiamo l'ordine dal campo nascosto: $ordine = json_decode($_POST['ordine'] ?? '[]', true); $blocchiDisponibili = [ 'titolo' => ['tipo' => 'titolo', 'contenuto' => $_POST['titolo_hero'] ?? ''], 'testo' => ['tipo' => 'testo', 'contenuto' => $_POST['testo'] ?? ''], 'immagine' => ['tipo' => 'immagine', 'url' => $_POST['url_immagine'] ?? ''], ]; $blocchi = array_map(fn(string $tipo): array => $blocchiDisponibili[$tipo], $ordine); // il resto (json_encode, INSERT) resta identico al Passo 4 ?>
✅ Checkpoint — a questo punto hai: array_map (guida PHP Array) trasforma l'array di tipi nell'ordine scelto (es. ['testo','titolo']) nel corrispondente array di blocchi completi, pescandoli da $blocchiDisponibili — l'ordine scelto dall'utente nel Passo 6 determina finalmente l'ordine di rendering che renderizzaPagina() del Passo 4 già gestiva correttamente (scorre l'array nell'ordine in cui lo riceve).
7

Integrazione finale — come tutto si tiene insieme

🧩 Il flusso completo, dall'inizio alla fine
PassoCosa abbiamo costruito
1Tabella pagine con colonna JSON per la struttura a blocchi
2config.php: connessione condivisa
3editor.php salva UN blocco fisso di tipo testo, json_encode()
4Più tipi di blocco (titolo/testo/immagine/bottone); renderer.php con match(); pagina.php pubblico
5login.php; editor protetto, front-end pubblico invariato; htmlspecialchars già presente nel renderer
6Drag & drop nativo per riordinare i blocchi; PHP costruisce l'array nell'ordine scelto con array_map
renderizzaPagina() scritta al Passo 4 non viene mai modificata nei passi successivi: scorre semplicemente l'array di blocchi che riceve, nell'ordine in cui li riceve — il Passo 6 non tocca il renderer, cambia solo COME l'array arriva ordinato da editor.php.
🚀 Da qui in avanti
Estensioni naturali non trattate qui: anteprima live del blocco mentre lo si compila (senza dover salvare), più istanze dello stesso tipo di blocco nella stessa pagina, template di pagina predefiniti da cui partire.
🎓 Esercitazioni pratiche
1
Aggiungi un nuovo tipo di blocco "citazione"
Estendi renderizzaBlocco() del Passo 4 con un nuovo case 'citazione' che genera un <blockquote>, e aggiungi il campo corrispondente al form di editor.php e a $blocchiDisponibili del Passo 6.
⏱ 30 min📦 2 file modificati💻 Individuale
2
Pulsante "pubblica/nascondi" sulla pagina
Crea un'azione in editor.php che aggiorna pubblicata (Passo 1) con un semplice UPDATE, riprendendo il pattern di cambio stato già visto nel progetto CMS.
⏱ 20 min📦 1 file modificato💻 Individuale
3
Eliminare un blocco dalla lista prima di salvare
Estendi il JavaScript del Passo 6 con un pulsante "elimina" su ciascun <li> che lo rimuove dalla lista (e quindi dall'ordine finale) prima dell'invio del form, senza bisogno di modificare la logica PHP di editor.php.
⏱ 25 min📦 1 file modificato (solo JS)💻 Individuale