Managing Laravel Queues Efficiently With FrankenPHP, Redis and Docker

Managing Laravel Queues Efficiently With FrankenPHP, Redis and Docker

May 20, 2025

Introduction

TL;DR: Learn how to manage Laravel queues using Redis, Docker, and FrankenPHP. This guide walks through setting up a queue worker, dispatching jobs, and integrating Laravel Horizon — all within a Dockerized environment.

Continuing from the previous article on getting started with FrankenPHP and Docker we will now focus on managing Laravel queues using Redis and Docker. This setup is ideal for handling background tasks in a Laravel application, ensuring that your web server remains responsive while processing jobs asynchronously.

To get up and running quickly, we can use the demo repository that contains a sample Laravel application configured with Redis and Docker with FrankenPHP.

This article assumes that you know what Laravel queues are and how they work. If you are new to Laravel queues, I recommend checking out the official documentation for a comprehensive understanding.

Prerequisites

Before we begin, ensure you have the following installed on your machine:

Getting Started

You can either clone the demo repository or create a new Laravel application from scratch, based on previous article.

The rest of this article will assume you are using the docker-compose.yml from the demo repository (or the one from the previous article) as a starting point, and that you have a working Laravel application.

Remember to generate the application key and set up your .env file with the necessary configurations. You can do this by running (skip any steps that you already have done):

composer install
cp .env.example .env
docker compose build --no-cache
docker compose up -d
docker compose run --rm artisan key:generate

Configuration

To use Redis for queues in your Laravel application, you need to configure the .env file. Open the .env file and set the following values:

QUEUE_CONNECTION=redis
REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379

This is the basic setup for using Redis as the queue driver. The REDIS_HOST is set to redis, which is the name of the Redis service defined in the docker-compose.yml file.

⚠️

Security tip: For local development, the Redis container is fine as-is. But in production environments, it’s important to secure Redis properly:

  • Use authentication
  • Bind only to trusted networks
  • Avoid exposing Redis to the public internet

You can find more details in the official Redis Docker Hub documentation.

Creating and dispatching a Job

We need to have something to queue. We’ll create a simple job that outputs a message to our log file. You can create a job using the Artisan command:

docker compose run --rm artisan make:job LogMessageJob

This will create a new job class in the app/Jobs directory. Open the newly created LogMessageJob.php file and modify the handle method to log a message:

app/Jobs/LogMessageJob.php
<?php

namespace App\Jobs;


use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;

class LogMessageJob implements ShouldQueue
{
    use Queueable;

    /**
     * Create a new job instance.
     */
    public function __construct()
    {
        //
    }

    /**
     * Execute the job.
     */
    public function handle(): void
    {
        Log::info('LogMessageJob executed successfully', [
            'timestamp' => now(),
        ]);
    }
}

For simplicity, we are not passing any data to the job. You can modify this to suit your needs. Let’s queue the job in the routes/web.php file:

routes/web.php
<?php

use Illuminate\Support\Facades\Route;

Route::get('/log-message', function () {
    // Dispatch the job to the queue
    dispatch(new \App\Jobs\LogMessageJob());

    return 'Job dispatched!';
});

Now, when you visit the /log-message route, it will dispatch the LogMessageJob to the Redis queue:

curl http://localhost/log-message

You should see the message “Job dispatched!” in your browser.

Testing the Queue Worker

At this point, let’s verify that the job has been properly queued. You can do this by running the following command:

$ docker compose run --rm artisan queue:monitor default
  # Should ouput something like this
  Queue name ............................................................................................ Size / Status
  [redis] default .............................................................................................. [1] OK

This verifies that we have a job queued in the default queue. For this article, we will use the default queue.

Let’s verify that our job can be processed, before queuing more jobs. You can do this by running the following command:

$ docker compose run --rm artisan queue:work --once
  # should output something like this
   INFO  Processing jobs from the [default] queue.

  2025-05-20 10:54:19 App\Jobs\LogMessageJob .................................................................. RUNNING
  2025-05-20 10:54:19 App\Jobs\LogMessageJob ............................................................ 226.54ms DONE

This will process the first job in the queue and then exit. You should see a message in your log file indicating that the job was executed successfully.

$ cat storage/logs/laravel.log
  [2025-05-20 10:55:27] local.INFO: LogMessageJob executed successfully {"timestamp":"2025-05-20 10:55:27"}

Hopefully, everything works as expected so far. If you see any errors, please check the logs for more information.

Running the Queue Worker in the background

Now that we know that our job can be processed, we want to run the queue worker in the background. Docker is great for this, as we can run the queue worker in a separate container.

ℹ️
we can run Artisan commands with the PHP binary inside the container (located at /usr/local/bin/php). For this guide, the purpose is to leverage FrankenPHP’s CLI capabilities to run the queue worker, which can be accessed with /usr/local/bin/frankenphp php-cli

Let’s set up a Service in our Docker Compose setup, that will run the worker as a separate container. Open the docker-compose.yml file and add the following service:

docker-compose.yml
  # New Service
  worker:
    image: laravel-app
    volumes:
      - ".:/app"
    working_dir: /app
    command: "frankenphp php-cli artisan queue:work"
    # Let's make sure that Redis and our Database is up and running as well.
    depends_on: 
      - redis
      - mysql

This will create a new service called worker that will run the queue worker in the background. The depends_on option ensures that the Redis and MySQL services are up and running before starting the worker. You can start the worker by running the following command:

docker compose up -d worker

This will start the worker in the background. You can check the logs of the worker by running:

docker compose logs -f worker

Having an extra terminal open, you can now follow the logs while dispatching jobs. You can do this by running the following command, to dispatch a new job:

curl http://localhost/log-message

You should see the job being processed in the logs of the worker container:

$ docker compose logs -f worker
  worker-1  |   2025-05-20 11:09:58 App\Jobs\LogMessageJob ......................... RUNNING
  worker-1  |   2025-05-20 11:09:58 App\Jobs\LogMessageJob ................... 211.46ms DONE

You can now dispatch as many jobs as you want, and the worker will process them in the background. You can also run multiple workers by running the docker compose up -d worker command multiple times. Each worker will process jobs from the same queue, allowing you to scale your application easily.

ℹ️
As a bonus info, you can use the same approach for scheduling tasks in your Laravel application. You can create a new service in your docker-compose.yml file that runs the schedule:work command. The documentation states that schedule:work can be used for local development, but doesn’t directly mention that it can be used in production. However, it is a great way to run scheduled tasks in the background, without having to set up a cron job.

Laravel Horizon … and FrankenPHP

Running multiple workers is great, but it can be hard to manage them. This is where Laravel Horizon comes in. Horizon provides a beautiful dashboard and code-driven configuration for your Redis queues.

Managing this in a containerized environment is a bit tricky, but it is possible. There are some caveats with FrankenPHP, so if you want to use Horizon without FrankenPHP it is doable by using the php artisan horizon command, referencing the local PHP binary instead.

This particular section will make it possible for you to run Horizon with FrankenPHP.

Installing Horizon

You can install Horizon by running the following command:

composer require laravel/horizon
docker compose run --rm artisan horizon:install

This will install Horizon and publish the configuration file to config/horizon.php. You can customize the configuration file to suit your needs.

ℹ️
You can customize Horizon’s behavior in config/horizon.php or via environment variables (e.g., HORIZON_PREFIX, QUEUE_CONNECTION).

What’s the problem?

Horizon relies on the PHP_BINARY constant to determine which PHP binary to use. Unfortunately, FrankenPHP doesn’t set this constant, leading to a Permission denied error when Horizon attempts to launch workers or supervisors.

$ docker compose run --rm artisan horizon
     
     INFO  Horizon started successfully.

   sh: 1: exec: : Permission denied
   sh: 1: exec: : Permission denied
   sh: 1: exec: : Permission denied
   ...

This is because Horizon tries to run the php command, but PHP_BINARY is empty when using FrankenPHP, so it doesn’t know which binary to execute.

Fixing the problem

The quick fix is to not use FrankenPHP’s php-cli command, but instead use the local PHP binary. But as mentioned, the purpose of this article is to use FrankenPHP for everything, so we need to fix this.

Here’s a workaround to make it work with FrankenPHP:

In your AppServiceProvider, add the following to your boot method:

app/Providers/AppServiceProvider.php
use Laravel\Horizon\SupervisorCommandString;
use Laravel\Horizon\WorkerCommandString;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        SupervisorCommandString::$command = 'exec /usr/local/bin/frankenphp php-cli artisan horizon:supervisor';
        WorkerCommandString::$command = 'exec /usr/local/bin/frankenphp php-cli artisan horizon:work';
    }
}

This will override the default command used by Horizon to run the supervisor and worker commands. Now you can run Horizon with FrankenPHP:

$ docker compose run --rm artisan horizon
     
     INFO  Horizon started successfully.

Putting it into the Docker Compose setup

Now that we have Horizon working with FrankenPHP, we can add it to our Docker Compose setup. Open the docker-compose.yml file and add the following service:

docker-compose.yml
  # New Service
  horizon:
    image: laravel-app
    volumes:
      - ".:/app"
    working_dir: /app
    command: "frankenphp php-cli artisan horizon"
    # Horizon allows for a working healthcheck, so let's add one
    healthcheck:
      test: ["CMD", "frankenphp", "php-cli", "artisan", "horizon:status"]
      timeout: 10s
      retries: 3    
    depends_on: 
      - redis
      - mysql

This will create a new service called horizon that will run the Horizon command in the background. You can start Horizon by running the following command:

docker compose up -d horizon

Accessing the Horizon dashboard is as simple as visiting http://localhost/horizon in your browser. You should see the Horizon dashboard, where you can monitor your queues and jobs.

Laravel Horizon dashboard showing active workers and job metrics
Horizon Dashboard