Costruire un e-commerce con Laravel, Filament e Docker
In questo articolo ricostruiamo passo passo un progetto e-commerce fittizio chiamato WebByte, interamente basato su Laravel, dockerizzato e dotato di un pannello di amministrazione scritto con Filament. Il progetto è pensato come punto di partenza riutilizzabile: una base pulita, senza dipendenze esotiche, che copre i problemi reali che incontra chiunque metta insieme queste tre tecnologie. La demo è pubblicata su https://webbyte.it.
La struttura del progetto
Una prima decisione riguarda la disposizione dei file. Tenere il docker-compose.yml nella stessa cartella del progetto Laravel funziona, ma crea un problema sottile: il file .env di Compose (usato per interpolare variabili come ${UID} nello YAML) collide con il .env di Laravel, perché hanno lo stesso nome di default. La soluzione è separare i due livelli:
webbyte.it/
├── docker-compose.yml
├── .env # env di Compose (UID, GID)
└── site/ # progetto Laravel
├── .env # env di Laravel
├── app/
├── bootstrap/
├── public/
└── docker/
├── nginx/default.conf
├── php/Dockerfile
└── cmd/start.sh
Con questa struttura Compose legge webbyte.it/.env per l'interpolazione, mentre Laravel legge webbyte.it/site/.env a runtime. Nessuna ambiguità.
Il docker-compose.yml
Lo stack è composto da tre servizi: PHP-FPM (costruito da Dockerfile), Nginx come reverse proxy, e MySQL 8 come database. L'healthcheck su MySQL è fondamentale per far partire l'applicazione solo quando il database accetta connessioni.
name: webbyte
services:
app:
build:
context: ./site
dockerfile: docker/php/Dockerfile
args:
UID: ${UID:-1000}
GID: ${GID:-1000}
container_name: webbyte_app
working_dir: /var/www
environment:
APP_ENV_FILE: .env.dev
volumes:
- ./site:/var/www
command: ["/var/www/docker/cmd/start.sh"]
depends_on:
db:
condition: service_healthy
networks:
- webbyte
nginx:
image: nginx:1.27-alpine
container_name: webbyte_nginx
ports:
- "8080:80"
volumes:
- ./site:/var/www
- ./site/docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
depends_on:
- app
networks:
- webbyte
db:
image: mysql:8.0
container_name: webbyte_db
environment:
MYSQL_DATABASE: webbyte
MYSQL_ROOT_PASSWORD: root
MYSQL_USER: webbyte
MYSQL_PASSWORD: webbyte
ports:
- "3307:3306"
volumes:
- webbyte_db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "webbyte", "-pwebbyte"]
interval: 5s
timeout: 3s
retries: 10
networks:
- webbyte
networks:
webbyte:
driver: bridge
volumes:
webbyte_db_data:
Il problema dell'ownership tra host e container
Quando si monta una directory dell'host come volume, i file scritti dal container appaiono sull'host con l'utente con cui gira il processo dentro il container. L'immagine php:8.4-fpm gira come www-data (uid 33), ma il tuo utente sull'host ha tipicamente uid 1000 su Linux o 501 su macOS. Il risultato è un conflitto: i file creati dal container non sono modificabili dall'host, e viceversa.
La soluzione canonica è costruire un'immagine in cui l'utente interno ha lo stesso uid e gid dell'utente dell'host. Il Dockerfile parametrizza questi valori come build arg:
FROM php:8.4-fpm
ARG UID=1000
ARG GID=1000
ARG USER=webbyte
RUN apt-get update && apt-get install -y \
git curl zip unzip \
libpng-dev libonig-dev libxml2-dev libzip-dev libicu-dev \
&& docker-php-ext-configure intl \
&& docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip intl \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Creo un utente con lo stesso uid dell'utente host
RUN groupadd -g ${GID} ${USER} \
&& useradd -u ${UID} -g ${USER} -m -s /bin/bash ${USER}
# Configuro php-fpm per girare come il nuovo utente
RUN sed -i "s/user = www-data/user = ${USER}/g" /usr/local/etc/php-fpm.d/www.conf \
&& sed -i "s/group = www-data/group = ${USER}/g" /usr/local/etc/php-fpm.d/www.conf \
&& sed -i "s/listen.owner = www-data/listen.owner = ${USER}/g" /usr/local/etc/php-fpm.d/www.conf \
&& sed -i "s/listen.group = www-data/listen.group = ${USER}/g" /usr/local/etc/php-fpm.d/www.conf
WORKDIR /var/www
# Precreo le directory scrivibili con il giusto owner
RUN mkdir -p /var/www/storage/framework/cache/data \
/var/www/storage/framework/sessions \
/var/www/storage/framework/views \
/var/www/storage/logs \
/var/www/bootstrap/cache \
&& chown -R ${USER}:${USER} /var/www
USER ${USER}
EXPOSE 9000
CMD ["php-fpm"]
Le estensioni intl e gd non sono opzionali: intl serve a Filament per la formattazione dei numeri e delle valute (TextColumn::money('EUR') chiama NumberFormatter), mentre gd serve per la gestione delle immagini in qualsiasi CMS. Dimenticare libicu-dev tra le dipendenze di sistema è l'errore più comune: senza quella libreria, docker-php-ext-install intl fallisce silenziosamente.
Per passare i valori di uid e gid si esportano come variabili d'ambiente prima del build, oppure si scrivono nel file .env di Compose:
UID=501
GID=20
Una piccola insidia: in bash la variabile UID è readonly, quindi export UID=501 dà errore. Con il file .env letto da Compose il problema non si pone, perché l'interpolazione avviene senza passare per la shell.
Lo script di bootstrap
Il container PHP non deve limitarsi ad avviare php-fpm: deve prima eseguire composer install, aspettare che MySQL sia pronto, lanciare le migrazioni e preparare la cache di Filament. Tutte queste operazioni stanno in uno script di avvio che diventa il command del servizio.
#!/bin/bash
set -euo pipefail
cd /var/www
echo "==> Verifico file env"
if [ ! -f .env ]; then
cp .env.example .env
fi
echo "==> Composer install"
composer install --no-interaction --prefer-dist --optimize-autoloader
echo "==> Attendo MySQL"
until php -r "try { new PDO('mysql:host=db;dbname=webbyte', 'webbyte', 'webbyte'); exit(0); } catch (Exception \$e) { exit(1); }" 2>/dev/null; do
echo " ... db non pronto, riprovo"
sleep 2
done
echo "==> Genero APP_KEY se manca"
php artisan key:generate --force --no-interaction
echo "==> Clear cache e migrazioni"
php artisan config:clear
php artisan cache:clear
php artisan migrate --seed --force
echo "==> Asset Filament"
php artisan filament:optimize
php artisan filament:assets
echo "==> Avvio php-fpm"
exec php-fpm
Due dettagli tecnici meritano attenzione. Il set -euo pipefail è più severo del classico set -e: cattura anche variabili non definite e fallimenti all'interno delle pipe. L'exec php-fpm finale è cruciale perché sostituisce il processo bash con php-fpm, così i segnali inviati da docker compose stop arrivano direttamente al processo PHP e il container si ferma in modo pulito.
Un errore che si vede spesso è usare l'hook post_start di Compose per eseguire lo script. È tentatorio perché sembra "giusto", ma gli hook lifecycle girano in background, non bloccano l'avvio del servizio principale, e non c'è modo di propagare il loro exit code al container. Il pattern corretto è quello mostrato sopra: lo script è il comando del container, se fallisce il container muore e l'errore è visibile in docker compose logs.
File env multipli per Laravel
Laravel supporta nativamente file .env alternativi tramite la variabile APP_ENV_FILE. Se la variabile è valorizzata, Laravel carica il file indicato invece del default .env. Questo permette di tenere configurazioni distinte per sviluppo, staging e test senza modificare il codice.
Nel docker-compose.yml si inietta la variabile come environment del container:
app:
environment:
APP_ENV_FILE: .env.dev
Da quel momento tutti i comandi artisan leggono e scrivono su .env.dev. Per esempio, php artisan key:generate popola la chiave direttamente in quel file. La convenzione tipica è tenere .env.example versionato come template, .env.dev per lo sviluppo locale, e .env.testing per phpunit (quest'ultimo è l'unico caso in cui Laravel usa automaticamente il file giusto in base al contesto, senza bisogno di APP_ENV_FILE).
La configurazione Nginx
Nginx proxa le richieste PHP al container app tramite FastCGI sulla porta 9000. Il nome del servizio in Compose diventa automaticamente un hostname risolvibile nella rete interna, quindi fastcgi_pass app:9000 funziona senza configurazioni aggiuntive di DNS.
server {
listen 80;
server_name _;
root /var/www/public;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass app:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.ht { deny all; }
}
Ricerca full-text con MySQL
Per una ricerca su più entità (prodotti e articoli del blog) senza introdurre motori esterni come Meilisearch o Typesense, MySQL 8 offre indici FULLTEXT nativi con ranking per rilevanza. La migrazione aggiunge due indici:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration {
public function up(): void
{
DB::statement('ALTER TABLE products ADD FULLTEXT products_fulltext (name, description)');
DB::statement('ALTER TABLE posts ADD FULLTEXT posts_fulltext (title, excerpt, body)');
}
public function down(): void
{
DB::statement('ALTER TABLE products DROP INDEX products_fulltext');
DB::statement('ALTER TABLE posts DROP INDEX posts_fulltext');
}
};
La logica di ricerca vive in un service dedicato. Sanitizza la query rimuovendo operatori booleani potenzialmente pericolosi, poi costruisce una query in boolean mode dove ogni token diventa un match con prefisso (+laptop* +gam*), così l'utente ottiene risultati anche per termini parziali:
<?php
namespace App\Services;
use App\Models\Post;
use App\Models\Product;
use Illuminate\Database\Eloquent\Collection;
class SearchService
{
private const MIN_TOKEN = 3;
public function search(string $raw): array
{
$query = $this->sanitize($raw);
if ($query === '') {
return ['products' => collect(), 'posts' => collect(), 'query' => ''];
}
return [
'query' => $raw,
'products' => $this->searchProducts($query),
'posts' => $this->searchPosts($query),
];
}
private function searchProducts(string $q): Collection
{
if ($this->canUseFullText($q)) {
$boolean = $this->toBooleanMode($q);
return Product::with('category')
->selectRaw('*, MATCH(name, description) AGAINST (? IN BOOLEAN MODE) AS score', [$boolean])
->whereRaw('MATCH(name, description) AGAINST (? IN BOOLEAN MODE)', [$boolean])
->orderByDesc('score')
->limit(20)
->get();
}
// Ripiego su LIKE per query con token troppo corti
return Product::with('category')
->where(function ($w) use ($q) {
$w->where('name', 'like', "%{$q}%")
->orWhere('description', 'like', "%{$q}%");
})
->limit(20)
->get();
}
private function sanitize(string $raw): string
{
$clean = preg_replace('/[+\-><\(\)~*\"@]+/u', ' ', $raw);
return trim(preg_replace('/\s+/', ' ', $clean));
}
private function canUseFullText(string $q): bool
{
foreach (explode(' ', $q) as $token) {
if (mb_strlen($token) >= self::MIN_TOKEN) {
return true;
}
}
return false;
}
private function toBooleanMode(string $q): string
{
$tokens = array_filter(
explode(' ', $q),
fn($t) => mb_strlen($t) >= self::MIN_TOKEN
);
return implode(' ', array_map(fn($t) => "+{$t}*", $tokens));
}
}
Il valore minimo dei token (3 caratteri) riflette il default di innodb_ft_min_token_size. Quando l'utente cerca termini più corti, il servizio ripiega automaticamente su LIKE per non restituire risultati vuoti. I punteggi MATCH di tabelle diverse non sono confrontabili tra loro, quindi i risultati per prodotti e articoli vengono presentati in sezioni separate invece che in un'unica lista ordinata.
Il pannello di amministrazione con Filament
Filament è un pannello amministrativo costruito sopra Livewire, Alpine.js e Tailwind. Si installa via Composer e genera un panel provider personalizzabile:
composer require filament/filament:"^3.2"
php artisan filament:install --panels
Durante l'installazione Filament chiede il nome del panel e genera app/Providers/Filament/AdminPanelProvider.php. È qui che si personalizza l'URL del pannello, impostandolo su /dashboard invece del default /admin:
public function panel(Panel $panel): Panel
{
return $panel
->default()
->id('admin')
->path('dashboard')
->login()
->colors([
'primary' => Color::Cyan,
])
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
->pages([
Pages\Dashboard::class,
])
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
->widgets([
Widgets\AccountWidget::class,
Widgets\FilamentInfoWidget::class,
])
->authMiddleware([
Authenticate::class,
]);
}
Filament richiede un model User che implementi FilamentUser, dove il metodo canAccessPanel funziona da gate per ogni richiesta verso il pannello:
<?php
namespace App\Models;
use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable implements FilamentUser
{
use Notifiable;
protected $fillable = ['name', 'email', 'password'];
protected $hidden = ['password', 'remember_token'];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
public function canAccessPanel(Panel $panel): bool
{
// In produzione conviene filtrare per dominio o ruolo
return true;
}
}
In produzione il metodo va stretto: un return true permanente significa che qualsiasi utente registrato può entrare nel pannello. Il controllo tipico è su un ruolo o sul dominio email aziendale.
Le resource di Filament
Ogni entità amministrabile diventa una "resource" di Filament: una classe che dichiara il form di editing e la tabella di listing. Il generatore introspetta la tabella quando si passa --generate:
php artisan make:filament-resource Product --generate
Il risultato è una classe dove il form e la tabella si compongono in modo dichiarativo. Questo è un esempio condensato della resource per i prodotti:
public static function form(Form $form): Form
{
return $form->schema([
Forms\Components\Select::make('category_id')
->relationship('category', 'name')
->required(),
Forms\Components\TextInput::make('name')
->required()
->live(onBlur: true)
->afterStateUpdated(fn ($state, callable $set) =>
$set('slug', \Illuminate\Support\Str::slug($state))),
Forms\Components\TextInput::make('slug')
->required()
->unique(ignoreRecord: true),
Forms\Components\Textarea::make('description')->required()->rows(5),
Forms\Components\TextInput::make('price')
->numeric()->prefix('€')->required(),
Forms\Components\TextInput::make('stock')
->numeric()->default(0)->required(),
Forms\Components\Toggle::make('featured'),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')->searchable()->sortable(),
Tables\Columns\TextColumn::make('category.name')->sortable(),
Tables\Columns\TextColumn::make('price')
->money('EUR')->sortable(),
Tables\Columns\TextColumn::make('stock')->sortable(),
Tables\Columns\IconColumn::make('featured')->boolean(),
])
->filters([
Tables\Filters\SelectFilter::make('category_id')
->relationship('category', 'name')
->label('Categoria'),
Tables\Filters\TernaryFilter::make('featured'),
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
]);
}
La chiamata live(onBlur: true) sul campo name, combinata con afterStateUpdated, aggiorna lo slug in tempo reale quando l'utente esce dal campo. unique(ignoreRecord: true) verifica l'unicità dello slug escludendo il record corrente durante l'editing, altrimenti la validazione fallirebbe quando si modifica un prodotto senza cambiare lo slug.
Paginazione personalizzata
Dalla versione 8, Laravel usa Tailwind come tema di default per la paginazione. In un progetto che non include Tailwind questo diventa un problema: i controlli appaiono senza stile. La soluzione è pubblicare una view custom e registrarla come default nel service provider.
@if ($paginator->hasPages())
<nav class="pagination" role="navigation" aria-label="Pagination">
@if ($paginator->onFirstPage())
<span class="page-item disabled">← Prec</span>
@else
<a class="page-item" href="{{ $paginator->previousPageUrl() }}" rel="prev">← Prec</a>
@endif
@foreach ($elements as $element)
@if (is_string($element))
<span class="page-item dots">{{ $element }}</span>
@endif
@if (is_array($element))
@foreach ($element as $page => $url)
@if ($page == $paginator->currentPage())
<span class="page-item active">{{ $page }}</span>
@else
<a class="page-item" href="{{ $url }}">{{ $page }}</a>
@endif
@endforeach
@endif
@endforeach
@if ($paginator->hasMorePages())
<a class="page-item" href="{{ $paginator->nextPageUrl() }}" rel="next">Succ →</a>
@else
<span class="page-item disabled">Succ →</span>
@endif
</nav>
@endif
La view va salvata in resources/views/vendor/pagination/webbyte.blade.php, poi registrata nel boot() dell'AppServiceProvider:
use Illuminate\Pagination\Paginator;
public function boot(): void
{
Paginator::defaultView('vendor.pagination.webbyte');
Paginator::defaultSimpleView('vendor.pagination.webbyte');
}
Placeholder per le immagini mancanti
Un trucco utile per gestire le immagini in evidenza senza dipendere da asset esterni è generare un placeholder SVG al volo quando la colonna image è nulla. Un model helper restituisce la URL corretta o ripiega su una rotta dedicata:
public function imageUrl(): string
{
if ($this->image && file_exists(public_path($this->image))) {
return asset($this->image);
}
return route('placeholder', ['text' => urlencode(strtoupper(substr($this->name, 0, 2)))]);
}
La rotta placeholder punta a un invokable controller che costruisce un SVG con pattern a griglia e le iniziali del prodotto, mantenendo i colori del tema e garantendo un fallback visivo coerente:
public function __invoke(Request $request): Response
{
$text = substr($request->query('text', 'WB'), 0, 4);
$w = (int) $request->query('w', 800);
$h = (int) $request->query('h', 500);
$svg = <<<SVG
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {$w} {$h}" width="{$w}" height="{$h}">
<defs>
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#161927"/>
<stop offset="100%" stop-color="#0a0b10"/>
</linearGradient>
</defs>
<rect width="{$w}" height="{$h}" fill="url(#g)"/>
<text x="50%" y="50%" text-anchor="middle" dominant-baseline="central"
font-family="monospace" font-size="120" font-weight="700"
fill="#00e5ff" opacity="0.75">{$text}</text>
</svg>
SVG;
return response($svg, 200, [
'Content-Type' => 'image/svg+xml',
'Cache-Control' => 'public, max-age=86400',
]);
}
Conclusioni
La combinazione Laravel più Filament più Docker copre circa l'80% delle esigenze di un e-commerce medio senza scrivere una riga di codice per il backoffice. I punti critici — come già visto — non stanno tanto nelle singole tecnologie, quanto nei punti di contatto: l'ownership dei file tra host e container, la sincronizzazione tra l'avvio di MySQL e le migrazioni di Laravel, le estensioni PHP che Filament richiede silenziosamente. Una volta risolti, lo sviluppo quotidiano diventa fluido e ripetibile: docker compose up e si ha un ambiente completo.
Il progetto completo di WebByte è visitabile su https://webbyte.it, dove è possibile esplorare il frontend pubblico e verificare dal vivo la ricerca full-text, il catalogo prodotti e la sezione blog.