rajurayhan / larastreamer
A lightweight, storage-agnostic video streaming engine for Laravel — HTTP Range, signed URLs, authorization, and cloud disks.
Requires
- php: ^8.3
- 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 || ^4.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0 || ^11.0
- pestphp/pest: ^3.0 || ^4.0
- pestphp/pest-plugin-laravel: ^3.0 || ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-30 17:21:41 UTC
README
A lightweight, storage-agnostic video streaming engine for Laravel — HTTP Range, signed URLs, authorization, and cloud disks.
Not a transcoding platform. It does not generate renditions, package HLS/DASH, or talk to Mux / Cloudflare Stream. Delivery only.
Requires PHP 8.3+ and Laravel 12 or 13.
Status: 3.0. Progressive MP4 by default. Opt-in HLS/DASH serving, captions, metadata, and a Streamable model trait. The v1 Raju\Streamer\Helpers\VideoStream class was removed — use Streamer::file($path)->stream().
See docs/v3.md for the 3.0 design notes.
Contents
- Installation
- Quick start
- Usage
- HTTP Range, HEAD, and validators
- Offload
- Security
- Configuration
- Migrating from v2 / v1
- Production notes
- Testing
- License
Installation
composer require rajurayhan/larastreamer
The service provider and Streamer facade alias register automatically.
Publish the configuration (optional — values are merged even if you skip this):
php artisan vendor:publish --tag=larastreamer
Publish the player views (optional):
php artisan vendor:publish --tag=larastreamer-views
Add a disk in config/filesystems.php if you keep videos off the default local disk:
'videos' => [ 'driver' => 'local', 'root' => storage_path('app/private/videos'), 'visibility' => 'private', 'throw' => false, ], 's3' => [ 'driver' => 's3', 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), 'region' => env('AWS_DEFAULT_REGION'), 'bucket' => env('AWS_BUCKET'), ],
Point Larastreamer at that disk (or leave the defaults and prefix files under uploads/):
LARASTREAMER_DISK=videos LARASTREAMER_PATH=
Quick start
use Raju\Streamer\Facades\Streamer; // In a controller — Range, HEAD, and ETag are handled for you. return Streamer::disk('videos')->file('courses/lesson-01.mp4')->stream();
{{-- After enabling the signed route — see Signed URLs --}} <x-larastreamer::player src="courses/lesson-01.mp4" />
Usage
Fluent API
Every delivery call starts from the Streamer facade and a PendingStream builder.
| Method | Returns | Purpose |
|---|---|---|
Streamer::file($path) |
PendingStream |
Default disk, prefixed with storage.path (uploads by default) |
Streamer::disk($name) |
PendingStream |
Named Laravel disk; later file() paths are used as-is |
->file($path) |
PendingStream |
Relative disk path, or an absolute local file that exists |
->captions($tracks) |
PendingStream |
VTT/SRT tracks for stream() / embedData() / the player |
->authorize($cb) |
PendingStream |
Per-call authorization (this request only) |
->drm($configuration) |
PendingStream |
Attach a DrmConfiguration or application DrmProvider |
->stream() |
Response |
Stream, redirect, offload, or rewrite a playlist |
->download() |
Response |
Attachment disposition (remote disks 302) |
->redirect($expires) |
Response |
302 to a disk temporaryUrl() |
->temporaryUrl($expires) |
string |
Cloud signed URL |
->meta() |
VideoMeta |
Size, MIME, mtime, ETag; duration/codec if a Probe is bound |
->embedData($expires) |
array |
Public URL payload for a player |
Streamer::signedUrl($path, $expires) |
string |
App-signed built-in route URL |
Streamer::authorize($cb) |
Streamer |
Request-scoped default authorizer |
use Raju\Streamer\Facades\Streamer; return Streamer::file($absoluteOrRelative)->stream(); return Streamer::disk('videos') ->file('courses/lesson-01.mp4') ->captions([ ['src' => 'courses/lesson-01.en.vtt', 'srclang' => 'en', 'label' => 'English', 'default' => true], ]) ->stream(); $meta = Streamer::disk('videos')->file('courses/lesson-01.mp4')->meta(); // size, mime, last_modified, etag — duration/codec only if a Probe is bound return Streamer::disk('s3') ->file($video->path) ->authorize(fn (string $path, $user) => $user?->can('viewVideo', $path)) ->stream(); $url = Streamer::disk('s3') ->file($video->path) ->temporaryUrl(now()->addMinutes(30)); return Streamer::disk('s3') ->file($video->path) ->redirect(); return Streamer::disk('videos') ->file($path) ->download(); $data = Streamer::disk('videos') ->file($path) ->embedData(); // url, type, mime, expires_at, kind, captions
$expires on redirect(), temporaryUrl(), embedData(), and signedUrl() accepts a DateTimeInterface, an integer number of seconds, or null (uses security.default_expiration, 1800 seconds).
Controllers and routes
Use your own routes. The package route is off by default.
use App\Models\Lesson; use Illuminate\Support\Facades\Route; use Raju\Streamer\Facades\Streamer; Route::get('/lessons/{lesson}/watch', function (Lesson $lesson) { return Streamer::disk($lesson->disk) ->file($lesson->path) ->authorize(fn (string $path, $user) => $user?->can('view', $lesson)) ->stream(); })->middleware('auth')->name('lessons.watch'); Route::get('/lessons/{lesson}/download', function (Lesson $lesson) { return $lesson->download(); })->middleware('auth')->name('lessons.download');
namespace App\Http\Controllers; use App\Models\Lesson; use Illuminate\Http\Request; use Raju\Streamer\Facades\Streamer; use Symfony\Component\HttpFoundation\Response; final class LessonStreamController { public function __invoke(Request $request, Lesson $lesson): Response { $this->authorize('view', $lesson); return Streamer::disk($lesson->streamDisk()) ->file($lesson->streamPath()) ->captions($lesson->streamCaptions()) ->stream(); } }
Default disk vs named disk
Streamer::file($path) uses storage.disk and prefixes storage.path (uploads by default), unless $path is already under that prefix or is an existing absolute local file.
// config: disk=local, path=uploads Streamer::file('lesson-01.mp4')->stream(); // → local disk, path uploads/lesson-01.mp4 Streamer::file('uploads/lesson-01.mp4')->stream(); // → local disk, path uploads/lesson-01.mp4 (prefix not doubled) Streamer::file('/var/www/storage/app/private/uploads/lesson-01.mp4')->stream(); // → absolute local file, still jailed to the disk root
Streamer::disk('videos')->file($path) uses that path on the given disk as-is. No uploads/ prefix.
Streamer::disk('videos')->file('courses/lesson-01.mp4')->stream(); // → videos disk, path courses/lesson-01.mp4
Streamable models
No migrations. The model already has a path column (and optionally disk / captions).
use Illuminate\Database\Eloquent\Model; use Raju\Streamer\Concerns\Streamable; class Lesson extends Model { use Streamable; // Defaults: `path` attribute, config default disk, `captions` attribute if present. public function streamDisk(): string { return $this->disk ?? 'videos'; } public function streamPath(): string { return $this->path; } /** * @return list<array{src: string, srclang?: string, label?: string, default?: bool}> */ public function streamCaptions(): array { return $this->captions ?? []; } }
return $lesson->stream(); return $lesson->download(); $lesson->streamUrl(now()->addMinutes(30)); $lesson->streamUrl(1800); // seconds $data = $lesson->embedData();
streamUrl() and embedData() need a resolvable public URL (signed route for local disks, temporaryUrl() for remote). They never return a raw disk path.
Cloud disks
Any Laravel filesystem disk works. Local disks are streamed (or offloaded). S3, R2, Spaces, MinIO, and Wasabi go through Flysystem — no custom SDKs.
Remote objects redirect by default (storage.remote.strategy = redirect), including download(). Do not proxy gigabytes through PHP unless you set the strategy to proxy.
return Streamer::disk('s3')->file($path)->stream(); // 302 to temporaryUrl() return Streamer::disk('s3')->file($path)->redirect(); return Streamer::disk('s3')->file($path)->temporaryUrl(now()->addMinutes(30)); return Streamer::disk('s3')->file($path)->download(); // 302 + Content-Disposition on the signed URL
To proxy a private bucket through PHP (small files, or when every byte must run authorize/events):
// config/larastreamer.php 'storage' => [ 'remote' => [ 'strategy' => 'proxy', ], ],
Signed URLs
Built-in routes are off by default. When you enable them, Laravel's signed middleware is required. security.signed_urls is enforced: if it is false, signedUrl() throws and the built-in route 404s.
// config/larastreamer.php 'route' => [ 'enabled' => true, 'prefix' => 'stream', 'middleware' => ['signed'], 'name' => 'larastreamer.stream', ],
The route is GET /stream?file=courses/lesson-01.mp4 (not /{filename}). Nested paths work because the file is a query parameter.
use Raju\Streamer\Facades\Streamer; $url = Streamer::signedUrl('courses/lesson-01.mp4', expires: now()->addMinutes(30)); // Blade <video controls src="{{ Streamer::signedUrl($lesson->path) }}"></video>
Unsigned, expired, and tampered signatures are rejected by Laravel (403). Missing file or security.signed_urls = false is 404.
embedData() returns a signed URL when routes are on. If routes are off, pass url: to the player or catch StreamException.
Authorization
Default: allow (the app already decided to call stream(), or the signed route already validated the signature).
Bind a process-wide policy in a service provider:
use Raju\Streamer\Contracts\Authorization; final class LessonPolicyAuthorization implements Authorization { public function authorize(string $path, mixed $user): bool { return $user?->can('viewVideo', $path) === true; } } // AppServiceProvider::register() $this->app->bind(\Raju\Streamer\Contracts\Authorization::class, LessonPolicyAuthorization::class);
Streamer::authorize() is request-scoped and does not leak to the next HTTP request:
Streamer::authorize(fn (string $path, $user) => $user?->can('viewVideo', $path)); return Streamer::disk('videos') ->file($path) ->authorize(fn (string $path, $user) => $user?->can('viewVideo', $path)) ->stream();
Denied requests return 403 and dispatch VideoStreamUnauthorized. The per-call hook wins over the request-scoped hook, which wins over the container binding.
Metadata
$meta = Streamer::disk('videos')->file('courses/lesson-01.mp4')->meta(); $meta->size; // int $meta->mime; // video/mp4 $meta->lastModified; // unix timestamp or null $meta->etag; // W/"size-mtime-or-hash" $meta->duration; // float|null $meta->codec; // string|null $meta->toArray(); $meta['size']; // ArrayAccess, read-only
Duration and codec are filled only when you bind an optional Probe. There is no FFprobe dependency.
use Raju\Streamer\Contracts\Probe; use Raju\Streamer\Storage\ResolvedVideo; final class FfprobeAdapter implements Probe { public function inspect(ResolvedVideo $video): array { // Run your own probe. Return whatever you have. return [ 'duration' => 612.4, 'codec' => 'avc1', ]; } } $this->app->bind(Probe::class, FfprobeAdapter::class);
Captions
.vtt and .srt are served through the same jail and signed route. The player renders <track kind="subtitles">.
return Streamer::disk('videos') ->file('lesson.mp4') ->captions([ ['src' => 'lesson.en.vtt', 'srclang' => 'en', 'label' => 'English', 'default' => true], ['src' => 'lesson.es.vtt', 'srclang' => 'es', 'label' => 'Español'], ]) ->stream();
embedData()['captions'] rewrites each src to a public URL (signed or temporary). Tracks that fail to resolve are omitted.
Disable caption serving with captions.enabled = false.
HLS and DASH serving (opt-in)
Progressive MP4 is the default. HLS/DASH serving is off until you enable it. Transcoding is not in 3.0 — no FFmpeg.
'hls' => [ 'enabled' => true, 'rewrite' => true, 'player' => 'native', // native | hlsjs 'hlsjs_src' => 'https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js', ], 'dash' => [ 'enabled' => true, 'rewrite' => true, ],
When enabled, those extensions and MIME types are merged into the allowlist. When disabled, .m3u8 / .mpd are 404.
| Kind | Extensions | MIME |
|---|---|---|
| HLS playlist | m3u8 |
application/vnd.apple.mpegurl (and common aliases) |
| HLS segments | ts, m4s |
video/mp2t, video/iso.segment |
| DASH | mpd, m4s |
application/dash+xml |
stream() on a .m3u8 / .mpd rewrites relative segment (and #EXT-X-KEY / #EXT-X-MAP / #EXT-X-MEDIA) URIs:
- local disk → signed
?file=URL - remote disk →
temporaryUrl()
Absolute https:// URIs are left alone. ../ inside a playlist is 404. Playlists are Cache-Control: private, max-age=0, no-store. Segments are not fetched during rewrite.
return Streamer::disk('videos')->file('courses/lesson-01.m3u8')->stream();
Safari can play HLS natively. For Chrome/Firefox set hls.player to hlsjs and use the Blade player — HLS.js is loaded from hls.hlsjs_src, not vendored in Composer.
DRM playback
Larastreamer can play externally packaged encrypted HLS or DASH through the existing Blade component. It does not encrypt media, store content keys, issue licenses, or proxy license challenges. Widevine, PlayReady, and FairPlay therefore require a compatible external packager and licensed DRM service. ClearKey is suitable only for development.
use Raju\Streamer\Drm\DrmConfiguration; use Raju\Streamer\Drm\KeySystem; use Raju\Streamer\Facades\Streamer; $drm = new DrmConfiguration( licenseServers: [ KeySystem::Widevine->value => route('licenses.widevine', $movie), KeySystem::PlayReady->value => route('licenses.playready', $movie), ], licenseHeaders: ['Authorization' => 'Bearer '.$shortLivedPlaybackToken], manifestUrl: $cdn->signedManifestUrl($movie), ); return Streamer::disk($movie->disk) ->file($movie->manifest_path) ->drm($drm) ->embedData();
The Blade component accepts the same configuration and keeps its existing public API:
<x-larastreamer::player disk="videos" src="protected/movie.mpd" :drm="$drm" />
You can implement Raju\Streamer\Contracts\DrmProvider when license URLs or short-lived headers depend on the viewer or asset. Its configuration(DrmContext $context) method runs only after Larastreamer authorizes and resolves the requested manifest. The browser receives only the allowlisted license/content/certificate headers and Shaka robustness settings in DrmConfiguration; never pass raw content keys, private keys, vendor server credentials, or long-lived client secrets.
All DRM endpoint URLs require HTTPS, except loopback HTTP URLs for local development. The default player engine is the pinned https://cdn.jsdelivr.net/npm/shaka-player@5.2.9/dist/shaka-player.compiled.js build; set drm.shaka_src to a self-hosted copy if required. Shaka is loaded only when DRM data is attached, so ordinary progressive and HLS playback remains unchanged.
For production, prefer encrypted assets in a private S3-compatible origin behind a CDN and provide manifestUrl. The CDN then serves manifest and segment bytes without placing PHP in the hot path. If an encrypted manifest is on a local disk and has no override, Larastreamer creates an encrypted, expiring bearer ticket scoped to that disk, asset directory, and current user. The initial authorize() callback decides whether that capability may be issued; callbacks are not serialized or replayed on later browser requests. Every playback request still validates the ticket and user identity, applies the filesystem jail and MIME checks, and runs the application's bound Authorization implementation. The default web middleware hydrates Laravel's session; add auth to drm.playback_middleware if the viewer must remain logged in throughout playback.
The player emits safe lifecycle events without URLs, headers, license payloads, or provider error messages:
video.addEventListener('larastreamer:drm-ready', function () { console.log('Protected playback is ready'); }); video.addEventListener('larastreamer:drm-error', function (event) { console.error(event.detail.code, event.detail.stage, event.detail.shakaCode); });
DRM protects decryption keys and raises the cost of copying media; it cannot prevent screen capture.
Downloads
return Streamer::disk('videos')->file($path)->download();
Local files send Content-Disposition: attachment. Remote disks 302 to a temporary URL with ResponseContentDisposition — they do not stream the object through PHP.
Player
<x-larastreamer::player src="clip.mp4" /> <x-larastreamer::player src="lesson.m3u8" /> <x-larastreamer::player url="https://cdn.example.test/clip.mp4" mime="video/mp4" poster="thumb.jpg" :autoplay="false" :controls="true" /> <x-larastreamer::player src="clip.mp4" :captions="[['src' => 'en.vtt', 'srclang' => 'en', 'label' => 'English', 'default' => true]]" />
| Prop | Type | Notes |
|---|---|---|
src |
string |
Disk-relative path. Resolved through embedData() (signed/temporary URL). |
url |
string |
Public URL. Skips disk resolution. Use this when routes are off. |
mime |
string |
type on <source> |
poster |
string |
Native poster |
autoplay |
bool |
Default false |
controls |
bool |
Default true |
captions |
array |
Override tracks; otherwise uses embedData()['captions'] |
disk |
string |
Named disk used when resolving src |
drm |
DrmConfiguration|DrmProvider |
Enables conditional protected playback through Shaka |
Extra HTML attributes pass through to <video>.
Native <video> by default. No JavaScript is vendored. If hls.player = hlsjs and the source is a playlist, the component loads HLS.js from hls.hlsjs_src.
If there is no resolvable public URL, the tag is an empty <video data-empty="true"> with no <source> — never a disk path.
$data = Streamer::disk('videos')->file($path)->embedData(now()->addHour()); // [ // 'url' => 'https://…', // signed or temporary — never a disk path // 'type' => 'video', // 'mime' => 'video/mp4', // 'expires_at' => '2026-09-08T12:00:00+00:00', // 'kind' => 'progressive', // progressive | hls | dash | caption // 'captions' => [ /* public src URLs */ ], // ]
Events
Listen in your app as usual:
use Raju\Streamer\Events\VideoStreamFailed; use Raju\Streamer\Events\VideoStreamStarted; Event::listen(VideoStreamStarted::class, function (VideoStreamStarted $event): void { // $event->path, disk, userId, range, strategy }); Event::listen(VideoStreamFailed::class, function (VideoStreamFailed $event): void { report(new RuntimeException($event->reason)); });
| Event | When |
|---|---|
VideoStreamStarted |
Delivery begins |
VideoStreamCompleted |
After the body is sent (local BinaryFileResponse uses a terminating callback) |
VideoStreamFailed |
Missing file, bad MIME, or other stream error |
VideoStreamUnauthorized |
authorize() returned false |
Payloads are small: path, disk, user id, range, strategy (file / offload / proxy / redirect / playlist). VideoStreamFailed also has a reason.
Exceptions
Responses never include absolute server paths.
| Exception | Status | When |
|---|---|---|
VideoNotFound |
404 | Missing, empty, directory, traversal, or disallowed MIME/extension |
UnauthorizedStream |
403 | Authorizer returned false |
InvalidRange |
416 | Unsatisfiable Range (Content-Range: bytes */{size}) |
StreamException |
500 | Signed routes disabled, embed URL cannot be built, and other delivery errors |
signedUrl() and embedData() throw StreamException when a public URL cannot be built. stream() / download() / redirect() convert package exceptions into HTTP responses.
HTTP Range, HEAD, and validators
| Request | Status | Headers |
|---|---|---|
| Full GET | 200 | Content-Type, Content-Length, Accept-Ranges: bytes, ETag, Last-Modified |
| Valid Range | 206 | Content-Length of the range, Content-Range: bytes {start}-{end}/{size} |
If-None-Match matches, no Range |
304 | Empty body, ETag / Last-Modified |
If-Range mismatch |
200 | Full body, Range ignored |
| Invalid Range | 416 | Content-Range: bytes */{size} |
| HEAD | same headers as GET | no body |
Supported ranges: bytes=0-999, bytes=1000-, bytes=-500.
ETag is weak: W/"{size}-{mtime}" locally, or a path hash when mtime is missing on a remote object.
Offload (local only)
'offload' => [ 'enabled' => true, 'driver' => 'nginx', // nginx | apache 'prefix' => '/internal-videos/', ],
Larastreamer returns an empty body plus X-Accel-Redirect or X-Sendfile. nginx honors the original request Range and serves 206; PHP does not consume the file. Apache X-Sendfile does not reliably forward Range — disable offload or use nginx if clients need byte ranges.
Example nginx location:
location /internal-videos/ { internal; alias /var/www/storage/app/private/uploads/; }
The alias must match the disk root (plus storage.path if you use the default prefix). Tests assert headers only; nginx must be configured in the environment.
Security
- Paths are resolved through the disk. Local files are
realpath'd and must stay under the disk root. - Traversal (
../, encoded) is rejected with 404, including URIs inside rewritten playlists. - MIME and extension allowlists apply. Client
Content-Typeis ignored. HLS types are not in the default list. - Missing, empty, directory, and disallowed files are 404. Responses never include absolute server paths.
- Default cache for private/signed content:
Cache-Control: private, max-age={n}. Public cache is opt-in (streaming.cache = public). security.signed_urlsmust staytrueto use the built-in route.- Playlists are never stored in a shared cache (
no-store).
Configuration
See config/larastreamer.php after publishing.
| Key | Default | Notes |
|---|---|---|
route.enabled |
false |
Load GET /{prefix}?file= |
route.prefix |
stream |
URL prefix |
route.middleware |
['signed'] |
Required when the route is on |
route.name |
larastreamer.stream |
Used by signedUrl() |
storage.disk |
env('LARASTREAMER_DISK', 'local') |
Default disk for Streamer::file() |
storage.path |
env('LARASTREAMER_PATH', 'uploads') |
Prefix for Streamer::file() only |
storage.remote.strategy |
redirect |
redirect or proxy |
streaming.buffer_size |
1048576 |
Proxy read chunk size |
streaming.max_age |
3600 |
Cache-Control max-age |
streaming.cache |
private |
private or public |
security.signed_urls |
true |
Enforced for the built-in route |
security.default_expiration |
1800 |
Seconds when $expires is omitted |
allowed_mimes |
progressive video types | Extra HLS/DASH/caption types merge when enabled |
allowed_extensions |
mp4, webm, ogv, mov, avi, mpeg, mpg |
Same merge rules |
hls.enabled / dash.enabled |
false |
Opt-in playlist serving |
hls.rewrite / dash.rewrite |
true |
Rewrite relative URIs |
hls.player |
native |
native or hlsjs |
hls.hlsjs_src |
jsDelivr HLS.js | CDN string only |
drm.enabled |
true |
Enable DRM configuration and the local playback route |
drm.shaka_src |
pinned Shaka Player 5.2.9 | May be replaced with a self-hosted build |
drm.playback_route_name |
larastreamer.playback |
Local encrypted asset route name |
drm.playback_middleware |
['web'] |
Middleware for local DRM asset requests; add auth when required |
captions.enabled |
true |
Allow .vtt / .srt |
offload.enabled |
false |
Local sendfile / accel |
Migrating from v2 / v1
| Old | 3.0 |
|---|---|
Raju\Streamer\Helpers\VideoStream |
Removed. Streamer::file($path)->stream() |
Streamer::authorize() mutates a singleton |
Request-scoped StreamContext |
embedData()['url'] as a disk path |
Signed URL, or StreamException |
Unused security.signed_urls |
Enforced |
| HLS types in a custom allowlist | Enable hls.enabled / dash.enabled |
Production notes
- Prefer
redirect()/temporaryUrl()for S3-compatible disks.download()on remote disks follows the same rule. - Enable offload behind nginx for large local files if you need Range; Apache
X-Sendfileis full-file only. - Keep
route.enabledfalse unless you need the built-in signed endpoint. - Do not expose a public cache for private lessons.
- Bind
Authorizationin a service provider rather than relying onStreamer::authorize()in middleware that might not run on every request.
Testing
composer test node --test tests/JavaScript/*.test.mjs vendor/bin/pint --test vendor/bin/phpstan analyse --memory-limit=1G
License
MIT