> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sevalla.com/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> These are the docs for Sevalla, a platform for deploying applications, databases, static sites, object storage and load balancers. The dashboard is at https://app.sevalla.com.
> Bold text in steps is an exact dashboard label.
> To act on a Sevalla account, prefer the Sevalla MCP server (https://mcp.sevalla.com/mcp, see https://docs.sevalla.com/ai-agents/overview.md), then the Sevalla CLI (https://docs.sevalla.com/developer-tools/cli.md), then the REST API (https://api-docs.sevalla.com, OpenAPI spec at https://api.sevalla.com/v3/openapi.json).
> Every page index is at https://docs.sevalla.com/llms.txt.

# Run Laravel queues and scheduled tasks

> Run Laravel queued jobs in a Sevalla background worker on the database or Redis, stop the worker cleanly on every deployment, and run the scheduler with schedule:work.

A queued job is work that runs outside the request, such as sending an order confirmation. Laravel keeps queued jobs in a queue, the database by default, and `php artisan queue:work` runs them in a background worker: a process that runs from the same image as the web process with its own start command. Laravel's scheduler runs in a second background worker.

## At a glance

What this guide adds to the application from [Deploy a Laravel application](https://docs.sevalla.com/guides/php/laravel/deploy.md#at-a-glance):

| | |
| - | - |
| Processes | `Queue worker` background worker (`exec php artisan queue:work`); `Scheduler` background worker (`exec php artisan schedule:work`, always 1 instance) |
| Services | The deploy guide's PostgreSQL database holds the queue; optionally a Redis or Valkey database over an internal connection |
| Size | **H1** for each worker |

### Environment variables

| Variable | Required | Available during | Secret | Value |
| - | - | - | - | - |
| `QUEUE_CONNECTION` | No | Runtime | No | `redis` for the Redis queue. Without it, Laravel 13 uses `database`. |
| `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD`, `REDIS_URL` | With Redis | Runtime | `REDIS_PASSWORD` and `REDIS_URL` | Added by the internal connection to the Redis database |

The workers read the same variables as the web process, listed in [Deploy a Laravel application](https://docs.sevalla.com/guides/php/laravel/deploy.md#environment-variables).

## Before you begin

* A Laravel application deployed on Sevalla, as in [Deploy Laravel](https://docs.sevalla.com/guides/php/laravel/deploy.md).

## Create a job

Create a job class:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
php artisan make:job SendOrderConfirmation
```

Replace the generated class with a job that takes an order ID and logs a line:

```php app/Jobs/SendOrderConfirmation.php theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
<?php

namespace App\Jobs;

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

class SendOrderConfirmation implements ShouldQueue
{
    use Queueable;

    public function __construct(public int $orderId)
    {
    }

    public function handle(): void
    {
        Log::info("Sending the confirmation for order {$this->orderId}");
    }
}
```

Queue it from a controller or anywhere else with `SendOrderConfirmation::dispatch($order->id)`. Laravel 13's queue connection is `database` unless `QUEUE_CONNECTION` says otherwise, and the deploy guide's migrations created the `jobs` table, so the job waits in PostgreSQL with no other configuration. Commit and push the job.

## Run the worker on Sevalla

**1. Create the background worker**

On the application's **Processes** tab, click **Create process**. Choose **Background worker**, enter `Queue worker` as the **Name** and this **Custom start command**:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
exec php artisan queue:work
```

Choose a **Resource** and click **Create process**.

**2. Deploy**

Click **Deploy** in the undeployed changes bar at the bottom of the page.

![The Create process sheet with Background worker selected, the Name and Custom start command fields, the resource and the scaling settings](https://assets.sevalla.com/screenshots/light/app-processes-worker-create.png)

One worker instance runs one job at a time. To run more at once, add instances in the process's **Scaling** section; each instance takes its own jobs from the queue. See [Scaling](https://docs.sevalla.com/applications/scaling.md).

### Stop the worker cleanly on deployments

Sevalla runs a custom start command with `/bin/sh -l -c`. `exec` replaces that shell with the worker, so the worker receives the `SIGTERM` that Sevalla sends when it stops an instance, for example during a deployment: it finishes the job it's running, logs `Worker STOPPED Interrupted`, and exits. The worker handles the signal with PHP's `pcntl` extension, which Ingot installs for Laravel.

Without `exec`, the shell receives the signal and ignores it. The worker keeps taking jobs until Sevalla stops the instance, 30 seconds after the shutdown began, often in the middle of a job. That job stays reserved until `retry_after` (90 seconds) has passed, and then fails with `App\Jobs\SendOrderConfirmation has been attempted too many times.`

Sevalla sends `SIGTERM` 5 seconds after the shutdown begins, so a job has about 25 seconds to finish. Split longer work into smaller jobs, or start the worker with `--tries=3` so that a job stopped with its instance runs again. See [How processes run](https://docs.sevalla.com/applications/processes.md#how-processes-run).

## Use Redis for the queue

The database queue works without another service, but every worker queries the `jobs` table for new jobs, every 3 seconds while the queue is empty. A Redis queue takes that load off PostgreSQL.

**1. Create the database**

In the sidebar, click **Databases**, then click **Create database**. Under **Database**, choose **Redis**, or **Valkey**: this guide works with both. Enter `storefront-redis` as the **Display name**, choose the same **Data center** as the application and a **Size**, then click **Create database**.

**2. Connect it to the application**

In the sidebar, click **Applications**, then click the application. Click the **Networking** tab. Under **Connected services**, click **Add internal connection**. Choose `storefront-redis` as the **Service**, turn on **Add environment variables to the application**, and click **Add internal connection**. Sevalla adds `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD` and `REDIS_URL`, the names Laravel's `config/database.php` reads. See [Connect an application](https://docs.sevalla.com/databases/connect.md#connect-an-application).

**3. Switch the queue connection**

Click the **Environment variables** tab, then click **Add environment variables**. Add `QUEUE_CONNECTION` with the value `redis`, and click **Add environment variables**.

**4. Deploy**

Click the **Overview** tab, then click **Deploy now** under **Recent deployments**. Check the **Branch** and click **Deploy**.

Laravel talks to Redis with the `redis` PHP extension (phpredis), its default client, which Ingot installs for Laravel. It reads the `valkey://` URL that Sevalla adds for a Valkey database too. Jobs still waiting in the `jobs` table stay there: let the worker finish them before you switch, or run `php artisan queue:work database --stop-when-empty` in the [web terminal](https://docs.sevalla.com/applications/web-terminal.md) to work through them.

## Run the scheduler

Laravel's scheduler runs the tasks you define in `routes/console.php`. Add one that runs every minute, so you can see it work:

```php routes/console.php theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
<?php

use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;

Artisan::command('inspire', function () {
    $this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');

Schedule::command('inspire')->everyMinute();
```

Commit and push the change, then run the scheduler in its own background worker:

**1. Create the background worker**

On the application's **Processes** tab, click **Create process**. Choose **Background worker**, enter `Scheduler` as the **Name** and `exec php artisan schedule:work` as the **Custom start command**. Choose a **Resource**, keep the instances at 1, and click **Create process**.

**2. Deploy**

Click **Deploy** in the undeployed changes bar at the bottom of the page.

Keep the scheduler at 1 instance: each instance runs every task. Use a background worker with `schedule:work` rather than a [cron job](https://docs.sevalla.com/applications/processes.md#cron-jobs) that runs `php artisan schedule:run` every minute:

* `schedule:work` checks the schedule at the start of every minute in a process that's already running. A cron job starts a new instance for each run and boots Laravel before it checks: a cron job that runs only the scheduler takes 4 to 9 seconds a run.
* `schedule:work` starts each minute's tasks in a process of their own, so a task that takes 5 minutes doesn't hold up the next minute's tasks. A cron job skips its next run while the previous one is still active, the default of its **If the previous run is still active** setting.

A cron job costs only for the seconds each run takes, so when every task is short and a few seconds' delay doesn't matter, you can create a **Cron job** with the start command `php artisan schedule:run` and the schedule `* * * * *` instead.

## Update the Dockerfile

If the application builds from the [Dockerfile](https://docs.sevalla.com/guides/php/laravel/deploy.md#deploy-with-a-dockerfile) in the deploy guide, install the `pcntl` extension, which lets the worker stop cleanly, and the `redis` extension for the Redis queue. Ingot installs both on its own. Change the first `RUN` line:

```dockerfile Dockerfile theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
RUN install-php-extensions pdo_pgsql pcntl redis \
```

The `ldd` line finds the libraries the `redis` extension loads, so the final stage installs them with no other change. The start commands are the same with both build strategies: the image has `/bin/sh`, and `php` is on the `PATH` of the login shell Sevalla starts them with.

## Verify

1. When the deployment's status is **Deployed**, click the **Web terminal** tab, and in the shell that opens in the **Web** process, queue a job:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   php artisan tinker --execute 'App\Jobs\SendOrderConfirmation::dispatch(42);'
   ```

2. Click the **Logs** tab and look at the `Queue worker` process's runtime logs. They show the job running, the line it logs, and `DONE`:

   ```text Runtime logs theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   2026-10-29 18:40:10 App\Jobs\SendOrderConfirmation ................. RUNNING
   [2026-10-29 18:40:10] production.INFO: Sending the confirmation for order 42
   2026-10-29 18:40:10 App\Jobs\SendOrderConfirmation ............. 9.60ms DONE
   ```

3. The `Scheduler` process's runtime logs show a line every minute:

   ```text Runtime logs theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   2026-10-29 18:55:00 Running ['artisan' inspire] .............. 414.63ms DONE
   ```

If no job runs, read the worker's runtime logs, and check that `QUEUE_CONNECTION` is the same for the web process and the worker: both read it from the application's environment variables. To see jobs that failed, run `php artisan queue:failed` in the web terminal.

### Check from a terminal

These checks need no dashboard. `API`, `H`, `APP_ID` and `WEB_ID` are set as in [Deploy with the API](#deploy-with-the-api).

1. Queue a job in the web process with [Run a command in a process](https://api-docs.sevalla.com/v3/applications/run-a-command-in-a-process) (`execProcessCommandV3`), the API's counterpart of the web terminal. The command runs without a shell, as a list of arguments, and the key needs write access to the application:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s -X POST "$API/applications/$APP_ID/processes/$WEB_ID/exec" -H "$H" -H "Content-Type: application/json" \
     -d '{"command": ["php", "artisan", "tinker", "--execute", "App\\Jobs\\SendOrderConfirmation::dispatch(42);"], "timeout": 30}' \
     | jq -c .
   ```

   ```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   {"stdout":"","stderr":"","exit_code":0}
   ```

2. A few seconds later, the line the job logs, from [Get application runtime logs](https://api-docs.sevalla.com/v3/applications/get-application-runtime-logs) (`getApplicationRuntimeLogsV3`), which searches the last hour by default:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s "$API/applications/$APP_ID/runtime-logs?search=order%2042" -H "$H" | jq -r '.[].message'
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   [2026-10-30 05:24:35] production.INFO: Sending the confirmation for order 42
   ```

3. The scheduler's last run of `inspire`:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s "$API/applications/$APP_ID/runtime-logs?search=inspire%5D&limit=1" -H "$H" | jq -r '.[].message'
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
     2026-10-30 05:24:00 Running ['artisan' inspire] .............. 423.75ms DONE
   ```

CLI: `sevalla apps processes exec $WEB_ID --app-id $APP_ID --timeout 30 -- php artisan tinker --execute 'App\Jobs\SendOrderConfirmation::dispatch(42);'`, then `sevalla apps logs runtime $APP_ID --search "order 42"`. MCP: `sevalla_api_update` with `execProcessCommandV3`, and `search_runtime_logs`.

## Deploy with the API

The same steps as [Run the worker on Sevalla](#run-the-worker-on-sevalla) and [Run the scheduler](#run-the-scheduler), as calls to the [Sevalla API](https://docs.sevalla.com/developer-tools/api.md). `APP_ID`, `WEB_ID` and `H1_ID` are the application, its web process and the **H1** size from [Deploy with the API](https://docs.sevalla.com/guides/php/laravel/deploy.md#deploy-with-the-api) in the deploy guide, with `API` and `H` set as there. Push the job and the schedule first: the deployment builds the branch as it is.

**1. Create the two workers**

[Create process](https://api-docs.sevalla.com/v3/applications/create-process) (`createProcessV3`) with `type` `worker` needs the size and a `scaling_strategy`. Keep `exec` in both start commands:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for worker in "Queue worker|exec php artisan queue:work" "Scheduler|exec php artisan schedule:work"; do
  IFS='|' read -r name command <<< "$worker"
  curl -s -X POST "$API/applications/$APP_ID/processes" -H "$H" -H "Content-Type: application/json" \
    -d "$(jq -n --arg name "$name" --arg command "$command" --arg size "$H1_ID" \
      '{display_name: $name, type: "worker", entrypoint: $command, resource_type_id: $size,
        scaling_strategy: {type: "manual", config: {instanceCount: 1}}}')" \
    | jq -c '{display_name, entrypoint}'
done
```

CLI: `sevalla apps processes create $APP_ID --name "Queue worker" --type worker --resource-type $H1_ID --command "exec php artisan queue:work"`, and the same for `Scheduler`. MCP: `sevalla_api_create` with `createProcessV3` and the same body.

**2. Deploy**

[Trigger deployment](https://api-docs.sevalla.com/v3/applications/trigger-deployment) (`triggerDeploymentV3`):

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DEPLOYMENT_ID=$(curl -s -X POST "$API/applications/$APP_ID/deployments" -H "$H" -H "Content-Type: application/json" \
  -d '{}' | jq -r .id)
```

CLI: `sevalla apps deployments trigger $APP_ID`. MCP: `trigger_deployment`.

**3. Wait for the deployment**

Poll [Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`) until the status is final: `success`, `failed`, `cancelled` or `skipped`. The loop keeps polling through a failed request and stops after 30 minutes:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for i in $(seq 1 120); do
  STATUS=$(curl -s "$API/applications/$APP_ID/deployments/$DEPLOYMENT_ID" -H "$H" | jq -r '.status // empty')
  case $STATUS in success|failed|cancelled|skipped) break ;; esac
  sleep 15
done
echo "$STATUS"
```

CLI: `sevalla apps deployments get $DEPLOYMENT_ID --app-id $APP_ID`. MCP: `get_deployment`.

To move the queue to Redis, create and connect the database and add its variables as in [Cache Laravel with Redis](https://docs.sevalla.com/guides/php/laravel/caching.md#deploy-with-the-api), with `QUEUE_CONNECTION=redis` in place of `CACHE_STORE=redis`, then deploy. Then run the checks in [Check from a terminal](#check-from-a-terminal).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.