Skip to main content

Modernizing Laravel Twill with Octane

Laravel42 - Software Design

Laravel42

24 June, 2026

Laravel twill modernization blog header

We recently took our marketing site — a Laravel 13 app running the Twill CMS — and gave it a top-to-bottom modernization: a high-performance application server, a fresh JavaScript toolchain, a move from MySQL to PostgreSQL, tighter security headers, and a clean zero-downtime deploy pipeline.

This post is the tutorial we wish we'd had. Every section is a self-contained upgrade you can apply to your own Laravel/Twill project, complete with the gotchas that cost us an afternoon so they don't cost you one.

The stack we landed on:

Layer Before After
PHP 8.2, FPM 8.5, Octane on RoadRunner
Frontend Node 18, npm Node 26, pnpm 11.8 via Corepack
Database MySQL PostgreSQL (same server)
Edge Cloudflare (Tunnel, CSP, DNS SSL)

1. Going high-performance with Laravel Octane + RoadRunner

Traditional PHP boots the entire framework on every request. Octane keeps your app in memory between requests using a long-lived worker, which is dramatically faster — but it also means application state now leaks between requests unless you're careful. That distinction is the whole story of this section.

Install

composer require laravel/octane spiral/roadrunner-cli spiral/roadrunner-http
php artisan octane:install --server=roadrunner

Point config/octane.php at RoadRunner (env-overridable):

'server' => env('OCTANE_SERVER', 'roadrunner'),

Run it locally:

php artisan octane:start --server=roadrunner --host=127.0.0.1 --port=8000

Gotcha: singletons that build themselves destructively

Twill registers its admin navigation and its block collection once, into process-global registries. Under FPM that's fine (fresh process per request). Under Octane, the first request builds them and every request after gets an empty shell — the backend modules vanish from the sidebar, and block rendering 500s with newInstance() on null.

The fix is to warm those bindings so a single instance survives across Octane's per-request sandbox:

// config/octane.php
'warm' => [
    ...Octane::defaultServicesToWarm(),

    // Twill's block manager consumes process-global static registries as it
    // builds (it unset()s them). Warming keeps one shared instance per worker
    // so block rendering doesn't 500 after the first request.
    \A17\Twill\TwillBlocks::class,

    // Admin nav links are registered once per worker in AppServiceProvider::boot().
    // Warming keeps the populated singleton alive across requests.
    \A17\Twill\TwillNavigation::class,
],

If you register your own singletons in a service provider's boot() and rely on their populated state, add them here too.

Gotcha: .rr.yaml must exist and be writable

RoadRunner reads a .rr.yaml, and Octane touch()es base_path('.rr.yaml') on boot. Keep it minimal — Octane injects workers/address/static-path via CLI flags:

# .rr.yaml
version: "3"

http:
  static:
    calculate_etag: true

Commit this file. We originally gitignored it and generated it at deploy time, which led to "Permission denied" errors when the deploy user couldn't write to the app root. Since Octane only updates its mtime (never the contents), committing it causes no git churn and guarantees it ships with correct ownership.


2. Refreshing the JavaScript toolchain: Node 26 + pnpm via Corepack

We pinned the toolchain in package.json so every machine — laptop, CI, server — uses the same versions:

{
  "packageManager": "pnpm@11.8.0",
  "engines": {
    "node": ">=26",
    "pnpm": ">=11"
  }
}

Corepack (bundled with Node) reads packageManager and provisions the exact pnpm version automatically — no global pnpm install drift:

corepack enable
corepack prepare pnpm@11.8.0 --activate
pnpm install
pnpm run build      # vite build

Gotcha: pnpm v11 blocks build scripts by default

pnpm 11 refuses to run dependency post-install scripts unless you allowlist them (a supply-chain safety default). If a native package (e.g. esbuild) needs its postinstall, you'll see ERR_PNPM_IGNORED_BUILDS. Allow specific ones in pnpm-workspace.yaml:

# pnpm-workspace.yaml
onlyBuiltDependencies:
  - esbuild
  - core-js

Gotcha: non-interactive shells on the server

CI and deploy shells don't have your ~/.zshrc PATH, and Corepack will try to prompt before downloading pnpm — which hangs a deploy. Always export this in automation:

export COREPACK_ENABLE_DOWNLOAD_PROMPT=0

3. Upgrading RoadRunner HTTP to v4

spiral/roadrunner-http v4 is a clean major bump. Update the constraint and let Composer resolve the matching server binary:

// composer.json
"spiral/roadrunner-http": "^4.1"
composer update spiral/roadrunner-http spiral/roadrunner-cli
./vendor/bin/rr get-binary     # fetches the matching `rr` binary
php artisan octane:start --server=roadrunner   # smoke test

Tip: rr get-binary hits the GitHub API, which is rate-limited on shared CI IPs (HTTP 403). In Docker, copy the binary from the official image instead: COPY --from=ghcr.io/roadrunner-server/roadrunner:2025.1.15 /usr/bin/rr /usr/local/bin/rr


4. Switching from MySQL to PostgreSQL (same server)

We moved off MySQL to PostgreSQL — kept on the same VPS, no managed service — for first-class JSONB, stricter typing, better full-text search, and CTE/window-function ergonomics that Twill's nested-set and activity-log queries benefit from.

Install Postgres alongside MySQL

You don't have to remove MySQL to switch; install Postgres next to it and migrate when ready.

sudo apt-get update
sudo apt-get install -y postgresql postgresql-contrib

sudo -u postgres psql <<'SQL'
CREATE ROLE laravel42 WITH LOGIN PASSWORD 'a-strong-password';
CREATE DATABASE laravel42 OWNER laravel42;
GRANT ALL PRIVILEGES ON DATABASE laravel42 TO laravel42;
SQL

On a panel (Ploi/Forge), use its "Add database" UI and just pick PostgreSQL as the type — it provisions the role/db for you.

Point Laravel at Postgres

DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=laravel42
DB_USERNAME=laravel42
DB_PASSWORD=a-strong-password

Laravel ships a pgsql connection out of the box in config/database.php; no code change is needed beyond the env. Make sure the PHP extension is present:

sudo apt-get install -y php8.5-pgsql
php -m | grep pdo_pgsql

Move the data with pgloader

For a one-shot schema and data migration from MySQL, pgloader is the gold standard — it maps types, auto-increment → identity, and tinyint(1)boolean automatically:

sudo apt-get install -y pgloader

pgloader \
  mysql://laravel42:mysqlpass@127.0.0.1/laravel42 \
  postgresql://laravel42:a-strong-password@127.0.0.1/laravel42

Prefer a clean schema from your migrations and only copy rows? Run php artisan migrate against the empty Postgres DB first, then pgloader with --with "data only".

Gotchas when porting an existing app

  • Booleans: MySQL stores tinyint(1); Postgres has a real boolean. pgloader casts it, but double-check where('active', 1) style queries — use true/false.
  • Case sensitivity: Postgres folds unquoted identifiers to lowercase and string comparisons are case-sensitive. Use ILIKE (or LOWER(...)) where you relied on MySQL's case-insensitive LIKE.
  • GROUP BY strictness: Postgres requires every non-aggregated selected column in GROUP BY. MySQL's lax mode let you skip them.
  • Auto-increment after import: sequences can lag behind copied IDs. Reset them so new inserts don't collide:
SELECT setval(
  pg_get_serial_sequence('twill_users', 'id'),
  (SELECT MAX(id) FROM twill_users)
);
-- repeat per table, or script it across all tables
  • JSON columns: prefer jsonb over json for indexing and operators; Laravel's $casts = ['payload' => 'array'] works the same.

Run the test suite against Postgres before flipping production, and keep MySQL around (stopped) for a day or two as a rollback path.


5. Security & UX hardening

Content Security Policy

We added a CSP via middleware, running in report-only first so we could observe violations before enforcing:

$response->headers->set('Content-Security-Policy-Report-Only', $policy);

A real-world lesson: if you're behind Cloudflare with Page Shield or Bot Fight Mode enabled, Cloudflare injects its own scripts (/cdn-cgi/...) at the edge. Those show up as CSP violations and "deprecated feature" console warnings that aren't from your app. Test against your origin before blaming your own policy.

Contact form: consent + repopulation

We added a required privacy-consent checkbox (validated server-side) and made the form repopulate on validation failure:

// ContactController validation
'privacy_consent' => ['required', 'accepted'],
{{-- repopulate after a failed submit --}}
<input type="text" name="message" value="{{ old('message') }}">
<input type="checkbox" name="privacy_consent" required>

Don't forget the migration:

$table->boolean('privacy_consent')->default(false);

A captcha rule that fails gracefully

External validation services go down. Wrap network calls so a transient outage doesn't hard-block legitimate users:

try {
    $response = Http::asForm()->post($verifyUrl, [...]);
} catch (ConnectionException $e) {
    return; // fail open on provider outage, log it
}

6. Testing & dependency hygiene

Make the suite pass under load

Long-lived/in-memory test runs can exhaust PHP's default memory. Bump it in phpunit.xml, and fake outbound HTTP so tests never hit the network:

<ini name="memory_limit" value="512M"/>
protected function setUp(): void
{
    parent::setUp();
    Http::fake(); // no real captcha/analytics calls in tests
}

Audit dependencies

composer audit

Some transitive packages are simply abandoned (we inherited a few azure-storage-* ones via Twill). Rather than fail CI on noise you can't fix, downgrade abandoned reporting:

// composer.json
"config": {
    "audit": { "abandoned": "report" }
}

7. Cleaning large files out of Git history

We'd accidentally committed a DB dump and a storage.tar.gz — and once a blob is in history, git rm doesn't shrink the repo or unblock pushes. Use git-filter-repo:

# Back up first, then rewrite history removing the blobs
git filter-repo --path database/dumps/laravel42.sql --path storage.tar.gz --invert-paths
git push --force-with-lease --all

Then prevent recurrence:

/database/dumps
*.sql
*.bundle

8. Zero-downtime deploys on Ploi

Ploi manages the VPS; Octane runs as a long-lived daemon, and the deploy script rebuilds and gracefully reloads workers. The ordering here is load-bearing — get it wrong and a first deploy fails because vendor/ doesn't exist yet.

cd /home/laravel42-xxxx/laravel42.com

git pull origin main

# 1) Dependencies FIRST — everything below needs vendor/
composer install --no-interaction --prefer-dist --optimize-autoloader --no-dev

# 2) Frontend assets (build before maintenance: live app is unaffected)
export COREPACK_ENABLE_DOWNLOAD_PROMPT=0
export NVM_DIR="$HOME/.nvm"; [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
corepack enable 2>/dev/null || true
corepack prepare pnpm@11.8.0 --activate
pnpm install --frozen-lockfile --prod=false
pnpm run build

# 3) RoadRunner bits for Octane
[ -f .rr.yaml ] || printf 'version: "3"\nhttp:\n  static:\n    calculate_etag: true\n' > .rr.yaml
[ -x ./rr ] || ./vendor/bin/rr get-binary

# 4) Storage symlink + DB
php artisan storage:link || true
php artisan migrate --force

# 5) Rebuild ALL caches (not just routes)
php artisan optimize:clear
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache

# 6) Reload workers LAST, so they boot the new code + caches
! php artisan octane:status || php artisan octane:reload

echo "🚀 Application deployed!"

The long-running Octane daemon

The deploy script only reloads Octane — something has to keep it running. In Ploi, enable its built-in Octane support, or add a Daemon:

Command:   /usr/bin/php8.5 artisan octane:start --server=roadrunner --host=127.0.0.1 --port=8000 --workers=auto --max-requests=500
Directory: /home/laravel42-xxxx/laravel42.com
User:      laravel42-xxxx
Processes: 1

Then proxy nginx at Octane and set OCTANE_HTTPS=true (TLS terminates at nginx):

location / {
    proxy_pass http://127.0.0.1:8000;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
}

Lessons from the deploy logs

  • Surface stderr. Panels often log only stdout, so a failed deploy shows an empty log. Add exec 2>&1 at the top of the script and you'll actually see the error.
  • Install Node on the server (or build assets in CI and ship public/build). A [WARN] Unsupported engine for Node is harmless; a missing node binary is not.
  • composer install must run before anything that touches vendor/bin/rr or artisan.

9. SSL via the Cloudflare DNS challenge

For a wildcard cert (or to issue before pointing traffic at the box), use the DNS-01 ACME challenge with a scoped Cloudflare API token — not the Global API key.

Create a token at dash.cloudflare.com → API Tokens with:

  • Zone → DNS → Edit
  • Zone → Zone → Read
  • Scoped to the single zone.

Verify and use it:

curl -s -H "Authorization: Bearer $CF_Token" \
  https://api.cloudflare.com/client/v4/user/tokens/verify   # "status":"active"

export CF_Token="..."
acme.sh --issue --dns dns_cf -d laravel42.com -d '*.laravel42.com'

A scoped token limits the blast radius to DNS edits on one zone if it ever leaks.


Wrap-up: the upgrade checklist

  • Octane + RoadRunner installed; stateful singletons added to warm
  • .rr.yaml committed (mtime-only churn is fine)
  • packageManager + engines pinned; Corepack provisioning pnpm
  • spiral/roadrunner-http on v4; matching rr binary
  • Migrated MySQL → PostgreSQL (pgloader); sequences reset; suite green on pgsql
  • CSP (report-only → enforce); contact-form consent + graceful captcha
  • Test suite green with Http::fake() and a sane memory_limit
  • composer audit clean (abandoned → report)
  • Large blobs purged from Git history; ignores in place
  • Ploi deploy script ordered correctly; Octane daemon + nginx proxy
  • Wildcard SSL via a scoped Cloudflare DNS token

Each of these is independently valuable — but together they turn a stock Laravel/Twill site into a fast, resilient, reproducible deployment. Happy shipping.