projektgopher/laravel-ffmpeg-tools
projektgopher/laravel-ffmpeg-tools is a thin, opinionated wrapper around the FFmpeg binary: you describe the operation (thumbnail, transcode, HLS, probe) and it builds/executes the command for you, without hand-assembling flag strings.
Install the package:
composer require projektgopher/laravel-ffmpeg-tools
Make sure a real ffmpeg (and ffprobe) binary is available on the machine that runs your queue workers, not just your laptop. Verify with ffmpeg -version. If you deploy in containers, install it in the image and pin the version.
Publish the config (if the package ships one) and check the binary path / timeout settings:
php artisan vendor:publish --tag=ffmpeg-tools-config
Start with the smallest possible use case — generate a poster frame for an uploaded video — then graduate to transcoding. Read the README's examples first, then src/ for the fluent builder and any DTOs, since those define the actual method surface. Don't trust blog posts; the API here is young and has moved between 0.x releases.
Confirm the Laravel 12 constraint in composer.json. v0.8.1 adds Laravel 12 support, so on a fresh Laravel 12 app this should be the minimum version you require (^0.8.1).
Always run outside the request cycle. FFmpeg is slow and CPU-bound. Put every invocation behind a queued job:
final class GenerateThumbnail implements ShouldQueue
{
use Queueable;
public function __construct(public Video $video) {}
public function handle(): void
{
$path = FFmpegTools::input(Storage::disk('local')->path($this->video->source_path))
->thumbnailAt('00:00:03')
->to(Storage::disk('local')->path("thumbs/{$this->video->id}.jpg"))
->run();
}
}
Local disk in, cloud disk out. FFmpeg needs real filesystem paths and random access. Download from S3 to a tmp directory, operate, then Storage::disk('s3')->put(...), and clean up in a finally block. Don't hand FFmpeg an S3 URL and expect streaming to work reliably.
Chain the fluent builder and log the resolved command. Build the operation, dump ->command() (or the string equivalent) into your logs, then ->run(). When something fails, that line is what you paste into a shell to reproduce outside Laravel.
Model integration. Use an observer or a booted() hook to dispatch the job when a media record is created, and store status on the model (pending / processing / failed / ready) so your UI can poll. Avoid doing conversion work inside the observer itself.
Concurrency control. Add WithoutOverlapping to transcoding jobs and consider a dedicated queue (->onQueue('media')) so a 10-minute encode doesn't starve your mail queue. A small RateLimited middleware keyed by user ID prevents a bulk upload from saturating the box.
Testing. In feature tests, either bind a fake implementation of the underlying executor in the container, or point the config at a stub binary. Assert on the command that would have been built rather than on real output; then keep one or two slow integration tests with a tiny fixture (a 1-second clip committed to tests/fixtures) that actually exercise the binary in CI.
Progress reporting. If you need a percentage, tail FFmpeg's stderr in the command's callback and write to cache; broadcasting per-percent from a queue worker gets noisy fast.
ffmpeg -version in a health check.queue.worker --timeout and retry_after must both exceed your worst-case encode. If retry_after < runtime you'll get duplicate jobs running concurrently on the same file — a classic source of corrupted output.try/finally and unlink() the input and output on failure; monitor disk usage on your workers.-threads) and consider lowering resolution in a first pass for large inputs.ready.^0.8.1 (not *) and read the changelog on every bump; the API surface can change without a major version signal.PATH of the worker process (php-fpm/supervisor often has a different PATH than your shell)? 4) Is it a permission problem on the output directory?How can I help you explore Laravel packages today?