shell_exec has been disabled. Run FFmpeg jobs from PHP anyway

You wrote shell_exec('ffmpeg -i input.mp4 ...'), pushed it to shared hosting, and got back Warning: shell_exec() has been disabled for security reasons. So you ran composer require php-ffmpeg/php-ffmpeg instead, and that threw a Symfony\Component\Process\Exception\LogicException before FFmpeg was ever consulted. Every support article you've found since then tells you to open WHM, which you don't have.
Quick answer: The errorshell_exec() has been disabled for security reasonsmeans your host listedshell_execin php.ini'sdisable_functions, and on a stock cPanel serverexec,passthru,system,popen,proc_openandpcntl_execare usually listed alongside it. That's why PHP-FFMpeg and laravel-ffmpeg also fail: they're object wrappers around Symfony Process, which needsproc_open.disable_functionscan only be set in php.ini or the PHP-FPM pool config, so no code-level workaround exists. If you can't get root, send the video to an HTTP video API such as FFmpeg Micro and poll for the result, which spawns no process and needs only ext-curl.
Read your own disable_functions before you change anything
Your host's list is readable from PHP in one line, no phpinfo() or support ticket needed. Drop this in a scratch file and hit it in a browser or the CLI:
<?php
$disabled = array_filter(array_map('trim', explode(',', ini_get('disable_functions'))));
sort($disabled);
print_r($disabled);
foreach (['exec', 'shell_exec', 'system', 'passthru', 'popen', 'proc_open'] as $fn) {
printf("%-12s %s\n", $fn, function_exists($fn) ? 'available' : 'disabled');
}
function_exists() returns false for a disabled function, so the second loop tells you the truth even if the ini string is formatted oddly or split across includes. Run it on the same SAPI your app runs on. A CLI binary on shared hosting often has a looser ini than the PHP-FPM pool serving your site, which is why "it works over SSH" is a trap.
A stock cPanel list kills the whole family, not one function
Hosts don't disable shell_exec on its own. KnownHost's own knowledge base entry lists the group that gets switched off together: exec, passthru, shell_exec, system, proc_open, popen, pcntl_exec. Every one of those hands a string to the operating system, so removing one entry and leaving the rest changes nothing.
Under PHP-FPM the directive isn't even in a php.ini you can see. It sits in a per-domain pool file, typically /opt/cpanel/ea-phpXX/root/etc/php-fpm.d/yourdomain.tld.conf, and editing it takes root plus an FPM restart. disable_functions is PHP_INI_SYSTEM, which means .user.ini, .htaccess and ini_set() cannot touch it. Neither can error suppression: @shell_exec() still doesn't execute anything, it just hides the warning so your $output is silently null and you spend an afternoon debugging the wrong thing.
Domain India's hosting KB spells out the reasoning behind the policy: the process-spawning and raw-socket families are disabled so that one compromised account can't attack every other site on the machine. That's a sound policy. It just wasn't written with your thumbnail job in mind.
PHP-FFMpeg and laravel-ffmpeg are process spawners, so proc_open takes them with it
Composer installing a package cleanly tells you nothing about whether it can run. PHP-FFMpeg and protonemedia/laravel-ffmpeg are object APIs that build an FFmpeg argument list and then shell out through Symfony Process. With proc_open gone, Symfony Process throws immediately, which is exactly what PHP-FFMpeg issue #895 documents: The Process class relies on proc_open, which is not available on your PHP installation. FFmpeg's absence or presence never enters into it.
laravel-ffmpeg has a second failure. It wants real ffmpeg and ffprobe binaries at the paths in FFMPEG_BINARIES and FFPROBE_BINARIES, and throws ExecutableNotFoundException: Unable to load FFMpeg when they aren't there. On shared hosting you typically have neither the binary nor the right to run it. No package on Packagist can route around a PHP_INI_SYSTEM setting, so swapping libraries is wasted effort. n8n users hit the same breakage when the Execute Command node went away, covered in keeping your FFmpeg step alive after n8n 3.0: the host removed the ability to spawn a process, and every tool built on spawning went with it.
Re-enabling exec is a real option and a bad trade on a box you share
Plenty of hosts will say yes. Phurix's cPanel FAQ points customers at WHM's MultiPHP INI Editor to drop the entry, and the common advice on shared plans is to ask support for a per-account exemption. So the option is real.
It's still a bad trade. The opening that runs your ffmpeg string is the same opening an attacker uses after any one of your plugins gets popped, which is why cyberciti's disable_functions guide treats these functions as the first thing to remove on a PHP box. If your app accepts uploads, the filename is attacker-controlled, and a filename interpolated into a shell string can carry command substitution that runs before FFmpeg starts. escapeshellarg() is not the safety net it looks like. It has known gaps, particularly on Windows, and the only reliable fix is removing the shell layer rather than escaping harder. We made the same argument about untrusted input reaching FFmpeg directly in the protocol whitelist bypass.
There's a practical problem too. Even with exec back, a four-minute 1080p encode has to survive max_execution_time (30 seconds by default), the FPM pool's request_terminate_timeout, nginx's 60-second proxy_read_timeout, and whatever per-account CPU and process ceiling your host enforces through CloudLinux LVE. You can win the argument with support and still get a 504.
The same three jobs as an HTTP request that spawns nothing
An HTTP request is not a process. curl_exec() isn't on anyone's disable_functions list, so a video API works on hosting where no library that touches proc_open can run. The direct mapping from the command you were trying to run:
| Job | What you'd run in a shell | What you send instead |
|---|---|---|
| Compress | `ffmpeg -i in.mp4 -c:v libx264 -crf 28 out.mp4` | `options: [{-c:v, libx264}, {-crf, 28}]` |
| Resize to 720p | `ffmpeg -i in.mp4 -vf scale=-2:720 out.mp4` | `preset: {"resolution": "720p"}` |
| Thumbnail at 5s | `ffmpeg -ss 5 -i in.mp4 -frames:v 1 t.jpg` | `outputFormat: "jpg"` with `-ss` and `-frames:v` in `options` |
The full compress job in plain PHP, no Composer package required:
<?php
const API = 'https://api.ffmpeg-micro.com';
const KEY = 'YOUR_API_KEY';
function fm(string $method, string $path, ?array $body = null): array {
$ch = curl_init(API . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . KEY,
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => $body ? json_encode($body) : null,
]);
$res = curl_exec($ch);
curl_close($ch);
return json_decode($res, true);
}
$file = '/home/user/public_html/uploads/clip.mp4';
$size = filesize($file); // JSON number, never a quoted string
$up = fm('POST', '/v1/upload/presigned-url', [
'filename' => 'clip.mp4',
'contentType' => 'video/mp4',
'fileSize' => $size,
]);
$fh = fopen($file, 'rb'); // step 2 needs no API key
$ch = curl_init($up['result']['uploadUrl']);
curl_setopt_array($ch, [
CURLOPT_PUT => true,
CURLOPT_INFILE => $fh,
CURLOPT_INFILESIZE => $size,
CURLOPT_HTTPHEADER => ['Content-Type: video/mp4'],
CURLOPT_RETURNTRANSFER => true,
]);
curl_exec($ch); curl_close($ch); fclose($fh);
$ok = fm('POST', '/v1/upload/confirm', [
'filename' => $up['result']['filename'],
'fileSize' => $size,
]);
$job = fm('POST', '/v1/transcodes', [
'inputs' => [['url' => $ok['result']['fileUrl']]],
'outputFormat' => 'mp4',
'options' => [
['option' => '-c:v', 'argument' => 'libx264'],
['option' => '-crf', 'argument' => '28'],
],
]);
echo $job['id']; // "abc123-job-uuid", status "pending"
That's FFmpeg Micro, and the API docs carry the rest of the endpoint shapes. If you'd rather not hand-roll the curl, there's a first-party package, ffmpeg-micro/ffmpeg-micro-php on Packagist, PHP 8.1+, needing ext-curl, ext-json, ext-mbstring and Guzzle. No binary, no proc_open. The free tier is 200 tokens with no card, one token buys 10 seconds of input video, so that's roughly 33 minutes to test with, capped at 100 MB per input file. Billing is per input second with no rounding up to the minute, and failed jobs are never billed.
Where this fits in a Laravel queue
Submit-then-poll maps onto a queued job almost exactly, so your web request returns in milliseconds and nothing blocks an FPM worker. Store the job id, then re-queue the check with release() instead of sleeping:
public function handle(): void
{
$status = fm('GET', '/v1/transcodes/' . $this->jobId);
if ($status['status'] === 'pending' || $status['status'] === 'processing') {
$this->release(10); // check again in 10 seconds
return;
}
if ($status['status'] === 'completed') {
$dl = fm('GET', '/v1/transcodes/' . $this->jobId . '/download');
Storage::put($this->dest, fopen($dl['url'], 'rb'));
}
}
Status checks and downloads don't cost tokens, so polling every 10 seconds costs nothing but a worker tick. Signed download URLs expire after 10 minutes; call /download again for a fresh one.
Pitfalls that waste an afternoon
Five pitfalls, in rough order of how often they bite:
- Sending
"fileSize": "12345678"as a string returns a 400.filesize()gives you an int, so don't json_encode it after a string cast. - Testing over SSH and deploying to FPM. Different ini, different
disable_functions, different result. - Assuming your host's "FFmpeg installed" claim helps. A binary you can't invoke is a binary you don't have.
- Processing inside the web request. Even an API call plus download can outrun a 30-second
max_execution_timeon a large file. Queue it. - Forgetting that a presigned upload URL expires after 10 minutes. Request it right before you PUT, not at the top of a long script.
When running FFmpeg yourself is still the right call
If you have root on a VPS and a predictable workload, installing FFmpeg and calling it from a worker process is cheaper per minute and gives you every filter, including the -filter_complex graphs an API will restrict. The cost is that you own the binary and its CVE schedule, which is a real job when a vulnerability ships with no patched release to upgrade to. If what you actually want is a timeline editor with a drag-and-drop UI, neither an API nor a CLI is the right class of tool; look at a template-editor service instead.
FAQ
Can I override disable_functions in .user.ini or .htaccess?
No. disable_functions is PHP_INI_SYSTEM, which means it can only be set in the main php.ini or the PHP-FPM pool config, and nothing in .user.ini, .htaccess or ini_set() overrides it. The restriction is deliberate: a per-directory override would make the setting useless as a security control.
Does @shell_exec() or switching to popen get around the block?
Neither works. The @ operator only suppresses the warning, so the call still returns null, and popen, proc_open, system, passthru and exec are normally disabled in the same directive on a cPanel host. Check with function_exists() rather than guessing which one survived.
Why did composer require php-ffmpeg install fine and still not work?
Composer only downloads PHP source files, and PHP-FFMpeg's source installs cleanly anywhere. The package is a wrapper around Symfony Process, so it fails at runtime with "The Process class relies on proc_open" the first time you ask it to do anything, and it fails even with FFmpeg installed.
Should I ask my host to enable exec for just my account?
Asking is reasonable and some hosts will agree, but a per-account exemption still gives any remote code execution bug in your app a working shell on a machine you share with other tenants. On a shared box the safer answer is to move the video work off the server entirely instead of reopening process spawning.
My host says FFmpeg is installed on the server. Does that help?
An installed FFmpeg binary is useless to PHP when every function that can launch it is disabled. It only becomes usable if the host also enables exec or proc_open for your account, and even then a long encode has to fit inside max_execution_time and the per-account CPU limits shared plans enforce.
If you'd rather have a working key than a support ticket, the quickstart runs the whole flow after sign-up: it creates a temporary API key and walks presigned upload, transcode, poll and download, with the matching request and response shown at each step. Free tier, no card.
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.
You might also like

The FFmpeg Brotli CVE has no patch. Block it at ingest instead.
The ffmpeg Brotli CVE (CVE-2026-111497) reaches FFmpeg through libjxl and JPEG XL brob boxes. How to check your build and what to block at ingest right now.

FFmpeg Builds for Windows: Essentials or Full, gyan or BtbN
FFmpeg builds for Windows, decided: gyan essentials vs full, static vs shared, gyan vs BtbN, with real OS and CPU requirements and the codec drift to avoid.

n8n 3.0 disables community nodes: keep your FFmpeg step alive
Unverified n8n 3.0 community nodes stop loading, including every local-FFmpeg node. Use the env-var stopgap, or replace the video step with one HTTP call.
Ready to process videos at scale?
Start using FFmpeg Micro's simple API today. No infrastructure required.
Get Started Free