Ir al contenido principal

Modernizando Laravel Twill con Octane

Laravel42 - Software Design

Laravel42

24 June, 2026

Laravel twill modernization blog header

Hace poco tomamos nuestro sitio de marketing — una app Laravel 13 con el CMS Twill — y le hicimos una modernización de arriba a abajo: un servidor de aplicación de alto rendimiento, un toolchain de JavaScript renovado, migración de MySQL a PostgreSQL, headers de seguridad más estrictos y un pipeline de deploy limpio con cero downtime.

Este post es el tutorial que nos hubiera gustado tener. Cada sección es una mejora independiente que puedes aplicar a tu propio proyecto Laravel/Twill, incluyendo los problemas que nos costaron una tarde para que a ti no te cuesten una.

El stack final:

Capa Antes Después
PHP 8.2, FPM 8.5, Octane sobre RoadRunner
Frontend Node 18, npm Node 26, pnpm 11.8 vía Corepack
Base de datos MySQL PostgreSQL (mismo servidor)
Edge Cloudflare (Tunnel, CSP, DNS SSL)

1. Alto rendimiento con Laravel Octane + RoadRunner

El PHP tradicional arranca todo el framework en cada request. Octane mantiene la app en memoria entre requests usando un worker de larga duración, lo cual es drásticamente más rápido — pero también significa que el estado de la aplicación se filtra entre requests si no tienes cuidado. Esa distinción es toda la historia de esta sección.

Instalación

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

Apunta config/octane.php a RoadRunner (configurable por env):

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

Ejecútalo localmente:

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

Trampa: singletons que se autodestruyen al construirse

Twill registra la navegación del admin y su colección de bloques una sola vez, en registros globales del proceso. Con FPM no hay problema (proceso nuevo por request). Con Octane, el primer request los construye y todos los requests siguientes reciben un cascarón vacío — los módulos del backend desaparecen del sidebar y el renderizado de bloques devuelve un 500 con newInstance() on null.

La solución es hacer warm de esos bindings para que una sola instancia sobreviva a través del sandbox por request de Octane:

// config/octane.php
'warm' => [
    ...Octane::defaultServicesToWarm(),
 
    // El block manager de Twill consume registros estáticos globales del proceso
    // mientras se construye (les hace unset()). El warm mantiene una instancia
    // compartida por worker para que el renderizado de bloques no falle con 500
    // después del primer request.
    \A17\Twill\TwillBlocks::class,
 
    // Los links de navegación del admin se registran una vez por worker en
    // AppServiceProvider::boot(). El warm mantiene vivo el singleton poblado
    // entre requests.
    \A17\Twill\TwillNavigation::class,
],

Si registras tus propios singletons en el boot() de un service provider y dependes de su estado poblado, agrégalos acá también.

Trampa: .rr.yaml debe existir y ser escribible

RoadRunner lee un .rr.yaml, y Octane le hace touch() a base_path('.rr.yaml') al arrancar. Dejalo mínimo — Octane inyecta workers/address/static-path por flags de CLI:

# .rr.yaml
version: "3"
 
http:
  static:
    calculate_etag: true

Commitea este archivo. Originalmente lo teníamos en el gitignore y lo generábamos en el deploy, lo que provocaba errores de "Permission denied" cuando el usuario de deploy no podía escribir en la raíz de la app. Como Octane solo actualiza su mtime (nunca el contenido), commitearlo no genera churn en git y garantiza que llega con los permisos correctos.


2. Renovando el toolchain de JavaScript: Node 26 + pnpm vía Corepack

Fijamos el toolchain en package.json para que todas las máquinas — laptop, CI, servidor — usen las mismas versiones:

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

Corepack (incluido con Node) lee packageManager y provisiona la versión exacta de pnpm automáticamente — sin drift por instalar pnpm global:

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

Trampa: pnpm v11 bloquea scripts de build por defecto

pnpm 11 se niega a ejecutar scripts post-install de dependencias a menos que los pongas en una allowlist (es un comportamiento por defecto de seguridad contra ataques de supply chain). Si un paquete nativo (ej. esbuild) necesita su postinstall, vas a ver ERR_PNPM_IGNORED_BUILDS. Permítelos explícitamente en pnpm-workspace.yaml:

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

Trampa: shells no interactivas en el servidor

Las shells de CI y deploy no tienen tu PATH de ~/.zshrc, y Corepack va a intentar preguntar antes de descargar pnpm — lo que cuelga un deploy. Siempre exportá esto en automatización:

export COREPACK_ENABLE_DOWNLOAD_PROMPT=0

3. Actualizando RoadRunner HTTP a v4

spiral/roadrunner-http v4 es un salto de versión mayor limpio. Actualiza la restricción y deja que Composer resuelva el binario del servidor correspondiente:

// composer.json
"spiral/roadrunner-http": "^4.1"
composer update spiral/roadrunner-http spiral/roadrunner-cli
./vendor/bin/rr get-binary     # descarga el binario `rr` correspondiente
php artisan octane:start --server=roadrunner   # prueba de humo

Tip: rr get-binary consulta la API de GitHub, que tiene rate limit en IPs compartidas de CI (HTTP 403). En Docker, copiá el binario desde la imagen oficial: COPY --from=ghcr.io/roadrunner-server/roadrunner:2025.1.15 /usr/bin/rr /usr/local/bin/rr


4. Migrando de MySQL a PostgreSQL (mismo servidor)

Nos pasamos de MySQL a PostgreSQL — manteniéndolo en el mismo VPS, sin servicio administrado — por el soporte nativo de JSONB, tipado más estricto, mejor búsqueda full-text y la ergonomía de CTEs y window functions que beneficia las consultas de nested-set y activity-log de Twill.

Instala Postgres junto a MySQL

No hace falta eliminar MySQL para migrar; instala Postgres al lado y migra cuando estés listo.

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

En un panel (Ploi/Forge), usa la UI de "Add database" y simplemente elige PostgreSQL como tipo — provisiona el role/db por ti.

Apunta Laravel a 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 trae una conexión pgsql de fábrica en config/database.php; no se necesita ningún cambio de código más allá del env. Asegurate de que la extensión PHP esté presente:

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

Mover los datos con pgloader

Para una migración de esquema y datos de un solo golpe desde MySQL, pgloader es el estándar de oro — mapea tipos, auto-increment → identity, y tinyint(1)boolean automáticamente:

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

¿Prefieres un esquema limpio desde tus migraciones y solo copiar filas? Corre php artisan migrate contra la base Postgres vacía primero, después pgloader con --with "data only".

Trampas al portar una app existente

  • Booleanos: MySQL guarda tinyint(1); Postgres tiene un boolean real. pgloader lo castea, pero revisa las queries estilo where('active', 1) — usa true/false.
  • Sensibilidad a mayúsculas: Postgres convierte los identificadores sin comillas a minúsculas y las comparaciones de strings son case-sensitive. Usa ILIKE (o LOWER(...)) donde dependías del LIKE case-insensitive de MySQL.
  • Rigurosidad del GROUP BY: Postgres requiere que cada columna seleccionada no agregada esté en el GROUP BY. El modo laxo de MySQL te permitía omitirlas.
  • Auto-increment después del import: las secuencias pueden quedar desfasadas respecto a los IDs copiados. Resetealas para que los nuevos inserts no colisionen:
SELECT setval(
  pg_get_serial_sequence('twill_users', 'id'),
  (SELECT MAX(id) FROM twill_users)
);
-- repetir por tabla, o scriptear para todas las tablas
  • Columnas JSON: prioriza jsonb sobre json para indexado y operadores; el $casts = ['payload' => 'array'] de Laravel funciona igual. Corre la suite de tests contra Postgres antes de cambiar producción, y mantén MySQL disponible (detenido) por un día o dos como vía de rollback.

5. Endurecimiento de seguridad y UX

Content Security Policy

Agregamos un CSP vía middleware, corriendo en modo report-only primero para poder observar las violaciones antes de enforcearlo:

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

Una lección del mundo real: si estás detrás de Cloudflare con Page Shield o Bot Fight Mode habilitado, Cloudflare inyecta sus propios scripts (/cdn-cgi/...) en el edge. Esos aparecen como violaciones de CSP y warnings de "deprecated feature" en la consola que no son de tu app. Testea contra tu origin antes de culpar a tu propia policy.

Formulario de contacto: consentimiento + repoblación

Agregamos un checkbox obligatorio de consentimiento de privacidad (validado server-side) e hicimos que el formulario se repoblara ante fallos de validación:

// Validación en ContactController
'privacy_consent' => ['required', 'accepted'],
{{-- repoblar después de un submit fallido --}}
<input type="text" name="message" value="{{ old('message') }}">
<input type="checkbox" name="privacy_consent" required>

No te olvides de la migración:

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

Una regla de captcha que falla con gracia

Los servicios de validación externos se caen. Envuelve las llamadas de red para que una caída transitoria no bloquee a los usuarios legítimos:

try {
    $response = Http::asForm()->post($verifyUrl, [...]);
} catch (ConnectionException $e) {
    return; // fail open ante caída del proveedor, loguear
}

6. Testing e higiene de dependencias

Haz que la suite pase bajo carga

Las corridas de tests de larga duración o en memoria pueden agotar la memoria por defecto de PHP. Auméntala en phpunit.xml, y fakea el HTTP saliente para que los tests nunca lleguen a la red:

<ini name="memory_limit" value="512M"/>
protected function setUp(): void
{
    parent::setUp();
    Http::fake(); // sin llamadas reales a captcha/analytics en tests
}

Auditar dependencias

composer audit

Algunos paquetes transitivos simplemente están abandonados (heredamos varios azure-storage-* vía Twill). En vez de romper el CI con ruido que no puedes arreglar, baja la severidad del reporte de abandonados:

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

7. Limpiando archivos grandes del historial de Git

Habíamos commiteado por accidente un dump de base de datos y un storage.tar.gz — y una vez que un blob está en el historial, git rm no reduce el repo ni desbloquea los pushes. Usa git-filter-repo:

# Haz backup primero, después reescribe el historial eliminando los blobs
git filter-repo --path database/dumps/laravel42.sql --path storage.tar.gz --invert-paths
git push --force-with-lease --all

Después prever a futuro:

/database/dumps
*.sql
*.bundle

8. Deploys sin downtime en Ploi

Ploi administra el VPS; Octane corre como un daemon de larga duración, y el script de deploy reconstruye y hace un reload graceful de los workers. El orden acá es crítico — si lo cambias, un primer deploy falla porque vendor/ todavía no existe.

cd /home/laravel42-xxxx/laravel42.com
 
git pull origin main
 
# 1) Dependencias PRIMERO — todo lo que sigue necesita vendor/
composer install --no-interaction --prefer-dist --optimize-autoloader --no-dev
 
# 2) Assets del frontend (compilar antes del mantenimiento: la app en vivo no se afecta)
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) Componentes de RoadRunner para 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) Symlink de storage + base de datos
php artisan storage:link || true
php artisan migrate --force
 
# 5) Reconstruir TODOS los caches (no solo las rutas)
php artisan optimize:clear
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
 
# 6) Recargar workers AL FINAL, para que arranquen con el código nuevo + caches
! php artisan octane:status || php artisan octane:reload
 
echo "🚀 ¡Aplicación deployada!"

El daemon de Octane de larga duración

El script de deploy solo hace reload de Octane — algo tiene que mantenerlo corriendo. En Ploi, habilita su soporte nativo de Octane, o agrega un 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

Después configura nginx como proxy hacia Octane y setea OCTANE_HTTPS=true (TLS termina en 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";
}

Lecciones de los logs de deploy

  • Muestra stderr. Los paneles suelen loguear solo stdout, así que un deploy fallido muestra un log vacío. Agrega exec 2>&1 al inicio del script y vas a ver el error real.
  • Instalá Node en el servidor (o compila los assets en CI y envía public/build). Un [WARN] Unsupported engine para Node es inofensivo; un binario node faltante no lo es.
  • composer install debe correr antes de cualquier cosa que toque vendor/bin/rr o artisan.

9. SSL vía el challenge DNS de Cloudflare

Para un certificado wildcard (o para emitirlo antes de apuntar el tráfico al servidor), usa el challenge DNS-01 de ACME con un API token de Cloudflare con alcance limitado — no la Global API key.

Crea un token en dash.cloudflare.com → API Tokens con:

  • Zone → DNS → Edit
  • Zone → Zone → Read
  • Con alcance limitado a la zona específica. Verifícalo y úsalo:
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'

Un token con alcance limitado reduce el radio de impacto a ediciones DNS en una sola zona si alguna vez se filtra.


Resumen: la checklist de la actualización

  • Octane + RoadRunner instalados; singletons con estado agregados al warm
  • .rr.yaml commiteado (el churn de solo mtime está bien)
  • packageManager + engines fijados; Corepack provisionando pnpm
  • spiral/roadrunner-http en v4; binario rr correspondiente
  • MySQL → PostgreSQL migrado (pgloader); secuencias reseteadas; suite verde en pgsql
  • CSP (report-only → enforce); consentimiento en formulario de contacto + captcha graceful
  • Suite de tests verde con Http::fake() y un memory_limit razonable
  • composer audit limpio (abandoned → report)
  • Blobs grandes purgados del historial de Git; ignores configurados
  • Script de deploy en Ploi con el orden correcto; daemon de Octane + proxy nginx
  • SSL wildcard vía un token DNS de Cloudflare con alcance limitado

Cada una de estas mejoras tiene valor por sí sola — pero en conjunto transforman un sitio Laravel/Twill estándar en un deploy rápido, resiliente y reproducible. ¡Buen shipping!