Le REST API sono oggi la spina dorsale di qualsiasi applicazione moderna: le usano le app mobile per comunicare con il server, i frontend React e Vue per ricevere dati, i sistemi di terze parti per integrarsi con il tuo prodotto. Eppure costruirne una in PHP viene spesso percepito come qualcosa di complicato, riservato a chi usa framework come Laravel.
In questa guida vedremo come realizzare una REST API completa in PHP puro — senza framework, zero dipendenze — collegata a un database MySQL, con le quattro operazioni fondamentali (GET, POST, PUT, DELETE) e una struttura pronta per la produzione.
Cos'è una REST API e perché PHP è ancora ottimo
REST (Representational State Transfer) è un'architettura che definisce come client e server si scambiano dati via HTTP. Le regole sono poche e semplici:
| Metodo HTTP | Operazione | Esempio endpoint | Cosa fa |
|---|---|---|---|
| GET | Leggi | /api/articoli |
Restituisce la lista degli articoli |
| GET | Leggi uno | /api/articoli/5 |
Restituisce l'articolo con ID 5 |
| POST | Crea | /api/articoli |
Crea un nuovo articolo |
| PUT | Modifica | /api/articoli/5 |
Aggiorna l'articolo con ID 5 |
| DELETE | Elimina | /api/articoli/5 |
Cancella l'articolo con ID 5 |
PHP gestisce nativamente tutti questi metodi HTTP, iò JSON con json_encode() e json_decode(), e parla con MySQL tramite PDO. Non hai bisogno di altro per costruire un'API robusta.
Struttura del progetto
├── index.php <-- router principale
├── config.php <-- credenziali DB
├── Database.php <-- classe connessione PDO
├── Articolo.php <-- classe modello
├── .htaccess <-- riscrittura URL
Ogni file ha un compito preciso: index.php instrada le richieste, Articolo.php contiene la logica dei dati, Database.php gestisce la connessione. Separazione netta, facile da estendere.
Database MySQL — crea la tabella
Esegui questo SQL in phpMyAdmin:
CREATE DATABASE IF NOT EXISTS mia_api CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE mia_api;
CREATE TABLE articoli (
id INT AUTO_INCREMENT PRIMARY KEY,
titolo VARCHAR(255) NOT NULL,
contenuto TEXT,
autore VARCHAR(100),
creato_il DATETIME DEFAULT CURRENT_TIMESTAMP,
aggiornato DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Dati di esempio
INSERT INTO articoli (titolo, contenuto, autore) VALUES
('Primo articolo', 'Contenuto del primo articolo...', 'Mario Rossi'),
('Secondo articolo', 'Contenuto del secondo articolo...', 'Lucia Bianchi');
SQL
config.php e Database.php
<?php
// config.php
define('DB_HOST', 'localhost');
define('DB_NAME', 'mia_api');
define('DB_USER', 'root'); // cambia con il tuo utente
define('DB_PASS', 'password'); // cambia con la tua password
define('DB_CHARSET', 'utf8mb4');
PHP — config.php
<?php
// Database.php
require_once 'config.php';
class Database {
private static ?PDO $instance = null;
public static function connect(): PDO {
if (self::$instance === null) {
$dsn = 'mysql:host=' . DB_HOST
. ';dbname=' . DB_NAME
. ';charset=' . DB_CHARSET;
self::$instance = new PDO($dsn, DB_USER, DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
return self::$instance;
}
}
PHP — Database.php
Database::connect() crea la connessione PDO solo alla prima chiamata e la riusa per tutta la richiesta — zero connessioni duplicate.
Articolo.php — il modello
Tutta la logica SQL sta qui. Il controller (index.php) non tocca mai il database direttamente.
<?php
// Articolo.php
require_once 'Database.php';
class Articolo {
private PDO $db;
public function __construct() {
$this->db = Database::connect();
}
// GET /articoli — lista tutti
public function getAll(): array {
$stmt = $this->db->query(
'SELECT id, titolo, autore, creato_il FROM articoli ORDER BY creato_il DESC'
);
return $stmt->fetchAll();
}
// GET /articoli/{id} — leggi uno
public function getOne(int $id): ?array {
$stmt = $this->db->prepare(
'SELECT * FROM articoli WHERE id = :id LIMIT 1'
);
$stmt->execute([':id' => $id]);
$row = $stmt->fetch();
return $row ?: null;
}
// POST /articoli — crea
public function create(array $data): array {
$stmt = $this->db->prepare(
'INSERT INTO articoli (titolo, contenuto, autore)
VALUES (:titolo, :contenuto, :autore)'
);
$stmt->execute([
':titolo' => $data['titolo'] ?? '',
':contenuto' => $data['contenuto'] ?? '',
':autore' => $data['autore'] ?? '',
]);
return $this->getOne((int)$this->db->lastInsertId());
}
// PUT /articoli/{id} — aggiorna
public function update(int $id, array $data): ?array {
$stmt = $this->db->prepare(
'UPDATE articoli
SET titolo = :titolo, contenuto = :contenuto, autore = :autore
WHERE id = :id'
);
$stmt->execute([
':titolo' => $data['titolo'] ?? '',
':contenuto' => $data['contenuto'] ?? '',
':autore' => $data['autore'] ?? '',
':id' => $id,
]);
return $stmt->rowCount() > 0 ? $this->getOne($id) : null;
}
// DELETE /articoli/{id} — elimina
public function delete(int $id): bool {
$stmt = $this->db->prepare('DELETE FROM articoli WHERE id = :id');
$stmt->execute([':id' => $id]);
return $stmt->rowCount() > 0;
}
}
PHP — Articolo.php
index.php — il router
Questo è il cuore dell'API. Legge il metodo HTTP e l'URL, instrada alla funzione giusta e risponde sempre in JSON.
<?php
// index.php
require_once 'Articolo.php';
// Imposta sempre Content-Type JSON
header('Content-Type: application/json; charset=UTF-8');
// Gestione CORS (per chiamate da altri domini o frontend JS)
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
// Pre-flight CORS
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit();
}
// Helper: risposta JSON
function risposta(int $code, mixed $data): void {
http_response_code($code);
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
exit();
}
// Parsing URL — estrae /api/articoli e /api/articoli/5
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$parti = explode('/', trim($uri, '/'));
// $parti[0] = 'api', $parti[1] = 'articoli', $parti[2] = ID (opzionale)
$risorsa = $parti[1] ?? '';
$id = isset($parti[2]) && is_numeric($parti[2]) ? (int)$parti[2] : null;
$metodo = $_SERVER['REQUEST_METHOD'];
// Body JSON (per POST e PUT)
$body = json_decode(file_get_contents('php://input'), true) ?? [];
// ── Router ───────────────────────────────────────────────────
if ($risorsa !== 'articoli') {
risposta(404, ['errore' => 'Risorsa non trovata']);
}
$articolo = new Articolo();
try {
// GET /api/articoli — lista tutti
if ($metodo === 'GET' && $id === null) {
$lista = $articolo->getAll();
risposta(200, ['successo' => true, 'totale' => count($lista), 'dati' => $lista]);
}
// GET /api/articoli/{id} — leggi uno
if ($metodo === 'GET' && $id !== null) {
$item = $articolo->getOne($id);
if (!$item) risposta(404, ['errore' => 'Articolo non trovato']);
risposta(200, ['successo' => true, 'dati' => $item]);
}
// POST /api/articoli — crea
if ($metodo === 'POST') {
if (empty($body['titolo'])) {
risposta(422, ['errore' => 'Il campo titolo e\' obbligatorio']);
}
$nuovo = $articolo->create($body);
risposta(201, ['successo' => true, 'messaggio' => 'Articolo creato', 'dati' => $nuovo]);
}
// PUT /api/articoli/{id} — aggiorna
if ($metodo === 'PUT' && $id !== null) {
$aggiornato = $articolo->update($id, $body);
if (!$aggiornato) risposta(404, ['errore' => 'Articolo non trovato o nessuna modifica']);
risposta(200, ['successo' => true, 'messaggio' => 'Articolo aggiornato', 'dati' => $aggiornato]);
}
// DELETE /api/articoli/{id} — elimina
if ($metodo === 'DELETE' && $id !== null) {
$eliminato = $articolo->delete($id);
if (!$eliminato) risposta(404, ['errore' => 'Articolo non trovato']);
risposta(200, ['successo' => true, 'messaggio' => 'Articolo eliminato']);
}
risposta(405, ['errore' => 'Metodo non consentito']);
} catch (Exception $e) {
error_log($e->getMessage());
risposta(500, ['errore' => 'Errore interno del server']);
}
PHP — index.php
.htaccess — URL puliti
Senza questo file gli URL avrebbero index.php nel mezzo. Con la riscrittura Apache, /api/articoli/5 punta sempre a index.php:
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [QSA,L]
.htaccess
Esempio di risposta JSON
Una chiamata GET /api/articoli restituisce:
"successo": true,
"totale": 2,
"dati": [
{
"id": 1,
"titolo": "Primo articolo",
"autore": "Mario Rossi",
"creato_il": "2025-06-01 10:23:00"
}
]
}
Una chiamata POST /api/articoli con body JSON crea l'articolo e restituisce il record appena inserito con HTTP 201. Un DELETE /api/articoli/5 su un ID inesistente risponde con HTTP 404 e un messaggio chiaro.
Testare l'API con Postman o curl
Con curl da terminale
# GET tutti gli articoli
curl https://tuosito.it/api/articoli
# GET articolo singolo
curl https://tuosito.it/api/articoli/1
# POST — crea nuovo articolo
curl -X POST https://tuosito.it/api/articoli \
-H "Content-Type: application/json" \
-d '{"titolo":"Nuovo","contenuto":"Testo...","autore":"Anna"}'
# PUT — aggiorna
curl -X PUT https://tuosito.it/api/articoli/1 \
-H "Content-Type: application/json" \
-d '{"titolo":"Aggiornato","contenuto":"Nuovo testo","autore":"Anna"}'
# DELETE
curl -X DELETE https://tuosito.it/api/articoli/1
BASH — curl
Sicurezza: le basi prima di andare in produzione
- Usa sempre prepared statements PDO (come nell'esempio) — protezione nativa da SQL injection
- Aggiungi autenticazione API Key o JWT negli header per le operazioni di scrittura
- Valida e sanifica ogni campo prima di scrivere nel DB
- Imposta CORS restrittivo in produzione — sostituisci
*con il dominio del tuo frontend - Usa HTTPS sempre — senza TLS le chiamate viaggiano in chiaro
- Logga gli errori su file con
error_log(), mai mostrarli all'utente finale - Aggiungi rate limiting per IP per evitare abusi dell'endpoint
- Considera un campo
api_keynella tabella utenti per autenticare i client
Esempio autenticazione API Key minimalista
// Aggiungere in index.php, subito dopo gli header
$apiKey = $_SERVER['HTTP_X_API_KEY'] ?? '';
$keysValide = ['chiave-segreta-1', 'chiave-segreta-2']; // meglio da DB
if ($metodo !== 'GET' && !in_array($apiKey, $keysValide, true)) {
risposta(401, ['errore' => 'Chiave API non valida o assente']);
}
// Le letture GET restano pubbliche, scritture richiedono la chiave
PHP — auth semplice
Conclusioni
Hai appena costruito una REST API completa in PHP puro — routing, CRUD, JSON, PDO, gestione errori e CORS — senza installare nemmeno un pacchetto Composer. La struttura che hai visto è già quella usata in produzione da centinaia di progetti reali.
Da qui puoi estendere aggiungendo autenticazione JWT, paginazione sui risultati, endpoint di ricerca con FULLTEXT MySQL, o una seconda risorsa (es. /api/utenti) seguendo esattamente lo stesso schema.
LIMIT e OFFSET, oppure integra questa API come backend per un'app React o Vue — è già pronta per farlo.