Módulo 15: Configuración y despliegue
Objetivos del módulo
- Entender las diferencias entre los entornos
devyprod(APP_ENV,APP_DEBUG) - Seguir un checklist de producción completo
- Gestionar variables de entorno reales,
composer dump-env prody secretos cifrados - Preparar el código: dependencias optimizadas, caché, assets con AssetMapper y migraciones
- Ajustar el rendimiento con OPcache, preloading y la realpath cache
- Configurar los logs de producción con Monolog
- Servir la aplicación con Nginx + PHP-FPM, Apache o FrankenPHP (modo worker)
- Construir una imagen Docker de producción y conocer las plataformas y herramientas de despliegue
- Mantener vivos los workers de Messenger con Supervisor o systemd
- Aplicar caché HTTP y mantener la aplicación actualizada y monitorizada
1. Entornos: dev frente a prod
📘 Concepto: Un entorno es un conjunto de configuración con el que se ejecuta el mismo código. En
devSymfony te ayuda a desarrollar (errores detallados, recarga automática, profiler); enprodtodo está optimizado para la velocidad y la seguridad. Es como un coche de rally: en el taller va con el capó abierto y los diagnósticos conectados; en carrera, todo cerrado y a máximo rendimiento.
Dos variables deciden cómo arranca la aplicación:
| Variable | Valores | Efecto |
|---|---|---|
APP_ENV | dev, prod, test (o los que crees) | Qué configuración se carga (config/packages/* + bloques when@prod:) |
APP_DEBUG | 1 / 0 | Modo depuración. Si no se define, vale 1 en todos los entornos salvo prod |
Qué cambia en prod
| Aspecto | dev | prod |
|---|---|---|
| Errores | Página con traza completa | Página genérica (sin datos internos) |
| Profiler y barra de depuración | Activados | No instalados (--dev) |
| Caché del contenedor y rutas | Se regenera al cambiar ficheros | Se compila una vez; no detecta cambios |
| Plantillas Twig | Se recompilan al cambiar | Compiladas en la caché |
| Assets (AssetMapper) | Servidos dinámicamente por Symfony | Compilados en public/assets/ |
| Logs | Todo, en var/log/dev.log | Solo cuando hay errores (fingers crossed) |
| Dependencias | Incluye require-dev | Solo require |
⚠️ Importante: Nunca pongas
APP_ENV=devoAPP_DEBUG=1en un servidor público. Las páginas de error dedevmuestran rutas de ficheros, consultas SQL, variables de entorno… y el profiler expone todavía más. Es uno de los fallos de seguridad más habituales en aplicaciones Symfony.
2. Checklist de producción
Antes de cada despliegue (y sobre todo del primero), repasa esta lista:
APP_ENV=prodyAPP_DEBUG=0.APP_SECRETcon un valor aleatorio y único (nunca el de.env).- Contraseñas y claves en variables de entorno reales o en el vault de secretos, nunca en Git.
composer install --no-dev --optimize-autoloader.composer dump-env prod(opcional, mejora el arranque).- Caché limpia y precalentada (
cache:clearenprod). - Assets compilados (
asset-map:compile). - Migraciones ejecutadas (
doctrine:migrations:migrate). - OPcache activado y configurado; preloading si es posible.
- Servidor web con document root en
public/y soloindex.phpejecutable. - HTTPS obligatorio y trusted proxies configurados si hay un balanceador delante.
- Permisos de escritura solo en
var/(y en la carpeta de subidas, si existe). - Workers de Messenger y del Scheduler gestionados por Supervisor/systemd.
- Logs enviados a un sitio donde alguien los mire.
- Páginas de error personalizadas (
templates/bundles/TwigBundle/Exception/error404.html.twig…). - Copias de seguridad automáticas de la base de datos (¡y probadas!).
💡 Consejo: Convierte esta lista en un script de despliegue (lo verás en la sección 12). Lo que se hace a mano, tarde o temprano se olvida.
3. Variables de entorno en producción
El orden de carga
En desarrollo usas ficheros .env. En producción tienes dos opciones (ambas válidas):
- Variables de entorno reales, definidas por el sistema, el servidor web, Docker o la plataforma de hosting.
- Un fichero
.env.prod.localen el servidor, con los valores de producción (no se sube a Git).
| Prioridad | Fuente |
|---|---|
| 1 (máxima) | Variables de entorno reales del sistema |
| 2 | .env.prod.local |
| 3 | .env.prod |
| 4 | .env.local (no se carga en test) |
| 5 (mínima) | .env |
📘 Concepto: Las variables reales siempre ganan: si
DATABASE_URLestá definida en el sistema, Symfony ignora el valor de cualquier.env. Por eso las plataformas cloud y Docker funcionan sin ficheros.envde producción.
composer dump-env prod
Leer y analizar varios ficheros .env en cada petición tiene un coste. Este comando los combina en un único fichero PHP optimizado:
composer dump-env prod
Genera .env.local.php, que Symfony carga directamente (sin parsear nada) y que tiene prioridad sobre los .env. Si en producción todo viene de variables reales, puedes generarlo vacío:
composer dump-env prod --empty
Si en el servidor no tienes Composer, el equivalente es:
php bin/console dotenv:dump prod
⚠️ Importante:
.env.local.phpcontiene valores en claro. No lo subas a Git y regenera el fichero cada vez que cambies una variable.
APP_SECRET
APP_SECRET se usa para firmar cookies de “recuérdame”, enlaces de login y tokens CSRF. Genera uno distinto por entorno:
php -r 'echo bin2hex(random_bytes(32)), PHP_EOL;'
4. Secretos: el vault de Symfony
Algunas variables son especialmente delicadas: la contraseña de la base de datos, la clave del servicio de correo, la de una pasarela de pago… Symfony puede guardarlas cifradas en el propio repositorio.
📘 Concepto: El vault de secretos usa criptografía asimétrica. La clave pública (que sí va a Git) sirve para cifrar: cualquiera del equipo puede añadir o cambiar secretos. La clave privada (que nunca va a Git) sirve para descifrar: solo la tiene el servidor de producción. Es como un buzón: todos pueden echar cartas, pero solo el cartero tiene la llave.
Generar las claves de producción
APP_RUNTIME_ENV=prod php bin/console secrets:generate-keys
Crea dos ficheros en config/secrets/prod/:
| Fichero | ¿A Git? | Uso |
|---|---|---|
prod.encrypt.public.php | Sí | Cifrar secretos |
prod.decrypt.private.php | NO | Descifrar secretos (solo en el servidor) |
⚠️ Importante: Comprueba que
config/secrets/prod/prod.decrypt.private.phpestá en.gitignore(la receta lo añade). Si se sube por error, considera todos los secretos comprometidos: rota las claves y cambia las contraseñas.
Añadir y consultar secretos
# añade (o reemplaza) un secreto; te pide el valor de forma oculta
APP_RUNTIME_ENV=prod php bin/console secrets:set MAILER_DSN
# valor aleatorio, útil para APP_SECRET
APP_RUNTIME_ENV=prod php bin/console secrets:set APP_SECRET --random
# listar (y ver valores, si tienes la clave privada)
APP_RUNTIME_ENV=prod php bin/console secrets:list --reveal
# eliminar
APP_RUNTIME_ENV=prod php bin/console secrets:remove MAILER_DSN
Los secretos se usan exactamente igual que cualquier variable de entorno:
# config/packages/mailer.yaml
framework:
mailer:
dsn: '%env(MAILER_DSN)%'
Si existe una variable de entorno real con el mismo nombre, tiene prioridad sobre el secreto.
Llevar la clave privada a producción
Dos formas:
- Copiar el fichero
prod.decrypt.private.phpal servidor (por SSH, nunca por Git). - Variable de entorno
SYMFONY_DECRYPTION_SECRETcon la clave codificada en base64:
php -r 'echo base64_encode(require "config/secrets/prod/prod.decrypt.private.php");'
Para no descifrar en cada petición, puedes volcar los secretos al vault local durante el despliegue:
APP_RUNTIME_ENV=prod php bin/console secrets:decrypt-to-local --force
Esto escribe los valores en .env.prod.local (y después puedes ejecutar composer dump-env prod).
💡 Consejo: Si tu plataforma ya ofrece un gestor de secretos (variables de entorno cifradas de Upsun, secrets de Docker/Kubernetes, AWS Secrets Manager…), puedes usarlo en lugar del vault. Lo importante es la regla: los secretos nunca en claro en Git.
5. Preparar el código
Dependencias
composer install --no-dev --optimize-autoloader
| Opción | Efecto |
|---|---|
--no-dev | No instala require-dev (MakerBundle, PHPUnit, profiler…) |
--optimize-autoloader (-o) | Genera un mapa de clases: el autoload deja de buscar ficheros |
--classmap-authoritative (-a) | Más agresivo: si una clase no está en el mapa, no existe |
--no-interaction | Sin preguntas (scripts y CI) |
⚠️ Importante: Si aparece “class not found” durante
composer install, exportaAPP_ENV=prodantes: los scripts de Composer (cache:clear,assets:install…) se ejecutan en el entorno indicado, y endevintentarían cargar bundles que no has instalado.
Comprueba también que el servidor cumple los requisitos:
composer check-platform-reqs # versión de PHP y extensiones requeridas
Caché
APP_ENV=prod APP_DEBUG=0 php bin/console cache:clear
En prod, cache:clear borra la caché y la precalienta (compila el contenedor, las rutas, las plantillas…). Si quieres separar los pasos, por ejemplo al construir una imagen Docker:
php bin/console cache:warmup --env=prod
📘 Concepto: En
prodSymfony no comprueba si has cambiado ficheros de configuración o plantillas. Si modificas algo en el servidor y no limpias la caché, no verás el cambio. Es intencionado: comprobar fechas de ficheros en cada petición es lento.
Assets con AssetMapper
En dev, Symfony sirve los ficheros de assets/ al vuelo. En prod tienes que compilarlos a public/assets/, con un hash en el nombre para que puedan cachearse para siempre:
php bin/console asset-map:compile
[OK] Compiled 24 assets
Manifest written to public/assets/manifest.json
⚠️ Importante: No ejecutes
asset-map:compileen tu máquina de desarrollo: Symfony empezaría a servir los ficheros compilados y no verías tus cambios. Si lo haces por error, borrapublic/assets/.
Como los nombres llevan hash (app-3f2a1b.js), configura el servidor web para enviarlos con caché de larga duración (lo verás en la sección 8).
Migraciones
php bin/console doctrine:migrations:status
php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
💡 Consejo: Haz las migraciones compatibles hacia atrás cuando puedas: primero añade la columna nueva (nullable), despliega el código que la usa y, en un despliegue posterior, elimina la vieja. Así, durante los segundos del despliegue, la versión antigua y la nueva del código funcionan con la misma base de datos. Y haz siempre una copia de seguridad antes de migrar.
6. Rendimiento: OPcache, preloading y realpath cache
OPcache
📘 Concepto: PHP compila cada fichero
.phpa bytecode antes de ejecutarlo. OPcache guarda ese bytecode en memoria compartida para no recompilar en cada petición. En una aplicación Symfony, activarlo puede multiplicar el rendimiento. Desde PHP 8.5, OPcache viene siempre incluido en PHP.
Configuración recomendada para producción (php.ini o un fichero en conf.d/):
; /etc/php/8.5/fpm/conf.d/90-symfony.ini
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=32
opcache.max_accelerated_files=32531
; no comprobar si los ficheros han cambiado (máximo rendimiento)
opcache.validate_timestamps=0
; realpath cache: evita resolver rutas de ficheros una y otra vez
realpath_cache_size=4096K
realpath_cache_ttl=600
⚠️ Importante: Con
opcache.validate_timestamps=0, PHP no ve los cambios en el código hasta que reinicias PHP-FPM (o FrankenPHP). Añade ese reinicio a tu script de despliegue:sudo systemctl reload php8.5-fpm.
Preloading
Al compilar el contenedor, Symfony genera config/preload.php con la lista de clases que conviene precargar: PHP las carga una sola vez al arrancar el servidor y quedan disponibles para todas las peticiones.
opcache.preload=/var/www/blog/config/preload.php
; obligatorio con preload: el usuario con el que se ejecuta PHP
opcache.preload_user=www-data
Puedes reforzarlo compilando el contenedor en un único fichero:
# config/services.yaml
parameters:
.container.dumper.inline_factories: true
💡 Consejo: El preloading exige reiniciar PHP en cada despliegue (las clases precargadas no se actualizan). Si tu despliegue no puede reiniciar PHP, no lo actives.
Autoloader
Ya lo tienes con composer install --optimize-autoloader. Si dispones de APCu, --apcu-autoloader cachea además las búsquedas de clases en memoria.
7. Logs en producción con Monolog
En prod no quieres escribir cada consulta y cada evento en el log (llenaría el disco), pero cuando algo falla sí quieres todo el contexto. La receta de MonologBundle lo resuelve con el handler fingers_crossed:
# config/packages/monolog.yaml (receta)
when@prod:
monolog:
handlers:
main:
type: fingers_crossed
action_level: error # se "dispara" al llegar un error
handler: nested
excluded_http_codes: [404, 405]
channels: ["!deprecation"]
buffer_size: 50 # mensajes que guarda en memoria
nested:
type: stream
path: php://stderr # en Docker/plataformas cloud, a la salida de error
level: debug
formatter: monolog.formatter.json
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine"]
deprecation:
type: stream
channels: [deprecation]
path: php://stderr
formatter: monolog.formatter.json
📘 Concepto:
fingers_crossed(“cruzar los dedos”) guarda los mensajes de cada petición en memoria. Si la petición termina bien, los descarta. Si aparece un mensaje de nivelerroro superior, vuelca todos los mensajes de esa petición (incluidos losdebugprevios) al handler anidado. Así tienes el contexto completo de los fallos sin el ruido de las peticiones correctas.
En un VPS clásico, quizá prefieras ficheros con rotación diaria:
when@prod:
monolog:
handlers:
nested:
type: rotating_file
path: '%kernel.logs_dir%/%kernel.environment%.log'
level: debug
max_files: 14 # conserva dos semanas
formatter: monolog.formatter.json
💡 Consejo: El formato JSON parece menos legible, pero es el que entienden las herramientas de agregación de logs (Grafana Loki, ELK, Datadog…). Y para los errores, un servicio de error tracking como Sentry (
sentry/sentry-symfony) te avisa en tiempo real con la traza completa.
8. Servidor web
La regla de oro: el document root es public/
El servidor web solo debe ver la carpeta public/. Así es imposible acceder desde el navegador a .env, config/, src/ o var/. Y dentro de public/ solo debe ejecutarse un fichero PHP: el front controller index.php.
Nginx + PHP-FPM
# /etc/nginx/sites-available/blog.conf
server {
listen 80;
server_name blog.example.com;
return 301 https://$host$request_uri; # todo a HTTPS
}
server {
listen 443 ssl;
http2 on;
server_name blog.example.com;
ssl_certificate /etc/letsencrypt/live/blog.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/blog.example.com/privkey.pem;
root /var/www/blog/current/public;
location / {
# sirve el fichero si existe; si no, se lo pasa a Symfony
try_files $uri /index.php$is_args$args;
}
# assets compilados por AssetMapper: llevan hash, se pueden cachear "para siempre"
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
location ~ ^/index\.php(/|$) {
fastcgi_pass unix:/var/run/php/php8.5-fpm.sock;
fastcgi_split_path_info ^(.+\.php)(/.*)$;
include fastcgi_params;
# $realpath_root resuelve el enlace simbólico "current" (despliegues atómicos + OPcache)
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
# variables de entorno (alternativa a .env.prod.local)
# fastcgi_param APP_ENV prod;
# impide URLs como /index.php/algo
internal;
}
# cualquier otro .php: 404
location ~ \.php$ {
return 404;
}
error_log /var/log/nginx/blog_error.log;
access_log /var/log/nginx/blog_access.log;
}
⚠️ Importante: Tras desplegar, comprueba que
https://blog.example.com/index.phpno es accesible directamente y quehttps://blog.example.com/.envdevuelve 404.
Apache
Con Apache 2.4 y PHP-FPM, la configuración de producción va en el virtual host (DocumentRoot /var/www/blog/current/public y FallbackResource /index.php). Para hostings compartidos o pruebas rápidas, el paquete symfony/apache-pack crea un .htaccess en public/ con las reglas de reescritura:
composer require symfony/apache-pack
<VirtualHost *:80>
ServerName blog.example.com
DocumentRoot /var/www/blog/current/public
<Directory /var/www/blog/current/public>
AllowOverride None
Require all granted
FallbackResource /index.php
</Directory>
<FilesMatch \.php$>
SetHandler proxy:unix:/var/run/php/php8.5-fpm.sock|fcgi://dummy
</FilesMatch>
</VirtualHost>
💡 Consejo: Con
AllowOverride NoneApache no lee.htaccessen cada petición, lo que es más rápido. Usa.htaccess(yAllowOverride All) solo si no puedes tocar la configuración del servidor.
Detrás de un proxy o balanceador
Si delante de tu aplicación hay un balanceador, una CDN o un proxy inverso que termina el HTTPS, Symfony verá las peticiones como HTTP y con la IP del proxy. Indícale en quién confiar:
# config/packages/framework.yaml
framework:
trusted_proxies: '%env(TRUSTED_PROXIES)%' # p. ej. "127.0.0.1,REMOTE_ADDR"
trusted_headers: ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port']
9. FrankenPHP y el modo worker
📘 Concepto: FrankenPHP es un servidor de aplicaciones PHP moderno construido sobre el servidor web Caddy: un único binario que sirve ficheros estáticos, gestiona HTTPS automáticamente (con Let’s Encrypt), soporta HTTP/2 y HTTP/3, y ejecuta PHP sin necesidad de PHP-FPM.
Su característica estrella es el modo worker. En el modelo clásico (PHP-FPM), cada petición arranca Symfony desde cero: carga el kernel, el contenedor, los servicios… y lo tira todo al terminar. En modo worker, la aplicación arranca una vez y se queda en memoria atendiendo peticiones una tras otra.
flowchart LR
subgraph FPM["PHP-FPM (clásico)"]
R1[Petición] --> B1[Arrancar kernel] --> H1[Atender] --> X1[Destruir todo]
end
subgraph W["FrankenPHP modo worker"]
B2[Arrancar kernel una vez] --> H2[Atender petición 1] --> H3[Atender petición 2] --> H4[...]
end
Desde Symfony 7.4, el modo worker de FrankenPHP está soportado de forma nativa por el componente Runtime: no necesitas paquetes adicionales. Un Caddyfile mínimo:
# Caddyfile
blog.example.com {
root public/
encode zstd br gzip
php_server {
# activa el modo worker
worker ./public/index.php
}
}
frankenphp run --config Caddyfile
⚠️ Importante: En modo worker los servicios se reutilizan entre peticiones. Si un servicio guarda estado en una propiedad (un contador, el usuario actual, una caché en un array…), ese estado “se filtra” a la siguiente petición. Los servicios con estado deben implementar
Symfony\Contracts\Service\ResetInterfacepara limpiarse. Prueba bien tu aplicación antes de activar el modo worker en producción.
| PHP-FPM + Nginx | FrankenPHP | |
|---|---|---|
| Piezas | Dos servicios (Nginx y FPM) | Un binario |
| HTTPS | Certbot u otro | Automático |
| Modo worker | No | Sí (gran mejora de rendimiento) |
| Madurez y documentación | Muy alta | Alta y creciendo |
| Riesgo de fugas de estado | Ninguno | Hay que vigilar los servicios con estado |
10. Docker para producción
Una imagen Docker empaqueta tu aplicación con su PHP, extensiones y configuración: lo que funciona en tu CI es exactamente lo que se ejecuta en producción.
Dockerfile multi-stage
📘 Concepto: Un Dockerfile multi-stage usa varias etapas: una para construir (con Composer y herramientas) y otra final, más ligera, que solo contiene lo necesario para ejecutar. Las herramientas de construcción no viajan a producción.
# syntax=docker/dockerfile:1
############################
# Etapa base: PHP + extensiones
############################
FROM dunglas/frankenphp:1-php8.5 AS base
WORKDIR /app
# install-php-extensions viene incluido en la imagen de FrankenPHP
RUN install-php-extensions intl pdo_pgsql apcu zip
COPY --link docker/php/prod.ini $PHP_INI_DIR/conf.d/zz-app.ini
############################
# Etapa de construcción
############################
FROM base AS build
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
ENV APP_ENV=prod
# primero solo los ficheros de Composer: aprovecha la caché de capas de Docker
COPY --link composer.json composer.lock symfony.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --no-interaction
# después el resto del código
COPY --link . .
RUN composer dump-autoload --classmap-authoritative --no-dev \
&& composer run-script --no-dev post-install-cmd \
&& php bin/console asset-map:compile \
&& php bin/console cache:warmup
############################
# Imagen final
############################
FROM base AS prod
ENV APP_ENV=prod \
APP_DEBUG=0 \
SERVER_NAME=":80" \
FRANKENPHP_CONFIG="worker ./public/index.php"
COPY --from=build --chown=www-data:www-data /app /app
Y un .dockerignore para no copiar lo que no toca:
.git
.env.local
.env.*.local
var/
vendor/
public/assets/
config/secrets/prod/prod.decrypt.private.php
tests/
docker build --target prod -t blog:1.0 .
docker run -p 8080:80 \
-e DATABASE_URL="postgresql://app:secret@db:5432/blog?serverVersion=17" \
-e APP_SECRET="..." \
blog:1.0
💡 Consejo: No reinventes la rueda: symfony-docker (
github.com/dunglas/symfony-docker), mantenido por el autor de FrankenPHP, es la plantilla oficial de referencia. Incluye entornos de desarrollo y producción, HTTPS automático, modo worker, Mercure y un entrypoint que espera a la base de datos y ejecuta las migraciones.
⚠️ Importante: Las migraciones no deben ejecutarse durante el
docker build(no hay base de datos). Hazlas al arrancar el contenedor (entrypoint) o como un paso separado del despliegue.
11. Plataformas y herramientas de despliegue
| Opción | Cómo es | Ideal para |
|---|---|---|
| Upsun (la plataforma de la empresa antes conocida como Platform.sh) | PaaS: haces git push y la plataforma construye y despliega. Se configura con YAML en el repositorio. Es la recomendada por Symfony y patrocina el proyecto | Equipos que no quieren administrar servidores |
| VPS (Hetzner, OVH, DigitalOcean…) | Un servidor Linux que administras tú: Nginx/FrankenPHP, PHP, base de datos… | Control total y coste bajo |
| Contenedores (Kubernetes, Docker Swarm, servicios gestionados) | Despliegas imágenes Docker | Escalado horizontal, infraestructura moderna |
| Deployer | Herramienta PHP que automatiza despliegues por SSH a uno o varios VPS | VPS con despliegues atómicos y rollback |
Despliegues atómicos con Deployer
📘 Concepto: Un despliegue atómico prepara la nueva versión en una carpeta aparte (
releases/20261003-1015/) y, cuando todo está listo, cambia de golpe un enlace simbólico (current) para que apunte a ella. Si algo falla, basta con volver a apuntar a la versión anterior (rollback).
/var/www/blog/
├── current -> releases/5 (enlace simbólico a la versión activa)
├── releases/
│ ├── 4/
│ └── 5/
└── shared/ (lo que se conserva entre versiones)
├── .env.local
└── var/log/
composer require --dev deployer/deployer
vendor/bin/dep init
<?php
// deploy.php
namespace Deployer;
require 'recipe/symfony.php';
set('repository', 'git@github.com:tu-usuario/blog.git');
set('keep_releases', 5);
add('shared_files', ['.env.local']);
add('shared_dirs', ['var/log', 'public/uploads']);
add('writable_dirs', ['var', 'public/uploads']);
host('blog.example.com')
->set('remote_user', 'deployer')
->set('deploy_path', '/var/www/blog');
task('assets:compile', function (): void {
run('{{bin/console}} asset-map:compile');
});
task('workers:restart', function (): void {
run('{{bin/console}} messenger:stop-workers');
});
task('php:reload', function (): void {
run('sudo systemctl reload php8.5-fpm');
});
after('deploy:vendors', 'assets:compile');
after('deploy:vendors', 'database:migrate');
after('deploy:symlink', 'php:reload');
after('deploy:symlink', 'workers:restart');
after('deploy:failed', 'deploy:unlock');
vendor/bin/dep deploy # despliega
vendor/bin/dep rollback # vuelve a la versión anterior
12. Workers de Messenger en producción
Los correos asíncronos del módulo 11 y las tareas del Scheduler del módulo 14 necesitan procesos messenger:consume corriendo siempre. Si se caen, no se envía nada (y nadie se entera).
Buenas prácticas para los workers
- No los dejes correr indefinidamente: usa
--time-limit=3600o--memory-limit=128Mpara que se reinicien periódicamente y no acumulen memoria. - Reinícialos en cada despliegue con
php bin/console messenger:stop-workers: si no, seguirán ejecutando el código antiguo. - Usa un gestor de procesos que los vuelva a lanzar cuando terminan.
Con Supervisor
; /etc/supervisor/conf.d/blog-messenger.conf
[program:blog-messenger]
command=php /var/www/blog/current/bin/console messenger:consume async scheduler_default --time-limit=3600 --memory-limit=128M
user=www-data
numprocs=2
startsecs=0
autostart=true
autorestart=true
startretries=10
process_name=%(program_name)s_%(process_num)02d
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start blog-messenger:*
sudo supervisorctl status
⚠️ Importante: Si tienes varios workers consumiendo
scheduler_default, protege el schedule con->lock()(módulo 14) para que las tareas no se ejecuten dos veces. Otra opción es dedicar un único proceso al scheduler y varios aasync.
Con systemd
# /etc/systemd/system/blog-messenger@.service
[Unit]
Description=Blog - Symfony messenger-consume %i
After=network.target
[Service]
User=www-data
ExecStart=php /var/www/blog/current/bin/console messenger:consume async --time-limit=3600
Restart=always
RestartSec=30
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now blog-messenger@1.service blog-messenger@2.service
sudo systemctl status 'blog-messenger@*'
Script de despliegue “a mano” (resumen)
Si no usas Deployer, este script reúne todos los pasos vistos:
#!/usr/bin/env bash
set -euo pipefail # aborta ante cualquier error
cd /var/www/blog
export APP_ENV=prod APP_DEBUG=0
git fetch --tags && git checkout "$1" # ./deploy.sh v1.4.0
composer install --no-dev --optimize-autoloader --no-interaction
composer dump-env prod
php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
php bin/console asset-map:compile
php bin/console cache:clear
sudo systemctl reload php8.5-fpm # OPcache con validate_timestamps=0
php bin/console messenger:stop-workers # Supervisor los relanza con el código nuevo
echo "Desplegada la versión $1"
13. Caché HTTP
📘 Concepto: La caché HTTP evita que Symfony genere una y otra vez la misma página. La respuesta lleva cabeceras (
Cache-Control,ETag…) que indican al navegador, a una CDN o a un proxy inverso durante cuánto tiempo pueden reutilizarla. La petición más rápida es la que no llega a PHP.
Cabeceras con el atributo #[Cache]
use Symfony\Component\HttpKernel\Attribute\Cache;
final class BlogController extends AbstractController
{
// listado público: cacheable 5 minutos por navegadores y proxies
#[Route('/', name: 'app_home')]
#[Cache(public: true, maxage: 300, smaxage: 600, mustRevalidate: true)]
public function index(ArticleRepository $articles): Response
{
return $this->render('blog/index.html.twig', [
'articles' => $articles->findLatestPublished(10),
]);
}
}
O directamente sobre la respuesta:
$response = $this->render('blog/show.html.twig', ['article' => $article]);
$response->setPublic();
$response->setMaxAge(300); // navegador
$response->setSharedMaxAge(3600); // proxies y CDN
$response->setLastModified($article->getUpdatedAt() ?? $article->getPublishedAt());
return $response;
| Directiva | Significado |
|---|---|
public | Cualquier caché (también compartidas) puede guardarla |
private | Solo el navegador del usuario (contenido personalizado) |
max-age=N | Segundos de validez para el navegador |
s-maxage=N | Segundos de validez para cachés compartidas (proxy, CDN) |
must-revalidate | Al caducar, hay que revalidar antes de reutilizarla |
ETag / Last-Modified | Permiten responder 304 Not Modified sin reenviar el cuerpo |
⚠️ Importante: Nunca marques como
publicuna página que dependa del usuario (su perfil, su panel de autor). Symfony, por seguridad, convierte enprivatelas respuestas cuando hay una sesión iniciada; aun así, piensa siempre qué páginas son iguales para todos.
Reverse proxy
Para que alguien guarde esas respuestas necesitas un reverse proxy: Varnish, una CDN (Cloudflare, Fastly…) o el propio de Symfony, escrito en PHP, que se activa con una línea:
# config/packages/framework.yaml
when@prod:
framework:
http_cache: true
💡 Consejo: Para páginas mayoritariamente públicas con un trozo personalizado (“Hola, Ana”), investiga ESI (Edge Side Includes) o carga ese fragmento con Turbo Frames: así la página principal se cachea y solo el fragmento se genera por usuario.
14. Monitorización y actualizaciones
Monitorizar
- Errores: un servicio de error tracking (Sentry, Bugsnag…) o, como mínimo, alertas sobre los logs de nivel
error/critical. - Disponibilidad: un health check que compruebe la aplicación y la base de datos, consultado cada minuto por un servicio externo:
#[Route('/health', name: 'app_health', methods: ['GET'])]
public function health(Connection $connection): JsonResponse
{
try {
$connection->executeQuery('SELECT 1');
} catch (\Throwable) {
return $this->json(['status' => 'error', 'database' => 'down'], 503);
}
return $this->json(['status' => 'ok']);
}
- Rendimiento: herramientas APM (Blackfire, Tideways, New Relic…) para encontrar las peticiones lentas.
- Colas: vigila los mensajes fallidos con
php bin/console messenger:failed:show.
Seguridad de las dependencias
composer audit # vulnerabilidades conocidas en tus dependencias
composer outdated --direct # qué paquetes tienen versiones más nuevas
symfony check:security # alternativa con Symfony CLI
💡 Consejo: Añade
composer audita tu CI y activa Dependabot o Renovate en GitHub para recibir pull requests automáticas con las actualizaciones.
Actualizar Symfony
📘 Concepto: Symfony publica una versión menor cada seis meses (mayo y noviembre) y una mayor cada dos años. Las menores (8.0 → 8.1) son compatibles hacia atrás. Las mayores (7.4 → 8.0) eliminan todo lo que se marcó como obsoleto (deprecated) en la anterior. La última menor de cada serie (como la 7.4) es LTS, con soporte ampliado.
Actualización menor (p. ej. 8.1 → 8.2 cuando se publique):
{
"extra": {
"symfony": {
"require": "8.2.*"
}
}
}
# cambia también las restricciones "symfony/*" de require a 8.2.*, y después:
composer update "symfony/*" --with-all-dependencies
composer recipes:update # revisa si las recetas de Flex han cambiado
php bin/phpunit
Actualización mayor (p. ej. 7.4 → 8.0):
- Actualiza primero a la última menor de tu serie (7.4).
- Elimina todas las deprecations: revisa la barra de depuración y el profiler (panel Logs → Deprecations), ejecuta los tests con
failOnDeprecation="true"y consultavar/log/dev.log. - Cuando no queden avisos, cambia a
8.0.*y ejecutacomposer update "symfony/*" -W. - Lee el fichero
UPGRADE-8.0.mddel repositorio de Symfony para los cambios que no se detectan automáticamente.
Rector: refactorización automática
Rector aplica automáticamente muchos de los cambios de una actualización (nuevos atributos, métodos renombrados, sintaxis de PHP moderna…):
composer require --dev rector/rector
<?php
// rector.php
use Rector\Config\RectorConfig;
return RectorConfig::configure()
->withPaths([__DIR__.'/src', __DIR__.'/tests'])
->withPhpSets() // modernizar a tu versión de PHP
->withComposerBased(symfony: true, doctrine: true, phpunit: true, twig: true)
->withPreparedSets(symfonyCodeQuality: true);
vendor/bin/rector process --dry-run # muestra los cambios sin aplicarlos
vendor/bin/rector process # los aplica
⚠️ Importante: Ejecuta Rector siempre sobre un árbol de Git limpio y revisa el diff antes de hacer commit. Es una herramienta muy potente, pero no sustituye a tus tests.
15. Ejercicios
Ejercicio 1: Simular producción en local
Ejecuta tu blog en modo producción en tu máquina: crea .env.prod.local con tus datos, ejecuta composer dump-env prod, asset-map:compile y APP_ENV=prod php bin/console cache:clear, y arranca symfony serve. Provoca un 404 y un 500 y compara las páginas de error con las de dev. Al terminar, deshaz los cambios (borra .env.local.php y public/assets/).
Ejercicio 2: Secretos
Genera las claves del vault de prod, añade MAILER_DSN y APP_SECRET (--random) como secretos, comprueba con secrets:list --reveal que están ahí y verifica con git status que la clave privada no aparece como fichero a subir.
Ejercicio 3: Páginas de error personalizadas
Crea templates/bundles/TwigBundle/Exception/error404.html.twig y error.html.twig usando el layout del blog. Pruébalas en dev visitando /_error/404 y /_error/500.
Ejercicio 4: Configuración de Nginx
Escribe el server block de Nginx para tu blog con redirección a HTTPS, caché de un año para /assets/ y bloqueo de cualquier .php que no sea index.php. Valídalo con nginx -t (en una máquina virtual o un contenedor nginx).
Ejercicio 5: Imagen Docker
Construye la imagen del Dockerfile de la sección 10, levántala junto a un contenedor de PostgreSQL con un compose.yaml, ejecuta las migraciones con docker compose exec y comprueba que el blog funciona en http://localhost:8080.
Ejercicio 6: Workers
Escribe la configuración de Supervisor para dos workers de la cola async y uno dedicado a scheduler_default. Explica por escrito qué pasaría con los correos si olvidas ejecutar messenger:stop-workers tras un despliegue que cambia la plantilla del email.
Ejercicio 7: Caché HTTP
Añade #[Cache] al listado de artículos y a la página de detalle (con Last-Modified basado en la fecha de actualización). Comprueba las cabeceras con curl -I y verifica que una segunda petición con If-Modified-Since devuelve 304.
Ejercicio 8: Auditoría y actualización
Ejecuta composer audit y composer outdated --direct en tu proyecto. Configura Rector con el rector.php de la sección 14, ejecútalo en modo --dry-run y analiza tres de los cambios que propone.
Resumen
| Concepto | Sintaxis / herramienta |
|---|---|
| Entorno y depuración | APP_ENV=prod, APP_DEBUG=0 |
| Variables en producción | Variables reales o .env.prod.local |
Optimizar .env | composer dump-env prod / dotenv:dump prod |
| Claves del vault | APP_RUNTIME_ENV=prod php bin/console secrets:generate-keys |
| Añadir secreto | secrets:set NOMBRE [--random] |
| Clave privada en el servidor | Fichero prod.decrypt.private.php o SYMFONY_DECRYPTION_SECRET |
| Dependencias | composer install --no-dev --optimize-autoloader |
| Caché | cache:clear (y cache:warmup) con APP_ENV=prod |
| Assets | php bin/console asset-map:compile |
| Migraciones | doctrine:migrations:migrate --no-interaction --allow-no-migration |
| OPcache | opcache.validate_timestamps=0, memory_consumption, max_accelerated_files |
| Preloading | opcache.preload=config/preload.php, opcache.preload_user |
| Logs | Monolog fingers_crossed + stream/rotating_file en JSON |
| Servidor web | Document root public/, solo index.php ejecutable |
| Apache sencillo | composer require symfony/apache-pack |
| FrankenPHP | Caddy + PHP; worker ./public/index.php |
| Docker | Dockerfile multi-stage; plantilla dunglas/symfony-docker |
| Despliegue atómico | Deployer (recipe/symfony.php, dep deploy, dep rollback) |
| Workers | Supervisor/systemd + --time-limit + messenger:stop-workers |
| Caché HTTP | #[Cache(public: true, maxage: 300)], http_cache: true |
| Seguridad | composer audit |
| Actualizar | extra.symfony.require, composer update "symfony/*" -W, sin deprecations |
| Refactorización | Rector (withComposerBased(symfony: true)) |