Proxy immagini S3 in Laravel: URL puliti con caching CDN
Se servi immagini e file multimediali direttamente da un object storage compatibile con S3 (come Hetzner Object Storage, AWS S3 o DigitalOcean Spaces), probabilmente sei abituato a URL che assomigliano a questo:
https://storage.example.com/my-bucket/project/files/posts/hello-world/cover.pngÈ brutto. Espone il tuo provider di storage e il nome del bucket. E se il tuo bucket è privato, non puoi nemmeno servire quegli URL direttamente. Ti servirebbero URL firmati o un proxy.
Ecco come ho sostituito tutti gli URL grezzi dei media S3 sul mio blog Laravel con percorsi proxy puliti passando dall'applicazione, mantenendo al contempo una cache CDN di 24 ore per le prestazioni.
L'architettura
Gestiamo un classico stack Laravel + Octane dietro Traefik con Cloudflare. Tutti i media sono archiviati su un object storage compatibile con S3 in due bucket: uno privato per i media caricati (project/files) e uno pubblico per le immagini Open Graph generate automaticamente (project/og-images).
In origine, l'app generava URL direttamente verso l'object storage usando Storage::url(). Questo significava:
- Gli URL del bucket privato erano inaccessibili (e passare a pubblico non era un'opzione)
- I dettagli del provider erano esposti nell'HTML di ogni pagina
- Nessun controllo sulla cache a livello di applicazione
Passo 1: creare un helper per gli URL
Per prima cosa, ho creato una classe MediaUrlBusiness che centralizza la generazione degli URL per entrambi i dischi. Invece di chiamare Storage::disk('og-images')->url($path) o Storage::url($path) nei template blade, ora chiamo:
MediaUrlBusiness::forOgImage($path); // → /storage/og-images/posts/slug.png
MediaUrlBusiness::forMedia($path); // → /storage/media/posts/slug/cover.pngQuesto restituisce un percorso relativo pulito come /storage/og-images/.... Tutti i 6+ template blade che prima generavano URL S3 grezzi ora usano questo singolo helper.
Passo 2: il controller del proxy
Il ObjectProxyController intercetta tutte le richieste /storage/{disk}/{path}. Esso:
- Valida che il disco sia uno di quelli consentiti (
media→ bucket privato,og-images→ bucket pubblico) - Risolve la chiave S3 completa usando il prefisso configurato del disco
- Trasmette il file in streaming dall'object storage usando
response()->stream()con gli headerContent-TypeeContent-Lengthappropriati - Imposta un header
Cache-Control: public, max-age=86400in modo che Cloudflare e i browser facciano caching in modo aggressivo
Ecco il cuore del codice:
$disk = Storage::disk($diskMap[$diskSegment]);
$stream = $disk->readStream($fullPath);
return response()->stream(
callback: function () use ($stream) {
fpassthru($stream);
fclose($stream);
},
headers: [
'Content-Type' => $disk->mimeType($fullPath),
'Content-Length' => $disk->fileSize($fullPath),
'Cache-Control' => 'public, max-age=86400, immutable',
]
);Un dettaglio che mi ha tratto in inganno: Storage::download() e streamDownload() bufferizzano l'intero file in memoria. Per immagini di grandi dimensioni, questo spreca memoria. Passare a readStream() + response()->stream() invia il file direttamente da S3 al client senza buffering.
Nota: il driver S3 di Laravel supporta fileSize(). Per i dischi personalizzati, usa invece $disk->size().
Importante: qualsiasi cosa nel bucket privato è ora accessibile tramite il proxy se qualcuno indovina il percorso. Assicurati che quel bucket contenga solo asset destinati al pubblico.
Passo 3: configurazione di route e middleware
La route del proxy si trova in routes/static.php insieme ad altre route frontend cacheabili:
Route::get(uri: '/storage/{disk}/{path}', action: ObjectProxyController::class)
->where('path', '.*')
->name('storage.proxy');Ho anche modificato il middleware SetCacheControlHeader per saltare le risposte che hanno già un header Cache-Control impostato (dato che il controller del proxy imposta il proprio). In questo modo il middleware non sovrascrive la nostra politica di cache di 24 ore accuratamente calibrata.
Passo 4: collegarlo ovunque
L'helper MediaUrlBusiness è ora usato in:
- Viste show di post, pagine e tag, sia per le immagini OG che per le immagini in evidenza
- Listing della homepage e degli archivi, per le immagini delle card dei post
- Componenti dei case study, per le immagini dei servizi
OgImageBusiness, per archiviare e recuperare le immagini OG generate
Ogni chiamata a Storage::disk('og-images')->url(...) o Storage::url(...) è stata sostituita con il metodo MediaUrlBusiness::for*() appropriato.
E il pannello di amministrazione?
Il pannello di amministrazione Filament ha il proprio proxy per i media. L'attuale MediaProxyController gestisce i problemi di CORS quando gli editor sfogliano e inseriscono immagini nel RichEditor. Questo rimane così com'è; è autenticato e non è pensato per il caching pubblico.
Il risultato
Prima:
<img src="https://storage.example.com/my-bucket/project/files/posts/hello-world/cover.png">Dopo:
<img src="/storage/media/posts/hello-world/cover.png">Pulito, indipendente dal provider e amichevole per la cache. L'URL non rivela più il mio provider di storage o il nome del bucket. E se mai cambiassi provider, dovrei solo cambiare la configurazione del disco. Gli URL pubblici restano gli stessi.
Altri articoli recenti
Laravel AI 1.1 e 1.2: Agent Skills, Cohere e structured output più solidi
Le release 1.1.0 e 1.2.0 di laravel/ai portano gli Agent Skills, l'approvazione obbligatoria per i tool built-in e nuovi provider come Cohere e Laya self-hosted. Sul fronte qualità arrivano i fix allo structured output per Bedrock e il supporto alle provider options. Aggiornati anche i modelli di default, con Claude 4.7+ che ignora i sampling params.
Laravel 13.35.0: scheduler più controllabile, code più flessibili e response Markdown
Laravel 13.35.0 porta Schedule::alwaysOnOneServer(), il supporto a schedule:interrupt per i comandi di lunga durata, --memory in percentuale per queue:work e il nuovo metodo markdown() sulla response factory. Tante piccole migliorie anche a test, view compilate in parallelo e dischi local.
BashBox: un interprete Bash in sandbox per agenti AI in PHP
BashBox è un interprete bash scritto in puro PHP che permette di eseguire gli script generati dagli agenti AI senza mai ricorrere a proc_open, exec o shell_exec. Ogni comando è una classe PHP che lavora su un filesystem virtuale, con limiti di esecuzione, quote disco e accesso di rete configurabili. Ideale per chi sviluppa agenti con il Laravel AI SDK o altre librerie simili.