PHP-FPM vs Laravel Octane — lo que realmente cambia
PHP-FPM sigue el modelo "shared nothing": cada petición carga el framework desde cero, ejecuta el código y libera toda la memoria. Es robusto por diseño — una fuga en una petición muere con ella. Octane rompe este contrato. La aplicación se inicializa una vez, luego las peticiones se encadenan en el mismo proceso. La ganancia de rendimiento es masiva, pero la responsabilidad del desarrollador aumenta.
Seis diferencias estructurales a conocer
- Ciclo de vida compartido — el contenedor IoC de Laravel, los providers y los singletons se instancian una vez y persisten entre todas las peticiones de un worker. Un valor escrito en un singleton en la petición N sigue ahí en la petición N+1.
- Ganancia de rendimiento documentada — los benchmarks de la documentación oficial de Laravel Octane muestran 4× a 6× más peticiones/segundo frente a PHP-FPM 8.3 en endpoints típicos, al eliminar el bootstrap de PHP en cada petición.
- Variables estáticas = fugas garantizadas — cualquier propiedad
staticescrita durante una petición contamina las siguientes. Las Facades de Laravel ocultan referencias estáticas; algunas las limpia Octane, otras no. - Archivos estáticos servidos por FrankenPHP — el binario incluye un servidor HTTP completo. En un VPS, no se necesita nginx delante de PHP: FrankenPHP maneja TLS, HTTP/2 y HTTP/3 de forma nativa.
- Memoria RSS estable ≠ memoria filtrada — la memoria del proceso sube al arrancar y luego se estabiliza. Una fuga real se detecta por crecimiento continuo a lo largo de miles de peticiones, no por el nivel inicial.
- Workers y concurrencia — Octane arranca
--workers=Nprocesos. Cada worker mantiene su propio estado; no hay memoria compartida entre workers, pero un mismo worker procesa sus peticiones secuencialmente. - Compatibilidad Laravel 10 y 11 — Octane 2.x soporta Laravel 10+. FrankenPHP como driver está disponible desde
laravel/octane2.3. PHP 8.2 mínimo, PHP 8.3 recomendado.
Prerrequisitos antes de empezar
Un VPS con 2 GB de RAM mínimo (4 GB recomendados para un stack completo con Redis y queue workers), acceso root, Docker 25+ y Docker Compose v2. La aplicación debe ejecutarse en Laravel 10 o 11, con laravel/octane 2.x instalado (composer require laravel/octane). Traefik 3.x gestionará los certificados Let's Encrypt y el enrutamiento HTTPS. No se necesita nginx adicional: FrankenPHP reemplaza completamente la capa HTTP.
Desplegar Laravel Octane + FrankenPHP + Traefik en Docker Compose
Instalar laravel/octane y publicar la configuración
En tu proyecto Laravel:
composer require laravel/octane php artisan octane:install --server=frankenphpEsto publica
config/octane.phpy añade las variables de entorno en.env.example. Verifica queOCTANE_SERVER=frankenphpesté definido.Escribir el Dockerfile para FrankenPHP
FROM dunglas/frankenphp:1-php8.3-alpine RUN install-php-extensions \ pdo_mysql \ redis \ opcache \ pcntl \ zip \ intl WORKDIR /app COPY --chown=www-data:www-data . . RUN composer install --no-dev --optimize-autoloader --no-interaction \ && php artisan config:cache \ && php artisan route:cache \ && php artisan view:cache EXPOSE 8000 CMD ["php", "artisan", "octane:frankenphp", "--host=0.0.0.0", "--port=8000", "--workers=4", "--max-requests=500"]La opción
--max-requests=500reinicia el worker después de 500 peticiones: una red de seguridad contra fugas de memoria residuales.Escribir el docker-compose.yml completo
services: app: build: . restart: unless-stopped environment: APP_ENV: production APP_KEY: ${APP_KEY} DB_HOST: db REDIS_HOST: redis CACHE_DRIVER: redis SESSION_DRIVER: redis QUEUE_CONNECTION: redis OCTANE_SERVER: frankenphp networks: - internal - traefik labels: - "traefik.enable=true" - "traefik.http.routers.app.rule=Host(`${DOMAIN}`)" - "traefik.http.routers.app.entrypoints=websecure" - "traefik.http.routers.app.tls.certresolver=letsencrypt" - "traefik.http.services.app.loadbalancer.server.port=8000" depends_on: db: condition: service_healthy redis: condition: service_healthy worker: build: . restart: unless-stopped command: ["php", "artisan", "queue:work", "redis", "--sleep=3", "--tries=3", "--max-time=3600"] networks: - internal depends_on: db: condition: service_healthy redis: condition: service_healthy scheduler: build: . restart: unless-stopped command: ["sh", "-c", "while true; do php artisan schedule:run >> /dev/null 2>&1; sleep 60; done"] networks: - internal db: image: mysql:8.0 restart: unless-stopped volumes: - db_data:/var/lib/mysql networks: - internal healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 10s timeout: 5s retries: 5 redis: image: redis:7-alpine restart: unless-stopped volumes: - redis_data:/data networks: - internal healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 3s retries: 5 volumes: db_data: redis_data: networks: internal: traefik: external: trueDesplegar Traefik con certificados automáticos
docker network create traefik mkdir -p /opt/traefik/letsencrypt touch /opt/traefik/letsencrypt/acme.json chmod 600 /opt/traefik/letsencrypt/acme.json docker compose -f /opt/traefik/docker-compose.yml up -dPreparar el archivo .env de producción
Variables críticas a definir en el VPS:
APP_ENV=production APP_DEBUG=false APP_KEY=base64:... # generado con php artisan key:generate DOMAIN=tu-dominio.com DB_DATABASE=laravel DB_USERNAME=laravel DB_PASSWORD=contraseña_fuerte DB_ROOT_PASSWORD=root_contraseña OCTANE_SERVER=frankenphp CACHE_DRIVER=redis SESSION_DRIVER=redis QUEUE_CONNECTION=redisNunca hagas commit de
.enven git. En el VPS, gestiónalo conscpo un gestor de secretos.Ejecutar las migraciones sin downtime
docker compose run --rm app php artisan migrate --forcePara un despliegue zero-downtime, usa el flag
--stepen migraciones que añaden columnas NOT NULL.Arrancar el stack y verificar el healthcheck
docker compose up -d --build docker compose psEspera a que
dbyredisalcancen el estadohealthyantes de queapparranque.Verificar el estado con octane:status
docker compose exec app php artisan octane:statusEste comando muestra el servidor activo (frankenphp), el número de workers en ejecución y el estado global. Una respuesta
runningconfirma que los workers responden.Configurar recarga en caliente para despliegues
docker compose build app worker scheduler docker compose up -d --no-deps app worker schedulerPara recargar workers sin cortar conexiones activas:
docker compose exec app php artisan octane:reload
Lista de compatibilidad antes de pasar a producción
Migrar a Octane no requiere reescribir la aplicación, pero algunos patrones comunes fallan en silencio. Revisa esta lista en tu código antes del primer despliegue.
8 puntos a verificar obligatoriamente
- Singletons con estado mutable — cualquier servicio registrado con
$this->app->singleton()que almacene datos específicos de una petición debe reiniciarse en unOctaneServiceProvider. Auditoría:grep -r 'singleton' app/Providers. - Sesiones — con
SESSION_DRIVER=redislas sesiones sobreviven correctamente. El driverfilepuede crear condiciones de carrera con múltiples workers. Migra aredisodatabaseantes de cambiar a Octane. - Conexiones de base de datos — Octane mantiene conexiones DB persistentes por worker. Verifica que MySQL acepte
max_connections ≥ workers × replicas. - Variables estáticas en modelos o servicios —
static $cache = []en un modelo Eloquent persiste entre peticiones. Reemplaza con inyección de dependencia con alcance de petición. - Event listeners no idempotentes — un listener registrado en
boot()se registra solo una vez, pero se ejecuta en cada petición que dispara el evento. Problema solo si acumula estado global. - Jobs y colas — los Jobs no corren en el proceso principal de Octane sino en el contenedor
workerseparado. No se requieren adaptaciones para el código de los Jobs. - Archivos subidos y almacenamiento — monta un volumen para
/app/storagepara que los archivos persistan entre reconstrucciones de imagen. - Paquetes de terceros — verifica la compatibilidad con Octane de cada paquete. Desactiva Debugbar y Telescope en producción.
FrankenPHP vs Swoole vs RoadRunner: elegir tu driver Octane
Desplace la tabla
| Criterio | FrankenPHP | Swoole | RoadRunner |
|---|---|---|---|
| Instalación | Imagen Docker oficial `dunglas/frankenphp` — sin extensiones PECL a compilar | Extensión PECL `swoole` — requiere `pecl install swoole` o imagen personalizada | Binario Go separado (`roadrunner`) — descargado vía Composer (`spiral/roadrunner`) |
| HTTP/2 y HTTP/3 | Nativo — FrankenPHP está construido sobre Caddy que soporta HTTP/3 (QUIC) de serie | HTTP/2 soportado, HTTP/3 no nativo | HTTP/2 vía plugin, HTTP/3 no soportado nativamente |
| Compatibilidad con extensiones PHP | Muy amplia — basado en PHP-FPM oficial, la mayoría de extensiones se instalan normalmente | Incompatibilidades conocidas con algunas extensiones bloqueantes | Amplia, pero algunas extensiones PECL complejas pueden necesitar adaptaciones |
| Recomendación oficial Laravel | Driver recomendado desde Laravel 11 (2024), listado primero en la documentación de Octane | Primer driver histórico, aún soportado pero menos destacado | Soportado, alternativo a Swoole, común en ecosistemas Symfony/Spiral |
| Complejidad operacional | Baja — un solo contenedor, Dockerfile mínimo, sin demonio separado | Media — la extensión swoole debe compilarse con la versión PHP correcta | Media — el binario RoadRunner debe sincronizarse con la versión del paquete Composer |
Monitorizar Octane en producción con octane:status
El comando php artisan octane:status consulta el servidor FrankenPHP en tiempo real y muestra el número de workers activos y el estado global. Intégralo en tu monitorización:
docker compose exec -T app php artisan octane:status | grep -q 'running' && echo OK || echo FAILPara recargar workers sin cortar el tráfico:
docker compose exec app php artisan octane:reloadSigue la memoria RSS del proceso principal: un crecimiento continuo tras miles de peticiones indica una fuga. El comando docker stats app da la memoria en tiempo real.
Resolución de problemas: errores frecuentes en producción
Fuga de memoria progresiva: la memoria del contenedor app crece sin bajar nunca. Causa probable: un singleton acumula datos por petición. Reduce --max-requests a 100 mientras identificas el origen.
502 Bad Gateway de Traefik: verifica que el contenedor esté en la red traefik, que la etiqueta traefik.http.services.app.loadbalancer.server.port=8000 corresponda al puerto expuesto, y que el contenedor esté healthy.
Worker que reinicia en bucle: busca FATAL en docker compose logs app. Causa más común: una migración no ejecutada que lanza una QueryException al arrancar.
Sesiones perdidas entre peticiones: cambia a SESSION_DRIVER=redis.
Zona horaria incorrecta: añade APP_TIMEZONE en .env y RUN apk add --no-cache tzdata en el Dockerfile.
Próximos pasos
Ya tienes un stack Laravel Octane funcional en VPS. Los siguientes artículos cubren los pasos naturales: [Desplegar Laravel en un VPS](/blog/deployer-laravel-vps), [Desplegar con Traefik](/blog/deployer-avec-traefik), [Docker Compose production checklist](/blog/docker-compose-production-checklist), [Alojar Redis en VPS](/blog/heberger-redis-vps), [PHP 8.2 fin de vida — checklist de migración](/blog/php-82-fin-de-vie-checklist-migration).