amjadiqbal / laravel-media-viewer
Show PDFs, video and audio from any Laravel disk without leaking private files: Blade and Livewire components with a pdf.js PDF viewer (page navigation, zoom, search, thumbnails), a Video.js player with HLS (hls.js), signed expiring URLs and Range-aware streaming.
Package info
github.com/amjadiqbal/laravel-media-viewer
pkg:composer/amjadiqbal/laravel-media-viewer
Requires
- php: ^8.3
- illuminate/console: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/filesystem: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/routing: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- illuminate/view: ^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.20
- livewire/livewire: ^3.6|^4.0
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
- phpstan/phpstan: ^2.0
Suggests
- livewire/livewire: For the <livewire:media-viewer-gallery> component (the Blade components work inside Livewire without it).
Provides
None
Conflicts
None
Replaces
None
README
Laravel Media Viewer: private PDF viewer and video player for Laravel
Show PDFs, video and audio stored on any Laravel disk, private ones included, without making the files public. Blade and Livewire components give you a pdf.js PDF viewer (page navigation, zoom, search, thumbnails) and a Video.js player with HLS, and every file is served through a signed, expiring URL (bound to the user when someone is signed in) with proper Range support.
Docs and demo: amjad.com.pk/laravel/packages/laravel-media-viewer
<x-media-viewer::pdf disk="s3" path="contracts/{{ $contract->file }}" /> <x-media-viewer::video disk="local" path="courses/intro/index.m3u8" />
Why
Showing an uploaded file in Laravel usually means one of three compromises:
storage:linkmakes it public. Anyone with the URL has the file, forever.- A plain download route breaks media. Without Range requests, videos can't seek and large PDFs load in full before the first page appears.
- The browser's own PDF viewer differs everywhere. It looks different in each browser, ignores toolbar options in some, and behaves differently again on iOS.
This package fixes all three. Files stay on a private disk. The page gets a short-lived signed URL; when a user is signed in, only that user can use it. The stream route answers Range requests correctly, and the viewers are the same pdf.js and Video.js UI in every browser.
Features
- PDF viewer (pdf.js 6.4.299): page navigation, zoom steps, page width or whole page, text search with match count and highlighting, full screen, optional download button, keyboard friendly, dark mode.
- PDF thumbnails for lists, rendered lazily as they scroll into view. Through Range
requests the browser fetches only what its page needs. In our test, page 1 of a 4.6 MB,
60-page PDF took 216 KB. (On a remote disk whose stream can't seek, such as S3 in
streammode, the app still reads the file from the start up to the requested offset; useredirectmode there, see Delivery.) - Video and audio (Video.js 10.1.0):
.mp4,.webm,.mov,.mp3,.m4a,.ogg,.wav,.flacand more, with seeking over Range requests. - HLS from a private disk:
.m3u8plays through Video.js's hls.js adapter. Playlists are rewritten so every variant, segment, init map and key is a signed URL too. - Livewire: the components work inside Livewire components and with
wire:navigate, and<livewire:media-viewer-gallery>gives you a file list with thumbnails next to a viewer. - Security (details in Security model): disk and extension allow-lists, signed and expiring URLs (bound to the signed-in user), an optional Gate checked on every request, path-traversal protection, and Subresource Integrity for every pdf.js and Video.js file.
- Any disk:
local,public, S3, R2 or anything else Flysystem supports. Optionally redirect to the disk's own temporary URL to save your server's bandwidth.
Requirements
- PHP 8.3+
- Laravel 12 or 13
- Livewire 3 or 4, only for the gallery component (tested with Livewire 4.4)
Installation
composer require amjadiqbal/laravel-media-viewer
That's all. There is nothing to build, and nothing to publish. The first viewer on a page prints the small script and stylesheet it needs, and pdf.js and Video.js load from jsDelivr with integrity hashes the first time they are used.
Then allow the disks you want to show (local and public are allowed by default):
php artisan vendor:publish --tag=media-viewer-config
// config/media-viewer.php 'disks' => ['local', 'public', 's3'],
Usage
<x-media-viewer::pdf disk="local" path="contracts/acme.pdf" /> <x-media-viewer::pdf disk="s3" :path="$invoice->pdf_path" height="70vh" scale="page-fit" {{-- auto, page-width (default), page-fit, or a number like 1.5 --}} :page="3" :download="true" :search="true" filename="Invoice 2026-001.pdf" />
PDF thumbnails
@foreach ($documents as $document) <x-media-viewer::pdf-thumbnail disk="s3" :path="$document->path" :width="160" :page="1" :href="route('documents.show', $document)" /> @endforeach
Video and HLS
<x-media-viewer::video disk="local" path="videos/intro.mp4" poster-path="videos/intro.jpg" /> {{-- .m3u8 switches to HLS automatically. Segments next to it on the same disk are signed for you. --}} <x-media-viewer::video disk="s3" path="courses/101/index.m3u8" aspect-ratio="21 / 9" muted autoplay />
Options: poster (a URL) or poster-path (on the same disk), aspect-ratio, autoplay,
muted, loop, preload, and hls to force HLS for a source without the .m3u8 extension.
Anything in the slot goes inside the <video> element, for example captions:
<x-media-viewer::video disk="local" path="videos/intro.mp4"> <track kind="captions" srclang="en" label="English" src="{{ MediaViewer::url('local', 'videos/intro.en.vtt') }}" default> </x-media-viewer::video>
Audio
<x-media-viewer::audio disk="local" path="podcast/episode-12.mp3" />
Pick the viewer from the file
<x-media-viewer::file :disk="$attachment->disk" :path="$attachment->path" />
Public URLs
Every component also accepts src for a URL you already trust. It is not signed or checked:
<x-media-viewer::video src="https://stream.example.com/live/index.m3u8" />
Livewire
The Blade components work inside Livewire components. Each viewer is keyed by its file, so Livewire leaves it alone on unrelated updates and replaces it when the file changes. A ready-made gallery is included:
<livewire:media-viewer-gallery disk="s3" :paths="$ticket->attachments->pluck('path')->all()" />
disk and paths are locked: the browser can choose among the files you passed, but it
can't add another file to the list.
If a viewer only appears after a Livewire action (in a modal, say), print the assets in your
layout's <head> and turn off automatic printing:
<head> ... @mediaViewerAssets </head>
MEDIA_VIEWER_INJECT_ASSETS=false
Signed URLs in your own code
use AmjadIqbal\MediaViewer\Facades\MediaViewer; MediaViewer::allows('s3', 'contracts/acme.pdf'); // disk, extension and Gate checks MediaViewer::url('s3', 'contracts/acme.pdf'); // signed URL (default lifetime from config) MediaViewer::url('s3', 'contracts/acme.pdf', 5); // ...valid for 5 minutes
Security model
Each part below exists because something real goes wrong without it.
| Control | What it stops |
|---|---|
Disk allow-list (disks) |
A crafted request reading from a disk you never meant to expose. |
Extension allow-list with fixed Content-Types (extensions) |
The route serving an uploaded .html or .svg from your own origin (stored XSS). The disk's MIME guess is never used. |
| Signed URLs (Laravel URL signing) | Changing the disk or path in the URL. |
Expiry (ttl, 120 minutes by default) |
Links that keep working forever. Keep it longer than your longest video, because the player keeps making requests with the same URL while it seeks. |
User binding (bind_to_user) |
A copied link working for someone else. When a user is signed in, the URL only works for that user. |
Gate (gate) |
Access you revoke still working. The ability is checked when the viewer renders and again on every request, including each HLS segment. |
Cache-Control: private, no-cache |
A cached copy outliving a logout on a shared computer. The browser must revalidate, which re-runs the checks; an allowed viewer gets a cheap 304. |
| Path normalisation | ../ escaping the disk root, in the URL or inside an HLS playlist. |
Content-Security-Policy: sandbox, nosniff |
A served file being treated as a page. |
| Subresource Integrity | A changed pdf.js or Video.js file on the CDN. Every module, including the dynamically imported Video.js chunks, is covered by an import map with sha384 hashes, and the pdf.js worker is fetched with its hash. |
A Gate example:
// app/Providers/AppServiceProvider.php Gate::define('viewMedia', function (?User $user, string $disk, string $path) { return $user && Document::where('path', $path)->where('team_id', $user->team_id)->exists(); });
// config/media-viewer.php 'gate' => 'viewMedia',
What this is not: a viewer is not DRM. A browser that may view a file can always save it. This package makes sure only the right people, and only for a while, get to view it.
Delivery: stream or redirect
delivery is stream by default: the app streams every file itself, with full Range support,
from any disk. Local disks seek straight to the requested range. Many remote disks (S3 through
Flysystem, for example) return a stream that can't seek, so the app reads and discards the bytes
before the range: correct, but slow for seeking far into a large remote file.
For any disk that provides temporary URLs (S3 and S3-compatible disks such as R2, or a local
disk with serve enabled) you can set delivery to redirect. The route still checks the signature,
user and Gate, then redirects to the disk's own temporaryUrl(), so the bytes come straight
from the bucket. HLS playlists are still rewritten by the app. pdf.js and hls.js fetch the file
with JavaScript, so the bucket needs CORS:
[{ "AllowedOrigins": ["https://your-app.com"], "AllowedMethods": ["GET", "HEAD"], "AllowedHeaders": ["Range"], "ExposeHeaders": ["Content-Range", "Content-Length", "Accept-Ranges"] }]
Assets: CDN or self-hosted
By default pdf.js 6.4.299 and Video.js 10.1.0 load from jsDelivr, pinned to exact versions, with integrity hashes. To serve them from your own domain instead:
npm i -D pdfjs-dist@6.4.299 @videojs/cdn@10.1.0 php artisan media-viewer:assets
MEDIA_VIEWER_ASSETS=local
The command copies only the files the viewers load into public/vendor/media-viewer, and
refuses any file whose hash doesn't match the pinned version.
Content Security Policy: allow script-src and style-src for https://cdn.jsdelivr.net
(unless self-hosted), connect-src for the same (pdf.js fetches its worker, fonts and maps),
and worker-src blob: (the integrity-checked pdf.js worker starts from a blob URL).
Configuration
All options, with comments, are in config/media-viewer.php:
disks, extensions, ttl, bind_to_user, gate, delivery, route.prefix,
route.middleware, assets.source, assets.integrity, assets.inject, and the PDF defaults
(height, scale, download). The views and translations can be published too
(--tag=media-viewer-views, --tag=media-viewer-lang). Colours are CSS variables
(--mv-accent, --mv-bg, --mv-surface, ...) on .mv.
Browser support
Tested in Chrome on macOS. In a real browser we checked PDF rendering, search, zoom and page
navigation; MP4 seeking over Range requests; a two-level HLS stream from a private disk;
audio; lazy thumbnails; the Livewire gallery; wire:navigate; user binding; and refused
tampered files. Video.js 10 and pdf.js 6 target current Chrome, Edge, Firefox and Safari
(including iOS). Safari and iOS have not been tested by hand yet. If you find a problem
there, please open an issue.
Testing
composer test # Pest scripts/ci-local.sh # Pint, PHPStan and Pest on PHP 8.3-8.5 x Laravel 12-13
Need this built into your app, or something custom?
I build document portals, course platforms and Laravel packages for teams. If you'd rather have private file viewing designed and wired into your app for you (per-team permissions, watermarking, transcoding to HLS, audit logs), or need something this package doesn't do, get in touch.
Support
- 🐛 Bug or feature request: open an issue
- 🔒 Security issue: please report privately via GitHub's security tab
Need this customised, or something built?
I'm available for custom development, package integration, and technical consulting.
More packages
Part of a family of open-source packages. See all of them.
| laravel-driver-js | Product tours and onboarding for Laravel |
| kiln | Deploy-time OPcache control for Laravel |
| laravel-tiptap | Tiptap editor for Laravel |
| laravel-logpulse | Log health monitoring and alerting |
Credits
- pdf.js by Mozilla (Apache-2.0)
- Video.js (Apache-2.0), which bundles hls.js (Apache-2.0)
- This package is independent and not affiliated with Mozilla, Video.js or hls.js.
- Amjad Iqbal
License
MIT. See LICENSE.md.
