phpffmpegapi

PHP-FFMpeg vs laravel-ffmpeg vs exec(): which one breaks where

··10 min read
PHP-FFMpeg vs laravel-ffmpeg vs exec(): which one breaks where

Composer resolved fine. The first encode worked on your laptop, then staging returned Unable to load FFMpeg and a 504 on the same upload. Nothing in either library's README warned you, because what broke isn't the library.

Quick answer: The php-ffmpeg vs laravel-ffmpeg decision is a style choice, not an operations one: laravel-ffmpeg wraps PHP-FFMpeg and adds Laravel's Filesystem, HLS export and config, while both still shell out to the same ffmpeg and ffprobe binaries that composer require never installs. Pick laravel-ffmpeg inside Laravel when you control the server, encodes finish in a minute or two, and you want local HLS variants; pick raw exec() only when disable_functions is empty and someone owns the flag string across FFmpeg upgrades. If the binary isn't yours to install, patch or time out, send the job to an HTTP API like FFmpeg Micro instead and the entire class of binary failures leaves your codebase.

What composer require installs, and what it leaves for you

Both PHP packages install a process driver, not an encoder. PHP-FFMpeg's README says the library needs a working FFMpeg install and both the ffmpeg and ffprobe binaries, either locatable on PATH or supplied explicitly when you call FFMpeg::create() (README). laravel-ffmpeg's install instructions are blunter: the package does not install FFmpeg on your computer, and you're told to confirm ffmpeg -version yourself first.

The install count explains the confusion. php-ffmpeg/php-ffmpeg sits at roughly 26.4 million installs with 187 dependent packages, supports PHP ^8.0 through ^8.5, and last released v1.4.0 in January 2026 (Packagist). That popularity reads like an encoder. It's a wrapper around proc_open.

That one fact decides all three columns below: every local option runs the same binary through the same process layer. If you landed here from the PECL direction, the old native extension is a dead end too, covered in the ffmpeg-php extension is dead.

Shelling out with exec(): fine locally, gone on shared hosting

Calling exec() or shell_exec() directly is the lightest option and the one that fails most loudly. On a typical cPanel server, shell_exec is disabled by default through the disable_functions directive, and the symptom is the exact string Warning: shell_exec() has been disabled for security reasons (Phurix). No app config fixes that. It's the host's php.ini, and on managed hosting you aren't getting it changed. The workarounds, including ones that still run jobs from PHP, are in shell_exec has been disabled.

The slower failure is flag rot. A hardcoded command string is a contract with one FFmpeg version, and FFmpeg 9 hard-removed -vsync, -top, -qphist and -filter_complex_script, so a thumbnail script that ran for four years starts returning Unrecognized option 'vsync' the week someone runs apt upgrade. FFmpeg 9.1: what to re-test before you upgrade lists what else moved.

Shelling out is still correct for a cron job on a VM you own, where the command is in version control and one person re-tests it after upgrades.

PHP-FFMpeg: a clean object API over a binary you still have to find

PHP-FFMpeg gives you an object API, filter chains and format classes instead of string concatenation, a real improvement over building flags by hand. Its failure signals are specific enough to grep for. A missing or misconfigured binary throws ExecutableNotFoundException, surfacing as Unable to load FFMpeg or Unable to load FFProbe, which bites people on Valet and Homestead where the web user's PATH differs from the shell's (Laracasts).

Two rougher edges show up only in production. When PHP-FFMpeg hits its configured timeout it raises ProcessTimedOutException while the underlying ffmpeg process keeps running (issue #671); one report has a 3600 second timeout firing with the background encode still alive and no supported way to log it and continue (issue #86). Your PHP side dies, your CPU does not. On a shared box that's how you end up with four orphaned encodes pinning every core.

Progress reporting is the other one. PHP-FFMpeg has carried an open report that the progress callback only ever emits 99% since August 15, 2016 (github.com); a newer thread collects reports of no notifications at all and of the listener firing once at 100% (issue #794). If your product promises a progress bar, budget for that.

laravel-ffmpeg: best maintained, same binary, now with 504s

laravel-ffmpeg is the strongest of the three on code quality and the one I'd reach for inside Laravel. It layers disk-aware I/O over PHP-FFMpeg so you work in fromDisk('s3') terms, and it ships HLS export with AES-128 encryption including rotating keys, concatenation, image sequences and complex filters. Current requirements are PHP ^8.2 through ^8.5 with illuminate/contracts ^11.0 through ^13.0 (Packagist).

Its issue tracker still fills with the same two reports. The archetypal one is Unable to load FFMpeg, resolved by installing the binary, locating it with which ffmpeg, then setting FFMPEG_BINARIES and FFPROBE_BINARIES explicitly (issue #351). That fix works and it doesn't generalize: it's a per-machine answer to a dependency you re-acquire on every new container, CI runner and deploy target.

The second is timing. Transcoding a large upload inside a web request produces nginx 504 Gateway Time-out while the encode runs happily on the server, and the community answer is always chunk it and queue it (Laracasts). Queueing moves the ceiling without removing it: on Laravel Vapor a queued job may process for a maximum of 15 minutes, and SQS retries anything not deleted or released inside the configured queue timeout (Vapor docs). A 40 minute webinar re-encode doesn't fit, and the retry hands you a second copy of the same encode.

The fourth column: an HTTP job, where the binary isn't yours

The option page one never prints is keeping the Composer package and giving up the binary. You still composer require something (ffmpeg-micro/ffmpeg-micro-php on Packagist, PHP 8.1+, with a Python package alongside it), but the process that runs ffmpeg isn't in your container, so disable_functions, PATH, FFMPEG_BINARIES and PHP-FPM's request_terminate_timeout all stop being inputs to whether your video converts.

Here's the same job both ways. Local:

ffmpeg -i input.mov -vf scale=-2:1080 -c:v libx264 -crf 23 -c:a aac output.mp4

As a job:

curl -X POST https://api.ffmpeg-micro.com/v1/transcodes \
  -H "Authorization: Bearer $FFMPEG_MICRO_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "inputs": [{"url": "https://cdn.example.com/input.mov"}],
    "outputFormat": "mp4",
    "preset": {"quality": "medium", "resolution": "1080p"}
  }'

That returns a job id and "status": "pending". The shape maps onto the queued Laravel job you were going to hand-build anyway, except the worker holds no process and can't time out mid-encode:

class TranscodeUpload implements ShouldQueue
{
    public function handle(): void
    {
        $job = Http::withToken(config('services.ffmpeg_micro.key'))
            ->post('https://api.ffmpeg-micro.com/v1/transcodes', [
                'inputs' => [['url' => $this->upload->source_url]],
                'outputFormat' => 'mp4',
                'preset' => ['quality' => 'medium', 'resolution' => '1080p'],
            ])->json();

        $this->upload->update(['transcode_job_id' => $job['id']]);

        PollTranscode::dispatch($this->upload)->delay(now()->addSeconds(10));
    }
}

PollTranscode does a GET /v1/transcodes/{id}, re-dispatches itself while status is pending or processing, and calls /download for a signed URL on completed. Each run takes milliseconds, so a 40 minute source and a 40 second source cost the same worker time. Status checks, listing and downloads are free; you're billed on input duration at one token per 10 seconds, metered per second with no rounding up to the minute, and failed jobs are never billed. The quickstart runs presigned upload, transcode, poll and download against a temporary key if you want the whole loop before writing the job class. For what to do when a poll comes back failed, why your transcode job failed has the branch logic.

The other thing that leaves is patch duty. When an FFmpeg CVE lands with fixes in master and no release carrying them, somebody rebuilds the image; your PHP Docker image has an FFmpeg layer is about who that somebody is.

How to pick, including when an API is the wrong call

The deciding question isn't which library has the nicer API, it's whether you control the machine that runs the binary and how long your longest job is.

Your situationPickThe error you'd otherwise chase
Own VM, Laravel, encodes under ~2 minuteslaravel-ffmpeg`Unable to load FFMpeg` on a new deploy target
Plain PHP, own VM, scripted batchesPHP-FFMpeg`ExecutableNotFoundException`, orphaned processes after `ProcessTimedOutException`
One cron job, pinned FFmpeg, one maintainer`exec()``Unrecognized option 'vsync'` after an upgrade
Shared or managed hostingHTTP API`shell_exec() has been disabled for security reasons`
Serverless (Vapor, Lambda), long encodesHTTP API504 Gateway Time-out, then a 15 minute job ceiling

Keep the wrapper libraries when any of these is true:

  • You need HLS variants with AES-128 and rotating keys written to your own disk, which laravel-ffmpeg does and a transcode API doesn't replicate.
  • Your media is legally confined to your infrastructure and can't be uploaded anywhere.
  • Jobs are tiny and constant (thumbnailing a 3 second clip on upload), where a network round trip costs more than the encode.
  • You already run a tuned encoding box with GPU filters and you're happy maintaining it.

An API is the wrong call when your input never leaves the building, or when you need frame-exact control over flags the API doesn't expose. It's the right call the moment your answer to "which machine has ffmpeg on it?" is "whichever one the deploy landed on."

FAQ about PHP FFmpeg libraries

Does composer require actually install FFmpeg?

No. composer require php-ffmpeg/php-ffmpeg and composer require pbmedia/laravel-ffmpeg both install PHP code that launches a binary you must install separately, and laravel-ffmpeg's own install page says the package does not install FFmpeg on your computer. Run ffmpeg -version and ffprobe -version on the target machine before you believe the encode works.

If exec() is in disable_functions, will PHP-FFMpeg still work?

PHP-FFMpeg won't work when the process functions are disabled, because the library drives ffmpeg through PHP's process layer, not a native extension. The same applies to laravel-ffmpeg, since it sits on PHP-FFMpeg. On hosts that disable those functions, an HTTP API is the only route that leaves your code unchanged in shape.

Why does my encode return a 504 when ffmpeg is still running fine?

A 504 Gateway Time-out is nginx or PHP-FPM giving up on the request while the ffmpeg process continues on the server, which is why the output file often appears minutes after the browser errored. Move the encode into a queued job, and if the job itself can exceed your platform's limit (15 minutes on Laravel Vapor), submit the work somewhere that returns a job id immediately and poll for status.

Why does my PHP-FFMpeg progress listener only fire at 99%?

PHP-FFMpeg's progress callback has a long-standing reliability problem, with reports of it emitting only 99%, only 100%, or nothing at all, and the oldest of those issues has been open since 2016. If you're showing users a progress bar, drive it off a status field you control rather than off the library's listener.

Is laravel-ffmpeg still worth using in 2026?

laravel-ffmpeg is still the best-maintained PHP option and the right pick when you own the server, your encodes are short, and you want encrypted HLS variants written to a local or S3 disk. What it can't solve is the dependency underneath it: a binary that must exist, stay patched, and finish inside your request or queue timeout on every machine you deploy to.

Swapping the library for a job id is a two-file change in a Laravel app: one job that POSTs the transcode, one that polls and saves the download URL. Sign up free to get a key and 200 tokens (about 33 minutes of video, no card) and point that job at the API. There's no FFmpeg to install and no server to run, and the same endpoint works from your code, from n8n, Make and Zapier, or from an AI agent.

About Javid Jamae

Founder & CEO at FFmpeg Micro

Javid is a software engineer, author, and entrepreneur with over 25 years of professional software development experience across enterprise, startup, and consulting environments. He founded FFmpeg Micro to make video processing accessible to developers through a simple, automation-first REST API.

Software EngineeringVideo ProcessingFFmpegCloud ArchitectureAPI DesignAutomation

Ready to process videos at scale?

Start using FFmpeg Micro's simple API today. No infrastructure required.

Get Started Free