phpffmpeghosting

shell_exec has been disabled. Run FFmpeg jobs from PHP anyway

··10 min read
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 error shell_exec() has been disabled for security reasons means your host listed shell_exec in php.ini's disable_functions, and on a stock cPanel server exec, passthru, system, popen, proc_open and pcntl_exec are usually listed alongside it. That's why PHP-FFMpeg and laravel-ffmpeg also fail: they're object wrappers around Symfony Process, which needs proc_open. disable_functions can 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:

JobWhat you'd run in a shellWhat 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_time on 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.

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