Streaming vidéo HLS avec Symfony 7, FrankenPHP, Docker et FFmpeg

the-frankenPHP-streaming-POC

Introduction

Le streaming vidéo en PHP, est-ce vraiment une bonne idée ?
Lorsqu’on parle de diffuser des vidéos sur un site web, on pense souvent à des solutions comme YouTube ou Vimeo. Pourtant, de plus en plus de projets souhaitent héberger et gérer leurs propres vidéos directement, pour des raisons de contrôle, de branding ou de performance. Mais diffuser de la vidéo, c’est aussi gérer le média le plus lourd du web, avec tous les problèmes que cela implique : bande passante, compression, adaptation de qualité, mise en cache, et fluidité de lecture.

Imaginez par exemple que vous souhaitiez ajouter une vidéo en arrière-plan sur la page d’accueil de votre site. Vous aimeriez que, comme sur YouTube, la qualité s’adapte automatiquement au débit Internet de chaque utilisateur pour garantir une lecture fluide, sans sacrifier les performances du site.

Symfony 7 est-il prêt pour ce genre de défi ? Peut-on réellement mettre en place un streaming HLS (HTTP Live Streaming) en PHP, tout en tirant parti de Varnish pour la mise en cache, de FFMPEG pour l’encodage adaptatif, et d’un peu de JavaScript pour ajuster le buffer et la qualité en temps réel ?

Dans cet article, nous allons créer un POC (Proof of Concept) complet pour comprendre comment diffuser efficacement de la vidéo sur un site Symfony 7, sans ralentir l’expérience utilisateur.

Projet simple et moderne : Symfony 7.3, FrankenPHP, FFMPEG et Varnish

Pour ce proof of concept (POC), on va construire un projet léger mais complet basé sur :

  • Symfony 7.3 comme framework principal,
  • FrankenPHP pour le serveur web performant et natif PHP,
  • FFMPEG pour l’encodage et la génération des segments HLS,
  • Varnish en reverse proxy pour le cache,
  • PostgreSQL pour la persistance (stockage des infos vidéos),
  • et un frontend simple basé sur PicoCSS pour ne pas perdre de temps sur le design.

Pourquoi cette stack ?

Parce qu’on cherche une solution moderne et performante, tout en restant facile à déployer avec Docker. L’objectif n’est pas encore de gérer la sécurité (login, rôles, ACL), mais simplement de mettre en place un système de streaming adaptatif fonctionnel.


Architecture Docker

Pour que notre POC soit réaliste et facilement déployable, nous allons construire une architecture Docker modulaire.
Le cœur de l’application repose sur Symfony 7.3 exécuté avec FrankenPHP, et nous allons y ajouter tous les services nécessaires pour gérer efficacement le streaming vidéo adaptatif.

Notre docker-compose.yml contiendra :

  • Conteneur Symfony/FrankenPHP → héberge l’application Symfony, le serveur web intégré et éventuellement FFMPEG si l’on choisit de l’installer directement dans cette image.
  • Conteneur FFMPEG (optionnel) → permet d’encoder et segmenter les vidéos sans surcharger le conteneur principal.
  • Conteneur Varnish → agit comme reverse proxy et cache haute performance pour les segments vidéo HLS et les playlists .m3u8.
  • Conteneur PostgreSQL → stocke les métadonnées des vidéos (titre, URL HLS, formats disponibles).
  • Conteneur RabbitMQ → gère le traitement asynchrone (ex. lancer un encodage vidéo en arrière-plan sans bloquer la requête HTTP).

Pourquoi RabbitMQ dans un projet vidéo ?

Dans un workflow vidéo, l’encodage HLS peut durer plusieurs secondes voire minutes selon la taille et la résolution.
Pour ne pas bloquer l’utilisateur, on envoie la tâche dans RabbitMQ depuis Symfony (via Messenger), et un worker dédié exécute le traitement FFMPEG.
Ainsi :

On prépare le terrain pour une mise à l’échelle facile (scaling horizontal).

L’utilisateur peut continuer à naviguer sur le site,

Les encodages peuvent être mis en file et traités en parallèle,

La vidéo : le souci majeur

Quand on parle de streaming, le problème numéro un, c’est la taille des fichiers vidéo. Une vidéo HD ou 4K peut peser plusieurs centaines de mégaoctets, voire plusieurs gigas.
Cela pose rapidement des problèmes : temps d’upload, ressources serveur monopolisées, risques de timeout… et c’est là qu’intervient le traitement asynchrone.

Pourquoi faire de l’async dans un POC ?

À première vue, mettre en place RabbitMQ + worker dédié pour un simple POC peut sembler overkill. Après tout, on pourrait simplement traiter la vidéo dans la même requête HTTP.
Mais ici, nous sommes en local, donc aucune contrainte de cloud ou de budget. C’est justement le moment idéal pour tester une architecture plus ambitieuse et préparer le terrain pour un vrai déploiement.


L’idée : un worker dédié à l’encodage vidéo

Puisque nous utilisons FrankenPHP, nous pouvons très bien créer un worker PHP dédié à l’encodage et au traitement des vidéos.
Voici la logique :

  1. L’utilisateur charge une vidéo depuis l’interface /admin.
  2. Le fichier est envoyé au serveur (ici nous avons augmenté la limite d’upload PHP à 500 Mo dans php.ini).
  3. Au lieu de traiter directement le fichier, Symfony envoie un message dans RabbitMQ.
  4. Un worker asynchrone (via Symfony Messenger) prend la tâche en charge, exécute FFMPEG pour générer les segments HLS, puis met à jour la base PostgreSQL avec l’URL finale.

Cette méthode présente plusieurs avantages :

Séparation claire des responsabilités (API → traitement → diffusion)

Pas de blocage utilisateur (l’upload se termine vite et l’encodage se fait en arrière-plan)

Scalabilité (on peut multiplier les workers si nécessaire)


Dockerfile : préparer FrankenPHP pour la vidéo

Tout ne se joue pas dans le docker-compose.yml. Le Dockerfile est également un point clé, surtout si l’on veut gérer l’upload multi-résolution directement dans un environnement PHP.

Par défaut, PHP est pensé pour traiter des formulaires, des images ou de petits fichiers. Mais une vidéo en 1080p, ça n’a rien à voir avec un JPEG de 300 ko : on parle facilement de plusieurs centaines de mégaoctets, avec des temps de traitement longs.

C’est pourquoi il faut adapter les limites PHP :

  • upload_max_filesize et post_max_size poussés à 500M (sinon impossible d’uploader une vidéo).
  • max_execution_time et max_input_time augmentés à 300 secondes pour éviter les timeouts lors d’uploads/encodages.
  • memory_limit relevé pour absorber la charge de FFmpeg et des workers.

Ensuite, on ajoute FFmpeg directement dans l’image, puisqu’il est le moteur central de l’encodage et du découpage HLS.

Voici le Dockerfile utilisé dans ce POC :

FROM dunglas/frankenphp:latest

WORKDIR /app

RUN install-php-extensions \
	pdo_pgsql \
	gd \
	intl \
	zip \
	opcache \
	amqp

# Install FFmpeg
RUN apt-get update && apt-get install -y \
    ffmpeg \
    && rm -rf /var/lib/apt/lists/*

# Configure PHP for video uploads
RUN echo "upload_max_filesize = 500M" >> /usr/local/etc/php/conf.d/uploads.ini \
    && echo "post_max_size = 500M" >> /usr/local/etc/php/conf.d/uploads.ini \
    && echo "max_execution_time = 300" >> /usr/local/etc/php/conf.d/uploads.ini \
    && echo "memory_limit = 512M" >> /usr/local/etc/php/conf.d/uploads.ini \
    && echo "max_input_time = 300" >> /usr/local/etc/php/conf.d/uploads.ini

# Copy Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer

# Copy application
COPY . .
RUN chmod +x bin/console

# FrankenPHP with Symfony configuration
ENV FRANKENPHP_CONFIG="worker /app/public/index.php"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
ENV SERVER_NAME=localhost

EXPOSE 80 443

# Default command will be provided by the base image

Ce Dockerfile n’est évidemment pas “parfait” : en production, il faudrait affiner, notamment sur la taille finale de l’image, la séparation des rôles (un conteneur FFmpeg à part pour l’encodage lourd, par exemple), et la gestion fine de la mémoire.

Mais pour un POC, c’est une excellente base : tout est en place pour que Symfony 7 + FrankenPHP puissent uploader, encoder et streamer de la vidéo dans de bonnes conditions.

Workers asynchrones : encodage vidéo “comme en prod”, mais en local

L’objectif du POC est d’être réaliste : on veut simuler une archi où l’upload ne bloque jamais l’utilisateur et où l’encodage HLS multi-résolutions se fait en arrière-plan via Symfony Messenger + RabbitMQ.
Le flux est simple et robuste :

Admin (EasyAdmin) → Formulaire d’upload → Event Subscriber → File RabbitMQ → Worker dédié → FFmpeg → Mise à jour BDD → Fichiers HLS (public/…)

Service worker dans docker-compose

  worker:
    build:
      context: .
      dockerfile: Dockerfile
    volumes:
      - .:/app
      - frankenphp_var:/app/var
    environment:
      APP_ENV: ${APP_ENV:-dev}
      APP_SECRET: ${APP_SECRET:-change_me}
      DATABASE_URL: postgresql://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-!ChangeMe!}@database:5432/${POSTGRES_DB:-app}?serverVersion=16&charset=utf8
      RABBITMQ_URL: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq:5672
      # Optionnel : tuning Messenger
      MESSENGER_CONCURRENCY: "1"      # 1 par process, scale via replicas
      MESSENGER_PREFETCH: "10"        # messages préchargés par worker
    depends_on:
      database:
        condition: service_healthy
      rabbitmq:
        condition: service_healthy
    command: ["/app/bin/worker"]      # wrapper vers messenger:consume
    restart: unless-stopped
    deploy:
      replicas: 2                     # 2 instances pour paralléliser
      resources:
        limits:
          cpus: "1.0"
          memory: 768M
        reservations:
          cpus: "0.25"
          memory: 256M
    healthcheck:
      test: ["CMD-SHELL", "php -v >/dev/null 2>&1 || exit 1"]
      interval: 15s
      timeout: 3s
      retries: 5

Pourquoi ces choix ?

  • replicas: 2 : tu parallélises les encodages sans rendre chaque process obèse. En prod, tu scales horizontalement.
  • MESSENGER_PREFETCH : limite l’overfetch et évite qu’un worker monopolise trop de messages.
  • resources.limits : garde un couvercle sur CPU/RAM quand FFmpeg s’emballe.
  • restart: unless-stopped + healthcheck : résilience minimum, même en local.

Pipeline d’encodage asynchrone : upload instantané, traitement en arrière-plan

Notre POC mise sur une chaîne de traitement asynchrone pour que l’upload soit non bloquant et que l’encodage HLS se fasse hors requête HTTP. Concrètement :

  • Upload côté back-office (EasyAdmin)
    Le contrôleur VideosCrudController déclenche l’encodage en dispatchant un message (ProcessVideoMessagedès la fin de l’upload.
  • Routage vers RabbitMQ
    Dans config/packages/messenger.yaml, le transport async envoie le message dans la queue RabbitMQ prévue pour l’encodage.
  • Traitement en worker dédié
    Le script bin/worker exécute messenger:consume async avec des limites maîtrisées (mémoire, durée, nombre de messages).
    Le handler ProcessVideoMessageHandler délègue le travail sale au VideoProcessingService (encodage multi-résolutions, segmentation HLS, mise à jour BDD).

Résultat :
1) Upload → 2) Dispatch vers la file → 3) Worker → 4) Mise à jour du statut, pendant que la requête d’origine rend la main immédiatement. C’est le vrai asynchrone, pas du “pseudo-async”.

Détails d’implémentation

Le worker dédié

bin/worker pilote Messenger avec des garde-fous de prod (temps, mémoire, débit) :


set -Eeuo pipefail
exec php bin/console messenger:consume async \
  --limit=10 \
  --memory-limit=512M \
  --time-limit=3600 \
  -vv

Tu peux scaler en répliques (Docker deploy.replicas: 2) pour paralléliser plusieurs encodages.

Routage Messenger (RabbitMQ)

Dans messenger.yaml, le message vidéo est envoyé sur le transport async (RabbitMQ) :

framework:
  messenger:
    transports:
      async:
        dsn: '%env(RABBITMQ_URL)%'
    routing:
      App\Message\ProcessVideoMessage: async

Ajoute au besoin une stratégie de retry exponentiel pour encaisser les ratés d’encodage.

Handler et service d’encodage

  • ProcessVideoMessageHandler récupère l’ID vidéo, met à jour le statut (PENDING → PROCESSING → READY ou FAILED), puis appelle VideoProcessingService.
  • VideoProcessingService s’occupe de FFmpeg : profils 360p/720p/1080p, segments HLS (.ts/.m4s) et master playlist (master.m3u8). Les fichiers sont écrits dans public/videos/{id}/... pour que Varnish puisse les cacher agressivement.

États et UX

Pendant l’encodage, l’admin voit l’état en temps réel dans le back-office (statut en base). Une fois l’encodage terminé, l’URL HLS (/videos/{id}/master.m3u8) est prête pour le player.


Pourquoi c’est important

  • Non-bloquant : l’utilisateur n’attend jamais que FFmpeg termine.
  • Scalable : on augmente le nombre de workers quand la file grossit.
  • Robuste : isolation des pannes (un échec d’encodage ne flingue pas le site).
  • Cache-friendly : les segments HLS statiques sont servis vite via Varnish.

project docker containers image streaming

Architecture Docker en action

Une fois docker-compose up lancé, notre environnement tourne avec l’ensemble des services nécessaires au streaming :

  • app-1 : l’application Symfony 7 propulsée par FrankenPHP.
  • worker-1 et worker-2 : deux workers asynchrones qui consomment la file RabbitMQ et lancent les encodages vidéo avec FFmpeg.
  • varnish-1 : le reverse proxy cache, qui distribue rapidement les segments HLS.
  • database-1 : PostgreSQL pour stocker les métadonnées vidéos.
  • rabbitmq-1 : le broker de messages, avec interface de management dispo sur le port 15672.
  • mailer-1 : service de mail (Mailpit) pour tester les notifications.

On voit bien que tout est compartimenté : l’encodage est isolé des requêtes web, le cache est découplé du backend, et les workers peuvent être répliqués à volonté. C’est exactement ce qui permet à ce POC d’être scalable et de ressembler déjà à une architecture de production.


Résultat côté utilisateur

Après tout ce travail d’architecture (workers asynchrones, FFmpeg, segmentation HLS, cache Varnish), voici la page finale de lecture vidéo.

Ce qui frappe, c’est la simplicité. Un lecteur épuré, mais qui propose déjà :

  • un choix de qualité fluide (480p / 720p / 1080p),
  • une vitesse ajustable,
  • un affichage du statut de la vidéo (encodage terminé ou en cours),
  • des infos techniques (durée, poids, date d’upload),
  • la possibilité de télécharger chaque résolution individuellement.

Derrière cette interface se cache en réalité une architecture scalable et robuste. Chaque encodage est traité en arrière-plan par un worker dédié, les vidéos sont segmentées en HLS multi-résolutions, et Varnish se charge d’accélérer la diffusion.

show page for a basic streaming POC

Conclusion

Pour cette première partie, on est allé très loin — peut-être même trop loin pour un simple POC — mais c’était nécessaire pour éviter le piège du banal CRUD vidéo. Grâce à FrankenPHP, on a pu tester rapidement une architecture à la fois moderne et puissante.

Le parcours est fluide : en HTTPS via Caddy, on accède au back-office /admin, on upload une vidéo, et hop, elle est traitée en asynchrone par des workers dédiés via RabbitMQ. Les métadonnées sont stockées dans PostgreSQL, et côté frontend, / affiche la liste des vidéos.

Avec Varnish greffé au pipeline, le chargement est instantané et taillé pour absorber la charge. Sur la page /show/{id}, on profite d’un lecteur complet :

  • changement de résolution (480p, 720p, 1080p),
  • ajustement de la vitesse,
  • téléchargement des différentes versions disponibles.

Bref, ce POC montre que Symfony 7 + FrankenPHP peuvent déjà donner naissance à une solution de streaming adaptatif sérieuse, proche de ce qu’on retrouve chez les géants du secteur.

👉 La suite ? On va ajouter la sécurité, avec gestion d’accès public/privé, tokens temporaires et même du DRM façon Netflix (oui, avec Cloudflare et d’autres outils costauds). Mais ça, ce sera pour la deuxième partie

J’espère que cet article, dense mais passionnant, vous a plu autant que j’ai pris plaisir à le créer. On en discute en commentaires !

Cet article t’a plu ?

J’essaie le plus possible de rester objectif et de tester toujours plus d’idées sur Symfony 7 et FrankenPHP.

D’ailleurs je travaille en parallèle sur minikube, article bientôt prévu ^^

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *