izotopsvk/ffmpeg-bundle
A Symfony bundle that integrates FFmpeg into PHP applications, providing configuration and services for video/audio conversion, thumbnail generation, media probing, and other FFmpeg-powered processing tasks.
Reality check first: this is a Symfony bundle, not a Laravel package. There is no service provider, facade, or publishable config. On Laravel you skip the bundle entirely and use its underlying dependency (alchemy-fr/php-ffmpeg, or better the maintained php-ffmpeg/php-ffmpeg) inside a small first-party provider. The instructions below describe both paths — the Symfony path (bundle as designed) and the Laravel path (bundle rejected, library adopted).
Step 0 — install the binaries. The bundle ships no binaries; it shells out to ffmpeg and ffprobe. Nothing works until both exist on the host.
apt-get install -y ffmpeg # Debian/Ubuntu
brew install ffmpeg # macOS
In Docker, RUN apt-get install -y ffmpeg in the image — never mount or lazily download it at runtime. Pin the version.
Step 1 — install the package.
composer require izotopsvk/ffmpeg-bundle
On a modern Laravel or Symfony 5+ project this will likely conflict on symfony/framework-bundle / symfony/process constraints. If Composer refuses, do not force it with --ignore-platform-reqs — that is the signal to take the direct-library path instead.
Step 2 — register it (Symfony only).
// config/bundles.php (Symfony 4+/Flex — usually automatic)
return [
// ...
Izotop\FFMpegBundle\IzotopFFMpegBundle::class => ['all' => true],
];
Older skeletons: add the class to AppKernel::registerBundles().
Step 3 — discover the config tree rather than guessing. The bundle's config keys and alias are not well documented. Dump the reference:
bin/console config:dump-reference izotop_ffmpeg
# or
bin/console config:dump-reference ffmpeg
You are looking for the ffmpeg.binaries / ffprobe.binaries paths plus optional timeout and thread/persistent flags. Put the values in config/packages/ffmpeg.yaml and keep the binary paths in .env so staging/prod can differ:
# config/packages/ffmpeg.yaml
izotop_ffmpeg:
ffmpeg:
binaries: '%env(FFMPEG_BINARIES)%'
ffprobe:
binaries: '%env(FFPROBE_BINARIES)%'
timeout: 3600
Laravel equivalent (recommended for a Laravel codebase). Skip the bundle; wire the maintained library directly:
composer require php-ffmpeg/php-ffmpeg
// app/Providers/MediaServiceProvider.php
$this->app->singleton(FFMpeg::class, fn () => FFMpeg::create([
'ffmpeg.binaries' => config('media.ffmpeg_binaries'),
'ffprobe.binaries' => config('media.ffprobe_binaries'),
'timeout' => config('media.timeout'),
'threads' => config('media.threads'),
]));
$this->app->bind(MediaTranscoder::class, FFMpegTranscoder::class);
Bind an interface, not the concrete class. Call sites open/save/filters are identical between the bundle path and the direct path, so this is the seam that lets you swap in a managed transcoder later.
First use case — transcode to H.264/MP4.
use FFMpeg\Format\Video\X264;
$video = $ffmpeg->open(storage_path('app/in.mov'));
$video
->save(new X264('aac', 'libx264'), storage_path('app/out.mp4'));
That is the whole 30-minute onboarding job: prove the binaries are visible, prove open()/save() round-trips one file, and confirm ffprobe reports sane metadata on the output. Everything else is the PHP-FFMpeg API.
Inject a service, never build FFMpeg inline. Both on Symfony (constructor(FFMpeg\FFMpeg $ffmpeg)) and Laravel (constructor(MediaTranscoder $transcoder)), depend on the injected abstraction. The value of the wrapper — any wrapper — is that you never see a binary path at a call site.
Probe first, transcode second. Validate and route on ffprobe output before paying for a transcode:
$metadata = $ffmpeg->open($path)->getFFProbe()
->streams($path)->videos()->first();
if ($metadata->get('codec_name') === 'h264' && $metadata->get('duration') < 120) {
// already fine, skip the CPU
} else {
$transcodeJob->dispatch($path);
}
Use probing to reject inputs early — corrupt files, absurd durations, unaccepted codecs — before they occupy a worker.
Queue every transcode. A transcode on the request cycle is a timeout waiting to happen. On Laravel: a TranscodeVideo job with $timeout strictly below the queue worker's retry_after, and $tries = 1 (or an explicit idempotency key on the source object) so a slow job is never executed twice:
class TranscodeVideo implements ShouldQueue
{
public int $timeout = 1800;
public int $tries = 1;
public function handle(MediaTranscoder $transcoder): void
{
$transcoder->toMp4($this->source, $this->destination);
}
}
Wrap the library behind a narrow interface. Expose the verbs you actually use — toMp4, thumbnail, probe, extractAudio — not the whole fluent PHP-FFMpeg API. This is the one architectural choice worth making on day one: it lets you replace the self-hosted branch with Mux/MediaConvert/Cloudflare Stream without touching call sites, and it keeps the deprecated alchemy-fr tree (Symfony path) from leaking into your domain code.
Format objects for consistent output. Don't hand-roll codec flags. Use the format classes and set quality explicitly:
use FFMpeg\Format\Video\X264;
$format = (new X264('aac', 'libx264'))
->setKiloBitrate(1200)
->setAudioKiloBitrate(128);
Thumbnails and frame extraction are separate verbs on the opened video, and are usually the cheapest win to ship first:
$video->frame(FFMpeg\Coordinate\TimeCode::fromSeconds(3))
->save(storage_path('app/thumb.jpg'));
Watermark / overlay via the watermark filter — note this composes onto the same save() call and is where filter-chain complexity starts to grow:
$video->filters()
->watermark($logoPath, [
'position' => 'relative',
'bottom' => 20,
'right' => 20,
])
->synchronize();
Stream from object storage when you can. Long, high-bitrate inputs consume gigabytes of temp disk. Downloading whole files to local temp is the default naive pattern and the first thing to break under load; presign a read URL and let the binary seek remotely where the storage backend supports it.
Emit job metrics from day one. Duration, input bytes, probed codec, and exit status per job. Without these, failures surface as user-reported broken media rather than alerts.
This is a Symfony bundle — it is dead weight on Laravel. Auto-discovery won't fire, the config tree never loads, Resources/config/services.yml is never parsed, and the only thing you inherit is an abandoned transitive dependency. Do not install it in a Laravel app; take php-ffmpeg/php-ffmpeg directly.
The pinned upstream is the legacy tree. The bundle targets alchemy-fr/php-ffmpeg, which is superseded by php-ffmpeg/php-ffmpeg. Adopting the bundle locks you two levels deep onto the unmaintained lineage. If you are on Symfony and must use the bundle, confirm it resolves against a maintained fork first, otherwise vendor the ~50 lines of service definition and drop the wrapper entirely.
Zero stars, zero dependents, single maintainer. Treat as unmaintained on day one. There will be no compatibility fixes for PHP or Symfony upgrades. If you take it, name an internal owner willing to fork; otherwise writing the service definition yourself is cheaper than auditing this one.
Binary drift between dev and prod is the classic incident. Dev has ffmpeg 4.x from Homebrew, prod has 5.x baked months ago, a filter flag behaves differently, and the failure is silent. Pin the version in the image, use the same image locally, and assert codec availability in a health check at container start — fail at boot, not at first job.
Orphaned ffmpeg children. When a PHP job is killed mid-transcode (worker timeout, OOM), the child process keeps burning CPU. Wrap execution with a timeout strictly below the worker's retry_after, and have a reaper for ffmpeg processes whose parent queue job no longer exists.
Job timeout vs retry_after. If the job time limit exceeds the queue's retry_after, the queue re-picks the same job while the first is still running — duplicate transcodes, duplicate outputs, doubled CPU. Keep $timeout < retry_after with margin.
Temp disk is a hard constraint. Enforce a per-job input-size ceiling and a temp-cleanup policy on failure; partial output files are the common leak. Long inputs can otherwise fill a worker's disk and take down unrelated jobs.
Paths and shell escaping. PHP-FFMpeg escapes arguments, but user-supplied file names containing shell-significant or non-UTF8 bytes still bite. Normalize/validate uploads and never interpolate user input into filter strings or metadata.
Remote storage breaks seek-dependent filters. Filters that need random access (some overlays, precise frame extraction) can fail on backends with limited range requests. Verify per backend, not per library.
Probing malformed input fails ungracefully. ffprobe returns non-zero with unhelpful output on partial/truncated files. Wrap probe calls, treat them as the input-validation layer, and reject with a clear error rather than letting the exception escape as a 500.
Config keys are undocumented — dump them. Run config:dump-reference on the bundle alias (izotop_ffmpeg or ffmpeg — try both) before assuming key names. The binary path keys are the ones that matter; timeout and threads are the usual extras.
CPU-bound, IO-bound, does not belong on web hosts. Dedicated workers with an explicit concurrency cap sized to the transcode profile, autoscaled on queue depth not request rate. This is an infra decision the bundle does nothing to help with.
Extension point is the FFMpeg\FFMpeg factory itself: everything configurable lives in the create() array. If you need custom behavior (custom filters, custom binary selection per tenant), wrap the factory rather than subclassing the library's deep class hierarchy.
The real build-vs-buy question is not this package. It is self-hosted transcoding versus a managed transcoder. Every operational concern above — worker sizing, disk lifecycle, codec pins, timeouts, observability — vanishes on the managed branch. Make that decision deliberately before adopting anything in the self-host branch, because the wrapper choice is immaterial next to it.
How can I help you explore Laravel packages today?