Upload videos straight from the dashboard to a professional streaming provider — Cloudflare Stream, Bunny Stream, or Mux — and drop them onto any page with the video block's Hosted source. Playback is ad-free adaptive HLS with the provider's auto-generated poster, and the upload never touches your web server.
Feature key: video_hosting (default OFF). Toggle at Dashboard → Settings → Features; configure at Dashboard → Videos → Settings (admin only).
How it works
- Configure a provider (Settings): pick the active provider and enter its credentials. Each card has a Test connection button.
- Upload (Dashboard → Videos): the server registers the pending video with the provider and hands the browser a one-time upload target; the bytes go directly from the browser to the provider (TUS resumable for Cloudflare/Bunny, resumable PUT for Mux) — PHP upload limits and server bandwidth don't apply.
- Transcode status flips uploading → processing → ready automatically: the Videos page polls while anything is pending, a
video-hosting:pollLazyCron task (every 5 min) covers background updates, and optional provider webhooks make it near-instant. - Use it on a page: the page editor's video block gains a fourth Hosted (Video Library) source mode with a searchable picker. The saved value is the hosted video's id; the manifest URL + poster resolve at render time, so provider-side changes propagate.
- Playback uses the same lazy hls.js loader as the free Adaptive mode (
<video data-hls-src>): native HLS on Safari/iOS, hls.js elsewhere.
The free/core path is unchanged: the video block's Adaptive mode still accepts any pasted HLS/DASH manifest URL without this add-on.
Captions, chapters & playback stats
Every ready video gets a manage panel (the speech-bubble button on the Videos page) with three sections:
- Captions — upload a
.vttor.srtfile per language (SRT is converted to WebVTT automatically), or click Generate to have the provider auto-transcribe the audio (Cloudflare caption generation, Bunny transcription, Mux generated subtitles). Generation is asynchronous; the panel polls while a track is pending and downloads the finished VTT when it's ready. Deleting a track removes it at the provider and locally. - Chapters — named timeline points (
1:23+ title), stored on the video and written out as a WebVTT chapters track. Provider-agnostic; players with chapter menus (e.g. Safari) pick them up natively. - Playback stats — views + watch time pulled from the provider: Cloudflare Stream analytics (GraphQL, last 30 days; views are playback sessions), Bunny's lifetime video totals, and Mux delivery usage (seconds streamed over the last 30 days; per-view counts need Mux Data, which isn't embedded). A daily
video-hosting:sync-statsLazyCron task keeps the numbers fresh; the Refresh button pulls on demand.
Local caption mirror. Every caption track (uploaded or generated) is mirrored to storage/app/public/hosted-video-tracks/{id}/{lang}.vtt, and the public player renders plain same-origin <track kind="subtitles"> elements (plus <track kind="chapters">) inside the hosted <video>. That sidesteps provider CORS and authenticated caption endpoints entirely — the browser's native CC menu works under both native-HLS and hls.js playback. The provider copy still exists so provider-side players/manifests carry the captions too. Track files are cleaned up when a caption or the video is deleted.
Mux caveat: Mux ingests caption files by URL, so manual caption uploads require the site to be publicly reachable (it fetches the mirrored VTT from /storage/…). Auto-generated subtitles have no such constraint.
Providers
| Cloudflare Stream | Bunny Stream | Mux | |
|---|---|---|---|
| Credentials | Account ID, API token (Stream:Edit), customer subdomain code | Library ID, library API key, CDN hostname | Access token ID + secret |
| Browser upload | TUS (one-time URL via ?direct_user=true) |
TUS with presigned AuthorizationSignature |
Resumable PUT (@mux/upchunk) |
| Playback | customer-{code}.cloudflarestream.com/{uid}/manifest/video.m3u8 |
{cdn}/{guid}/playlist.m3u8 |
stream.mux.com/{playback_id}.m3u8 |
| Webhook auth | Webhook-Signature HMAC |
none (payload treated as refresh hint only) | Mux-Signature HMAC |
One provider is active for new uploads (video_hosting.provider), but every video row remembers the provider it lives on — switching providers never breaks existing videos. Deleting a video also deletes it at its original provider, so keep that provider's credentials configured.
Webhooks (optional)
Register the URLs shown on the settings page:
POST /video-hosting/webhook/cloudflare— register withPUT /accounts/{id}/stream/webhook; paste the returned secret into settings.POST /video-hosting/webhook/bunny— set as the library's webhook URL. Bunny webhooks are unsigned, so the payload is only used to decide which video to re-fetch from the API — the posted status itself is never trusted.POST /video-hosting/webhook/mux— add in Mux → Settings → Webhooks; paste the signing secret into settings.
All three controllers converge on one trust model: verify (where possible), then re-fetch truth from the provider's API via VideoHostingManager::refresh(). Without webhooks everything still works via polling.
Settings keys
| Key | Meaning |
|---|---|
video_hosting.provider |
cloudflare | bunny | mux (default cloudflare) |
video_hosting.cloudflare.account_id / .api_token / .customer_subdomain / .webhook_secret |
Cloudflare Stream |
video_hosting.bunny.library_id / .api_key / .cdn_hostname |
Bunny Stream |
video_hosting.mux.token_id / .token_secret / .webhook_secret |
Mux |
video_hosting.max_upload_bytes |
Dashboard upload cap (default 5 GB) |
Architecture
Module: app/Features/VideoHosting/ (standard feature-module layout; migrations run on toggle-on via FeatureActivator).
Models/HostedVideo— one row per video:provider,provider_uid,title,status(pending|uploading|processing|ready|error), duration/size/dimensions,thumbnail_url,playback_hls_url/playback_dash_url,metajson (per-provider extras like Muxasset_id/playback_id),captionsjson (per-language track records with localsrc),chaptersjson, and the synced stats columns (views_total,watch_seconds_total,stats_period,stats_synced_at). Public rendering reads the cachedHostedVideo::playbackMapCached()map — now including ready caption tracks + the chapters URL (query-free once warm; busted on every save/delete along with the response cache, since playback URLs are baked into cached HTML).Services/VideoHostingManager— resolves drivers,createUpload(),refresh(),syncStats(),deleteEverywhere().Services/HostedVideoTracks— caption + chapter orchestration:uploadCaption()(VTT/SRT → local mirror + provider push),requestGeneration(),syncFromProvider()(reconciles records, downloads newly-ready generated VTTs),deleteCaption(),saveChapters()(writes the chapters WebVTT).Services/VideoHostProvider— the driver interface (createDirectUpload,fetchStatus,delete,playbackUrls,thumbnailUrl,verifyWebhook,signedPlaybackUrl, plus the caption methodslistCaptions/captionVtt/uploadCaption/deleteCaption/requestAutoCaptionandfetchStats). Adding a fourth provider = one class + one entry inVideoHostingManager::PROVIDERS+ a settings card.- Editor tie-in —
DlSchemas\Video::schemaFields()adds a{prefix}_hosted_idfield (typehosted_video, feature-gated) and thehostedsource mode;x-dl.videorenders the hosted branch exactly like adaptive (data-hls-src+ provider thumbnail as poster). A deleted/processing video renders the neutral placeholder — never a fatal.
Reusing hosted videos from other add-ons (e.g. Courses)
Consume hosted video only through the module's public API:
use App\Features\VideoHosting\Models\HostedVideo;
use App\Features\VideoHosting\Services\VideoHostingManager;
$video = HostedVideo::query()->where('status', HostedVideo::STATUS_READY)->find($id);
$hls = $video->playback_hls_url; // public playback
$signed = app(VideoHostingManager::class)->signedPlaybackUrl($video, ttlSeconds: 3600); // gated playback
signedPlaybackUrl() is the gated-playback hook, implemented on all three drivers:
- Cloudflare Stream — mints a playback token server-side (
POST /stream/{uid}/token, cached for half the TTL so bursts don't mint one per view) and swaps it into the playback URL. No extra settings needed beyond the API token. Enforcement comes from settingrequireSignedURLson the video. - Bunny Stream — CDN token authentication:
?token=sha256(key + videoId + expires)&expires=…on the playlist URL, using the Token authentication key from the library's security settings (new field on the Videos settings page). Enforcement comes from enabling Embed View Token Authentication on the library. - Mux — a locally-minted RS256 JWT (
kid= signing key id,sub= playback id,aud: v) appended as?token=. Configure the Signing key ID + private key (Mux → Settings → Signing Keys) on the Videos settings page. Enforcement comes from creating playback IDs with thesignedpolicy.
It returns null whenever the video's provider isn't configured for signing — the caller decides whether to fall back to the public URL or refuse playback. In every case the minting works even for public videos (the token is simply ignored), so Courses can adopt per-video enforcement incrementally. Store hosted_videos.id foreign keys (not provider URLs) so signing, renames, and thumbnail refreshes keep working.