> ## 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 with Octane

> Serve Laravel with Octane on FrankenPHP's worker mode on Sevalla: the start command, workers per size, the Caddyfile for HTTPS, measured speed and memory, and state that outlives a request.

Laravel Octane keeps the application in memory between requests. Ingot serves Laravel with FrankenPHP's standard mode, which loads the framework, the configuration and the service providers again for every request; Octane runs FrankenPHP in worker mode, where each worker loads them once and then handles request after request. On Sevalla, you install Octane, add a Caddyfile that trusts Sevalla's proxy, and set the web process's start command: the image Ingot builds is the same.

## At a glance

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

| | |
| - | - |
| Start command | `php artisan optimize && exec php artisan octane:start --server=frankenphp --host=0.0.0.0 --port=$PORT --caddyfile=Caddyfile.octane --workers=2`, the web process's custom start command |
| Health check | `GET /up` returns `200` (readiness probe), unchanged |
| Size | **H1** or larger; set `--workers` for the size as in [Choose the number of workers](#choose-the-number-of-workers) |

### Environment variables

This guide adds no environment variables. The deploy guide lists the [application's variables](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).

## Install Octane

Install Octane 2 and set it up for FrankenPHP:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
composer require laravel/octane
php artisan octane:install --server=frankenphp
```

`octane:install` writes `config/octane.php` and downloads a FrankenPHP binary to the project's root for local development, which it adds to `.gitignore` together with `public/frankenphp-worker.php`, the worker script Octane writes when it starts. Neither goes into the repository: Ingot's image already has FrankenPHP in `/usr/local/bin` (version 1.13.4; Octane needs 1.13.0 or later), and Octane writes the worker script on its first start.

### Trust the Sevalla proxy

Octane starts FrankenPHP with its own Caddyfile, not Ingot's. That Caddyfile doesn't trust Sevalla's proxy, which ends HTTPS before the request reaches the application, so behind it Laravel sees every request as plain HTTP from the proxy:

* Laravel builds `http://` URLs, and browsers block the Vite stylesheet and scripts on the `https://` page as mixed content.
* `$request->ip()` returns the proxy's private address, such as `10.244.19.190`, for every visitor, so rate limits by IP count all visitors together.

Copy Octane's Caddyfile to `Caddyfile.octane` in the project's root and add three things, which Ingot's Caddyfile has too:

```text Caddyfile.octane theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{
	{$CADDY_GLOBAL_OPTIONS}

	admin {$CADDY_SERVER_ADMIN_HOST}:{$CADDY_SERVER_ADMIN_PORT}

	frankenphp {
		worker {
			file "{$APP_PUBLIC_PATH}/frankenphp-worker.php"
			{$CADDY_SERVER_WORKER_DIRECTIVE}
			{$CADDY_SERVER_WATCH_DIRECTIVES}
		}
	}

	servers {
		trusted_proxies static private_ranges
		client_ip_headers CF-Connecting-IP X-Forwarded-For
	}
}

{$CADDY_EXTRA_CONFIG}

{$CADDY_SERVER_SERVER_NAME} {
	log {
		level {$CADDY_SERVER_LOG_LEVEL}

		format filter {
			wrap {$CADDY_SERVER_LOGGER}
			fields {
				request>uri query {
					replace authorization REDACTED
				}
			}
		}
	}

	@forwarded_https {
		remote_ip private_ranges
		header X-Forwarded-Proto https
	}
	vars @forwarded_https https on

	@compressible not path /.well-known/mercure

	route {
		root * "{$APP_PUBLIC_PATH}"
		encode @compressible zstd br gzip

		{$CADDY_SERVER_EXTRA_DIRECTIVES}

		php_server {
			index frankenphp-worker.php
			try_files {path} frankenphp-worker.php
			resolve_root_symlink
			env HTTPS {vars.https}
			env REMOTE_ADDR {client_ip}
		}
	}
}
```

| Addition | What it does |
| - | - |
| `servers` block | Trusts the `X-Forwarded-For` header from private addresses, Sevalla's proxy, and takes the visitor's address from `CF-Connecting-IP`, which Cloudflare sets and a visitor can't. |
| `@forwarded_https` and `vars` | Marks a request as HTTPS when Sevalla's proxy says it arrived over HTTPS. |
| `env HTTPS` and `env REMOTE_ADDR` | Pass both to Laravel, which then builds `https://` URLs and returns the visitor's address from `$request->ip()`, with no trusted proxy settings in Laravel. |

The `{$...}` placeholders are the values Octane fills in when it starts, as in its own Caddyfile in `vendor/laravel/octane/src/Commands/stubs/Caddyfile`. When you update Octane, compare that file with yours.

### Show which server answers

Add a route that reports whether Octane serves it, and how many requests the worker that answers has handled. The count lives in a static property, which is what makes it useful: PHP resets it after every request in the standard mode, and keeps it for the worker's lifetime under Octane:

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

namespace App\Support;

class Visits
{
    public static int $count = 0;
}
```

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

use App\Support\Visits;
use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return view('welcome');
});

Route::get('/server', function () {
    Visits::$count++;

    return [
        'octane' => isset($_SERVER['LARAVEL_OCTANE']),
        'requests_in_this_worker' => Visits::$count,
    ];
});
```

Octane sets `LARAVEL_OCTANE` for the server it starts, which is how Laravel itself checks for Octane. Commit and push the changes, `composer.json`, `composer.lock`, `config/octane.php` and `Caddyfile.octane` included.

## Start Octane on Sevalla

These steps build with [Ingot](https://docs.sevalla.com/applications/builds/ingot.md). To build from your own Dockerfile instead, see [Update the Dockerfile](#update-the-dockerfile).

**1. Set the start command**

On the application's **Processes** tab, click **⋯** on the **Web** process, then click **Update process**. Enter this **Custom start command** and click **Update process**:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
php artisan optimize && exec php artisan octane:start --server=frankenphp --host=0.0.0.0 --port=$PORT --caddyfile=Caddyfile.octane --workers=2
```

**2. Deploy**

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

What each part of the command does:

| Part | Why |
| - | - |
| `php artisan optimize` | Caches the configuration, routes, events and views with the deployment's environment variables, as Ingot's own start script does. Octane loads the cached files once per worker. |
| `exec` | Replaces the shell that Sevalla starts the command with, so Octane receives `SIGTERM` when Sevalla stops the instance. See [Stop Octane cleanly](#stop-octane-cleanly). |
| `--host=0.0.0.0 --port=$PORT` | Octane listens on `127.0.0.1:8000` by default, which Sevalla's proxy can't reach. |
| `--caddyfile=Caddyfile.octane` | The Caddyfile from [Trust the Sevalla proxy](#trust-the-sevalla-proxy). |
| `--workers=2` | The number of workers, each a copy of the application in memory. Without it, the instance runs out of memory: see [Choose the number of workers](#choose-the-number-of-workers). |

Until the start command is set, Ingot's build log warns `Laravel Octane is installed, but Ingot runs FrankenPHP's standard mode`, and the application runs as before. Sevalla passes the web process's start command to Ingot, so the build plan's **Start** row shows the Octane command, `start in ingot.json or INGOT_START_CMD`.

## Choose the number of workers

Octane's default, `--workers=auto`, starts 2 workers for every CPU that FrankenPHP finds. In a Sevalla instance, that's the CPUs of the machine it runs on, not the size's share: 16 in the test, so 32 workers on every size. Each worker holds the whole application, so 32 workers used about 220 MB at rest on **H1** (0.3 GB) and crossed 0.3 GB under load, when Sevalla stopped the instance for running out of memory (`OOMKilled`) and started it again.

Set `--workers` for the size instead. A worker handles one request at a time, and requests to a Laravel application mostly use CPU, so 2 workers per vCPU keep the CPU busy without holding more copies of the application than it needs:

| Size | vCPU | `--workers` |
| - | - | - |
| **H1**, **S1**, **S2** | 0.3 to 1 | `2` |
| **S3** | 2 | `4` |

On **S2**, 4 workers served the same number of requests as 2 and used about 10 MB more. A worker that waits, for a slow API or a `sleep`, holds its place: when every worker is busy, new requests queue, and so does the readiness probe on `/up`. After 3 probes in a row take longer than the probe's timeout, Sevalla stops sending requests to the instance, and visitors get `503` until a worker is free. Queue slow work for a [background worker](https://docs.sevalla.com/guides/php/laravel/queues.md), or raise `--workers` and check the memory on the application's **Analytics** tab.

## Octane compared with the standard mode

Measured on Sevalla with the deploy guide's application, from the same image with and without the Octane start command, for 30 seconds with [k6](https://k6.io) and enough concurrent clients to use the whole CPU of the size. `/up` is Laravel's health route, which renders a short page without the database; `/` is the welcome page, which starts a session and saves it in PostgreSQL:

| Size | Server | `/up`, requests per second | `/`, requests per second | Memory at rest | Memory under load |
| - | - | - | - | - | - |
| **H1** | Standard mode | 90 | 21 | 66 MB | 211 MB |
| **H1** | Octane, 2 workers | 203 | 81 | 112 MB | 189 MB |
| **S1** | Standard mode | 159 | 28 | 65 MB | 208 MB |
| **S1** | Octane, 2 workers | 326 | 147 | 112 MB | 189 MB |
| **S2** | Standard mode | 327 | 27 | 66 MB | 230 MB |
| **S2** | Octane, 2 workers | 502 | 295 | 112 MB | 189 MB |
| **S3** | Standard mode | 712 | 43 | 67 MB | Not measured |
| **S3** | Octane, 4 workers | 1,023 | 372 | 125 MB | 204 MB |

Octane served more requests on every size, because a worker skips loading the framework for each request: 1.4 to 2.3 times as many on `/up`, and 4 to 11 times as many on `/`. In the standard mode, `/` stopped at about 30 requests per second from **S1** up, with the application's CPU less than half used; it opens a new database connection for every request, while an Octane worker keeps its own. On **S3**, the standard mode's slow answers on `/` made the readiness probe time out, and 38% of the requests failed while Sevalla took the instance out of service. On **S2** and **S3**, Octane didn't use the whole CPU with the clients the test had, so it can serve more than the table shows.

Octane holds more memory at rest, one copy of the application per worker, and about the same under load: the standard mode loads a copy for each request in progress.

## Stop Octane cleanly

Sevalla runs the start command with `/bin/sh -l -c`. `exec` replaces that shell with Octane's PHP process, so Octane receives the `SIGTERM` that Sevalla sends when it stops an instance during a deployment, and stops FrankenPHP, which finishes the requests in progress before it exits. Octane handles the signal with the `pcntl` extension, which Ingot installs for Laravel. In a test with 12-second requests arriving every 2 seconds during a deployment, every request returned `200`.

Without `exec`, the shell receives the signal and ignores it, and Sevalla stops the instance 30 seconds after the shutdown began, cutting off the requests in progress.

## State that outlives a request

A worker keeps everything that the application creates outside a request for its lifetime: static properties, singletons, and objects that a singleton holds. Request `/server` a few times: under Octane, `requests_in_this_worker` grows with each request that the same worker answers, while in the standard mode it's always `1`. The same happens to data you didn't mean to keep:

* **Static properties and arrays.** A static array that a request adds to grows with every request and is visible to the next visitor that the worker serves. Keep per-request data in the request, the session or the cache.
* **Singletons built from the request.** A singleton that receives the request, the configuration or the container in its constructor keeps the first request's values. Resolve them where you use them, or pass them to the method that needs them.
* **Memory that grows.** Octane restarts a worker after 500 requests (`--max-requests`, `500` by default), which returns memory that a leak holds. Lower it if a worker's memory keeps growing.
* **Database connections.** Each worker keeps its connection to the database open between requests, where the standard mode opens one for every request. With 4 workers, PostgreSQL listed 5 connections from the application: one per worker and one from the command that counted them. Count workers times instances against the database's connection limit.

Octane resets Laravel's own state between requests: the request, the session, the authenticated user, queued cookies, the locale, the configuration and the `array` cache store. See [Laravel's Octane documentation](https://laravel.com/docs/octane#dependency-injection-and-octane) for what else to watch.

## 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, the same start command runs Octane from your image too. Make three changes:

1. Install the `pcntl` extension, which Octane needs to stop FrankenPHP when the instance gets `SIGTERM`. Change the first `RUN` line:

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

2. Write Octane's worker script into the image. Your image's files belong to root, so Octane can't write it when it starts. Add a line to the `RUN` line that installs the Composer packages:

   ```dockerfile Dockerfile theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   RUN composer install --no-dev --no-interaction --no-progress --optimize-autoloader \
       && php artisan optimize --except=config \
       && cp vendor/laravel/octane/src/Commands/stubs/frankenphp-worker.php public/
   ```

3. Keep the FrankenPHP binary that `octane:install` downloaded for your computer out of the image. Git ignores it, so Sevalla's builds never see it, but a `docker build` on your computer would copy it into the image, which grew from 77 MB to 172 MB in a test. Add a line to `.dockerignore`:

   ```text .dockerignore theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   frankenphp
   ```

Without the second change, the web process fails to start with `copy(/app/public/frankenphp-worker.php): Failed to open stream: Permission denied`. The image's `CMD` still starts FrankenPHP's standard mode with the deploy guide's `Caddyfile`; the web process's start command replaces it.

## Verify

1. When the deployment's status is **Deployed**, click the **Logs** tab. The web process's runtime logs show `php artisan optimize` caching the configuration, then Octane's start:

   ```text Runtime logs theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
      INFO  Server running….

     Local: http://0.0.0.0:8080

     Press Ctrl+C to stop the server
   ```

2. Open `/server` on the application's domain, for example `https://storefront-x7k2m.sevalla.app/server`. It shows `"octane":true`.

3. Open the welcome page. Its styles load, which shows that Laravel builds `https://` URLs behind Sevalla's proxy.

### Check from a terminal

These checks need no dashboard. Use your application's domain:

1. Octane answers:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s https://storefront-x7k2m.sevalla.app/server | jq .octane
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   true
   ```

2. The stylesheet link uses `https://`:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s https://storefront-x7k2m.sevalla.app | grep -o '<link rel="stylesheet"[^>]*>'
   ```

   ```html theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   <link rel="stylesheet" href="https://storefront-x7k2m.sevalla.app/build/assets/app-6jSCucsl.css" />
   ```

3. The health route that the readiness probe requests:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s -o /dev/null -w '%{http_code}\n' https://storefront-x7k2m.sevalla.app/up
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   200
   ```

## Go back to the standard mode

To serve the application with FrankenPHP's standard mode again, clear the web process's **Custom start command** on the **Processes** tab and deploy with **Deploy now**, which builds a new image. Ingot writes the start command into the image it builds, so a restart without a build keeps running Octane.

## Troubleshoot

| Symptom | Cause | Fix |
| - | - | - |
| The web process restarts under load, and the instance's memory reaches the size's limit | Octane started 2 workers per CPU of the machine, 32 in the test, because the start command has no `--workers`. | Add `--workers=2`, or the number for the size from [Choose the number of workers](#choose-the-number-of-workers), then deploy. |
| The page has no styles, the browser reports mixed content, and `$request->ip()` returns a `10.` address | Octane runs with its own Caddyfile, which doesn't trust Sevalla's proxy. | Add `--caddyfile=Caddyfile.octane` with the file from [Trust the Sevalla proxy](#trust-the-sevalla-proxy). |
| The web process doesn't start, and its runtime logs show `copy(/app/public/frankenphp-worker.php): Failed to open stream: Permission denied` | The image built from your Dockerfile has no worker script, and Octane can't write one to the root-owned `public/` folder. | Copy the worker script during the build, as in [Update the Dockerfile](#update-the-dockerfile). |

## Deploy with the API

The same steps as [Start Octane on Sevalla](#start-octane-on-sevalla), as calls to the [Sevalla API](https://docs.sevalla.com/developer-tools/api.md). `APP_ID` and `WEB_ID` are the application and its web process 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 Octane changes first: the deployment builds the branch as it is.

**1. Set the start command**

[Update process](https://api-docs.sevalla.com/v3/applications/update-process) (`updateProcessV3`) with the start command in `entrypoint`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X PATCH "$API/applications/$APP_ID/processes/$WEB_ID" -H "$H" -H "Content-Type: application/json" \
  -d '{"entrypoint": "php artisan optimize && exec php artisan octane:start --server=frankenphp --host=0.0.0.0 --port=$PORT --caddyfile=Caddyfile.octane --workers=2"}' \
  | jq -r .entrypoint
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
php artisan optimize && exec php artisan octane:start --server=frankenphp --host=0.0.0.0 --port=$PORT --caddyfile=Caddyfile.octane --workers=2
```

CLI: `sevalla apps processes update $WEB_ID --app-id $APP_ID --command 'php artisan optimize && exec php artisan octane:start --server=frankenphp --host=0.0.0.0 --port=$PORT --caddyfile=Caddyfile.octane --workers=2'`. MCP: `sevalla_api_update` with `updateProcessV3` 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"
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
success
```

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

Then run the checks in [Check from a terminal](#check-from-a-terminal), with `https://$APP_HOST` as the domain. To build from the Dockerfile, send `{"build_type": "dockerfile", "dockerfile_path": "Dockerfile", "docker_context": "."}` to [Update application](https://api-docs.sevalla.com/v3/applications/update-application) (`updateApplicationV3`) before you deploy.


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