[Field-Tested Guide] Media Management in Laravel: Storage/Optimization/CDN Delivery for Images, Videos, and Documents, with Accessible Alt Text, Subtitles, and Captions
What you’ll learn (highlights)
- Designing the Filesystem (Storage) and securely saving to external storage like S3
- Image optimization (resize, WebP/AVIF, lazy loading,
srcset
/sizes
) and cache control - Security such as signed URLs,
Content-Disposition
, MIME validation, and virus scanning - Video streaming delivery, subtitles (WebVTT), transcripts, and honoring
prefers-reduced-motion
- Distribution policies for PDF/Office documents and providing summaries/alternative content
- Designing accessible alt text and captions, and building an editorial workflow
- Operations, monitoring, and tests (Dusk/Feature) plus a checklist
Intended readers (who benefits?)
- Laravel beginner to intermediate engineers: those who want to handle images/files safely while improving performance and accessibility together
- Tech leads for client work/in-house SaaS: those who want to standardize an extensible media architecture premised on S3/CDN
- Designers/content editors: those who want an operation flow that lets them write alt text, captions, and subtitles without hesitation
- QA/accessibility specialists: those who want to apply screen reader friendliness, keyboard operability, and color-independent UI to media as well
1. Introduction: Media features rest on three pillars—“Performance × Safety × Comprehensibility”
Media (images, video, PDFs, etc.) are central to user experience, but “can display” isn’t enough.
- Performance: lightweight files, proper sizing, caching, CDN
- Safety: MIME/extension validation, virus scanning, signed URLs, authorization
- Comprehensibility: alt text, subtitles, captions, transcripts, operable controls
Laravel can implement this end-to-end with Storage/Flysystem at its core, plus authorization, signed URLs, and response control. This article provides copy-pasteable code and operational patterns for real-world use.
2. Designing storage targets: Filesystem (Storage) and choosing between S3/local
2.1 Basic setup
Define disks (storage targets) in config/filesystems.php
. Use both local and S3 and choose per use case.
// .env
FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
AWS_DEFAULT_REGION=ap-northeast-1
AWS_BUCKET=example-bucket
AWS_URL=https://cdn.example.com // Public URL via CDN (optional)
// Save
$path = Storage::disk('s3')->putFile('uploads/images', $request->file('image'), 'private');
// Public URL (via CDN if configured)
$url = Storage::disk('s3')->url($path);
// Temporarily expose (10 minutes)
$tmpUrl = Storage::disk('s3')->temporaryUrl($path, now()->addMinutes(10), [
'ResponseContentDisposition' => 'inline',
]);
Design points:
- Items safe to expose should be served via a public bucket or through a CDN. For download-only, set
Content-Disposition: attachment
. - As a rule, store files private, and issue signed URLs only when downloading.
- If using multi-region/CNAME (CDN), use
AWS_URL
.
2.2 Persisting metadata
Manage media metadata in the database to support regeneration and language-specific captions.
Schema::create('media', function (Blueprint $t) {
$t->id();
$t->string('disk')->default('s3');
$t->string('path');
$t->string('mime');
$t->unsignedInteger('size');
$t->json('variants')->nullable(); // e.g., 'thumb','webp','avif'
$t->json('meta')->nullable(); // e.g., 'alt','caption','lang','transcript'
$t->foreignId('user_id')->nullable();
$t->timestamps();
});
3. Upload security: validation, sanitization, and scanning
3.1 Validation
$request->validate([
'image' => ['required','file','mimetypes:image/jpeg,image/png,image/webp','max:5120'], // 5MB
]);
- The client-side
accept
attribute is only a helper; always validate MIME on the server. max
is in KB. Specify size limits and communicate them in the UI.
3.2 Handling EXIF/geotags
Strip EXIF on upload to prevent leaking location info. Most image libraries (e.g., Intervention Image) can strip metadata.
3.3 Virus scanning
Use an external service or ClamAV to scan asynchronously right after upload (run via job queue). If positive, quarantine and inform the user with a safe message.
4. Image optimization: derived variants (resize/convert) and responsive delivery
4.1 Generating variants (job)
Save the original privately and generate derivatives (thumbnails, S/M/L, WebP/AVIF) in a queue.
class GenerateImageVariants implements ShouldQueue {
public function __construct(public int $mediaId) {}
public function handle(ImageService $svc): void {
$media = Media::findOrFail($this->mediaId);
$variants = $svc->makeVariants($media); // e.g., ['w320','w640','w1280','webp','avif']
$media->update(['variants' => $variants]);
}
}
Service example (concept):
class ImageService {
public function makeVariants(Media $m): array {
$src = Storage::disk($m->disk)->get($m->path);
// Resize to sizes, adjust JPEG quality, convert to WebP/AVIF, etc.
// After saving, return key -> URL
return [
'w320' => Storage::url("variants/{$m->id}/img@320.jpg"),
'w640' => Storage::url("variants/{$m->id}/img@640.jpg"),
'w1280'=> Storage::url("variants/{$m->id}/img@1280.jpg"),
'webp' => Storage::url("variants/{$m->id}/img.webp"),
'avif' => Storage::url("variants/{$m->id}/img.avif"),
];
}
}
4.2 srcset
/sizes
and <picture>
@php $v = $media->variants; @endphp
<picture>
@if(isset($v['avif']))
<source type="image/avif" srcset="{{ $v['avif'] }}">
@endif
@if(isset($v['webp']))
<source type="image/webp" srcset="{{ $v['webp'] }}">
@endif
<img
src="{{ $v['w640'] ?? $media->url }}"
srcset="{{ $v['w320'] ?? '' }} 320w, {{ $v['w640'] ?? '' }} 640w, {{ $v['w1280'] ?? '' }} 1280w"
sizes="(max-width: 640px) 90vw, 640px"
alt="{{ $media->meta['alt'] ?? '' }}"
width="640" height="400"
loading="lazy" decoding="async"
>
</picture>
Implementation points:
- Specify width and height to reduce CLS (layout shift).
- Use
loading="lazy"
anddecoding="async"
to improve perceived speed. - Use
<picture>
to prioritize AVIF/WebP and fall back to JPEG/PNG where unsupported.
4.3 Cache control and ETag
When delivering via a CDN, include a content hash in the filename at build time and add Cache-Control: public, max-age=31536000, immutable
. For dynamic responses, leverage ETag/Last-Modified.
5. Alt text & captions: make the editorial process foolproof
5.1 Input UI and storage
<x-form.input id="alt" name="meta[alt]" label="Alternative text" help="Used when the image cannot be displayed or for screen readers." />
<x-form.input id="caption" name="meta" label="Caption (optional)" />
<x-form.input id="lang" name="meta[lang]" label="Language code" help="e.g., ja, en" />
Save as a unified meta
:
$media->update([
'meta' => [
'alt' => $request->input('meta.alt'),
'caption' => $request->input('meta.caption'),
'lang' => $request->input('meta.lang','ja'),
],
]);
5.2 Guidelines for good alt text
- Explain the purpose of the image (e.g., “Storefront under a blue sky”).
- Words like “image” or “photo” are unnecessary (redundant).
- Decorative images should have an empty
alt=""
to suppress screen reader output. - For text images, mirror the same text in
alt
. For long passages, move the text to body content. - Switch
lang
per language and match the page’s primary language.
5.3 Captions and <figure>
<figure>
{{-- The <picture> from above --}}
<figcaption>{{ $media->meta['caption'] ?? '' }}</figcaption>
</figure>
6. Video delivery & accessibility: subtitles, controls, and autoplay
6.1 Basic embed
<video
controls
preload="metadata"
width="640" height="360"
poster="{{ $posterUrl }}"
style="max-width:100%"
>
<source src="{{ $h264Url }}" type="video/mp4">
<source src="{{ $webmUrl }}" type="video/webm">
<track kind="captions" src="{{ $vttJa }}" srclang="ja" label="日本語" default>
<track kind="captions" src="{{ $vttEn }}" srclang="en" label="English">
{{-- If you have audio descriptions, also add kind="descriptions" --}}
Your browser does not support the video tag.
</video>
<p class="sr-only" id="video-transcript">{{ $transcriptText }}</p>
Design points:
- Enable
controls
and avoid autoplay (if necessary, usemuted
and ensure user-initiated). - Provide subtitles using WebVTT, with multiple
<track>
elements per language. - For lengthy content, provide a separate transcript.
- On mobile connections, offer a lower bitrate and keep
preload
tometadata
.
6.2 Respecting prefers-reduced-motion
@media (prefers-reduced-motion: reduce) {
.hero-video { display:none; } /* e.g., show a poster image instead */
}
6.3 Streaming and protection
- For HLS/DASH, use segmented delivery plus short-lived signed URLs.
- For paid content, protect with layers like domain restrictions/DRM, and verify viewing rights via API.
7. Providing PDFs/documents: distribution policy and alternatives
- Ensure important info is also provided in HTML, not only as PDFs.
- PDFs should be tagged (heading structure, alt text) and embed actual text data (avoid image-only PDFs).
- For downloads, add
Content-Disposition: attachment; filename*=UTF-8''...
to prevent garbled filenames. - For long PDFs, include a summary and table of contents, and let users grasp the overview from an HTML page.
8. Download delivery: signed, Range, and headers
8.1 Signed route
Route::get('/files/{id}', [FileController::class, 'show'])
->middleware('signed')->name('files.show');
public function show(Request $req, int $id) {
$m = Media::findOrFail($id);
$this->authorize('view', $m); // Authorization
$stream = Storage::disk($m->disk)->readStream($m->path);
return response()->stream(function() use ($stream){
fpassthru($stream);
}, 200, [
'Content-Type' => $m->mime,
'Content-Length' => $m->size,
'Content-Disposition' => 'inline; filename="'.basename($m->path).'"',
'Cache-Control' => 'private, max-age=600',
]);
}
8.2 Supporting Range (large files)
For video/audio, it’s common to place a proxy/CDN that supports Range requests. If doing it in the app, implement Accept-Ranges
and partial responses (206). (Omitted here due to complexity.)
9. CDN and cache design
- Include a content hash in filenames (e.g.,
hero.5f2a9c.avif
) for long-term caching. - For dynamic generation, use
max-age
of 1–10 minutes and ETag. - Mind header-based cache keys on the CDN (e.g.,
Accept
/Accept-Language
). - For invalidation, it’s safer to distribute new URLs (update the hash).
10. Admin UX: safety rails for editors
- Make alt text mandatory for every image upload (exceptions allowed for decorative images).
- Provide inputs for “Caption” and “Context,” and guide editors not to duplicate article-side descriptions.
- When uploading videos, place subtitle upload fields alongside and warn if missing.
- Use color-independent status indicators (icons + text) to clearly show public/private/pending approval.
11. Operations: logs, monitoring, and lifecycle
- Record audit logs for save/view/delete (who/when/what).
- Use bucket lifecycle rules to auto-delete old derivative files.
- Retry failed derivative jobs and alert if thresholds are exceeded.
- Visualize CDN error rate, edge cache hit rate, and transfer volume to optimize costs.
12. Testing: Feature/Dusk/Accessibility
12.1 Feature (upload/download)
public function test_upload_and_signed_download(): void
{
Storage::fake('s3');
Sanctum::actingAs(User::factory()->create());
$resp = $this->postJson('/api/media', [
'image' => UploadedFile::fake()->image('photo.jpg', 800, 600)
])->assertCreated();
$mediaId = $resp->json('id');
$url = URL::temporarySignedRoute('files.show', now()->addMinutes(5), ['id'=>$mediaId]);
$this->get($url)->assertOk()->assertHeader('Content-Type','image/jpeg');
}
12.2 Dusk (UI/screen reader)
- Images have
alt
set. - Videos have
track[kind="captions"]
. - Image width/height are specified and CLS does not occur.
loading="lazy"
triggers lazy loading (verify by scrolling).
13. Sample: minimal media API implementation (excerpt)
13.1 Routes
Route::middleware(['auth:sanctum'])->group(function(){
Route::post('/api/media', [MediaController::class,'store']);
Route::patch('/api/media/{media}', [MediaController::class,'update']);
});
13.2 Controller
class MediaController extends Controller
{
public function store(Request $r)
{
$r->validate(['file'=>['required','file','max:8192']]);
$file = $r->file('file');
// MIME validation
$finfo = finfo_open(FILEINFO_MIME_TYPE);
abort_unless(in_array(finfo_file($finfo, $file->getRealPath()), ['image/jpeg','image/png','image/webp','application/pdf']), 422);
$path = Storage::disk('s3')->putFile('uploads', $file, 'private');
$m = Media::create([
'disk'=>'s3','path'=>$path,'mime'=>$file->getMimeType(),'size'=>$file->getSize(),
'meta'=>['alt'=>'','caption'=>'','lang'=>app()->getLocale()],
]);
dispatch(new GenerateImageVariants($m->id))->onQueue('media');
return response()->json(['id'=>$m->id,'url'=>Storage::url($path)], 201);
}
public function update(Request $r, Media $media)
{
$this->authorize('update', $media);
$media->update(['meta'=>array_merge($media->meta ?? [], $r->input('meta',[]))]);
return response()->json(['ok'=>true]);
}
}
14. Practice tips from real cases
- Hero images: Use
<picture>
with AVIF/WEBP/JP(E)G, and overlay text as HTML (avoid text baked into images). - Thumbnail grids: Create square-cropped derivatives, and build a grid with no CLS.
- Product images: Zoom must be keyboard accessible (Tab focus → Enter/Space to open, Esc to close).
- Tutorial videos: Show an onset volume notice at the beginning, and provide subtitles and a transcript.
- Lightweight transparent PNGs: Decide on a background color × combined with WebP/AVIF to greatly reduce size.
- Accountability for image description: Implement minimum character/validation for alt text (allow empty only when explicitly marked decorative).
15. Checklist (for distribution)
Storage/Delivery
- [ ] Storage config (S3/CDN) and use of
temporaryUrl
- [ ] Signed URLs, authorization, audit logs
- [ ] Strategy for Range/large files (CDN/proxy)
Image optimization
- [ ] Generate variants (by width/format)
- [ ]
<picture>
withsrcset
/sizes
,loading="lazy"
,decoding="async"
- [ ] Explicit width/height to suppress CLS
- [ ] Strip EXIF/geotags
Accessibility
- [ ] Make
alt
mandatory (empty for decorative) - [ ] Use
<figure><figcaption>
- [ ] Video
controls
, subtitles (VTT), transcript, suppress autoplay - [ ] Respect
prefers-reduced-motion
Security
- [ ] MIME/extension validation, size limits
- [ ] Virus scanning and quarantine
- [ ]
Content-Disposition
and cache strategy
Operations
- [ ] Retry failed jobs and notify
- [ ] Bucket lifecycle/cost visibility
- [ ] Review flow for alt text and subtitles
16. Common pitfalls and how to avoid them
- Storing originals publicly → risk of reuse/leakage. Use private originals + public derivatives only.
alt
is a copy-paste of the title → lacks context. Write to the image’s purpose.- Baking text into images → not translatable. Overlay HTML text instead.
- Autoplaying videos with audio on → surprise/burden. Default to user-initiated + subtitles.
- Missing width/height on
<img>
→ CLS. Fix dimensions or specify aspect ratio. - Ignoring caching → bandwidth cost surge. Hashed filenames + long-term caching.
17. Conclusion
- Build a flow on top of Storage and S3/CDN: store privately → generate derivatives → deliver publicly.
- For images, use
<picture>
andsrcset
, lazy loading, and explicit dimensions for lightweight, stable rendering. - For videos, provide
controls
, subtitles and transcripts, and suppress autoplay for an experience anyone can understand. - Make alt text and captions mandatory in the editorial flow; use empty
alt
for decorative images. - Bake in security (MIME/signed URLs/scanning) and operations (audit logs/retry on failure/lifecycle) from day one.
Media is the power to “communicate.” On a foundation of performance and safety, let’s grow expression that’s understandable to everyone as a team standard.
References
- Laravel Official
- Image optimization & delivery
- Accessibility
- Security/HTTP