Come ho costruito un e-commerce luxury in PHP nativo senza framework
Architettura, scelte tecniche e lezioni apprese nella costruzione di MAISON: un e-commerce da 33 tabelle, 37 file PHP, con Stripe, PayPal e Klarna integrati via cURL raw.
Perché PHP nativo e non Laravel, Symfony o WooCommerce
La prima domanda che mi fanno tutti: "Perché non hai usato un framework?". La risposta è pragmatica, non ideologica.
Il progetto nasce con vincoli precisi: hosting condiviso Aruba (no SSH, no Composer sul server, no deploy automatizzati), budget limitato del cliente, e necessità di un sistema che io potessi mantenere e modificare in autonomia totale — senza dipendere da aggiornamenti di terze parti, breaking changes di framework o plugin che smettono di essere supportati.
Con PHP nativo ho il controllo completo su ogni singola query, ogni redirect, ogni byte che esce dal server. Non c'è magic. Non c'è overhead. Il risultato è un e-commerce che carica in meno di 800ms su mobile con un hosting da 30€/anno.
Zero dipendenze = zero sorprese. Ogni riga di codice è mia, ogni bug è mio, ogni ottimizzazione è mia. Su un hosting condiviso senza SSH, questa è l'unica strategia che scala.
Architettura del database: 33 tabelle
Il cuore di MAISON è il database. 33 tabelle MySQL che gestiscono l'intero ecosistema: catalogo prodotti con varianti infinite (taglia/colore/stock), ordini con timeline, utenti con sessioni, carrello persistente, wishlist, recensioni, ticket di supporto, sistema coupon, e log di tutte le attività admin.
Schema delle relazioni principali
-- Prodotti: supporto varianti illimitate
products → id, name, slug, price, description, category_id
product_variants → id, product_id, size, color, stock, sku
product_images → id, product_id, url, position, is_primary
product_relations → id, product_id, related_id, type (cross-sell|upsell|related)
-- Ordini: stato con timeline
orders → id, user_id, status, total, shipping_address, created_at
order_items → id, order_id, product_id, variant_id, qty, price
order_timeline → id, order_id, status, note, created_at
-- Carrello persistente (anche per guest)
cart → id, session_id, user_id, created_at
cart_items → id, cart_id, product_id, variant_id, qty
La decisione chiave è stata separare product_variants dal prodotto principale. Ogni variante ha il suo stock indipendente, il suo SKU e può avere un prezzo diverso. Questo permette di gestire casi come "la versione in pelle costa 20€ in più" senza hack.
Il sistema di relazioni prodotto
La tabella product_relations gestisce cross-selling ("Compra insieme a"), upselling ("Versione premium") e prodotti correlati con un unico schema. Se un prodotto non ha relazioni manuali, il sistema fa fallback automatico: mostra prodotti della stessa categoria, ordinati per vendite.
// Smart fallback: relazioni manuali → stessa categoria → più venduti
function getRelatedProducts($productId, $type = 'related', $limit = 4) {
global $conn;
// 1. Relazioni manuali
$stmt = $conn->prepare("SELECT p.* FROM product_relations r
JOIN products p ON r.related_id = p.id
WHERE r.product_id = ? AND r.type = ? AND p.active = 1
LIMIT ?");
$stmt->bind_param("isi", $productId, $type, $limit);
$stmt->execute();
$results = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
if (count($results) >= $limit) return $results;
// 2. Fallback: stessa categoria, ordinati per vendite
$remaining = $limit - count($results);
$excludeIds = array_column($results, 'id');
$excludeIds[] = $productId;
$placeholders = implode(',', array_fill(0, count($excludeIds), '?'));
$sql = "SELECT p.* FROM products p
LEFT JOIN order_items oi ON p.id = oi.product_id
WHERE p.category_id = (SELECT category_id FROM products WHERE id = ?)
AND p.id NOT IN ($placeholders) AND p.active = 1
GROUP BY p.id ORDER BY COUNT(oi.id) DESC LIMIT ?";
// ... bind & execute
}
Integrazione pagamenti: Stripe, PayPal e Klarna senza SDK
Questo è il pezzo che ha richiesto più lavoro. Senza Composer sul server, non posso installare gli SDK ufficiali di Stripe e PayPal. Tutto passa da cURL raw con le API REST.
Stripe + Klarna (paga in 3 rate)
Stripe Checkout Session gestisce sia carte che Klarna. Il flusso: creo una session con i line items, Stripe fa il redirect al suo checkout hosted, e al ritorno verifico il pagamento via webhook.
function createStripeSession($order, $items) {
$lineItems = [];
foreach ($items as $item) {
$lineItems[] = [
'price_data' => [
'currency' => 'eur',
'product_data' => ['name' => $item['name']],
'unit_amount' => intval($item['price'] * 100),
],
'quantity' => $item['qty'],
];
}
$payload = [
'payment_method_types' => ['card', 'klarna'],
'line_items' => $lineItems,
'mode' => 'payment',
'success_url' => SITE_URL . '/checkout/success.php?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => SITE_URL . '/checkout/cancel.php',
'metadata' => ['order_id' => $order['id']],
];
return curlPost('https://api.stripe.com/v1/checkout/sessions',
$payload, STRIPE_SECRET_KEY);
}
PayPal Checkout API v2
PayPal è stato più complesso. L'API v2 richiede un flusso OAuth2: prima ottieni un access token, poi crei l'ordine, poi lo catturi dopo l'approvazione. Tre chiamate API per un singolo pagamento.
Il token OAuth2 di PayPal scade dopo 9 ore. L'errore che ho fatto inizialmente: cachare il token in sessione senza controllare la scadenza. Il fix: salvare anche il timestamp e rigenerare il token se time() > $tokenExpiry - 300 (5 minuti di margine).
Il pannello admin: 21 pagine senza framework JS
L'admin panel è la parte che mi ha dato più soddisfazione. Dashboard con KPI in tempo reale (ordini oggi, fatturato mese, carrelli abbandonati), gestione ordini con timeline visuale, catalogo con upload drag&drop delle immagini, sistema ticket integrato, e un activity log che traccia ogni azione degli admin.
Tutto in vanilla JS. Niente React, niente Vue. Le interazioni dinamiche (aggiornamento stato ordine, ricerca prodotti, modali) usano fetch() con endpoint PHP che restituiscono JSON. Il DOM manipulation è diretto.
Il bug più insidioso: sessioni admin
Ho passato 2 ore su un bug dove l'admin veniva disconnesso random. Il problema: usavo $_SESSION['admin'] (booleano) in alcune pagine e $_SESSION['admin_id'] (intero) in altre per verificare l'autenticazione. Quando la sessione si rigenerava, uno dei due valori si perdeva.
// PRIMA (bug): check inconsistente
if (!$_SESSION['admin']) { header('Location: login.php'); exit; } // alcune pagine
if (!$_SESSION['admin_id']) { header('Location: login.php'); exit; } // altre pagine
// DOPO (fix): un solo check, un solo valore
function requireAdmin() {
if (empty($_SESSION['admin_id']) || !is_numeric($_SESSION['admin_id'])) {
header('Location: login.php');
exit;
}
}
Mai usare due variabili di sessione diverse per lo stesso concetto. Un unico punto di verifica, una funzione centralizzata, zero ambiguità.
SEO: Schema.org, sitemap dinamica e URL pulite
Ogni prodotto ha JSON-LD con Product schema: nome, prezzo, disponibilità, brand, recensioni aggregate. La sitemap è generata dinamicamente da PHP, non statica — ogni nuovo prodotto viene incluso automaticamente.
Le URL sono clean: /shop/giacca-pelle-nera invece di /shop/product.php?id=47. Su Aruba senza mod_rewrite avanzato, ho usato un semplice .htaccess con RewriteRule che mappa gli slug ai parametri PHP.
Sistema email: PHPMailer senza Composer
PHPMailer su Aruba senza Composer significa upload manuale della cartella src/ via FTP e include diretto dei file. Funziona perfettamente. Il sistema invia 5 tipi di email: conferma ordine, reset password, richiesta recensione (7 giorni dopo la consegna), benvenuto e notifiche admin.
// PHPMailer senza Composer: include manuale
require_once __DIR__ . '/../vendor/PHPMailer/src/PHPMailer.php';
require_once __DIR__ . '/../vendor/PHPMailer/src/SMTP.php';
require_once __DIR__ . '/../vendor/PHPMailer/src/Exception.php';
use PHPMailer\PHPMailer\PHPMailer;
function sendOrderConfirmation($order, $items) {
$mail = new PHPMailer(true);
$mail->isSMTP();
$mail->Host = 'smtps.aruba.it';
$mail->SMTPAuth = true;
$mail->Username = SMTP_USER;
$mail->Password = SMTP_PASS;
$mail->SMTPSecure = 'ssl';
$mail->Port = 465;
// ... template HTML inline
}
Numeri e risultati
MAISON in produzione: 37 file PHP, 33 tabelle MySQL, 16 endpoint API, 21 pagine admin. Tempo di caricamento homepage: 780ms su mobile (Aruba shared hosting). Zero dipendenze NPM. Zero framework. Zero plugin. Tutto scritto a mano, tutto sotto controllo.
Il progetto dimostra che si può costruire un e-commerce completo e professionale con PHP nativo — se si ha un'architettura pulita e la disciplina di non accumulare debito tecnico.