E-commerce di esempio con Supabase e Laravel
In questo articolo vedremo come costruire un e-commerce di esempio utilizzando Supabase come backend dati (PostgreSQL gestito, autenticazione e storage) e Laravel come framework applicativo lato server. L'obiettivo non è realizzare una piattaforma completa pronta per la produzione, ma mostrare come le due tecnologie possano convivere in modo pulito, sfruttando PostgreSQL direttamente tramite Eloquent e Supabase per i servizi accessori come l'archiviazione delle immagini dei prodotti.
Architettura della soluzione
L'idea è semplice: Supabase fornisce un database PostgreSQL accessibile via connessione diretta e un bucket di storage per le immagini dei prodotti. Laravel si collega al database usando il driver pgsql standard, gestisce la logica di business, il carrello e il checkout, mentre le immagini vengono caricate su Supabase Storage tramite la sua REST API. L'autenticazione dei clienti rimane gestita da Laravel con laravel/breeze o laravel/sanctum, così da mantenere un unico punto di controllo per le sessioni.
Le entità principali del dominio sono tre: products, orders e order_items. I prodotti hanno un riferimento all'immagine pubblica ospitata su Supabase, mentre gli ordini tracciano lo stato del checkout e le righe d'ordine conservano il prezzo al momento dell'acquisto per evitare che modifiche successive ai listini alterino lo storico.
Configurazione del progetto Supabase
Dopo aver creato un nuovo progetto sulla dashboard di Supabase, è sufficiente annotare l'URL del progetto, la service role key (da usare solo lato server) e i parametri di connessione PostgreSQL disponibili nella sezione Database Settings. Questi valori verranno inseriti nel file .env di Laravel.
# File .env del progetto Laravel
DB_CONNECTION=pgsql
DB_HOST=db.abcdefghijklmno.supabase.co
DB_PORT=5432
DB_DATABASE=postgres
DB_USERNAME=postgres
DB_PASSWORD=la-tua-password-sicura
SUPABASE_URL=https://abcdefghijklmno.supabase.co
SUPABASE_SERVICE_KEY=eyJhbGciOi...
SUPABASE_BUCKET=product-images
All'interno della dashboard di Supabase creiamo un bucket pubblico chiamato product-images dalla sezione Storage. Essendo pubblico, le URL generate per i file caricati saranno direttamente accessibili dal frontend senza bisogno di token firmati, il che semplifica notevolmente la visualizzazione delle schede prodotto.
Schema del database con le migrazioni Laravel
Definiamo ora lo schema relazionale tramite le migrazioni di Laravel. Essendo Supabase un PostgreSQL standard, non servono accorgimenti particolari: possiamo usare i metodi dello schema builder come con qualsiasi altro database Postgres.
<?php
// Migrazione per la tabella dei prodotti
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->decimal('price', 10, 2);
$table->unsignedInteger('stock')->default(0);
// Percorso pubblico dell'immagine su Supabase Storage
$table->string('image_url')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('products');
}
};
Proseguiamo con le tabelle degli ordini. Notare l'uso di foreignId con constrained per garantire l'integrità referenziale e di decimal per i valori monetari, evitando l'uso di tipi a virgola mobile che introdurrebbero errori di arrotondamento.
<?php
// Migrazione per ordini e righe d'ordine
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('status')->default('pending');
$table->decimal('total', 10, 2);
$table->string('shipping_address');
$table->timestamps();
});
Schema::create('order_items', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained()->cascadeOnDelete();
$table->foreignId('product_id')->constrained()->restrictOnDelete();
$table->unsignedInteger('quantity');
// Prezzo congelato al momento dell'acquisto
$table->decimal('unit_price', 10, 2);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('order_items');
Schema::dropIfExists('orders');
}
};
Modelli Eloquent
I modelli Eloquent rispecchiano lo schema e definiscono le relazioni tra le entità. Il modello Product espone un accessor per ottenere il prezzo formattato, mentre Order contiene la relazione hasMany verso le righe d'ordine.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Order extends Model
{
protected $fillable = [
'user_id',
'status',
'total',
'shipping_address',
];
protected $casts = [
'total' => 'decimal:2',
];
public function items(): HasMany
{
return $this->hasMany(OrderItem::class);
}
}
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
protected $fillable = [
'name',
'slug',
'description',
'price',
'stock',
'image_url',
'is_active',
];
protected $casts = [
'price' => 'decimal:2',
'is_active' => 'boolean',
];
// Verifica se il prodotto è acquistabile
public function isAvailable(int $quantity = 1): bool
{
return $this->is_active && $this->stock >= $quantity;
}
}
Servizio per Supabase Storage
Per caricare le immagini dei prodotti incapsuliamo le chiamate alla REST API di Supabase in un servizio dedicato. Questo ci permette di isolare la logica di integrazione e di testarla più facilmente. Laravel offre il client HTTP Http che rende banali le richieste autenticate.
<?php
namespace App\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use RuntimeException;
class SupabaseStorageService
{
private string $baseUrl;
private string $serviceKey;
private string $bucket;
public function __construct()
{
$this->baseUrl = rtrim(config('services.supabase.url'), '/');
$this->serviceKey = config('services.supabase.service_key');
$this->bucket = config('services.supabase.bucket');
}
public function uploadProductImage(UploadedFile $file): string
{
// Genera un nome univoco per evitare collisioni
$fileName = Str::uuid()->toString() . '.' . $file->getClientOriginalExtension();
$path = "products/{$fileName}";
$response = Http::withHeaders([
'Authorization' => "Bearer {$this->serviceKey}",
'Content-Type' => $file->getMimeType(),
'x-upsert' => 'true',
])->withBody(
file_get_contents($file->getRealPath()),
$file->getMimeType()
)->post("{$this->baseUrl}/storage/v1/object/{$this->bucket}/{$path}");
if ($response->failed()) {
throw new RuntimeException('Caricamento su Supabase fallito: ' . $response->body());
}
// URL pubblica diretta al file
return "{$this->baseUrl}/storage/v1/object/public/{$this->bucket}/{$path}";
}
public function deleteProductImage(string $publicUrl): bool
{
$prefix = "{$this->baseUrl}/storage/v1/object/public/{$this->bucket}/";
$path = Str::after($publicUrl, $prefix);
$response = Http::withHeaders([
'Authorization' => "Bearer {$this->serviceKey}",
])->delete("{$this->baseUrl}/storage/v1/object/{$this->bucket}/{$path}");
return $response->successful();
}
}
Il servizio viene registrato come singleton nel container e la configurazione centralizzata in config/services.php, seguendo le convenzioni di Laravel per i servizi di terze parti.
<?php
// File config/services.php (frammento)
return [
// ... altri servizi
'supabase' => [
'url' => env('SUPABASE_URL'),
'service_key' => env('SUPABASE_SERVICE_KEY'),
'bucket' => env('SUPABASE_BUCKET', 'product-images'),
],
];
Controller per la gestione dei prodotti
Il controller amministrativo consente di creare nuovi prodotti caricando l'immagine su Supabase. La logica è volutamente lineare: validazione, upload, persistenza. In un progetto reale è buona pratica spostare la creazione in una classe Action o in un Service, ma per l'esempio manteniamo tutto nel controller.
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Product;
use App\Services\SupabaseStorageService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class ProductController extends Controller
{
public function __construct(
private readonly SupabaseStorageService $storage
) {
}
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'price' => ['required', 'numeric', 'min:0'],
'stock' => ['required', 'integer', 'min:0'],
'image' => ['required', 'image', 'max:2048'],
]);
// Caricamento dell'immagine su Supabase Storage
$imageUrl = $this->storage->uploadProductImage($request->file('image'));
Product::create([
'name' => $data['name'],
'slug' => Str::slug($data['name']),
'description' => $data['description'] ?? null,
'price' => $data['price'],
'stock' => $data['stock'],
'image_url' => $imageUrl,
'is_active' => true,
]);
return redirect()
->route('admin.products.index')
->with('status', 'Prodotto creato con successo.');
}
}
Checkout con transazione atomica
Il cuore di un e-commerce è il checkout. Qui dobbiamo garantire che la decrementazione dello stock, la creazione dell'ordine e l'inserimento delle righe avvengano in modo atomico: o tutto riesce, o nulla viene scritto. Laravel ci offre DB::transaction che sfrutta le transazioni native di PostgreSQL.
<?php
namespace App\Services;
use App\Models\Order;
use App\Models\Product;
use Illuminate\Support\Facades\DB;
use RuntimeException;
class CheckoutService
{
/**
* @param array<int, array{product_id: int, quantity: int}> $cartItems
*/
public function place(int $userId, array $cartItems, string $shippingAddress): Order
{
return DB::transaction(function () use ($userId, $cartItems, $shippingAddress) {
$total = 0;
$resolvedItems = [];
foreach ($cartItems as $item) {
// Blocco pessimistico sulla riga del prodotto
$product = Product::query()
->lockForUpdate()
->findOrFail($item['product_id']);
if (! $product->isAvailable($item['quantity'])) {
throw new RuntimeException("Stock insufficiente per: {$product->name}");
}
$product->decrement('stock', $item['quantity']);
$lineTotal = $product->price * $item['quantity'];
$total += $lineTotal;
$resolvedItems[] = [
'product_id' => $product->id,
'quantity' => $item['quantity'],
'unit_price' => $product->price,
];
}
$order = Order::create([
'user_id' => $userId,
'status' => 'pending',
'total' => $total,
'shipping_address' => $shippingAddress,
]);
$order->items()->createMany($resolvedItems);
return $order->load('items');
});
}
}
L'uso di lockForUpdate è particolarmente importante in uno scenario concorrente: impedisce che due richieste simultanee leggano lo stesso valore di stock e lo decrementino entrambe, portando a vendite superiori alle giacenze disponibili. PostgreSQL gestisce il blocco a livello di riga, quindi l'impatto sulle prestazioni è contenuto.
Row Level Security su Supabase
Anche se in questo esempio Laravel si collega a Supabase con le credenziali del superuser postgres, in scenari più maturi è consigliabile sfruttare la Row Level Security di PostgreSQL per limitare l'accesso ai dati. Supabase abilita RLS di default sulle nuove tabelle create tramite la dashboard, quindi è bene essere consapevoli di come funziona quando si mescolano migrazioni Laravel e strumenti Supabase.
-- Esempio di policy RLS sulla tabella ordini
-- applicabile nel caso di accesso diretto da client
alter table orders enable row level security;
create policy "users can read own orders"
on orders
for select
using (auth.uid() = user_id);
create policy "users can create own orders"
on orders
for insert
with check (auth.uid() = user_id);
Queste policy non interferiscono con le query eseguite da Laravel tramite la connessione postgres, che bypassa RLS in quanto superuser, ma proteggono l'accesso qualora in futuro si decidesse di esporre alcune tabelle direttamente ai client tramite le API auto-generate di Supabase.
Considerazioni finali
L'accoppiata Supabase e Laravel si rivela pragmatica: Laravel offre una struttura applicativa matura con validazione, routing, ORM e gestione delle sessioni, mentre Supabase elimina la necessità di gestire un'istanza PostgreSQL e fornisce storage e autenticazione pronti all'uso. Per un e-commerce di piccole-medie dimensioni questa combinazione permette di partire velocemente senza rinunciare alla flessibilità di un framework server-side completo.
I prossimi passi naturali per un progetto reale includono l'integrazione con un gateway di pagamento come Stripe, la gestione delle email transazionali tramite Laravel Notifications, l'introduzione di code di lavoro per l'elaborazione asincrona degli ordini e la definizione di policy RLS più granulari qualora si volesse esporre parte dei dati direttamente ai client via Supabase.