Skip to main content
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:

Environment variables

This guide adds no environment variables. The deploy guide lists the application’s variables.

Before you begin

Install Octane

Install Octane 2 and set it up for 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:
Caddyfile.octane
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:
app/Support/Visits.php
routes/web.php
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. To build from your own Dockerfile instead, see 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:
2

Deploy

Click Deploy in the undeployed changes bar at the bottom of the page.
What each part of the command does: 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: 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, 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 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: 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 for what else to watch.

Update the Dockerfile

If the application builds from the 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
  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
  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:
    .dockerignore
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:
    Runtime logs
  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:
  2. The stylesheet link uses https://:
  3. The health route that the readiness probe requests:

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

Deploy with the API

The same steps as Start Octane on Sevalla, as calls to the Sevalla API. APP_ID and WEB_ID are the application and its web process from 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 (updateProcessV3) with the start command in entrypoint:
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 (triggerDeploymentV3):
CLI: sevalla apps deployments trigger $APP_ID. MCP: trigger_deployment.
3

Wait for the deployment

Poll 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:
CLI: sevalla apps deployments get $DEPLOYMENT_ID --app-id $APP_ID. MCP: get_deployment.
Then run the checks in 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 (updateApplicationV3) before you deploy.