TA n°7-8 - Implémentation de formulaires pour entités liées
Importance de la contextualisation pour une application réelle

Table des matières

1. Introduction

Vous allez approfondir votre connaissance des Contrôleurs et formulaires Symfony, pour mettre en œuvre la gestion des entités liées dans le Modèle de données de l’application, de façon proche de ce qu’on attend dans une application réelle.

Dans une première partie, on verra le cœur du sujet avec une mise en œuvre allant plus loin que ce que fait le code généré par les makers.

Dans une seconde partie on verra comment gérer le télé-versement (upload) d’images dans l’application.

2. Étape 1 : Gestion d’associations 1-N dans une application réaliste

L’objectif de cette étape est de comprendre précisément comment mieux gérer les pages et formulaires de gestion de données liées par une association 1-N.

On va voir comment on doit dépasser les contrôleurs « CRUD basiques », pour s’approcher d’une expérience utilisateur réaliste, notamment quand on a des données liées entre-elles dans le modèle de données.

L’ajout de nouvelles fonctionnalités à une application nécessite l’extension du Modèle des données de l’application, et l’ajout du code de gestion des pages correspondantes.

On va examiner en particulier le cas des associations 1-N, qui sont très fréquentes en pratique.

On a déjà vu que des outils de génération de code permettent de faciliter le prototypage des pages et des formulaires. Plutôt que d’écrire du code « à la main » pour ajouter ce type de fonctions à l’application, on peut utiliser des outils fournis par Symfony pour générer du code, dans le maker bundle.

En particulier, on apprécie d’utiliser l’assistant make:crud qui génère pour nous des Contrôleurs et leurs gabarits. Ceci diminue le risque d’erreurs et d’oublis, et accélère le développement, au moins pour un prototype de la fonction voulue. Le code généré est du code PHP pour Symfony complètement « normal » qui peut (et doit) être retouché ensuite à loisir.

Cependant, pris tel-quel, ce code n’offre qu’un prototype, des mécanismes de CRUD très basiques, proches de ce dont on aurait besoin dans un backoffice destiné à des adminisrateurs.

Nous allons maintenant essayer d’aller plus loin que le prototypage, pour nous rapprocher de ce qu’on attend d’une application Web « normale ».

Nous allons voir l’importance de la finalisation qui doit être réalisée par les développeurs, pour gérer en particulier la contextualisation nécessaire dans la navigation entre pages et formulaires, dans une application destinée aux utilisateurs finaux.

2.1. TODO Étape 1-a : Ajout de l’entité Project

L’objectif de cette étape est d’ajouter l’entité Projet au modèle des données de l’application.

Dans cette nouvelle évolution de l’application fil-rouge ToDo, on ajoute une gestion de « projets » dans lesquels on peut ranger des tâches.

Il s’agit d’une simple association, transitoire, et non d’une composition. Un projet peut contenir de 0 à N tâches et une tâche peut être associée ou non à 1 projet.

Procédez aux modifications suivantes :

  1. Ajoutez l’entité Project qui sera liée aux entités des tâches Todo via une association 1-N, en utilisant le générateur Symfony pour Doctrine.

    symfony console make:entity
    
    Class name of the entity to create or update (e.g. DeliciousPopsicle):
    > Project
    
    created: src/Entity/Project.php
    created: src/Repository/ProjectRepository.php
    
    Entity generated! Now let's add some fields!
    You can always add more fields later manually or by re-running this command.
    
    New property name (press <return> to stop adding fields):
    > title
    
    Field type (enter ? to see all types) [string]:
    > 
    
    Field length [255]:
    > 
    
    Can this field be null in the database (nullable) (yes/no) [no]:
    > 
    
    updated: src/Entity/Project.php
    
    Add another property? Enter the property name (or press <return> to stop adding fields):
    > description
    
    Field type (enter ? to see all types) [string]:
    > text
    
    Can this field be null in the database (nullable) (yes/no) [no]:
    > yes
    
    updated: src/Entity/Project.php
    
    Add another property? Enter the property name (or press <return> to stop adding fields):
    > todos
    
    Field type (enter ? to see all types) [string]:
    > ?
    
    Main types
     * string
     * text
     * boolean
     * integer (or smallint, bigint)
     * float
    
    Relationships / Associations
     * relation (a wizard 🧙 will help you build the relation)
     * ManyToOne
     * OneToMany
     * ManyToMany
     * OneToOne
    
    Array/Object Types
     * array (or simple_array)
     * json
     * object
     * binary
     * blob
    
    Date/Time Types
     * datetime (or datetime_immutable)
     * datetimetz (or datetimetz_immutable)
     * date (or date_immutable)
     * time (or time_immutable)
     * dateinterval
    
    Other Types
     * ascii_string
     * decimal
     * guid
     * uuid
     * ulid
    
    
    Field type (enter ? to see all types) [string]:
    > OneToMany
    
    What class should this entity be related to?:
    > Todo
    
    A new property will also be added to the Todo class so that you can access and set the related Project object from it.
    
    New field name inside Todo [project]:
    > 
    
    Is the Todo.project property allowed to be null (nullable)? (yes/no) [yes]:
    > 
    
    updated: src/Entity/Project.php
    updated: src/Entity/Todo.php
    
    Add another property? Enter the property name (or press <return> to stop adding fields):
    > 
    
    
    
     Success! 
    
    
    Next: When you're ready, create a migration with symfony console make:migration
    

    Notez que le champ project ajouté à Todo ci-dessus, peut être mis à null, ce qui signifie qu’une tâche peut n’avoir aucun projet : association simple, et non composition.

  2. Ensuite, recréez la base de données :
    • symfony console doctrine:database:drop --force
    • symfony console doctrine:database:create
    • symfony console doctrine:schema:create
    • symfony console doctrine:fixtures:load -n
  3. Puis générez les classes gérant un contrôleur CRUD pour Project

    symfony console make:crud
    
    The class name of the entity to create CRUD (e.g. VictoriousPizza):
    > Project
    
    Choose a name for your controller class (e.g. ProjectController) [ProjectController]:
    > 
    
    Do you want to generate tests for the controller?. [Experimental] (yes/no) [no]:
    > 
    
    created: src/Controller/ProjectController.php
    created: src/Form/ProjectType.php
    created: templates/project/_delete_form.html.twig
    created: templates/project/_form.html.twig
    created: templates/project/edit.html.twig
    created: templates/project/index.html.twig
    created: templates/project/new.html.twig
    created: templates/project/show.html.twig
    
    
     Success! 
    
    
    Next: Check your new CRUD by going to /project/
    
  4. Vérifiez la présence des routes correspondantes dans l’application :

    symfony console debug:router
    
    
    ->app_project_index                  GET        ANY      ANY    /project/       
    
    
  5. Ajoutez enfin une entrée du menu Bootstrap, dans config/packages/bootstrap_menu.yaml :

    bootstrap_menu:
      menus:
        main:
          items:
            projects:
               label: 'Projects list'
               route: 'app_project_index'
    # ...
    
  6. Nettoyez le cache (Symfony se comporte souvent bizarement après l’exécution du maker de CRUD…) :

    symfony console cache:clear
    
  7. Testez que l’application fonctionne bien, et que le CRUD des projets fonctionne.

2.2. Étape 1-b : Gestion du lien entre projet et tâches

L’objectif de cette étape est de modifier le reste de l’application pour permettre la navigation entre projets et tâches

2.2.1. TODO Ajout du lien vers le projet dans le formulaire de modification des tâches

On souhaite tout d’abord pouvoir sauvegarder dans la base de données le fait qu’une tâche peut faire partie d’un projet.

  1. Chargez la page d’ajout d’une nouvelle tâche /todo/new

    Le formulaire permet de créer une tâche, mais pas de l’affecter à un projet à la création. De la même façon il faut permettre de modifier les tâches pour renseigner la valeur de l’attribut Todo::project qu’on a jouté à l’étape précédente.

  2. Modifiez en conséquence la classe de gestion du formulaire de modification des tâches (TodoType, dans src/Form/), pour ajouter un champ project dans le formbuilder.
  3. Testez dans l’application pour ouvrir le formulaire de modification de tâches d’une tâche créée juste auparavant.

    Normalement, à ce stade, le formulaire fonctionne, proposant une liste de projets vide : normal, nous n’avons pas encore saisi de projets dans la base.

  4. Ajoutez maintenant un nouveau projet.
  5. Retournez modifier une tâche. Normalement, vous devriez constater une erreur : la construction du formulaire, très basique, doit afficher la liste des projets dans une liste, et pour cela, elle doit les afficher sous forme de chaîne de caractères.

    Modifiez en conséquence la classe Project pour ajouter une méthode __toString(). Vous pouvez vous inspirer d’une autre méthode __toString() déjà présente dans une autre classe du modèle des données.

  6. Vérifiez que la modification des tâches fonctionne enfin, permettant d’y définir le projet d’une tâche.
  7. Utilisez la barre d’outils Symfony pour examiner le comportement de la requête POST de soumission des données modifiées dans le formulaire de modification de tâche (remontez à la requête POST précédent la redirection en cliquant sur le token de la requête précédente).

    Vous devriez voir, dans l’outil Doctrine la requête UPDATE du type UPDATE todos SET updated = ?, project_id = ? WHERE tid = ?. La clé étrangère du projet est bien modifiée : le nouveau champ fonctionne.

  8. Vous pouvez maintenant vérifier aussi avec un dump() que les liens vers les projets des tâches sont bien définis, même si on ne les affiche pas encore.

    Modifiez par exemple templates/todo/index.html.twig pour ajouter un {% dump todo %} à l’intérieur du {% for todo in todos %}.

    L’affichage du dump doit ressembler à ceci, pour les tâches qu’on a affectée à un projet :

    App\Entity\Todo {#682 ▼
      -id: 5
      -title: "..."
      -completed: false
      -created: DateTime @1666943460 {#680 ▶}
      -updated: DateTime @1666944055 {#681 ▶}
      -project: Proxies\__CG__\App\Entity\Project {#701 ▼
        +__isInitialized__: false
        -id: 1
        -title: null
        -description: null
        -todos: null
         …2
      }
    }
    

    Remarquez que tant qu’on n’a pas accédé aux attributs d’un projet, l’attribut interne todo:project référence une entité « proxy », qui n’est pas initialisée (__isInitialized__), mais porte bien l’identifiant du projet concerné (id).

    C’est le mécanisme de chargement paraisseux dont nous parlerons en cours. Tant qu’on n’accède pas aux attributs du Project, inutile de charger ses données depuis la base de données.

2.2.2. TODO Ajout du projet dans la consultation d’une tâche

Modifiez le gabarit d’affichage d’une tâche pour afficher son projet, s’il est renseigné.

  1. Modifiez templates/todo/show.html.twig pour ajouter l’affichage de l’attribut todo.project. Vous pouvez spécialiser l’affichage, au cas où il n’y a pas de projet pour une tâche, avec un bloc if :

    {%  if todo.project %}
     ...
    {% endif %}
    
  2. Comparez l’affichage entre tâches d’un projet ou tâches isolées
  3. Modifiez le gabarit pour ajouter des dump pour afficher la tâche en mémoire, au début du gabarit (avant l’affichage de son projet), et après l’affichage de l’attribut project :

    {% dump todo %}
    {% dump todo.project %}
    
    ...
    
    {%  if todo.project %}
       ...
    
       {{ todo.project }}
    
       {% dump todo %}
    
       ...
    {% endif %}
    
    
    
  4. Testez l’affichage de la page pour une tâche ayant un projet.
    1. Regardez les deux premiers dumps faits avant l’accès au champ todo.project pour son affichage effectif dans la page, avec {{ todo.project }} : normalement, il s’agit, comme dans l’étape précédente, d’une entité Proxy non initialisée.
    2. Regardez maintenant le dernier dump. Cette fois, comme l’objet a dû être affiché, Twig a provoqué l’accès réel à l’objet depuis la base de données. Cette fois, la classe Proxy est initialisée (__isInitialized__: true), et les valeurs des attributs du Project autres que l’id sont bien renseignés à leurs valeurs.
    3. Regardez, dans l’outil Doctrine de la barre Symfony, les requêtes qui sont faites en base : pour cette même tâche ayant un projet, il y a 2 requêtes :
      • « SELECT t0.tid AS tid_1, t0.title AS title_2, [...] FROM todos t0 WHERE t0.tid = ? » pour charger la tâche
      • et la deuxième, plus tardive, « SELECT t0.id AS id_1, t0.title AS title_2, [...] FROM project t0 WHERE t0.id = ? » pour charger son projet, pisqu’il s’agit de l’afficher.
    4. Comparez avec l’affichage d’une tâche n’ayant pas de projet. En toute logique, on voit bien qu’on ne fait qu’une requête.

Vous voyez clairement le mécanisme de Lazy loading à l’œuvre, avec le patron de conception Proxy utilisé par Doctrine.

2.2.3. TODO Ajout de la liste des tâches dans la consultation des projets

Maintenant que vous pouvez renseigner en base de données des liens entre tâches et projets, vous pouvez ajouter l’affichage de la liste des tâches d’un projet, dans le gabarit de la fiche de consultation d’un projet (templates/project/show.html.twig) :

{% dump(project) %}

{# FIXME: display the project todos
{% for todo in ... %}
...
   <a href="{{ path('app_todo_show', {'id' : todo.id}) }}">{{ todo.title }}</a>
...
{% else %}
   <em>no tasks found</em>
{% endfor %}
/FIXME #}

2.2.4. TODO Ajout du projet dans la liste des tâches

Ajoutez l’affichage du projet (s’il est défini) dans la ligne affichant chaque tâche, dans la page de consultation de toutes les tâches (gabarit templates/todo/index.html.twig).

2.2.5. TODO Ajout des éléments de navigation entre pages

Vous pouvez maintenant compléter les pages existantes pour permettre de passer de la consultation d’une tâche à son projet (s’il existe), et compléter ainsi la navigation dans le modèle de données.

Modifiez templates/todo/show.html.twig pour compléter l’affichage de todo.project sous forme de lien (en vous inspirant de ce qui vient d’être fait pour le lien inverse de projet à tâche, avec path()).

Attention : l’application doit continuer de fonctionner aussi bien pour des tâches ayant un projet, que pour des tâches sans projet.

2.3. Étape 1-c : Formulaire d’ajout d’une tâche dans un projet

On souhaite maintenant améliorer les pages de notre application pour gérer un formulaire moins basique que ceux générés par les assistants de génération de CRUD.

Vous allez ajouter un nouveau formulaire qui permet d’ajouter une tâche dans le contexte d’un projet, directement, une fois connu un projet.

On souhaite que l’utilisateur qui consulte la fiche d’un projet puisse ajouter une tâche depuis cette page de l’application, plutôt que de devoir revenir à la liste des tâches, et en créer une, qui serait ensuite rattachée au projet.

2.3.1. TODO Ajout du lien d’appel à un nouveau formulaire

Modifiez le gabarit d’affichage d’un projet, pour ajouter, en dessous de la liste des tâches du projet, un lien vers un formulaire (que nous allons ajouter juste après).

Modifiez templates/project/show.html.twig pour ajouter un lien du type :

<tr>
        <th>Tâches</th>
        <td>
                <ul>
                  {% for todo in project.todos %}
                  ...
                  {% else %}
                  ...
                  {% endfor %}
                </ul>
                  {# CHANGEZ le nom de route une fois le formulaire de création de tâche ajouté #}
                  <a href="{{ path('app_project_show', {'id' : project.id}) }}">add new task</a>
        </td>
</tr>

Pour l’instant, le lien pointe sur la même page. On le modifiera plus tard, une fois ajouté la gestion du formulaire de nouvelle tâche.

2.3.2. TODO Ajout du formulaire de création d’une tâche dans le contexte d’un projet

Comme on connaît le projet en cours de consultation, on va directement invoquer l’ajout d’une tâche à ce projet, en utilisant un nouveau formulaire d’ajout de tâche ayant connaissance du projet courant.

Ajoutez différents éléments au code de l’application :

  1. créez un nouveau gabarit nommé todo/newinproject.html.twig, sur le modèle de todo/new.html.twig;
  2. ajoutez une méthode TodoController::newInProject() reprenant certains éléments de TodoController::new(), mais ajoutant une todo à un projet existant

    Son code correspond à ce qui suit :

    //...
    use App\Entity\Project;
    
         //...
    
         #[Route('/newinproject/{id}', name: 'app_todo_newinproject', methods: ['GET', 'POST'])]
         public function newInProject(Request $request, EntityManagerInterface $entityManager, Project $project): Response
         {
                 $todo = new Todo();
                 // already set a project, so as to not need add that field in the form (in TodoType)
                 $todo->setProject($project);
    
                 $form = $this->createForm(...);
                 //...
                 if ($form->isSubmitted() && $form->isValid()) {
                         //...
                         return $this->redirectToRoute('app_project_show', ['id' => $project->getId()], Response::HTTP_SEE_OTHER);
                 }
    
                 return $this->render('todo/newinproject.html.twig', [
                         'project' => $project,
                         'todo' => $todo,
                         'form' => $form->createView(),
                 ]);
         }
    

    Remarquons quelques faits marquants sur ce code :

    • le chemin de cette nouvelle route, /todo/newinproject/{id}, est dérivé de /todo, le préfixe de la classe TodoController qui gère les tâches (on aurait pu faire le choix de le placer dans ProjectController mais ça aurait été peu judicieux car tirant trop de liens avec l’entité Todo)
    • on effectue un passage d’argument de l’instance du projet dans l’appel (un objet $project de type Project), qui est généré « automagiquement » en chargeant un projet ayant pour identifiant le numéro id extrait du chemin de la route (/todo/newinproject/{id}) via la fonctionnalité d’Entity Value Resolver de Symfony.

      Ce mécanisme est essentiel pour écrire du code très compact. C’est le même qui est utilisé pour app_project_show :

      L’Entity Value Resolver effectue le chargement des données depuis la base via Doctrine automatiquement, sans avoir besoin de faire un ->find($id).

    • ajout de la Todo à son Project si les données soumises sont correctes :

      $todo->setProject($project);
      

      On utilise ici la méthode du modèle doctrine générée par make:entity en cas d’association OneToMany.

    • comme pour new(), on effectue un appel à persist() (qui sert à « taguer » une entité du modèle comme devant être sauvée en base) uniquement sur la nouvelle Todo. Oui, et alors ?

      Mieux vaut vérifier : ici, on a défini un projet pour la tâche. Qui doit être sauvegardé en base ? Le projet doit-il être modifié suite à l’ajout de cette tâche ?

      Dans cette configuration, c’est la tâche qui contient la référence vers son projet, mais l’inverse n’est pas vrai : l’entité projet n’est pas modifiée. En base de données, cette association OneToMany s’est traduite par la clé étrangère de Project migrée dans Todo.

      On ne doit donc bien faire qu’un persist() sur cette nouvelle Todo, comme dans new(). On en est sûrs, cette fois.

    • Attention, une fois ajouté la nouvelle Todo, on redirige vers le point de départ (en principe) de l’accès à ce nouveau dialogue : la consultation du projet, donc la route app_project_show (donc on doit appeler getId() sur $project pour construire la route correcte);
    • Notez qu’on passe le projet directement au gabarit todo/newinproject.html.twig, ce qui permet de le customiser par rapport au précédent gabarit new.html.twig;
    • Notez que le sous-formulaire de todo/_form.html.twig n’a pas eu besoin d’être modifié : on peut toujours créer des todos sans projets (autorisé dans le modèle de données) si on passe par /todo/new.
  3. Modifiez enfin le gabarit templates/project/show.html.twig pour appeler cette nouvelle route app_todo_newinproject dans le lien d’ajout de la tâche.

    Vérifiez que ça fonctionne (vous pouvez ajouter des dump(), et vérifier les requêtes transmises à la base de données dans les outils du développeur Symfony.

  4. Modifiez enfin le gabarit de nouvelle tâche pour contextualiser, par exemple avec :

    <h1>Add a Todo to {{ project }}</h1>
    

Vous avez vu ci-dessus un élément clé pour la gestion des entités liées par une association 1-N (OneToMany).

2.3.3. Amélioration du formulaire d’ajout d’une nouvelle tâche à un projet (optionnelle)

Vous pouvez raffiner un peu la gestion de ce formulaire d’ajout d’une nouvelle tâche à un projet, afin de ne pas afficher le champ de modification du projet dans le formulaire HTML, puisque, dans ce cas, le projet est fixé.

Cette amélioration n’est pas cruciale, donc vous pouvez passer à la suite, si vous êtes pressés par le temps restant dans la séance de TP.

Actuellement l’ajout du champ est réalisé dans TodoType::buildForm() :

$builder
 ->add('title')
 ->add('project');
 ...

On peut modificer ce comportement, mais attention, car la génération du formulaire s’applique aussi bien à la création (via new() ou newInProject()) qu’à la modification…

Une solution consiste à dupliquer la classe TodoType pour réaliser une classe TodoAddType ayant un comportement différent, spécifique à cette méthode add().

Une autre solution consiste à garder une seule classe TodoType, et à utiliser le mécanisme des options, qu’on a déjà utilisé pour l’affichage optionnel du statut terminé de la tâche (dans une partie optionnelle d’une séance précédente), mais en l’adaptant à ce nouveau contexte.

  1. Modifiez le code de la méthode TodoController::newInProject() pour gérer le passage de l’option display_project.

    Modifiez le code dans la méthode new(), qui appelle la génération du formulaire, pour passer en argument un tableau d’options, qui contient la valeur de l’option display_project, qui doit alors être fausse :

    public function newInProject(Request $request, EntityManagerInterface $entityManager, Project $project): Response
    {
       $todo = new Todo();
       $todo->setProject($project);
       //...
    
       $form = $this->createForm(TodoType::class, $todo,
               [
                 'display_project' => false,
                 //...
               ]
               );
    
  2. Modifiez le code du gestionnaire de formulaire TodoType pour n’afficher le champ de formulaire que pour les tâches modifiables
    1. Ajoutez-lui une option, via la méthode configureOptions(), nommée display_project de type booléen, dont la valeur par défaut est fausse :

      public function configureOptions(OptionsResolver $resolver)
      {
        $resolver->setDefaults([
                'data_class' => Todo::class,
                'display_project' => true
        ]);
        $resolver->setAllowedTypes('display_project', 'bool');
      }
      
    2. Modifiez le comportement de la création du formulaire, dans buildForm() pour ajouter ou non certains champs, selon qu’ils doivent être inclus dans le formulaire, en fonction de la valeur de display_project :

      public function buildForm(FormBuilderInterface $builder, array $options): void
      {
         ...
         if($options['display_project'] ) 
         {
             $builder->add('project');
         }
         $builder->add(...);
      
         ...
      
  3. Testez que le champ de sélection du projet a bien disparu quand on crée la tâche dans le contexte du projet.

    Vérifiez néanmoins que même si le champ n’est plus affiché, il est bien sauvegardé en base de données.

  4. Testez la création d’une tâche sans projet, en repassant par /todo/new, pour vérifier que la modification de TodoType n’a pas cassé le mécanisme d’ajout de tâches isolées.

2.3.4. Conclusion : Importance de la contextualisation pour aller au-delà de ce que propose le make:crud

Nous avons terminé la gestion de la contextualisation dans le cas d’une association OneToMany, pour aller plus loin que ce que proposent les formulaires par défaut créés par l’assistant make:crud.

Il est particulièrement important de gérer ce type de contextualisation quand une telle association est « structurante », avec une relation de composition.

Par exemple, dans une application réelle, où cette fois, les Projets seraient composés de tâches, et où toute tâche devrait appartenir au projet, on ne pourrait plus créer des tâches sans avoir de projet comme point de départ.

On ferait bien-sûr évoluer le modèle de données pour que le champ correspondant à l’association vers le projet d’une tâche ne puisse être nul. Cela garantirait la cohérence des données.

Mais au niveau utilisabilité des interfaces Web, il serait crucial de passer par les projets pour accéder aux tâches et de pré-renseigner le projet de la tâche à sa création comme on vient de le faire. En particulier s’il y a des milliers de projets et qu’il devient impossible de retrouver un projet dans une champ de saisie comportant des milliers d’éléments.

Il est tout aussi indispensable de gérer de telles relations structurantes quand on est sur une application multi-utilisateurs, pour ne pas mélanger les données de différents utilisateurs.

On réalisera donc de nombreuses adaptations similaires à cette contextualisation :

  • au niveau du chargement des données : données du bon utilisateur, ou dans le bon sous-contexte passé en paramètres de la route;
  • et au niveau de la définition des valeurs des attributs pour les nouvelles entités : encore une fois en fonction du contexte implicite (utilisateur courant connecté), ou explicite, en fonction des arguments passés aux routes.

Vous allez mettre en œuvre ce genre de contextualisation dans votre projet de façon assez similaire à ce qu’on vient de faire ici.

3. TODO Étape 2 : Ajout d’une fonction de mise en ligne d’images

Ajouter une fonctionnalité permettant le télé-versement (upload) d’images vers le site, pour associer des images aux entités Pastes de l’application fil-rouge.

On va utiliser le module VichUploader pour Symfony (cf. https://github.com/dustin10/VichUploaderBundle).

Procédez aux étapes suivantes :

  1. Installation du bundle :

    symfony composer require vich/uploader-bundle
    

    Confirmer l’exécution de la recette « Do you want to execute this recipe? »

  2. Configurer un mapping de téléversement dans le fichier config/packages/vich_uploader.yaml qui a été installé par composer/flex (rafraîchir le contenu du projet dans Eclipse) :

    vich_uploader:
        db_driver: orm
    
        mappings:
            pastes:
                uri_prefix: /images/pastes
                upload_destination: '%kernel.project_dir%/public/images/pastes'
                namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
    
  3. Lier l’entité Paste avec le module de téléversement. Pour ce faire, annoter la classe Paste (dans src/Entity/Paste.php) comme étant gérée par le module (annotation @Vich\Uploadable) :

    //...
    use Symfony\Component\HttpFoundation\File\File;
    use Vich\UploaderBundle\Mapping\Annotation as Vich;
    
    #[ORM\Entity(repositoryClass: PasteRepository::class)]
    #[Vich\Uploadable]
    class Paste
    
  4. Il faut ensuite :

    • Ajouter les attributs permettant le stockage et la gestion des fichiers d’images téléversés :
      • l’attribut imageName sera persisté dans la base de données via Doctrine
      • l’attribut imageFile sera utilisé par le module de télé-versement, et sera associé au mapping défini précédamment dans config/packages/vich_uploader.yaml (appelé pastes, ici)
      • l’attribut imageUpdatedAt qui sera utilisé en interne pour le bon fonctionnement du module
    • Ajouter aussi les getters et setters correspondants en suivant les instructions de la documentation du module, ce qui revient à ajouter ceci :
    use Symfony\Component\HttpFoundation\File\File;
    
    //...
    
        #[Vich\UploadableField(mapping: 'pastes', fileNameProperty: 'imageName', size: 'imageSize')]
        private ?File $imageFile = null;
    
        #[ORM\Column(nullable: true)]
        private ?string $imageName = null;
    
        #[ORM\Column(nullable: true)]
        private ?int $imageSize = null;
    
        #[ORM\Column(nullable: true)]
        private ?\DateTimeImmutable $updatedAt = null;
    
    
        //...
        /**
         * If manually uploading a file (i.e. not using Symfony Form) ensure an instance
         * of 'UploadedFile' is injected into this setter to trigger the update. If this
         * bundle's configuration parameter 'inject_on_load' is set to 'true' this setter
         * must be able to accept an instance of 'File' as the bundle will inject one here
         * during Doctrine hydration.
         *
         * @param File|\Symfony\Component\HttpFoundation\File\UploadedFile|null $imageFile
         */
        public function setImageFile(?File $imageFile = null): void
        {
            $this->imageFile = $imageFile;
    
            if (null !== $imageFile) {
                // It is required that at least one field changes if you are using doctrine
                // otherwise the event listeners won't be called and the file is lost
                $this->updatedAt = new \DateTimeImmutable();
            }
        }
    
        public function getImageFile(): ?File
        {
            return $this->imageFile;
        }
    
        public function setImageName(?string $imageName): void
        {
            $this->imageName = $imageName;
        }
    
        public function getImageName(): ?string
        {
            return $this->imageName;
        }
    
        public function setImageSize(?int $imageSize): void
        {
            $this->imageSize = $imageSize;
        }
    
        public function getImageSize(): ?int
        {
            return $this->imageSize;
        }
    
  5. Modifier le formulaire d’ajout / édition de Pastes src/Form/PasteType.php :

    //...
    use Symfony\Component\Form\Extension\Core\Type\TextType;
    use Vich\UploaderBundle\Form\Type\VichImageType;
    
    //...
    
     class PasteType extends AbstractType
     {
    
         //...
         $builder
             //...
    
                ->add('imageName', TextType::class,  ['disabled' => true])
                ->add('imageFile', VichImageType::class, ['required' => false])
             ;
    
  6. Modifier le gabarit d’affichage des pastes templates/paste/show.html.twig :

    <tr>
        <th>Image</th>
        <td><img src="{{ vich_uploader_asset(paste, 'imageFile') }}"/></td>
    </tr>
    
  7. Modifier le content-type en fonction du type d’image, si une image est présente, dans src/Controller/PasteController.php :

    public function new(Request $request, PasteRepository $pasteRepository): Response
    {
         // ...
         if ($form->isSubmitted() && $form->isValid()) {
    
                 // Change content-type according to image's
                 $imagefile = $paste->getImageFile();
                 if($imagefile) {
                         $mimetype = $imagefile->getMimeType();
                         $paste->setContentType($mimetype);
                 }
    
                 //...
    
  8. Mettez à jour le schéma de la base de données :

    symfony console doctrine:schema:update --force
    
    

Vous pouvez tester le fonctionnement de la mise en ligne d’images dans les Pastes.

Vérifiez dans l’inspecteur des requêtes réseau du navigateur (ou dans les logs du serveur Web) d’où proviennent les images. Identifiez-vous leur répertoire de stockage sur le disque dur ?

Vous pouvez consulter les noms d’images sauvegardés en base de données, avec symfony console dbal:run-sql "select * from paste;".

À partir de ces informations, vous pourriez facilement ajouter des DataFixtures gérant des noms de fichiers d’images.

Le fonctionnement de ce module requiert la présence du module fileinfo dans PHP. Il convient éventuellement d’activer ce module dans la configuration de php.ini, en cas de problèmes.

Attention aux contraintes de déploiement, et à la sécurité, quand on utilise un tel module, qui manipule les fichers stockés dans un des répertoires du projet.

Il est probablement assez risqué d’utiliser un tel module en production, car le dépôt d’image modifie des fichiers à l’intérieur du sous-répertoire public/ du projet… de public/ à ../src/Controller/, il n’y a pas très loin, et un bug ou une faille de sécurité pourrait bien amener à compromettre facilement le code de l’application.

C’est pour éviter cela que certaines solutions de déploiement des applications PHP vont restreindre la possibilité à l’application de modifier le stockage où elle est déployée… Le répertoire var/ sera typiquement accessible en modification, mais peut-être pas public/. Ou bien, on utilisera un autre emplacement que le sous-répertoire var/ pour les fichiers temporaires générés.

4. Évaluation

À l’issue de cette séance, les éléments ci-dessous doivent vous paraître clairs :

  • Les outils de prototypage pour créer de nouvelles entités ou des Contrôleurs CRUD pour ces entités, qui génèrent du code PHP pour nous.
  • Comment on doit dépasser ce genre de prototypes CRUD, pour aller vers un schéma de routage contextualisé
  • La gestion du télé-versement de fichiers dans les formulaires, en tant que données « comme les autres ».

Auteur: Olivier Berger (TSP)

Created: 2023-10-24 Tue 18:51

Validate