Skip to content

Introduction

Every PHP developer who has reached for video knows the moment. You need to burn a line of text onto a clip, so you open a terminal, find an incantation on the internet, escape it twice, and drop it into your codebase:

shell_exec("ffmpeg -i in.mp4 -vf \"drawtext=text='Hello':x='if(lt(t\\,5)\\,t*10\\,50)':[email protected]\" out.mp4");

It works. Probably. Until a path has a space in it, or the font isn’t where you thought, or a codec is missing — and then FFmpeg hands you a wall of stderr and a non-zero exit code, and you’re bisecting backslashes at 2am with no idea which part gave out. (If you’ve ever watched a single quote take down thirty subtitle filters, you know exactly the feeling.)

ext-ffmpeg is the other path. FFmpeg, as a native part of PHP — the same line, written the way you’d write anything else in your application:

use FFmpeg\Media;
use FFmpeg\Filter\DrawTextVars;
Media::open('in.mp4')
->videoStream()
->drawText(
text: 'Hello',
x: fn (DrawTextVars $v) => min($v->t * 10, 50),
color: 'white',
)
->save('out.mp4');

No string to escape. No process to shell out to. No stderr to parse. Just objects, types, and methods your editor can autocomplete — and when something goes wrong, an exception that tells you exactly which call failed and why.

This isn’t a wrapper that builds a command and hands it to the ffmpeg binary. It’s a native extension — pure C against the Zend API — that links the FFmpeg libraries directly and runs them inside your process. There’s no CLI between you and the work.

That distinction is the whole point. Opening a file gives you a real object with real, readonly properties:

$media = Media::open('interview.mov');
$media->duration; // 184.5
$media->videoStream()->width; // 3840
$media->videoStream()->codec; // 'hevc'
$media->audioStream()->channelLayout; // '5.1'

You compose an output the same way — by describing what you want, not by assembling flags:

use FFmpeg\MediaEncoder;
use FFmpeg\Codec\{VideoCodec, AudioCodec};
(new MediaEncoder())
->addVideo($media->videoStream(), VideoCodec::H264)
->addAudio($media->audioStream(), AudioCodec::Copy)
->save('clip.mp4');

Re-encode the video, copy the audio through untouched, write it to disk. It reads like the sentence you’d use to describe it.

When the FFmpeg CLI fails, you get text on stderr and a guess. Here, every failure is a typed exception that carries the operation that failed and FFmpeg’s own error string:

use FFmpeg\Exception\{FileNotFoundException, EncoderNotFoundException};
try {
Media::open($path)->videoStream()->save('out.mp4', VideoCodec::H264);
} catch (FileNotFoundException $e) {
// the input wasn't there
} catch (EncoderNotFoundException $e) {
// this build of FFmpeg can't encode H.264 — $e->operation tells you where it gave out
}

You catch the specific thing you can recover from, and let the rest bubble. No regex over a log dump.

And the typo class fails early. When a filter parameter is an expression string, we run it through FFmpeg’s own av_expr_parse() the moment you define the filter — so a malformed expression throws at that line, naming the filter, long before the encode starts. Chain three hundred filters and mistype one, and you learn exactly which one.

Here’s what a binding can do that a command line never will. FFmpeg’s filters take expressions — tiny strings in a bespoke math language, evaluated once per frame. Centering text, fading something in, reacting to time: all of it gets crammed into quoted strings like x='(w-text_w)/2' and alpha='if(lt(t,1),t,1)'.

Replace that language with your language. A filter parameter can be a PHP closure, and it runs on every frame, with the real values FFmpeg knows at that instant — here, keeping the text centered, bobbing it gently, and fading it in over the first two seconds:

use FFmpeg\Media;
use FFmpeg\Filter\DrawTextVars;
Media::open('match.mp4')
->videoStream()
->drawText(
text: fn (DrawTextVars $v) => 'Score: ' . currentScore($v->t),
x: fn (DrawTextVars $v) => ($v->width - $v->textWidth) / 2,
y: fn (DrawTextVars $v) => $v->height - 120 + sin($v->t * 3) * 16,
alpha: fn (DrawTextVars $v) => min(1.0, $v->t / 2),
)
->save('out.mp4');

Those closures are real PHP. They read the frame’s timestamp, the rendered text’s dimensions, the video’s size — and they can call your code: hit a cache, branch on application state, compute anything PHP can compute. The expression language was a workaround for not being able to run a real one. You have a real one.

This is the one that isn’t possible any other way — and it’s the reason to build a native extension instead of a nicer wrapper.

The established PHP packages, the elegant fluent ones included, ultimately assemble an ffmpeg command and run it as a separate process. The code on your screen can look lovely, but underneath it’s still building a string and shelling out — so you’re still one quoting edge case from a stderr dump, and, more fundamentally, the decoded frame lives and dies inside a process you can’t reach into. The nice syntax is a paint job on the same machine.

ext-ffmpeg decodes frames in your process, in PHP’s own memory. A frame can go straight from FFmpeg to your code — or to the next native library in line — with nothing written to disk, nothing re-read, nothing piped between commands:

use FFmpeg\Frame;
$blurred = Media::open('interview.mp4')
->videoStream()
->mapFrames(function (Frame $frame) {
foreach (MediaPipe::detectFaces($frame) as $face) { // ext-mediapipe — same memory
OpenCV::blur($frame, $face->region); // ext-opencv — same frame buffer
}
return $frame;
});
$blurred->save('interview-blurred.mp4');

Decode → detect → blur → encode, as a single pass through one process, with the frame buffer shared the whole way — no temp directory of PNGs, no serialization tax per frame, no second runtime to coordinate. PHP isn’t standing outside a media pipeline anymore, conducting it through a command line. It’s running inside one.

That’s the line this extension is built to cross. Everything else is comfort; this is capability you can’t get from a string.

FFmpeg is enormous — hundreds of formats, codecs, and filters, decades of capability. The goal here isn’t a handful of convenience helpers over the popular 5%. It’s to express the whole thing as one coherent, idiomatic PHP API: streams you can probe, filters you can chain, frames you can reach into. Common operations get first-class, semantic methods; the long tail stays reachable through the same consistent shapes — so readability and completeness aren’t a trade-off.

None of this would exist without FFmpeg, the extraordinary project this is built upon. If this work is useful to you, please support FFmpeg too.

This is being designed in the open, and the API you see is the API we’re trying to get right — which is the perfect time to have an opinion. If a shape feels wrong, or there’s a workflow you wish read better, we want to hear it.

An Artisan Build project.

Built on FFmpeg — an independent binding, not affiliated with or endorsed by the FFmpeg project. Support FFmpeg.