I’ve been working on a project where we were using AWS elastic transcoder for media conversion. Elastic transcoder is a highly scalable solution for media transcoding. However, it charges your per minute for media conversion depending on your region. To reduce operational costs, we decided to shift away from AWS transcoder and use FFmpeg with Laravel for media conversion on our own servers. In this tutorial, I’ll show you how we can use FFmpeg for media conversion and defer processing using Laravel Queues.

Let’s get started by setting up a new project. Create a new Video model, its migration, and controller. We will store uploaded videos information on videos table.

php artisan make:model Video --migration --controller 1 php artisan make : model Video -- migration -- controller

class CreateVideosTable extends Migration { /** * Run the migrations. * * @return void */ public function up() { Schema::create('videos', function (Blueprint $table) { $table->increments('id'); $table->string('title'); $table->string('original_name'); $table->string('disk'); $table->string('path'); $table->string('stream_path')->nullable(); $table->boolean('processed')->default(false); $table->datetime('converted_for_streaming_at')->nullable(); $table->timestamps(); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::dropIfExists('videos'); } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 class CreateVideosTable extends Migration { /** * Run the migrations. * * @return void */ public function up ( ) { Schema:: create ( 'videos' , function ( Blueprint $table ) { $table -> increments ( 'id' ) ; $table -> string ( 'title' ) ; $table -> string ( 'original_name' ) ; $table -> string ( 'disk' ) ; $table -> string ( 'path' ) ; $table -> string ( 'stream_path' ) -> nullable ( ) ; $table -> boolean ( 'processed' ) -> default ( false ) ; $table -> datetime ( 'converted_for_streaming_at' ) -> nullable ( ) ; $table -> timestamps ( ) ; } ) ; } /** * Reverse the migrations. * * @return void */ public function down ( ) { Schema:: dropIfExists ( 'videos' ) ; } }

On file upload, we will store video title, original file name, and path of the stored file in the database. After upload, we will dispatch a Job for transcoding it to a web streamable format and update stream_path with the output file path, update converted_for_streaming_at timestamp and set processed to true after FFmpeg is done processing uploaded media file.

class Video extends Model { protected $dates = [ 'converted_for_streaming_at', ]; protected $guarded = []; } 1 2 3 4 5 6 7 8 class Video extends Model { protected $dates = [ 'converted_for_streaming_at' , ] ; protected $guarded = [ ] ; }

In Video model class, add the converted_for_streaming_at column to $dates array so that it should be mutated to dates like created_at or updated_at columns.

Add these routes to web.php file.

Route::group(['middleware' => ['auth']], function(){ Route::get('/', 'VideoController@index'); Route::get('/uploader', 'VideoController@uploader')->name('uploader'); Route::post('/upload', 'VideoController@store')->name('upload'); }); 1 2 3 4 5 6 7 8 Route:: group ( [ 'middleware' = > [ 'auth' ] ] , function ( ) { Route:: get ( '/' , 'VideoController@index' ) ; Route:: get ( '/uploader' , 'VideoController@uploader' ) -> name ( 'uploader' ) ; Route:: post ( '/upload' , 'VideoController@store' ) -> name ( 'upload' ) ; } ) ;

GET /uploader route will render a form for uploading videos and POST /upload route will handle the form submission, upload video, create a database record and dispatch an FFmpeg transcoding job. GET / index route will render videos view where all uploaded videos will be displayed in native HTML video player.

In VideoController add these methods.

class VideoController extends Controller { /** * Return video blade view and pass videos to it. * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View */ public function index() { $videos = Video::orderBy('created_at', 'DESC')->get(); return view('videos')->with('videos', $videos); } /** * Return uploader form view for uploading videos * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View */ public function uploader(){ return view('uploader'); } /** * Handles form submission after uploader form submits * @param StoreVideoRequest $request * @return \Illuminate\Http\RedirectResponse */ public function store(StoreVideoRequest $request) { $path = str_random(16) . '.' . $request->video->getClientOriginalExtension(); $request->video->storeAs('public', $path); $video = Video::create([ 'disk' => 'public', 'original_name' => $request->video->getClientOriginalName(), 'path' => $path, 'title' => $request->title, ]); ConvertVideoForStreaming::dispatch($video); return redirect('/uploader') ->with( 'message', 'Your video will be available shortly after we process it' ); } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 class VideoController extends Controller { /** * Return video blade view and pass videos to it. * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View */ public function index ( ) { $videos = Video:: orderBy ( 'created_at' , 'DESC' ) -> get ( ) ; return view ( 'videos' ) -> with ( 'videos' , $videos ) ; } /** * Return uploader form view for uploading videos * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View */ public function uploader ( ) { return view ( 'uploader' ) ; } /** * Handles form submission after uploader form submits * @param StoreVideoRequest $request * @return \Illuminate\Http\RedirectResponse */ public function store ( StoreVideoRequest $request ) { $path = str_random ( 16 ) . '.' . $request -> video -> getClientOriginalExtension ( ) ; $request -> video -> storeAs ( 'public' , $path ) ; $video = Video:: create ( [ 'disk' = > 'public' , 'original_name' = > $request -> video -> getClientOriginalName ( ) , 'path' = > $path , 'title' = > $request -> title , ] ) ; ConvertVideoForStreaming:: dispatch ( $video ) ; return redirect ( '/uploader' ) -> with ( 'message' , 'Your video will be available shortly after we process it' ) ; } }

Create uploader.blade.php under views directory.

@extends('layouts.app') @section('content') <div class="col-xs-12 col-sm-12 col-md-8 col-lg-6 mr-auto ml-auto mt-5"> <h3 class="text-center"> Upload Video </h3> <form method="post" action="{{ route('upload') }}" enctype="multipart/form-data"> <div class="form-group"> <label for="video-title">Title</label> <input type="text" class="form-control" name="title" placeholder="Enter video title"> @if($errors->has('title')) <span class="text-danger"> {{$errors->first('title')}} </span> @endif </div> <div class="form-group"> <label for="exampleFormControlFile1">Video File</label> <input type="file" class="form-control-file" name="video"> @if($errors->has('video')) <span class="text-danger"> {{$errors->first('video')}} </span> @endif </div> <div class="form-group"> <input type="submit" class="btn btn-default"> </div> {{csrf_field()}} </form> </div> @endSection 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 @ extends ( 'layouts.app' ) @ section ( 'content' ) <div class = "col-xs-12 col-sm-12 col-md-8 col-lg-6 mr-auto ml-auto mt-5" > <h3 class = "text-center" > Upload Video </h3> <form method = "post" action = "{{ route('upload') }}" enctype = "multipart/form-data" > <div class = "form-group" > <label for = "video-title" > Title </label> <input type = "text" class = "form-control" name = "title" placeholder = "Enter video title" > @ if ( $ errors -> has('title')) <span class = "text-danger" > { { $ errors -> first('title')}} </span> @endif </div> <div class = "form-group" > <label for = "exampleFormControlFile1" > Video File </label> <input type = "file" class = "form-control-file" name = "video" > @ if ( $ errors -> has('video')) <span class = "text-danger" > { { $ errors -> first('video')}} </span> @endif </div> <div class = "form-group" > <input type = "submit" class = "btn btn-default" > </div> {{csrf_field()}} </form> </div> @ endSection

Also, create a StoreVideoRequest form request for validating uploader form input.

php artisan make:request StoreVideoRequest 1 php artisan make : request StoreVideoRequest

class StoreVideoRequest extends FormRequest { /** * Determine if the user is authorized to make this request. * * @return bool */ public function authorize() { return true; } /** * Get the validation rules that apply to the request. * * @return array */ public function rules() { return [ 'title' => 'required', 'video' => 'required|file|mimetypes:video/*', ]; } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 class StoreVideoRequest extends FormRequest { /** * Determine if the user is authorized to make this request. * * @return bool */ public function authorize ( ) { return true ; } /** * Get the validation rules that apply to the request. * * @return array */ public function rules ( ) { return [ 'title' = > 'required' , 'video' = > 'required|file|mimetypes:video/*' , ] ; } }

We have a mimetypes validation rule with video/* wildcard to only allow video uploads.

Now create a ConvertVideoForStreaming job which will be dispatched after video is done uploading and a database record is created in VideoController@store method.

php artisan make:job ConvertVideoForStreaming 1 php artisan make : job ConvertVideoForStreaming

class ConvertVideoForStreaming implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public $video; /** * Create a new job instance. * * @param Video $video */ public function __construct(Video $video) { $this->video = $video; } /** * Execute the job. * * @return void */ public function handle() { // create a video format... $lowBitrateFormat = (new X264('libmp3lame', 'libx264'))->setKiloBitrate(500); $converted_name = $this->getCleanFileName($this->video->path); // open the uploaded video from the right disk... FFMpeg::fromDisk($this->video->disk) ->open($this->video->path) // add the 'resize' filter... ->addFilter(function ($filters) { $filters->resize(new Dimension(960, 540)); }) // call the 'export' method... ->export() // tell the MediaExporter to which disk and in which format we want to export... ->toDisk('public') ->inFormat($lowBitrateFormat) // call the 'save' method with a filename... ->save($converted_name); // update the database so we know the convertion is done! $this->video->update([ 'converted_for_streaming_at' => Carbon::now(), 'processed' => true, 'stream_path' => $converted_name ]); } private function getCleanFileName($filename){ return preg_replace('/\\.[^.\\s]{3,4}$/', '', $filename) . '.mp4'; } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 class ConvertVideoForStreaming implements ShouldQueue { use Dispatchable , InteractsWithQueue , Queueable , SerializesModels ; public $video ; /** * Create a new job instance. * * @param Video $video */ public function __construct ( Video $video ) { $this -> video = $video ; } /** * Execute the job. * * @return void */ public function handle ( ) { // create a video format... $lowBitrateFormat = ( new X264 ( 'libmp3lame' , 'libx264' ) ) -> setKiloBitrate ( 500 ) ; $converted_name = $this -> getCleanFileName ( $this -> video -> path ) ; // open the uploaded video from the right disk... FFMpeg:: fromDisk ( $this -> video -> disk ) -> open ( $this -> video -> path ) // add the 'resize' filter... -> addFilter ( function ( $filters ) { $filters -> resize ( new Dimension ( 960 , 540 ) ) ; } ) // call the 'export' method... -> export ( ) // tell the MediaExporter to which disk and in which format we want to export... -> toDisk ( 'public' ) -> inFormat ( $lowBitrateFormat ) // call the 'save' method with a filename... -> save ( $converted_name ) ; // update the database so we know the convertion is done! $this -> video -> update ( [ 'converted_for_streaming_at' = > Carbon:: now ( ) , 'processed' = > true , 'stream_path' = > $converted_name ] ) ; } private function getCleanFileName ( $filename ) { return preg_replace ( '/\\.[^.\\s]{3,4}$/' , '' , $filename ) . '.mp4' ; } }

In handle() method of the dispatched job, we will create a low bitrate X264 format. We will open uploaded file from public disk and add a resize filter to it. Then we will tell FFmpeg to start transcoding by calling export() method and output file to public disk in a low bitrate mp4 container format.

Before you go an test it, make sure you have installed Laravel FFmpeg package that we are using in our transcoding job.

composer require pbmedia/laravel-ffmpeg 1 composer require pbmedia / laravel - ffmpeg

Also, make sure you have ffmpeg binaries installed of your machine. If you’re running Linux, you can easily install it by running following apt install command.

sudo apt-get install ffmpeg 1 sudo apt - get install ffmpeg

You must also add FFmpeg Service Provider and Facade to app.php.

'providers' => [ ... Pbmedia\LaravelFFMpeg\FFMpegServiceProvider::class, ... ]; 'aliases' => [ ... 'FFMpeg' => Pbmedia\LaravelFFMpeg\FFMpegFacade::class ... ]; 1 2 3 4 5 6 7 8 9 10 11 'providers' = > [ . . . Pbmedia \ LaravelFFMpeg \ FFMpegServiceProvider:: class , . . . ] ; 'aliases' = > [ . . . 'FFMpeg' = > Pbmedia \ LaravelFFMpeg \ FFMpegFacade:: class . . . ] ;

and run following command to publish package configuration files.

php artisan vendor:publish --provider="Pbmedia\LaravelFFMpeg\FFMpegServiceProvider" 1 php artisan vendor : publish -- provider = "Pbmedia\LaravelFFMpeg\FFMpegServiceProvider"

If you’re running windows, you must add ffmpeg binaries to the system PATH. If you don’t have access to that, you can define these environment variables in your .env file.

FFMPEG_BINARIES='PATH_TO_FFMPEG_BINARUES' FFPROBE_BINARIES='PATH_TO_FFPROBE_BINARIES' 1 2 FFMPEG_BINARIES = 'PATH_TO_FFMPEG_BINARUES' FFPROBE_BINARIES = 'PATH_TO_FFPROBE_BINARIES'

Laravel Queues Configuration

You also need to configure queue connection in your env file. For this tutorial, I’m using database queue connection. Edit .env file and update QUEUE_CONNECTION variable to database.

Also run php artisan queue:table to create database queue table migration and php artisan migrate to create table. To deal with failed jobs, run php artisan queue:failed-table to create failed queue jobs migration table and php artisan migrate to create table.

Running Queue Worker

Before we go and test, run Laravel’s queue worker

php artisan queue:work --tries=3 --timeout=8600 1 php artisan queue : work -- tries = 3 -- timeout = 8600

we have added a --timeout flag to queue worker. This indicates that don’t want our queue jobs to run longer than 8600 seconds.

Now if you head over to /uploader route in your application and upload a video file, a database record will be created a transcoding job will be dispatched. You’ll be able to view your dispatched job in the terminal.

Displaying Videos

Create videos.blade.php file under view directory.

@extends('layouts.app') @section('content') <div class="col-xs-12 col-sm-12 col-md-8 col-lg-8 mr-auto ml-auto mt-5"> <h3 class="text-center"> Videos </h3> @foreach($videos as $video) <div class="row mt-5"> <div class="video" > <div class="title"> <h4> {{$video->title}} </h4> </div> @if($video->processed) <video src="/storage/{{$video->stream_path}}" class="w-100" controls></video> @else <div class="alert alert-info w-100"> Video is currently being processed and will be available shortly </div> @endif </div> </div> @endforeach </div> @endSection 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 @ extends ( 'layouts.app' ) @ section ( 'content' ) <div class = "col-xs-12 col-sm-12 col-md-8 col-lg-8 mr-auto ml-auto mt-5" > <h3 class = "text-center" > Videos </h3> @foreach($videos as $video) <div class = "row mt-5" > <div class = "video" > <div class = "title" > <h4> { { $ video -> title}} </h4> </div> @ if ( $ video -> processed) <video src = "/storage/{{$video->stream_path}}" class = "w-100" controls > </video> @else <div class = "alert alert-info w-100" > Video is currently being processed and will be available shortly </div> @endif </div> </div> @endforeach </div> @ endSection

We will display an alert for videos that are currently being processed. For processed videos, we will render a video element with transcoded stream_path.

Here’s a demo of what we have done so far.

I have set up a Github repository with example application code. If you run into any issue or have any questions, leave a comment and I will try to help you in any way possible.