The usual Laravel transcoding design is a queued job that shells out to FFmpeg. It works on your machine and it works in staging. Then a worker restarts during a deploy, and you have a half-written MP4, a job that retried from zero, and a status column that has said processing for six hours. None of that is an FFmpeg problem. It's what happens when a multi-minute CPU-bound process lives inside a job runner designed for short tasks.
TL;DR
A queued FFmpeg job fights Laravel's own assumptions. retry_after fires mid-encode and hands the payload to a second worker. queue:restart sets a flag that makes each worker exit once its current job returns, which a long exec() ignores until it finishes. And FFmpeg reports failure by exit code rather than by throwing, so a broken encode looks like a successful job. Direct upload removes the job entirely. The browser uploads to a signed URL, FastPix encodes and packages HLS, and your controller returns JSON in milliseconds. Encoding still fails sometimes, but it arrives as video.media.failed rather than as a row stuck on processing.
Why a Laravel FFmpeg queue job keeps failing
Laravel's queue system assumes work that finishes. retry_after, timeout, graceful restarts and Horizon's worker recycling are all built around that assumption, and a four-hour encode violates every one of them.
All three of the failures below read as infrastructure flakiness rather than as design.
A deploy strands the encode. php artisan queue:restart doesn't signal anything; it sets a timestamp and each worker exits after its current job returns. Horizon's supervisors do send SIGTERM, and a worker blocked in exec() on FFmpeg either dies with the child still running or takes the child down with it. Neither outcome updates your database.
`retry_after` fires while the job is still alive. Say retry_after is 90 seconds and the encode takes twenty minutes. The queue concludes the job died and hands the same payload to a second worker. Now two FFmpeg processes are writing to the same output path.
The exception handler never sees the real failure. FFmpeg writes its errors to stderr and exits with a code. Without an exit-status check and captured stderr, a failed encode looks like a successful job. The row stays processing, and Laravel's exception handler is never involved, because nothing threw.
| What Laravel expects | What an encode does |
|---|---|
| Jobs finish in seconds | Minutes to hours |
| Workers restart freely | Restart destroys in-flight work |
| Failures throw | FFmpeg fails via exit code and stderr |
| Memory footprint is small | CPU and disk bound for the duration |
What you need before you start
- A Laravel application on PHP 8.x with Composer.
- A FastPix account with an Access Token ID and Secret Key from activate your account.
- No FFmpeg binary, and no encoding tier on your servers.
Sizing Laravel servers for video transcoding
The second cost is capacity, and it's the one that shows up on the invoice rather than in Sentry.
Encoding is CPU-bound, and it's bound for the whole duration. A server that runs your web workload comfortably will run it badly while two encodes are in flight. The encoder is using every core it can get. So you either accept degraded response times during encoding, or you run separate encoding instances that sit idle most of the day.
Both choices are sized by the largest file you accept, not by the average one. That's what makes the arithmetic unpleasant. The 4K drone footage somebody uploads once a month sets the instance type you pay for every hour of every day.
Laravel video streaming without a local encoder
In a direct upload the file never reaches PHP, so there's no local file to encode. FastPix takes the upload directly from the browser, encodes it, produces the adaptive ladder, packages HLS and hosts the result. Your Laravel application's entire involvement is one controller action and one webhook route.
<?php
// app/Http/Controllers/VideoController.php
namespace App\Http\Controllers;
use App\Services\FastPixService;
use FastPix\Sdk\Models\Operations\DirectUploadVideoMediaAccessPolicy;
use FastPix\Sdk\Models\Operations\DirectUploadVideoMediaRequest;
use FastPix\Sdk\Models\Operations\PushMediaSettings;
class VideoController extends Controller
{
public function createUpload()
{
$upload = FastPixService::make()->inputVideo->directUploadVideoMedia(
new DirectUploadVideoMediaRequest(
corsOrigin: '*',
pushMediaSettings: new PushMediaSettings(
accessPolicy: DirectUploadVideoMediaAccessPolicy::Public,
),
),
);
return response()->json([
'url' => $upload->object->data->url,
'mediaId' => $upload->object->data->uploadId,
]);
}
}That action returns in milliseconds and holds no process open. There's no job to time out, no worker to recycle, and no temp file to clean up after a failed deploy. The adaptive ladder is built on the other side of the upload.
You can prove this end to end before committing to it. The free plan covers 10 videos and 100K streaming minutes a month with no credit card.
Store the mediaId against your own record before the upload starts. It's what the readiness event matches on later.
Encoding still fails, and now it tells you
Removing your own encoder doesn't remove bad source files. A corrupt upload or a container nothing can decode still fails. The difference is that the failure arrives as an event rather than as a silence.
video.media.ready carries the playback ID. video.media.failed carries the failure. Handle both. Handling only the first is how a record ends up stuck in processing, exactly as it did under FFmpeg:
// app/Http/Controllers/VideoController.php
// `use Illuminate\Http\Request;` goes at the top of the file, beside the other imports.
// Without it, Request resolves to App\Http\Controllers\Request and every delivery 500s.
public function webhook(Request $request)
{
$raw = $request->getContent();
if (! $this->signatureIsValid($raw, $request->header('FastPix-Signature'))) {
return response()->json(['message' => 'invalid signature'], 400);
}
$payload = json_decode($raw, true);
match ($payload['type'] ?? null) {
'video.media.ready' => $this->markVideoReady(
$payload['data']['id'],
$payload['data']['playbackIds'][0]['id'],
),
'video.media.failed' => $this->markVideoFailed($payload['data']['id']),
default => null,
};
return response()->json(['message' => 'ok']);
}That route has to be excluded from CSRF in bootstrap/app.php, because FastPix can't send a token and every delivery otherwise fails with 419. Excluding it means anything can post to that URL. That's why the handler reads getContent() first, checks the signature against those exact bytes, and only then decodes. signatureIsValid, markVideoReady and markVideoFailed are yours to write; the signature function is printed in full in why your Laravel video route returns a 419; the Signing Secret is Base64 and has to be decoded before it's used as the HMAC key. The video.media.ready webhook, and how to verify it covers the check.
What you give up, stated plainly
Removing FFmpeg removes capability, and it's worth being honest about which capability.
A local FFmpeg process can do anything FFmpeg can do. Arbitrary filter graphs, frame-exact edits, unusual codec combinations, audio manipulation nobody has an API for. A hosted pipeline does the common path very well and doesn't expose a filter graph. If your product's value is in a bespoke transform, keep the encoder. Accept the operational cost, because that cost is buying you something.
Most Laravel applications aren't in that position. They accept a user upload, they need it to play on phones and laptops, and the transform is the standard one. That's the case where the queue job is pure overhead.
The same tradeoff shows up on the upload side in Laravel video upload without touching php.ini. For the wider comparison of when a hosted pipeline replaces a local binary, FFmpeg alternative: using video APIs for streaming covers the decision without the Laravel framing.
Laravel video playback is one tag
Once the ready event has arrived, playback in Blade is a single element:
<fastpix-player playback-id="{{ $playbackId }}" stream-type="on-demand"></fastpix-player>with import '@fastpix/fp-player'; in resources/js/app.js. It's a web component, so Blade is enough and Inertia isn't required. The adaptive ladder is already built. The player negotiates quality against whatever connection the viewer has, and you authored no renditions at all.
In an Inertia app with SSR enabled, import the player inside useEffect instead. Player 1.0.21 and later guard their customElements registration, so a module-scope import no longer throws; it just ships the player bundle to every page.
Replace the encoding job in your Laravel app
Install fastpix/sdk. Replace the queued FFmpeg job with one controller action that returns a signed URL. Handle video.media.ready and video.media.failed on a CSRF-excluded route. Your workers go back to doing short work. The free plan covers 10 videos and 100K streaming minutes a month, no credit card. Run your worst source file through it and compare the result against what the local encoder produced.
Frequently Asked Questions (FAQs)
Can I stream video from Laravel without FFmpeg?
Yes. Streaming needs the file converted to an adaptive format such as HLS, and nothing requires that conversion to happen on your own servers. With direct upload, the browser sends the file to a signed URL and a hosted pipeline encodes it. Laravel handles one JSON response and one webhook. No binary is installed and no queue job runs.
Why does my Laravel FFmpeg queue job keep failing?
Usually because the job outlives what the queue expects. retry_after firing during a long encode hands the same payload to a second worker. Horizon's supervisors send SIGTERM mid-process during deploys, and queue:restart leaves a long exec() running until it returns on its own. FFmpeg also reports failure through an exit code and stderr rather than by throwing, so a failed encode can look like a successful job.
How do I compress video in Laravel?
Compression is one output of encoding, and the practical question is where encoding runs. Locally, it means installing FFmpeg, sizing servers for the largest file you accept, and owning the failure modes. With a video API, you send the file past PHP to a signed URL and receive an adaptive ladder back. The bitrate decisions are already made, per rendition.
Does the video file pass through my Laravel server?
No. The server creates a signed upload URL and the browser uploads directly to it. PHP memory limits, upload_max_filesize, and your web server's body-size cap are all uninvolved. No PHP-FPM process is held open for the duration of a transfer.
What happens when encoding fails without my own encoder?
FastPix sends video.media.failed instead of video.media.ready, carrying the media ID. Branch on the event type in your webhook handler and write a real failed state. Handling only the ready event leaves records stuck in processing, which is the same bug the FFmpeg version had.






