protonemedia/php-ffmpeg
A PHP wrapper for FFmpeg that simplifies video and audio processing. Convert formats, generate thumbnails, resize and clip media, extract frames, and read metadata through a clean, object-oriented API. Requires the FFmpeg binaries to be installed on your server.
This is a fork of php-ffmpeg/php-ffmpeg — the object-oriented wrapper around the ffmpeg / ffprobe binaries. It ships no Laravel service provider or facade; it is framework-agnostic and you wire it yourself. If your project is Laravel-first, note that the same author's pbmedia/laravel-ffmpeg is the package that provides the facade, config file and queue integration. Reach for this one only if you need the raw library (or a specific patch from the fork).
1. Install the binary first. The PHP package is useless without it:
composer require protonemedia/php-ffmpeg
# Debian/Ubuntu
apt-get install ffmpeg
# or use a static build and point at it explicitly
2. Register a single configured instance. Parsing config and constructing drivers per job is wasteful and error-prone; bind it as a singleton:
// app/Providers/AppServiceProvider.php
use FFMpeg\FFMpeg;
use FFMpeg\FFProbe;
$this->app->singleton(FFMpeg::class, fn () => FFMpeg::create([
'ffmpeg.binaries' => config('media.ffmpeg'),
'ffprobe.binaries' => config('media.ffprobe'),
'timeout' => 3600, // seconds; the default 3600 is often too low for long footage
'ffmpeg.threads' => 4,
]));
$this->app->singleton(FFProbe::class, fn () => FFProbe::create([
'ffprobe.binaries' => config('media.ffprobe'),
]));
3. First use case — thumbnail + transcode. Almost every app starts here: probe the upload, grab a poster frame, re-encode to a web-safe MP4.
use FFMpeg\Coordinate\TimeCode;
use FFMpeg\Format\Video\X264;
$video = app(FFMpeg::class)->open($request->file('video')->getRealPath());
// poster frame at 3s
$video->frame(TimeCode::fromSeconds(3))->save(storage_path("app/thumb.jpg"));
// transcode
$video->save(
(new X264('aac', 'libx264'))->setKiloBitrate(1200)->setAudioKiloBitrate(128),
storage_path("app/out.mp4")
);
Where to look first: the FFMpeg::open() return types (Video, Audio), FFMpeg\Format\Video\* and FFMpeg\Format\Audio\*, and FFMpeg\Filters\*. Those four namespaces cover 95% of day-to-day work.
Which upstream are you actually on? Current release is v1.2.0, a sync release that re-fetched upstream php-ffmpeg/php-ffmpeg and reverted some upstream changes in order to open PR #849. Before you copy snippets from upstream docs or blog posts, check the tag this fork was based on — its behaviour is "upstream at that point, minus whatever #849 reverts", not plain upstream.
Probe before you encode. Transcoding is the expensive step; always read metadata first and branch on it. This is what FFProbe is for and it is near-instant.
$probe = app(FFProbe::class);
$stream = $probe->streams($path)->videos()->first();
if ($probe->isValid($path) === false) {
throw new InvalidMediaException();
}
$duration = (float) $probe->format($path)->get('duration');
$width = (int) $stream->get('width');
$height = (int) $stream->get('height');
$codec = $stream->get('codec_name');
Never encode in an HTTP request. Even a 30-second clip blows past max_execution_time on a loaded box, and the request is un-resumable. Put the transcode in a queued job with a generous $timeout and $tries = 1 (retrying a partial encode is usually wrong).
class TranscodeVideo implements ShouldQueue
{
public $timeout = 1800;
public $tries = 1;
public function __construct(public string $input, public string $output) {}
public function handle(FFMpeg $ffmpeg): void
{
$format = (new X264('aac', 'libx264'))
->setKiloBitrate(1200)
->setAudioKiloBitrate(128)
->setAudioChannels(2);
$format->on('progress', function ($video, $format, float $percentage) {
Cache::put("encode:{$this->output}", $percentage, 3600);
});
$ffmpeg->open($this->input)->save($format, $this->output);
}
public function failed(Throwable $e): void { /* mark row failed, notify */ }
}
The progress callback fires from the driver's stdout parsing, so it is only as granular as ffmpeg's output — useful for a UI percentage, not for anything precise.
Chained filters — remember synchronize(). Video filters do not touch the audio stream by default; if you crop or resize and want the audio retained/aligned, call it explicitly.
use FFMpeg\Coordinate\Dimension;
$video->filters()
->resize(new Dimension(1280, 720))
->synchronize() // keeps audio in sync with trimmed/modified video
->framerate(new \FFMpeg\Coordinate\FrameRate(30), 30);
// trim a segment
$video->filters()->clip(TimeCode::fromSeconds(5), TimeCode::fromSeconds(15));
Filter order matters — ffmpeg applies them in the order added, so crop-then-scale produces different output than scale-then-crop.
Audio-only extraction. You do not need a video format object for the container; the Audio media type has its own formats:
use FFMpeg\Format\Audio\Mp3;
$ffmpeg->open($videoPath)
->save(new Mp3()->setAudioKiloBitrate(192), storage_path('app/audio.mp3'));
Skip work that doesn't need doing. A common production pattern is idempotent ingest: only transcode if the source codec/container/bitrate is outside your accepted envelope, otherwise just copy the file and record the metadata.
Custom presets as classes. Rather than scattering setAdditionalParameters([...]) through the codebase, subclass a format:
class InstagramSquare extends \FFMpeg\Format\Video\X264
{
public function getExtraParams(): array
{
return ['-pix_fmt', 'yuv420p', '-movflags', '+faststart'];
}
}
+faststart is worth having as a default — without it, browsers must download the whole MP4 before playback starts.
Docker / CI. The library shells out, so the binary must exist in the container and be on the path you configured. apt-get install -y ffmpeg in the image, and assert the path at boot (is_executable(config('media.ffmpeg'))) so failures surface at deploy, not mid-job.
Maintenance risk is still the headline, but it just moved in the right direction. The v1.2.0 release is a sync release, not a feature release: upstream php-ffmpeg/php-ffmpeg was re-fetched and a handful of upstream changes were reverted so the author could open PR #849. So the fork is tracking upstream again rather than being frozen where it sat in January 2022 — an improvement. But it also means the fork now deliberately diverges from upstream by whatever #849 reverts, and it is still a 1-star package with effectively no community to catch breakage. Default guidance stands: install upstream php-ffmpeg/php-ffmpeg unless you specifically need a patch from this fork. If you do adopt the fork, read PR #849 and diff the release against the upstream tag it was based on before you commit to it.
Treat v1.2.0 as a behaviour-changing upgrade, not a patch bump. Because the release pulls in upstream commits, anything you relied on from a 2022-era pin can shift: default codec arguments on X264/WebM, filter behaviour and ordering, exception types, and getExtraParams() semantics. The release notes say nothing about what landed, so assume nothing. Re-run your encode fixtures, re-check custom Format subclasses, and assert output codec/pix_fmt with ffprobe in CI rather than eyeballing a file. If the upgrade is risky and you don't need the sync, pin the previous version deliberately.
Binary version drift is the real source of bugs. The library builds a command string; it does not validate that your ffmpeg build has libx264 or libvpx. A "working" local setup against a static build will fail on a distro-packaged ffmpeg compiled without those encoders. Check with ffmpeg -encoders | grep libx264 and run the same binary version across dev and prod.
Constructor signatures for codecs are version-dependent. new X264('aac', 'libx264') and new WebM('libvorbis', 'libvpx') take codec args in the older API; newer upstream versions changed defaults. If you see "not enough arguments" or silent fallback to a wrong codec — especially right after moving to v1.2.0 — check the exact installed version's source rather than guessing or trusting an upstream doc page written against a different commit.
timeout is per-process, not per-request. The default is 3600 seconds. Long 4K encodes exceed it and the driver kills the process mid-write, leaving a truncated file. Always write to a temp path and move into place atomically after save() returns.
Disk, not memory. The library streams through ffmpeg and does not load the file into PHP memory — but it does keep large temp/.log artifacts on disk for some filters. Monitor disk in workers, and clean up intermediate files in a finally block.
Rotation metadata from phones. Portrait iPhone footage carries a rotation flag; ffprobe reports landscape dimensions while the visual is portrait. Read side_data / rotate on the stream and apply ->filters()->rotate(...) if you resize, or your "portrait" thumbnails come out sideways.
save() overwrites. There is no existence check; an existing output file is silently replaced. If that matters, gate on File::exists() yourself — the library's -y behaviour is not something to rely on as a safety net.
Never interpolate user input into setAdditionalParameters() or filter arguments. It becomes part of a shell command. Anything user-derived (filenames, quality selectors, crop boxes) must be cast to int or matched against an allowlist. This is the single most common way this library becomes an RCE.
Extension points. Beyond custom Format subclasses, FFMpeg\Driver\FFMpegDriver can be wrapped to log the exact command line — do that during debugging rather than dd()-ing inside vendor code. $video->getStreams() exposes the raw probe data when you need a field the typed getters don't cover. And if progress events look empty, confirm ffmpeg is actually writing to stdout (a mis-set -loglevel in extra params will silence them).
How can I help you explore Laravel packages today?