Modernizing Laravel Twill with Octane
Laravel42
24 June, 2026
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-binaryhits 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 realboolean.pgloadercasts it, but double-checkwhere('active', 1)style queries — usetrue/false. - Case sensitivity: Postgres folds unquoted identifiers to lowercase and string comparisons are case-sensitive. Use
ILIKE(orLOWER(...)) where you relied on MySQL's case-insensitiveLIKE. GROUP BYstrictness: Postgres requires every non-aggregated selected column inGROUP 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
jsonboverjsonfor 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>&1at 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 enginefor Node is harmless; a missingnodebinary is not. composer installmust run before anything that touchesvendor/bin/rrorartisan.
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.yamlcommitted (mtime-only churn is fine)packageManager+enginespinned; Corepack provisioning pnpmspiral/roadrunner-httpon v4; matchingrrbinary- 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 sanememory_limit composer auditclean (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.