notiziephp.it

Proxy immagini S3 in Laravel: URL puliti con caching CDN

Traduzione da danielpetrica.com
15 Giugno 2026 Tempo di lettura: 4 minuti
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.png

Questo 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 header Content-Type e Content-Length appropriati
  • Imposta un header Cache-Control: public, max-age=86400 in 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.

Condividi su: LinkedIn

Altri articoli recenti