Comment construire un mini CDN de zéro avec Symfony 7 et FrankenPHP

cdn from scratch

Introduction

Après avoir exploré la création d’un service de streaming — un POC ambitieux mais jamais totalement terminé — on repart sur une nouvelle expérimentation technique. Cette fois, l’objectif est clair : aller plus vite, plus scalable et se frotter à un sujet qui concerne tous les projets web modernes, la distribution de contenu. Concrètement, on va tenter de créer un CDN maison, de zéro, en s’appuyant sur FrankenPHP et Symfony 7.

Comme toujours, il s’agit d’un proof of concept : pas une solution prête pour la production, mais un terrain de jeu pour tester, se planter, apprendre et voir jusqu’où on peut pousser PHP et Symfony.

Un CDN pour quoi faire ?

Si vous avez suivi notre POC sur le service de streaming SVOD, on était limité par une architecture mono-serveur. Résultat : plus il y a d’utilisateurs, plus le serveur d’origine souffre, et les performances s’effondrent.

C’est là qu’intervient le CDN (Content Delivery Network), littéralement un réseau de distribution de contenu. L’idée est simple : répliquer le même service dans plusieurs régions (France, Allemagne, etc.) pour rapprocher le contenu des utilisateurs.

C’est exactement ce que fait Netflix avec sa plateforme de streaming : derrière une seule interface mondiale, les vidéos sont en réalité servies depuis des dizaines de points de présence (PoP) répartis sur la planète.


La stack technique du CDN maison

Pas question de partir sur une stack inutilement compliquée. Pour ce POC, on reste fidèle à notre combo préféré : FrankenPHP, Varnish et Symfony 7.

En version 1, l’orchestration passera par Symfony Messenger couplé à Doctrine. Le principe est simple :

  • on upload une vidéo,
  • elle est traitée dans un worker PHP via Messenger,
  • puis on génère tout le contenu nécessaire à sa distribution via le CDN.

Pour le stockage, on s’appuie sur PostgreSQL, qu’on va pousser au maximum de ses capacités pour ce premier prototype. L’idée est de garder une architecture claire, efficace et surtout évolutive pour tester la faisabilité d’un CDN maison.

Tout ça pourquoi ?

La réponse est simple : pas besoin d’Apache en 2025. Ce qu’on veut, c’est un binaire rapide et moderne pour faire tourner une application Symfony. Et c’est exactement ce que propose FrankenPHP.

👉 FrankenPHP, c’est plus qu’un simple serveur :

  • il intègre directement Caddy pour la gestion du HTTPS,
  • il sait exécuter PHP en mode worker (plus besoin de PHP-FPM),
  • il permet de tirer parti de HTTP/3 et des Early Hints,
  • et surtout, il offre une performance brute idéale pour servir un CDN maison.

Côté cache, on ne présente plus Varnish, la référence en proxy HTTP haute performance. Utilisé partout dans le monde (y compris chez des géants du e-commerce ou du streaming), il reste incontournable pour optimiser la délivrance de contenu.

Et pour la base de données ? Mon choix reste clair : PostgreSQL, que je considère comme le meilleur SGBD relationnel actuel. Robuste, extensible (index avancés, JSONB, full-text search), et capable d’encaisser une vraie charge si on le configure bien.

Finalement, ce POC veut démontrer une chose : tout part d’une idée simple. Avec les bons outils, même un projet ambitieux comme la mise en place d’un CDN peut se prototyper rapidement, sans tomber dans la complexité inutile.


Orchestration du traitement vidéo avec Symfony Workflow (state machine)

On garde le POC pragmatique : un pipeline lisible, traçable, et qui échoue proprement. D’où l’usage du composant Workflow de Symfony 7 en mode state machine : un seul état actif à la fois, transitions explicites, audit activé.

# config/packages/workflow.yaml
framework:
  workflows:
    video_processing:
      type: 'state_machine'
      audit_trail:
        enabled: true
      marking_store:
        type: 'method'
        property: 'status'
      supports:
        - App\Entity\Video
      initial_marking: uploaded
      places:
        - uploaded
        - extracting_metadata
        - generating_thumbnail
        - compressing_videos
        - creating_hls
        - generating_subtitles
        - processed
        - failed
      transitions:
        start_processing:
          from: uploaded
          to: extracting_metadata
        extract_metadata:
          from: extracting_metadata
          to: generating_thumbnail
        generate_thumbnail:
          from: generating_thumbnail
          to: compressing_videos
        compress_videos:
          from: compressing_videos
          to: creating_hls
        create_hls:
          from: creating_hls
          to: generating_subtitles
        generate_subtitles:
          from: generating_subtitles
          to: processed
        mark_failed:
          from: [extracting_metadata, generating_thumbnail, compressing_videos, creating_hls, generating_subtitles]
          to: failed
        retry_processing:
          from: failed
          to: extracting_metadata

Ce que ça apporte tout de suite :

  • un chemin de traitement linéaire (métadonnées → miniature → compressions → HLS → sous-titres) ;
  • une sortie d’urgence (mark_failed) depuis n’importe quelle étape lourde ;
  • un retry contrôlé (retry_processing) pour relancer proprement depuis l’extraction des métadonnées ;
  • un audit trail natif (qui a fait quoi, quand, avec quels états).

Astuce SEO/tech : on parle ici d’orchestration vidéo avec Symfony Workflowstate machine et FFmpeg dans un CDN vidéo.

Upload vidéo : validation, nommage sûr et déclenchement asynchrone

L’upload, c’est l’entrée du pipeline. On reste simple : un formulaire Symfony, des contraintes de validation côté serveur, un nommage sécurisé, on déplace le fichier dans le répertoire public et on déclenche le traitement via Messenger.

// src/Controller/DashboardController.php
#[Route('/upload', name: 'dashboard_upload')]
public function upload(Request $request): Response
{
    $video = new Video();

    $form = $this->createFormBuilder($video)
        ->add('title', TextType::class, [
            'label' => 'Video Title',
            'constraints' => [new NotBlank()]
        ])
        ->add('description', TextareaType::class, [
            'label' => 'Description',
            'required' => false,
            'attr' => ['rows' => 4]
        ])
        ->add('videoFile', FileType::class, [
            'label' => 'Video File',
            'mapped' => false,
            'required' => true,
            'constraints' => [
                new File([
                    'maxSize' => '500M',
                    'mimeTypes' => [
                        'video/mp4','video/avi','video/quicktime','video/x-msvideo',
                        'video/webm','video/x-flv','video/3gpp','video/x-ms-wmv'
                    ],
                    'mimeTypesMessage' => 'Please upload a valid video file (MP4, AVI, MOV, WEBM, FLV, 3GP, WMV)',
                ])
            ]
        ])
        ->add('submit', SubmitType::class, [
            'label' => 'Upload Video',
            'attr' => ['class' => 'btn btn-primary']
        ])
        ->getForm();

    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        /** @var UploadedFile $videoFile */
        $videoFile = $form->get('videoFile')->getData();

        if ($videoFile) {
            // 1) nommage sûr et unique
            $originalFilename = pathinfo($videoFile->getClientOriginalName(), PATHINFO_FILENAME);
            $safeFilename = transliterator_transliterate(
                'Any-Latin; Latin-ASCII; [^A-Za-z0-9_] remove; Lower()',
                $originalFilename
            );
            $newFilename = $safeFilename.'-'.uniqid().'.'.$videoFile->guessExtension();

            // 2) déplacement dans le dossier videos (configurable)
            $videoFile->move(
                $this->getParameter('videos_directory') ?? '/app/public/videos',
                $newFilename
            );

            // 3) persist des métadonnées minimales
            $video->setFilename($newFilename);
            $video->setOriginalFilename($videoFile->getClientOriginalName());
            $video->setMimeType($videoFile->getMimeType());
            $video->setFileSize($videoFile->getSize());
            $video->setStatus('uploaded');

            $this->entityManager->persist($video);
            $this->entityManager->flush();

            // 4) déclenchement async du pipeline
            $this->messageBus->dispatch(new ProcessVideoMessage($video->getId()));

            $this->addFlash('success', 'Video uploaded successfully! Processing will begin shortly.');
            return $this->redirectToRoute('dashboard_index');
        }
    }

    return $this->render('dashboard/upload.html.twig', [
        'form' => $form->createView(),
    ]);
}

Ce que fait ce code (en clair)

  • Validation côté serveur : taille max 500 Mo + whitelist de types MIME.
  • Nommage “safe” : translittération, nettoyage, suffixe unique — évite collisions et noms exotiques.
  • Stockage : le fichier est déplacé vers videos_directory (par défaut /app/public/videos).
  • Persist minimal : on enregistre les métadonnées utiles (nom, taille, MIME) + status=uploaded.
  • Asynchrone : on dispatch un ProcessVideoMessage($id) qui fera vivre le Workflow (métadonnées → miniatures → compressions → HLS → VTT → processed).

Pourquoi c’est adapté à un CDN vidéo ?

  • Dès uploaded, le Workflow prend la main et produit des artefacts cacheables (MP4 multi-qualités, playlists HLS, VTT).
  • La séparation upload → process (async) évite de bloquer la requête HTTP — indispensable quand FFmpeg s’en mêle.
  • Le nommage propre facilite la mise en cache côté Varnish et la purge ciblée par motif (/videos/processed/{slug}/…).

Et maintenant ?

Le socle est en place : une architecture simple mais solide.
Il reste encore pas mal de travail, notamment sur un point crucial : la gestion de la charge.

Comment optimiser le projet quand plusieurs utilisateurs veulent streamer la même vidéo en même temps ?
C’est exactement ce qu’on verra dans la partie 2, avec des pistes concrètes pour scaler et éviter que l’origin sature.

diagram de fonctionnement du cdn

Conclusion

On a maintenant une base solide et claire pour avancer.
Grâce à Symfony Workflow, on pilote proprement chaque étape du traitement vidéo. Avec un message asynchroneFFmpeg pour les compressions et la génération de chunks HLS, on obtient déjà un pipeline prêt à évoluer.

Tout est donc en place pour préparer une V2 plus concrète, avec comme objectif final de gérer des flux vidéo multi-sites en temps réel.
Un pas de plus après notre précédent POC de streaming vidéo, mais cette fois avec une perspective d’extrapolation vers un vrai CDN maison.

Et toi, tu as déjà testé ou construit un CDN pour tes projets ?

Cet article t’a plu ?

Sur ce blog j’explore et publie du contenu pour les dev PHP Symfony et aussi les geeks. Tu as déjà testé le SSE ? Justement check le lien

Laisser un commentaire

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