Modernizando Laravel Twill con Octane
Laravel42
24 June, 2026
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-binaryconsulta 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 unbooleanreal.pgloaderlo castea, pero revisa las queries estilowhere('active', 1)— usatrue/false. - Sensibilidad a mayúsculas: Postgres convierte los identificadores sin comillas a minúsculas y las comparaciones de strings son case-sensitive. Usa
ILIKE(oLOWER(...)) donde dependías delLIKEcase-insensitive de MySQL. - Rigurosidad del
GROUP BY: Postgres requiere que cada columna seleccionada no agregada esté en elGROUP 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
jsonbsobrejsonpara 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>&1al 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 enginepara Node es inofensivo; un binarionodefaltante no lo es. composer installdebe correr antes de cualquier cosa que toquevendor/bin/rroartisan.
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.yamlcommiteado (el churn de solo mtime está bien)packageManager+enginesfijados; Corepack provisionando pnpmspiral/roadrunner-httpen v4; binariorrcorrespondiente- 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 unmemory_limitrazonable composer auditlimpio (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!