If you're serving images and media files directly from an S3-compatible object storage (like Hetzner Object Storage, AWS S3, or DigitalOcean Spaces), you're probably used to URLs that look like this:
https://storage.example.com/my-bucket/project/files/posts/hello-world/cover.png
That's ugly. It exposes your storage provider and bucket name. And if your bucket is private, you can't even serve those URLs directly. You'd need signed URLs or a proxy.
Here's how I replaced all raw S3 media URLs on my Laravel blog with clean proxied paths through the application, while keeping 24-hour CDN caching for performance.
The architecture
We run a typical Laravel + Octane stack behind Traefik with Cloudflare. All media is stored on an S3-compatible object storage in two buckets: a private one for uploaded media (project/files) and a public one for auto-generated Open Graph images (project/og-images).
Originally, the app generated URLs directly to object storage using Storage::url(). This meant:
- Private bucket URLs were inaccessible (and switching to public was not an option)
- Provider details were exposed in every page's HTML
- No control over caching at the application level
Step 1: Build a URL helper
First, I created a MediaUrlBusiness class that centralizes URL generation for both disks. Instead of calling Storage::disk('og-images')->url($path) or Storage::url($path) in blade templates, I now call:
MediaUrlBusiness::forOgImage($path); // → /storage/og-images/posts/slug.png
MediaUrlBusiness::forMedia($path); // → /storage/media/posts/slug/cover.png
This returns a clean relative path like /storage/og-images/.... All 6+ blade templates that previously generated raw S3 URLs now use this single helper.
Step 2: The proxy controller
The ObjectProxyController catches all /storage/{disk}/{path} requests. It:
- Validates the disk is one of the allowed ones (
media→ private bucket,og-images→ public bucket) - Resolves the full S3 key using the disk's configured prefix
- Streams the file from object storage using
response()->stream()with properContent-TypeandContent-Lengthheaders - Sets a
Cache-Control: public, max-age=86400header so Cloudflare and browsers cache aggressively
Here's the core of it:
$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',
]
);
A detail that tripped me up: Storage::download() and streamDownload() buffer the entire file into memory. For large images, that wastes memory. Switching to readStream() + response()->stream() sends the file directly from S3 to the client without buffering.
Note: Laravel's S3 driver supports fileSize(). For custom disks, use $disk->size() instead.
Important: anything in the private bucket is now accessible through the proxy if someone guesses the path. Make sure that bucket only holds public-facing assets.
Step 3: Route and middleware setup
The proxy route lives in routes/static.php alongside other cacheable frontend routes:
Route::get(uri: '/storage/{disk}/{path}', action: ObjectProxyController::class)
->where('path', '.*')
->name('storage.proxy');
I also modified the SetCacheControlHeader middleware to skip responses that already have a Cache-Control header set (since the proxy controller sets its own). This way the middleware doesn't override our carefully tuned 24-hour cache policy.
Step 4: Wire it up everywhere
The MediaUrlBusiness helper is now used in:
- Post, page, and tag show views, for both OG images and feature images
- Homepage and archive listings, for post card images
- Case study components, for service images
-
OgImageBusiness, for storing and retrieving generated OG images
Every call to Storage::disk('og-images')->url(...) or Storage::url(...) was replaced with the appropriate MediaUrlBusiness::for*() method.
What about the admin panel?
The Filament admin panel has its own media proxy. The existing MediaProxyController handles CORS issues when editors browse and insert images in the RichEditor. That stays as-is; it's authenticated and not meant for public caching.
The result
Before:
<img src="https://storage.example.com/my-bucket/project/files/posts/hello-world/cover.png">
After:
<img src="/storage/media/posts/hello-world/cover.png">
Clean, provider-agnostic, and cache-friendly. The URL no longer leaks my storage provider or bucket name. And if I ever switch providers, I only need to change the disk configuration. The public URLs stay the same.
Need this for your site? If your team doesn't have the bandwidth to implement this, I can set it up for you: clean proxied image URLs, CDN caching, and private bucket security. Get in touch →

Top comments (0)