laravelphpvideo-encoding

Your Laravel video transcode queue doesn't need FFmpeg installed

··10 min read
Your Laravel video transcode queue doesn't need FFmpeg installed

A four-minute transcode will never survive an HTTP request, so you move it into a queue job and the problem moves with it. The tutorials that rank for this tell you to run php artisan queue:work --tries=3 --timeout=8600, which is one worker slot reserved for up to two hours and twenty minutes, plus an FFmpeg binary on every box and every container image that runs a worker.

Quick answer: A Laravel video transcode queue built on laravel-ffmpeg runs the encode inside the worker process, which is why those guides set --timeout=8600, raise Supervisor's stopwaitsecs, and install FFmpeg everywhere a worker lives. That DIY version works: dispatch the job, set the timeout above your longest video, keep retry_after higher still, and own the binary and its CVEs. If you'd rather not pin a worker slot for four minutes or patch an encoder, send the file to FFmpeg Micro in one API call, store the returned job id, and use $this->release($delay) to put the Laravel job back on the queue until the remote status reads completed: no FFmpeg to install, and failed jobs are never billed.

Why an 8600-second timeout is the honest number

An 8600-second timeout exists for one reason: the encode is running inside the worker process, so the worker has to stay alive for as long as the longest video takes. Laravel's default job timeout is 60 seconds, and a job-level $timeout property takes precedence over the --timeout flag you pass to queue:work (Laravel queue docs). The canonical Laravel plus FFmpeg tutorial raises it to 8600 and moves on.

That number isn't the real cost. The real cost is throughput: ten workers averaging a four-minute encode gives you 150 videos an hour, and every one of those slots is unavailable to the password reset emails and the Scout indexing jobs sharing the connection. Add a second queue and more workers and you've bought more boxes that each need a patched FFmpeg.

Here's the local version with protonemedia's laravel-ffmpeg, which is what most of these jobs look like inside:

public int $timeout = 8600;

public function handle(): void
{
    FFMpeg::fromDisk('uploads')
        ->open($this->upload->path)
        ->export()
        ->toDisk('renders')
        ->inFormat((new X264)->setKiloBitrate(2500))
        ->onProgress(fn (int $pct) => $this->upload->update(['progress' => $pct]))
        ->save("renders/{$this->upload->id}.mp4");
}

Nothing is wrong with that code. The package's README opens by telling you to verify you have the latest FFmpeg installed and to point FFMPEG_BINARIES and FFPROBE_BINARIES at it, which is the whole assumption in one line. If you're weighing the wrapper libraries against each other, we covered where each one breaks in PHP-FFMpeg vs laravel-ffmpeg vs exec().

The heartbeat is the price of keeping the encode local

Laravel Workflow's FFmpeg conversion post is the most honest page on this subject, and it proves the point by accident. It sets a roughly 5-second activity timeout and wires FFmpeg's progress callback to heartbeat the activity so the encode isn't killed mid-render. A heartbeat is a signal that an activity is still alive, which lets a long activity keep a short timeout.

That machinery exists only because the work is happening on your machine. A durable execution engine on top of your queue is a large dependency to add so a local process can say it's still breathing. When the unit of work is a remote job id, there's nothing to heartbeat. The id is the durable handle, and your database row already stores it.

The submit-and-poll job shape

Two small jobs replace one long one. The first submits the transcode and records the id; the second asks for status and releases itself back onto the queue until the answer changes.

There are no completion webhooks on FFmpeg Micro. The caller polls, a webhookUrl on a request is ignored, and release() is exactly the primitive Laravel gives you for that.

class SubmitTranscode implements ShouldQueue
{
    use Queueable;

    public int $tries = 3;
    public int $timeout = 60;

    public function __construct(public Upload $upload) {}

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

        $this->upload->update([
            'remote_job_id' => $job['id'],      // "abc123-job-uuid"
            'remote_status' => $job['status'],  // "pending"
        ]);

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

The poller is where the architecture changes. release() returns a job to the queue with an optional delay in seconds, so the job isn't available again until that time passes, and the worker is free in between.

class PollTranscode implements ShouldQueue
{
    use Queueable;

    public int $tries = 40;
    public int $timeout = 30;

    public function __construct(public Upload $upload) {}

    public function handle(): void
    {
        $id = $this->upload->remote_job_id;

        $job = Http::withToken(config('services.ffmpeg_micro.key'))
            ->get("https://api.ffmpeg-micro.com/v1/transcodes/{$id}")
            ->throw()
            ->json();

        if ($job['status'] === 'failed') {
            $this->upload->update(['remote_status' => 'failed']);
            $this->fail(new TranscodeFailed($id));
            return;
        }

        if ($job['status'] !== 'completed') {
            $this->release(min(30, 5 * $this->attempts()));
            return;
        }

        $url = Http::withToken(config('services.ffmpeg_micro.key'))
            ->get("https://api.ffmpeg-micro.com/v1/transcodes/{$id}/download")
            ->throw()
            ->json('url');

        Storage::disk('s3')->writeStream("renders/{$this->upload->id}.mp4", fopen($url, 'r'));

        $this->upload->update(['remote_status' => 'completed']);
    }
}

Each attempt holds a worker for roughly one HTTPS round trip. Forty attempts on that escalating delay is about 19 minutes of wall clock and a few seconds of total worker occupancy, against four minutes of a slot held solid. If the remote job never finishes, attempt 41 fails with Illuminate\Queue\MaxAttemptsExceededException and lands in failed_jobs like anything else.

The failed branch matters more than it looks, because user uploads are hostile. We covered the common causes and how to act on each in why your transcode job failed. Billing is per input second with no rounding up to the minute, so a four-minute source costs 24 tokens, and failed jobs are never billed, which makes a retry a decision rather than a wince. Checking status, listing jobs and downloading are free. The API docs carry the presigned upload steps that get gs_url into the row, and there's an official PHP client on Packagist if you'd rather not hand-roll the requests: composer require ffmpeg-micro/ffmpeg-micro-php, PHP 8.1+, covering create, list, get, cancel and download.

What changes in your queue config

Queue configuration stops being load-bearing once the encode leaves the worker. Before and after, same pipeline:

| Setting | Encode in the worker | Submit and poll |
| :-- | :-- | :-- |
| queue:work --timeout | Above your longest video (8600) | Default 60 is fine |
| retry_after | Must exceed the timeout, so 8700+ | Default 90 is fine |
| Supervisor stopwaitsecs | Longer than the longest encode | Seconds |
| Worker time per video | Full encode duration | A few HTTPS round trips |
| FFmpeg binary | On every worker and image | None |
| Cost of a retry | A full re-encode of CPU you paid for | 0, failed jobs are not billed |

Where the big-timeout advice breaks down

Deploys are where the in-worker pattern stops being a tuning exercise. queue:restart is the graceful path: it tells workers to exit after the current job. Supervisor then waits stopwaitsecs before sending SIGKILL, so that value has to exceed the runtime of your longest-running job, or a deploy hard-kills a worker four minutes into an encode and you have a partial file on disk and an attempt burned. Recent framework releases tightened how a worker responds to signals, including a `JobInterrupted` event in Laravel 13.31, but better interruption handling doesn't save an encode that was 90% done.

The second failure is quieter. Laravel's docs state that --timeout must be several seconds shorter than the connection's retry_after, or the same job gets processed twice. With an 8600-second timeout and a retry_after nobody raised past 90, your queue starts a second encode of the same file while the first is still running, and you pay CPU twice to produce the same output. We've watched the same double-execution bug eat renders in a no-code pipeline too, in your n8n workflow runs twice.

With submit-and-poll, a mid-poll SIGTERM costs you one HTTP GET. The encode isn't on your box, so a deploy during it loses nothing; the next poller picks up the same remote_job_id and the status is wherever it got to.

Then there's the part of the in-worker setup that never shows up in a tutorial: the CVE batch published on 9 November 2026 against FFmpeg through 9.0.2 includes encoder and GPU-path memory leaks and a High-severity double free reachable through h264_metadata with display_orientation=insert, the exact filter people use to fix rotated phone uploads. There's no 9.0.x release yet carrying the fixes, so self-hosters are patching from git. That burden lands once per worker image, every time, which we picked apart in your PHP Docker image has an FFmpeg layer.

When the encode should stay on your box

Keeping FFmpeg in the worker is the right call in a few real cases. If your media never leaves a private network for compliance reasons, if you're doing frame-accurate work against a custom build with filters nobody ships by default, or if you already run a GPU fleet at a volume where the per-second meter loses to your own hardware, the local encode wins and the long timeout is a fair price. An API is also the wrong shape for live or near-live work: submit-and-poll is a batch pattern, not an RTMP pipeline.

FAQ about Laravel video transcode queue jobs

Is there a webhook when the transcode finishes, or do I have to poll?

FFmpeg Micro has no completion webhooks for callers. You poll GET /v1/transcodes/:id until the status is completed or failed, and in Laravel the idiomatic way to poll is $this->release($seconds) from inside the queued job so the worker is free between checks rather than sitting in sleep().

Do I need Horizon, Laravel Workflow, or Temporal for this?

A submit-and-poll transcode needs none of the three. Laravel's queue gives you release(), $tries, $backoff and failed(), which covers the whole poll loop. Horizon is still useful for its dashboard and metrics, but the architecture doesn't depend on it, and durable execution engines earn their keep on multi-step sagas, not on one remote job id.

If my encode dies at 90%, do I pay for the 90% and then pay again for the retry?

Failed jobs are never billed on FFmpeg Micro, and billing is metered on input duration per second with no rounding up to the minute, so a failed render costs nothing and the retry costs what the first attempt would have. Running the encode on your own worker is the opposite: a job killed at 90% has already consumed the CPU time, and the retry buys that time again.

How do I show progress to a user if I'm not parsing FFmpeg's stdout?

The status response gives you status, lane, queue_position, started_at and completedAt, and your poller already writes those to the row the UI reads. There is no percentage field, so if you need a moving bar rather than a state label, show queue position and elapsed time instead. Local progress callbacks aren't a free alternative either: PHP-FFMpeg has carried an open report that the progress callback only ever emits 99% since August 15, 2016.

What should queue:work --timeout be once the encode is remote?

Leave --timeout at Laravel's default of 60 seconds and retry_after at 90, because no job in this shape runs longer than a single HTTPS request. Set the ceiling with $tries on the polling job instead: 40 attempts on an escalating delay gives you roughly 19 minutes before a stuck job gives up and fails loudly.

Pick one real upload, point a SubmitTranscode job at its gs:// URL, and watch a worker that used to be busy for four minutes handle it in a handful of round trips. Sign up free gets you a key and 200 tokens, about 33 minutes of video, with no card: no servers to run and no FFmpeg to install, callable from your Laravel app, from n8n, Make and Zapier, or from your AI agents.

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