Progetto Guidato — Creare un CRM con PHP e MySQL

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

Punto di partenza — cosa costruiamo

🎯 Obiettivo del progetto
Costruiremo un CRM minimo: un commerciale registra trattative (opportunità di vendita), le collega a contatti e aziende, ne segue l'avanzamento in una pipeline a fasi, e registra ogni interazione (chiamata, email, riunione). Nessun front-end pubblico qui: il CRM è un sistema interamente interno, accessibile solo previo login.
📁 Struttura delle cartelle del progetto
crm/ ├── config.php // connessione al database (Passo 2) ├── login.php // autenticazione (Passo 5) ├── trattative.php // elenco trattative (Passo 3) ├── nuova_trattativa.php // form nuova trattativa (Passo 3) ├── dettaglio.php // singola trattativa + interazioni (Passo 6) └── report.php // dashboard pipeline (Passo 6)
💡 Notare l'assenza di file "pubblici": a differenza del CMS, qui ogni pagina richiederà login fin dal Passo 5 — non esiste un equivalente di index.php aperto a tutti.
1

Il database — creiamo tutte le tabelle

🗄️ Schema completo, nell'ordine corretto
Ordine di creazione: prima aziende (indipendente), poi contatti (referenzia aziende), poi trattative (referenzia contatti e utenti — supponiamo utenti già esistente da un sistema di autenticazione condiviso), infine interazioni (referenzia trattative).
CREATE TABLE aziende ( id INT AUTO_INCREMENT PRIMARY KEY, ragione_sociale VARCHAR(200) NOT NULL, settore VARCHAR(100) ); CREATE TABLE contatti ( id INT AUTO_INCREMENT PRIMARY KEY, nome VARCHAR(100) NOT NULL, email VARCHAR(150), azienda_id INT, FOREIGN KEY (azienda_id) REFERENCES aziende(id) ); CREATE TABLE trattative ( id INT AUTO_INCREMENT PRIMARY KEY, titolo VARCHAR(200) NOT NULL, valore DECIMAL(10,2), fase ENUM('lead', 'qualificato', 'proposta', 'chiuso_vinto', 'chiuso_perso') DEFAULT 'lead', contatto_id INT, commerciale_id INT, creata_il TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (contatto_id) REFERENCES contatti(id) ); CREATE TABLE interazioni ( id INT AUTO_INCREMENT PRIMARY KEY, trattativa_id INT, tipo ENUM('chiamata', 'email', 'riunione', 'nota'), contenuto TEXT, creata_il TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (trattativa_id) REFERENCES trattative(id) );
💡 DECIMAL(10,2) per valore: mai FLOAT per il denaro, per evitare errori di arrotondamento in un contesto dove i numeri hanno valore economico reale.
✅ Checkpoint — a questo punto hai
4 tabelle collegate in catena (aziende → contatti → trattative → interazioni), nessun dato ancora, nessun file PHP.
2

Connessione e prima pagina di test

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

Funzionalità core — creare e listare trattative

🎯 Cosa costruiamo in questo passo
La versione più semplice possibile: un form che salva una trattativa con solo titolo e valore, una pagina che le elenca. Contatti, commerciale e interazioni restano fuori per ora.
nuova_trattativa.php
<?php require 'config.php'; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $titolo = $_POST['titolo'] ?? ''; $valore = (float) ($_POST['valore'] ?? 0); $stmt = $pdo->prepare("INSERT INTO trattative (titolo, valore) VALUES (:titolo, :valore)"); $stmt->execute(['titolo' => $titolo, 'valore' => $valore]); header('Location: trattative.php'); exit; } ?> <form method="post"> <input type="text" name="titolo" placeholder="Titolo trattativa" required> <input type="number" name="valore" step="0.01" placeholder="Valore €"> <button type="submit">Salva</button> </form>
💡 contatto_id e commerciale_id restano NULL: li aggiungeremo al Passo 4, senza toccare il resto di questo file.
trattative.php (sostituisce la versione di test del Passo 2)
<?php require 'config.php'; $stmt = $pdo->query("SELECT id, titolo, valore, fase FROM trattative ORDER BY creata_il DESC"); $trattative = $stmt->fetchAll(PDO::FETCH_ASSOC); ?> <h1>Le tue trattative</h1> <a href="nuova_trattativa.php">+ Nuova trattativa</a> <ul> <?php foreach ($trattative as $t): ?> <li><?= htmlspecialchars($t['titolo']) ?> — €<?= $t['valore'] ?> (<?= $t['fase'] ?>)</li> <?php endforeach; ?> </ul>
✅ Checkpoint — a questo punto hai: puoi creare trattative e vederle elencate, tutte in fase "lead" di default (dallo schema del Passo 1). Nessun collegamento a contatti o commerciali ancora.
4

Aggiungere le relazioni — contatti e azienda

🔗 Colleghiamo il contatto alla trattativa
Modifichiamo il form del Passo 3 per selezionare un contatto esistente, e creiamo un report che mostra trattativa + contatto + azienda insieme, tramite JOIN.
nuova_trattativa.php (MODIFICA rispetto al Passo 3)
<?php require 'config.php'; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $titolo = $_POST['titolo'] ?? ''; $valore = (float) ($_POST['valore'] ?? 0); $contattoId = (int) ($_POST['contatto_id'] ?? 0); $stmt = $pdo->prepare("INSERT INTO trattative (titolo, valore, contatto_id) VALUES (:titolo, :valore, :contatto_id)"); $stmt->execute(['titolo' => $titolo, 'valore' => $valore, 'contatto_id' => $contattoId]); header('Location: trattative.php'); exit; } // nuovo: elenco contatti per il menu a tendina $contatti = $pdo->query("SELECT id, nome FROM contatti")->fetchAll(PDO::FETCH_ASSOC); ?> <form method="post"> <input type="text" name="titolo" placeholder="Titolo trattativa" required> <input type="number" name="valore" step="0.01" placeholder="Valore €"> <select name="contatto_id"> <?php foreach ($contatti as $c): ?> <option value="<?= $c['id'] ?>"><?= htmlspecialchars($c['nome']) ?></option> <?php endforeach; ?> </select> <button type="submit">Salva</button> </form>
report.php (prima versione, verrà estesa al Passo 6)
📊 Vedere trattativa + contatto + azienda insieme
<?php require 'config.php'; $stmt = $pdo->query(" SELECT t.titolo, t.valore, t.fase, c.nome AS contatto, a.ragione_sociale AS azienda FROM trattative t LEFT JOIN contatti c ON t.contatto_id = c.id LEFT JOIN aziende a ON c.azienda_id = a.id ORDER BY t.creata_il DESC "); $righe = $stmt->fetchAll(PDO::FETCH_ASSOC); ?> <?php foreach ($righe as $r): ?> <p><?= htmlspecialchars($r['titolo']) ?> — <?= htmlspecialchars($r['contatto'] ?? 'nessun contatto') ?> (<?= htmlspecialchars($r['azienda'] ?? 'n/d') ?>)</p> <?php endforeach; ?>
✅ Checkpoint — a questo punto hai: LEFT JOIN doppio (trattative→contatti→aziende) — una trattativa senza contatto (create al Passo 3) compare comunque con "nessun contatto", invece di sparire come farebbe con INNER JOIN.
5

Permessi e sicurezza — proteggere e filtrare per commerciale

🎯 Login obbligatorio + visibilità per proprietario
A differenza del CMS (dove il front-end restava pubblico), qui OGNI pagina richiede login. In più, un commerciale junior deve vedere solo le proprie trattative — solo un manager vede tutto.
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']; // 'manager' o 'commerciale' header('Location: trattative.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>
trattative.php (MODIFICA rispetto al Passo 3: login + filtro per proprietario)
<?php session_start(); if (!isset($_SESSION['utente_id'])) { header('Location: login.php'); exit; } require 'config.php'; // se manager, vede tutto; se commerciale, solo le proprie trattative if ($_SESSION['ruolo'] === 'manager') { $stmt = $pdo->query("SELECT id, titolo, valore, fase FROM trattative ORDER BY creata_il DESC"); $trattative = $stmt->fetchAll(PDO::FETCH_ASSOC); } else { $stmt = $pdo->prepare("SELECT id, titolo, valore, fase FROM trattative WHERE commerciale_id = :id ORDER BY creata_il DESC"); $stmt->execute(['id' => $_SESSION['utente_id']]); $trattative = $stmt->fetchAll(PDO::FETCH_ASSOC); } ?> // il resto (il foreach che stampa l'elenco) resta identico al Passo 3
💡 Le prime due righe bloccano l'accesso senza login (identiche nel principio al CMS); la parte nuova è il filtro condizionale in base al ruolo — la stessa query di sempre, ma con o senza WHERE commerciale_id a seconda di chi guarda.
nuova_trattativa.php (MODIFICA: commerciale_id automatico)
<?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, con l'aggiunta di commerciale_id ... $stmt = $pdo->prepare("INSERT INTO trattative (titolo, valore, contatto_id, commerciale_id) VALUES (:titolo, :valore, :contatto_id, :commerciale_id)"); $stmt->execute([ 'titolo' => $titolo, 'valore' => $valore, 'contatto_id' => $contattoId, 'commerciale_id' => $_SESSION['utente_id'] ]); } ?>
✅ Checkpoint — a questo punto hai: nessun accesso senza login; ogni trattativa registra il vero commerciale che l'ha creata; il commerciale vede solo le proprie, il manager vede tutto.
6

Funzionalità avanzate — pipeline e interazioni

dettaglio.php (nuovo file)
📋 Cambiare fase e registrare un'interazione
<?php session_start(); if (!isset($_SESSION['utente_id'])) { header('Location: login.php'); exit; } require 'config.php'; $id = (int) ($_GET['id'] ?? 0); if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['nuova_fase'])) { $faseValide = ['lead', 'qualificato', 'proposta', 'chiuso_vinto', 'chiuso_perso']; if (in_array($_POST['nuova_fase'], $faseValide)) { $stmt = $pdo->prepare("UPDATE trattative SET fase = :fase WHERE id = :id"); $stmt->execute(['fase' => $_POST['nuova_fase'], 'id' => $id]); } } if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['nuova_interazione'])) { $stmt = $pdo->prepare("INSERT INTO interazioni (trattativa_id, tipo, contenuto) VALUES (:tid, :tipo, :contenuto)"); $stmt->execute(['tid' => $id, 'tipo' => $_POST['tipo'], 'contenuto' => $_POST['nuova_interazione']]); } $interazioni = $pdo->prepare("SELECT tipo, contenuto, creata_il FROM interazioni WHERE trattativa_id = :id ORDER BY creata_il ASC"); $interazioni->execute(['id' => $id]); $storico = $interazioni->fetchAll(PDO::FETCH_ASSOC); ?> <?php foreach ($storico as $i): ?> <p>[<?= $i['tipo'] ?>] <?= htmlspecialchars($i['contenuto']) ?></p> <?php endforeach; ?>
✅ Checkpoint — a questo punto hai: la fase (colonna scritta al Passo 1, mostrata al Passo 3) ora può essere aggiornata qui; ogni interazione registrata compare in ordine cronologico, costruendo lo storico completo della trattativa.
report.php (ESTENSIONE rispetto al Passo 4)
📊 Aggiungere il valore totale per fase
<?php // aggiunta al report del Passo 4: query di sintesi per la pipeline $stmt = $pdo->query(" SELECT fase, COUNT(*) AS numero, SUM(valore) AS totale FROM trattative WHERE fase NOT IN ('chiuso_vinto', 'chiuso_perso') GROUP BY fase "); $pipeline = $stmt->fetchAll(PDO::FETCH_ASSOC); ?>
💡 Questa query si AGGIUNGE a quella del Passo 4, non la sostituisce: report.php ora mostra sia l'elenco dettagliato (Passo 4) sia il riepilogo aggregato per fase (qui).
7

Integrazione finale — come tutto si tiene insieme

🧩 Il flusso completo, dall'inizio alla fine
PassoCosa abbiamo costruito
14 tabelle in catena: aziende → contatti → trattative → interazioni
2config.php: connessione condivisa
3nuova_trattativa.php + trattative.php: creare e listare (senza relazioni)
4Selezione contatto nel form; report.php con doppio LEFT JOIN
5login.php; filtro per proprietario su trattative.php; commerciale_id automatico
6dettaglio.php: cambio fase (validato con in_array) + interazioni; report.php esteso con GROUP BY/SUM per fase
La fase scritta nello schema al Passo 1 resta inerte fino al Passo 6, che la rende modificabile; il filtro per proprietario del Passo 5 si applica automaticamente anche alle query successive che usano la stessa struttura; ogni passo aggiunge senza rompere ciò che già funzionava.
🚀 Da qui in avanti
Estensioni naturali non trattate qui: notifiche quando una trattativa cambia fase, esportazione del report in CSV, storico modifiche fase (non solo interazioni manuali).
🎓 Esercitazioni pratiche
1
Aggiungi la data di chiusura prevista
Modifica trattative (Passo 1) aggiungendo data_chiusura_prevista DATE, poi aggiorna il form del Passo 3 e report.php per mostrarla ordinata con ORDER BY.
⏱ 30 min📦 Migrazione SQL + 2 file💻 Individuale
2
Classifica commerciali per trattative vinte
Crea classifica.php riusando il pattern JOIN+GROUP BY+HAVING del Passo 6: mostra i commerciali con almeno 3 trattative in fase 'chiuso_vinto', ordinati per numero decrescente.
⏱ 40 min📦 1 nuovo file💻 Individuale
3
Impedisci la modifica di trattative altrui
Estendi dettaglio.php del Passo 6 con un controllo simile a quello del CMS: un commerciale non-manager può modificare la fase solo se commerciale_id corrisponde al proprio id in sessione.
⏱ 25 min📦 1 file modificato💻 Individuale