aminyazdanpanah/php-shaka
PHP library that wraps Shaka Packager for DASH and HLS packaging and encryption, with Common Encryption support for Widevine, PlayReady, and raw key DRM. Includes live streaming and ad insertion examples.
1. Install the PHP wrapper and the actual binary. The Composer package is only a command builder; nothing works until the Shaka Packager executable exists on the machine.
composer require aminyazdanpanah/php-shaka
Then get Shaka Packager via Docker, a prebuilt release, or a source build, rename the artifact to packager (packager.exe on Windows), and either put it on the system PATH or remember its absolute path. In a Laravel app, put that decision in config rather than code:
// config/shaka.php
return [
'binaries' => env('SHAKA_PACKAGER_BINARY', '/usr/local/bin/packager'),
'timeout' => (int) env('SHAKA_TIMEOUT', 3600),
];
// AppServiceProvider::register()
$this->app->singleton(\Shaka\Shaka::class, fn () => \Shaka\Shaka::initialize([
'packager.binaries' => config('shaka.binaries'),
'timeout' => config('shaka.timeout'),
]));
2. Where to look first. Read the examples/ directory before the source — the README explicitly recommends it, and the class API is self-documenting but spread across Stream, DASHStream, HLSStream, and the option callback objects. Then skim the upstream Shaka Packager documentation, because every method here maps 1:1 onto a CLI flag and the upstream docs are where the real semantics live.
3. First use case: inspect, then transmux, then package. The safest entry point is mediaFileAnalysis(), which dumps stream info without writing anything — perfect for validating that the binary is reachable and that your input file is what you think it is:
$stream = \Shaka\Options\Streams\Stream::input(storage_path('app/input/master.mp4'));
$export = $shaka->streams($stream)->mediaFileAnalysis()->export(); // StreamCollection
Once that returns content, do a plain transmux to prove your paths and permissions, and only then add DASH/HLS or DRM:
$video = Stream::input($input)->streamSelector('video')->output("{$dir}/video.mp4");
$audio = Stream::input($input)->streamSelector('audio')->output("{$dir}/audio.mp4");
$shaka->streams($video, $audio)->mediaPackaging()->export();
Build streams as value objects, not inline chains. Streams are the unit of reuse. A factory that maps your domain (a Media model, an upload) to one or more Stream instances keeps controllers thin and makes multi-bitrate output trivial:
class StreamFactory
{
public function forSource(string $path, string $outDir): array
{
return collect(config('transcodes.renditions'))->map(function ($r) use ($path, $outDir) {
return Stream::input($path)
->streamSelector($r['type'])
->output("{$outDir}/{$r['name']}.mp4")
->bandwidth($r['bandwidth'])
->language($r['lang'] ?? null);
})->all();
}
}
Always package inside a queued job. Packaging is a long, blocking, CPU-bound child process. In Laravel, that belongs in a job with an explicit $timeout larger than the packager timeout, $tries = 1 (retrying a half-written segment tree is worse than failing), and a failed() hook that cleans the working directory.
class PackageVideo implements ShouldQueue
{
public $timeout = 3700;
public $tries = 1;
public function handle(\Shaka\Shaka $shaka, StreamFactory $factory): void
{
$dir = storage_path("app/packaging/{$this->media->id}");
File::ensureDirectoryExists($dir);
try {
$shaka->streams(...$factory->forSource($this->media->source_path, $dir))
->mediaPackaging()
->DASH("{$dir}/manifest.mpd")
->HLS("{$dir}/master.m3u8")
->export();
$this->publishToDisk($dir); // push to S3 / CDN origin
} finally {
File::deleteDirectory($dir);
}
}
}
Use the options callback for anything beyond the defaults. DASH, HLS and DRM each take a closure receiving an options object. The closure must return that object — this is the single most common integration bug:
$shaka->streams(...$streams)
->mediaPackaging()
->DASH('manifest.mpd', function ($options) {
return $options->generateStaticLiveMpd()
->minBufferTime(2)
->suggestedPresentationDelay(30)
->baseUrls('https://cdn.example.com/vod/');
})
->HLS('master.m3u8', fn ($options) => $options->hlsMasterPlaylistName('master.m3u8'))
->export();
Keep DRM keys in config/services, never in the repository. Widevine, PlayReady and raw-key modes all accept key material. Load them from the environment or a secrets manager, pass them into the job payload, and log nothing that includes the key string:
->Widevine([
'key_server_url' => config('drm.widevine.url'),
'content_id' => $media->id,
'protection_scheme' => 'cbcs',
])
For offline/licensed flows where you hold the keys yourself, prefer RawKey with keys sourced from your KMS in the job, so rotation only touches secrets.
Compose a pipeline: analysis → transcode → package → publish. mediaFileAnalysis() is useful as a pre-flight gate in the same job — verify the input actually contains the tracks your rendition config assumes before spending minutes packaging. It also gives you a cheap way to fail fast with a meaningful exception instead of a cryptic packager exit code.
Separate local write path from remote storage. The packager writes to the local filesystem only. Standard pattern: write to storage/app/packaging/{uuid}, then Storage::disk('s3')->putFileAs(...) per manifest and segment, or ship the whole directory via a multipart upload. Never point the packager at a stream wrapper — it is an external process and knows nothing about s3://.
Use stream selectors deliberately for text and multi-language. DASH roles come from DASHStream::input(...)->DASHRoles('main'), and defaultLanguage()/defaultTextLanguage() on the DASH options control which track the player picks. If you have multiple audio tracks, each needs its own Stream with language() set — otherwise players will default arbitrarily.
packager on your interactive PATH may be invisible. Always pass packager.binaries explicitly in production. Verify with which packager as the worker user.composer.json.timeout defaults can silently truncate long live or 4K jobs. Set both the Shaka config timeout and the queue worker's $timeout, with the worker's larger so you get a real exception rather than a killed process.$options is mandatory; function ($options) { $options->baseUrls(...); } silently discards the change. Same for stream chains: $stream->output(...) mutates and returns $this, so don't assume the original variable is untouched elsewhere.generateSidxInMediaSegments() / nogenerateSidxInMediaSegments(), and oddly-cased methods like Mp4IncludePsshInStream(). When something doesn't behave, don't guess: reproduce the equivalent packager command line by hand and confirm the flag exists in your installed binary's version. Flag sets drift between packager releases.segmentTemplate(), every stream in the packaging call must agree — mixed templated/non-templated streams produce broken manifests. Related: $Time$ in a template requires generateSidxInMediaSegments() per spec, and disables the approximate-timeline optimizations.Mp4IncludePsshInStream() on by default, PSSH boxes land in the stream; if your player reports "no license request," check the mpd's ContentProtection elements and whether you also needed protection_scheme. Getting cbcs vs cenc wrong is a classic Apple-vs-everyone-else failure.init.mp4/segment trees that will be re-uploaded on retry. Delete the working directory in a finally block and in failed().StreamCollection export; capture and log the raw output verbatim before parsing it. Most "the library is broken" reports are packager saying Cannot open file or Unsupported codec, not PHP.--ad_cues) against a real player early; it's the feature least likely to survive a packager upgrade.How can I help you explore Laravel packages today?