Symfony 7 sur hébergeur mutualisé (FTP uniquement) : pattern installer cron-poll
Photo by Safar Safarov on Unsplash
Déployer Symfony 7 en production sans SSH, sans Docker, sans Vercel — juste FTP et un fichier PHP qui se débrouille.
C'est le pattern qui fait tourner miguel.monwoo.com et info.monwoo.com depuis 12 mois chez LWS. Retour d'expérience complet, avec le système de reprise par tâche qui rend les déploiements robustes.
Le contexte : 70% des freelances FR sur hébergeur mutualisé
Quand on lit la doc Symfony officielle, on a l'impression que tout le monde déploie via Docker sur AWS, Vercel ou un VPS managé. La réalité du marché freelance français est différente : LWS, OVH, o2switch, PlanetHoster dominent — 20 à 50 € par an, pas par mois.
Mais mutualisé = pas de SSH, pas de Docker, pas de cron à la seconde près, pas de binaire exécutable, budget temps cron strict (240 s sur LWS).
Objectif : faire tourner Symfony 7 + SQLite + Webpack Encore en prod sur ce contexte. Voilà le pattern qui a stabilisé Monwoo après plusieurs itérations.
Vue d'ensemble : 1 fichier PHP fait tout le job
Un seul fichier installer.php uploadé à la racine, avec un token éphémère injecté au build. Visite de l'URL https://mon-site.fr/installer.php?token=... = l'installer prend la main, fait son travail, se self-delete à la fin.
4 modes de fonctionnement
| Mode | Quand | Action |
|---|---|---|
preview | 1ère visite admin | Affiche checklist pré-deploy, boutons "Lancer" |
launch | Clic admin | Démarre cycle install, maintenance mode |
cron | Cron LWS toutes les 4 min | Continue le travail là où le précédent s'est arrêté |
poll | JS dans browser | Affiche la progression en live |
Pourquoi un cron, et pas tout en une fois ?
Sur LWS, une requête HTTP est tuée à 30-60 secondes par le reverse-proxy. Or un cycle complet de déploiement Symfony peut prendre 5 à 15 minutes. Solution : cron LWS exécute l'installer en CLI toutes les 4 minutes, budget 240 s par exécution. Si pas fini, arrêt propre à 200 s (marge 60 s) et reprise au prochain cron.
Le système de reprise par tâche (state file)
Un fichier state JSON track ce qui est déjà fait :
{
"completed_phases": ["maintenance_enable", "backup", "extract", "restore"],
"completed_commands": 1,
"backup_dir": "/htdocs/_deploy-backups/2026-05-17-15-30"
}
Quand un nouveau cron démarre : lit le state, skip les phases OK, reprend la commande suivante, marque OK si succès.
Retour d'expérience : reprise intra-tâche (batch+resume)
Le système ci-dessus fonctionne pour les tâches qui tiennent dans 200 s. Mais quand une seule tâche dépasse (exemple : un fixture qui traite 134 000 lignes en base), le pattern doit aller plus loin : reprise intra-tâche.
Le 15 mai 2026, le fixture a dépassé le budget cron. Diagnostic : findAll() sur 134k entités + persist en boucle, sans $em->clear(). La mémoire gonfle, kill SIGTERM à 240 s.
Solution : pattern batch + clear + checkpoint resume :
$BATCH = 500;
$deadline = time() + 180;
$lastId = is_file($resumeFile) ? (int)file_get_contents($resumeFile) : 0;
while (time() < $deadline) {
$rows = $em->createQueryBuilder()->select('c')->from(MyEntity::class, 'c')
->where('c.id > :id')->setParameter('id', $lastId)
->orderBy('c.id', 'ASC')->setMaxResults($BATCH)
->getQuery()->getResult();
if (empty($rows)) { @unlink($resumeFile); return Command::SUCCESS; }
foreach ($rows as $r) { /* process */ $lastId = $r->getId(); }
$em->flush();
$em->clear(); // CRITIQUE
file_put_contents($resumeFile, (string)$lastId);
}
return Command::FAILURE; // installer retry au prochain cron
Résultat : 134 000 lignes traitées en 13 secondes au cron suivant. Les 3 ingrédients clés : WHERE c.id > :lastId (utilise PK index), $em->clear() (libère la mémoire), file_put_contents($resumeFile) (checkpoint qui survit aux kills).
Pitfalls LWS spécifiques
WAL SQLite + mix PDO/kernel
Si l'installer ouvre un PDO natif puis lance des commandes Doctrine, on peut avoir 2 connexions simultanées sur SQLite WAL = EntityManager flush bloque 60 s. Solution : $pdo = null; unset($pdo); avant le kernel Symfony.
Pas de binaire exécutable
LWS bloque l'exécution des binaires (chmod +x ne suffit pas). Solution : pré-compiler en local au build. Le ZIP embarque public/build/*, public/assets/manifest.json, public/bundles/*.
MultiViews pour clean URLs
mod_rewrite ne fonctionne pas fiablement sur LWS. Solution : Options -Indexes +MultiViews dans .htaccess — résout naturellement /cgu vers cgu.html.
Cache HTML browser stale
Après deploy, browsers servent l'ancienne version. Solution headers no-cache sur HTML/JSON :
<IfModule mod_headers.c>
<FilesMatch "\.(html|json)$">
Header set Cache-Control "no-cache, must-revalidate, max-age=0"
</FilesMatch>
<FilesMatch "_app/.*\.(js|css|woff2?)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
</IfModule>
Avantages du pattern vs alternatives
| Critère | VPS+Ansible | Docker+CI | Vercel | Installer cron-poll |
|---|---|---|---|---|
| Coût hébergement | 5-30€/mois | 20-100€/mois | 20-200€/mois | 2-5€/mois |
| Setup initial | 1-2 jours | 2-3 jours | 1h | 30 min |
| Maintenance OS | Oui | Partielle | Non | Non |
| Souveraineté FR | Variable | Variable | USA | FR |
| Reprise crash auto | Manuel | Variable | Auto | Auto (state file) |
Pour aller plus loin
Code complet installer.php (~3000 lignes) + build.sh + patterns fixture batch+resume + WAL checkpoint + maintenance mode + configuration LWS étape par étape, en pack Partner Monwoo (99 €) : walkthrough vidéo screencast 45 min + démo deploy live + diagnostic crash recovery.
Cas tordus ?
Hébergeur mutualisé spécifique non couvert (OVH PERF, o2switch Cpanel, PlanetHoster N0C) ? service@monwoo.com — 30 minutes d'échange gratuit.