fmonts/ffmpeg-bundle
A Symfony bundle that integrates FFmpeg into your PHP applications via the PHP-FFMpeg library, making it easy to transcode video and audio, extract thumbnails, read media metadata, and apply filters or watermarks through a simple, service-based API.
The first thing to internalize: this is a Symfony bundle, not a Laravel package. There is no service provider, no vendor:publish, no facade. In a Laravel codebase you will get ~90% of the value by installing the underlying library and hand-wiring the same three config values the bundle wires for you. Treat fmonts/ffmpeg-bundle as reference documentation for how to configure PHP-FFMpeg correctly, and copy that wiring into a service provider.
If you actually are on Symfony (6/7/8 — 0.8.7 added Symfony 8 support and fixed the Symfony 7.4 config deprecation):
composer require fmonts/ffmpeg-bundle
Enable it in config/bundles.php, then configure binaries. Keys mirror PHP-FFMpeg's option names but are nested — confirm against DependencyInjection/Configuration.php in the installed version rather than trusting the README:
# config/packages/fmonts_ffmpeg.yaml
fmonts_ffmpeg:
binary: /usr/bin/ffmpeg
threads: 12
timeout: 3600
ffprobe:
binary: /usr/bin/ffprobe
Inject the fmonts_ffmpeg service (alias FFMpeg\FFMpeg yourself if autowiring isn't registered in your version).
If you're on Laravel (the common case): skip the bundle, install the library, and bind it as a singleton so you get one configured instance per process instead of re-creating it per call.
composer require php-ffmpeg/php-ffmpeg
# and on the host/container: apt-get install -y ffmpeg (or alpine: apk add ffmpeg)
// config/ffmpeg.php
return [
'ffmpeg_binaries' => env('FFMPEG_BINARIES', '/usr/bin/ffmpeg'),
'ffprobe_binaries' => env('FFPROBE_BINARIES', '/usr/bin/ffprobe'),
'threads' => (int) env('FFMPEG_THREADS', 4),
'timeout' => (int) env('FFMPEG_TIMEOUT', 3600),
];
// app/Providers/FFmpegServiceProvider.php
public function register(): void
{
$this->app->singleton(FFMpeg::class, fn () => FFMpeg::create([
'ffmpeg.binaries' => config('ffmpeg.ffmpeg_binaries'),
'ffprobe.binaries' => config('ffmpeg.ffprobe_binaries'),
'ffmpeg.threads' => config('ffmpeg.threads'),
'timeout' => config('ffmpeg.timeout'),
]));
$this->app->singleton(FFProbe::class, fn () => FFProbe::create([
'ffprobe.binaries' => config('ffmpeg.ffprobe_binaries'),
'timeout' => config('ffmpeg.timeout'),
]));
}
First use case — the one you'll write on day one: a user uploads a phone video, you normalize it to a web-safe H.264/AAC MP4 at 720p and generate a poster frame.
$video = app(FFMpeg::class)->open($request->file('video')->getRealPath());
$video->filters()->resize(new Dimension(1280, 720), 'width')->synchronize();
$format = new X264('aac', 'libx264');
$format->setKiloBitrate(1200)->setAudioKiloBitrate(128)->setAudioChannels(2);
$video->save($format, storage_path('app/public/videos/'.$name.'.mp4'));
$video->frame(TimeCode::fromSeconds(3))
->save(storage_path('app/public/videos/'.$name.'.jpg'));
If binaries are wrong you'll get FFMpeg\Exception\ExecutableNotFoundException immediately on create() — that's your smoke test, and it's worth asserting in a boot check.
Never transcode in the request cycle. Encoding is minutes-long, CPU-bound, and will blow past max_execution_time and PHP-FPM request_terminate_timeout. Put it in a queued job, on a dedicated queue and a dedicated worker pool.
class TranscodeVideo implements ShouldQueue
{
public $timeout = 3600;
public $tries = 1; // never blindly retry an expensive encode
public $deleteWhenMissingModels = true;
public function __construct(public int $videoId) {}
public function handle(FFMpeg $ffmpeg): void
{
$video = Video::findOrFail($this->videoId);
$tmp = tempnam(sys_get_temp_dir(), 'src_');
// pull the original down to local disk — FFmpeg needs a real path
file_put_contents($tmp, Storage::disk('s3')->get($video->source_path));
$media = $ffmpeg->open($tmp);
// ... encode ...
$media->save($format, $tmpOut);
Storage::disk('s3')->put($video->output_path, fopen($tmpOut, 'r'));
@unlink($tmp); @unlink($tmpOut);
}
}
Run the worker with php artisan queue:work --queue=video --timeout=3600 --memory=512.
Wrap it behind your own interface. FFmpeg output is nondeterministic and slow; you want a seam for tests. Define VideoTranscoder with transcode(string $in, string $out, array $opts): void and let the FFmpeg implementation be one binding. In tests, bind a fake that copies the input file. This also saves you when you eventually swap php-ffmpeg for a raw Process call or a cloud transcoder.
Always probe before encoding. Users upload rotated, variable-framerate, 4K, or audio-only files. Use FFProbe to branch:
$probe = app(FFProbe::class);
$format = $probe->format($path); // duration, bit_rate, format_name
$streams = $probe->streams($path);
$hasVideo = count($streams->videos()) > 0;
$duration = (float) $format->get('duration');
setKiloBitrate() with a fixed value on a 4K source will look terrible; scale the bitrate off $format->get('bit_rate') or the resolution you detected.
Progress reporting is done via the format's event emitter, not a separate listener class:
$format->on('progress', function ($video, $format, float $percent) use ($videoId) {
Cache::put("transcode:{$videoId}", round($percent), 3600);
});
Poll that from a status endpoint. Note onProgress fires per pass and can restart — don't treat the percentage as monotonic.
Filters are the extension point for anything ffmpeg can do: ->resize(), ->crop(), ->watermark(), ->rotate(), ->synchronize() (call it before save() or filters silently don't apply). For anything the fluent API doesn't cover, PHP-FFMpeg lets you pass raw arguments through a custom FormatInterface — that's your escape hatch instead of shelling out.
Thumbnails at scale: grab several frames in a single open ($video->frame(TimeCode::fromSeconds($t))->save(...)) rather than re-opening the file per frame; open() spawns ffprobe each time.
binary / ffprobe.binary / threads; the PHP-FFMpeg constructor uses ffmpeg.binaries / ffprobe.binaries / ffmpeg.threads. Copy-pasting a bundle YAML into a Laravel config array produces a silently defaulted configuration that then fails on a binary path — the exact trap this package sets.Resources/config/services.xml to understand the wiring, that file is gone — look in Resources/config/ for the PHP equivalent. The user-facing config tree is unchanged; DependencyInjection/Configuration.php is still the source of truth for YAML keys. Nothing here changes the Laravel story: still hand-wire it.FFMpeg::create() does not validate anything until first use in some versions; assert binary existence yourself in a health check (is_executable(config('ffmpeg.ffmpeg_binaries'))).ffmpeg and ffprobe are separate binaries. Docker images that build ffmpeg from source sometimes strip ffprobe. If probing fails but encoding works, that's why.threads: 12 inside a 2-CPU container causes thrashing and slower encodes. Cap it at nproc and lower it on the queue worker so parallel jobs don't starve each other.libx264 and aac are not guaranteed — Debian's ffmpeg package has them, minimal static builds may not. Verify with ffmpeg -codecs | grep -E 'libx264|aac' in the exact image you deploy, or X264('aac', 'libx264') throws at runtime only.timeout in config, $job->timeout, queue:work --timeout, PHP max_execution_time, and the container's own limits are five separate knobs. A hung encode usually surfaces as a killed worker with no exception — check supervisor/worker logs before debugging the code.storage/app permissions bugs look like "Encoding failed" EncodingFailureException with the real error buried in $e->getPrevious(). Always log $e->getMessage() plus the command.try/finally and unlink; a few thousand orphaned 200MB temp files will fill the disk before you notice.NOASSERTION. Upstream PHP-FFMpeg and the original bundle are MIT, but the fork declares nothing. Before shipping this in a commercial product, diff the fork against upstream and confirm provenance. With ~12 stars, 0 dependents, and a maintainer-of-one, budget for vendoring or patching it yourself — the surface is small enough (one DI extension, one service definition) that forking the config into your own service provider is often the lower-risk move. The 0.8.7 release is a good sign (someone is tracking Symfony releases) but it is still a single-maintainer fork; don't read two commits as a support commitment.ffmpeg minor upgrade has changed default encoder settings (-crf, -preset) before and silently altered output quality across your library. Note the version in your deploy docs.How can I help you explore Laravel packages today?