Epsi - Symfony

Renaud Bredy
renaud.bredy@kibatic.com
bredy.renaud@gmail.com

Epsi Grenoble B2

2020  v 1.2

 

Objectifs du module

  • Assimiler chaque élément du concept MVC
  • Accéder à une base de données via Doctrine
  • Développer une application qui exploite une BDD
  • Comprendre l'architecture du framework symfony, et savoir le mettre en oeuvre via un développement.

Evaluation

  • 2 notes de Contrôle continu 
    • 1 QCM
    • 1 QCM
  • 1 projet

1. Structure du projet

Structure

  • bin/ : executables (console)
  • config/ : configurations
  • public/ : fichiers accessibles (index.php, css, images, uploads...)
  • src/ : Notre code source
  • templates/ : code de présentation (html)
  • tests/ : Tests du code source
  • translations/ : traductions
  • var/ : fichiers temporaires, fichiers  générés (cache, logs...)
  • vendor/ : libs externes

Fichiers

  • .env : comprend les variables d'environnement: comme le mot de passe de la bdd 
  • composer.json : Décrit les librairies php externes
  • .gitignore : décrit les fichiers à ignorer pour git
  • phpunit.xml.dist configuration de test
  • symfony.lock utilisé par symfony Flex (un plugin de composer)

2. Hello world

Controleur et routing

La page Hello world

  • Accessible via monsite/hello/world
  • Affiche  Hello World
<?php
// src/Controller/HelloController.php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class HelloController extends AbstractController
{
    /**
     * @Route("/hello/world", name="hello_world")
     */
    public function hello()
    {
        return new Response(
            '<html><body>Hello world!</body></html>'
        );
    }
}

La page Hello Name

L'url /hello/Renaud affichera "Hello Renaud"

L'url /hello/ affichera "Hello world"

 

Pour cela, on va modifier le routing

doc https://symfony.com/doc/4.2/routing.html

 

  1. Mettre une variable dans la route
  2. Mettre une valeur par default

 

La page Hello Worlds

On va afficher plusieurs fois le mot world
L'url /hello/4 affichera "Hello world world world world"
 

Mais, je souhaite que la route hello_name soit toujours accessible!

 

  1. Tout est ici dans ce paragraphe de documentation

 

3. Hello {{ world }}

Templates Twig

Twig


return $this->render('hello/hello_name.html.twig', array(
    'name' => $nameParameter,
));
<html><body>
<h1>hello {{ name }}</h1>
</body></html>

Modifions notre contrôleur

Et créons le fichier twig dans

templates/hello/hello_name.html.twig

Rajouter du style (1/3)

Objectifs:

  1. Le design est déjà intégré sur la homepage.  https://startbootstrap.com/themes/clean-blog/
  2. Utiliser le système d'extension de Twig pour avoir le même style sur toutes nos pages

Rajouter du style (2/3) 

 

                                                                

On utiliser asset() pour charger les fichier présents dans public/

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <meta name="description" content="">
    <meta name="author" content="">

    <title>{% block title %}Cours de Symfony{% endblock %}</title>
    {% block stylesheets %}{% endblock %}
    <link rel="icon" type="image/x-icon" href="{{ asset('favicon.ico') }}"/>

    <link href="{{ asset('vendor/bootstrap/css/bootstrap.css') }}" rel="stylesheet">
    <link href="{{ asset('css/blog-home.css') }}" rel="stylesheet">
</head>

Rajouter du style (3/3) 

 

A vous d'intégrer les pages Hello world!

Sans dupliquer le code, faire en sorte que le contenu soit contenu dans le design général du site

Lien vers la page Hello

Changer un item du menu en lien Hello

 

La fonction path génère une route via son nom

<a href="{{ path('hello_world') }}">Hello</a>

Et on peut lui passer des paramètres

{{ path('hello_name', {'name': 'Jean' }) }}

4. Les entités

Le modèle et la base de données

Configuration de la bdd

 

Sf Doc :  https://symfony.com/doc/current/doctrine.html

Installation sous Ubuntu : mysql ou mariadb

.env privé et perso

.env.dist publique et commité, liste les params

 

bin/console doctrine:database:create

L'objet Category

  1. Sous src/Entity, Créer la classe Category (Id, Name)
  2. L'instancier dans un BlogController
public function homepage()
{
    $category = new Category();
    $category->setName('Symfony');
	
    return $this->render('blog/hompage.html.twig', array(
      'categories' => array($category)
    ));
}

3. L'afficher dans le template twig

{% for category in categories %}
  <b>votre html</b>
{% endfor %}

Doctrine

object-relational mapping

Category lié à la BDD

Recréer Category en utilisant la commande Symfony

bin/console make:entity

 

-> on créé une entité Category

avec un attribut name

 

Répercuter les changements de schéma avec 

bin/console doctrine:schema:update

--dump-sql puis --force

 

Vérifier les changements en base de données

Récupérer les catégories

  1. Mettre quelques catégories dans la bdd
  2. Récupérer les catégories


  3.  
  4. Les afficher dans la homepage
//src/Controller/HomepageController
$categories = $this->getDoctrine()->getRepository(Category::class)->findAll();

Repository

  • Un repository centralise tout ce qui touche à la récupération des entités.
  • JAMAIS de requête SQL ailleurs que dans un repository.
  • Requetes en Doctrine Query Language (DQL).
  • Méthodes magiques:
$article = $repository->find(1);

$articles = $repository->findAll();

$articles = $repository->findBy(
  array('author' => 'Alexandre'), // Criteres
  array('date' => 'desc'),        // Tri
  5,                              // Limite
  0                               // Offset
);

DQL

Vision objet du SQL

N'est pas lié a un SGBD (mysql, postgres, oracle...)

public function findByCategoryAndYear(Category $category, $year)
{
  $qb = $this->createQueryBuilder('article');

  $qb->where('article.category = :category')
     ->andWhere('article.date BETWEEN :date_start AND :date_end')
     ->orderBy('article.date', 'DESC')
     ->setParameter('category', $category)
     ->setParameter('date_start', new \Datetime($year . '-01-01'))
     ->setParameter('date_end', new \Datetime($year . '-12-31'))
  ;

  return $qb
    ->getQuery()
    ->getResult()
  ;
}

Exercice (1/3)

 

Créer la classe Article (Id, title, content , createdAt)

 

Mettre un Article de test dans la base de données

Exercice (2/3)

  1. Récupérer le dernier article dans le contrôleur Homepage
  2. L'afficher dans la homepage

 

Exercice (3/3)

Doc http://symfony.com/doc/current/doctrine/associations.html​
 

  1. Grace à la doc (OneToMany et ManyToOne), lier l'article à la catégorie : Un article a une seule catégorie, une catégorie à plusieurs articles.
  2. Mettre à jour nos données en base
  3. Dans la homepage, afficher le nom de la catégorie pour chaque article

Lazy Loading

5. MVC

Modèle

Gère les données .

Son rôle est d'aller récupérer les informations dans la base de données, de les organiser et de les assembler.

C'est aussi son role de modifier les données.

On y trouve donc (entre autres) les requêtes SQL.

 

C'est nos Repositories et nos Entities

Vue

Récupère des variables et les affiche, sans les modifier ou effectuer des traitements.

 

Principalement du code HTML, CSS et Javascript.

 

Très peu de PHP pour de l'algorithmie simple: boucles et conditions.

Contrôleur

Fait le lien entre la vue et le modèle.

 

Le contrôleur décide en fonction d'un utilisateur, de ses droits et des paramètres des requêtes quels traitements doivent être effectués.

 

Il ne doit pas faire de traitement et doit faire le moins de ligne possible ;)

6. Services

L'injection de dépendance et l'architecture orienté service

Sans service

$repository = new ArticleRepository(
    new EntityManager(
         new Connection([
             'password' => '***'
             'user' => 'symfony'
             'database' => 'symfony'
             'host' => 'localhost'
         ]),
         new MySQLDriver(...)
    ), 
    new ClassMetadata(Article::class)
);

Avec service

$repository = $this->getContainer()->get(ArticleRepository::class)
class NewsletterManager
{
    private $mailer;

    public function __construct(\Mailer $mailer)
    {
        $this->mailer = $mailer;
    }
}

Service

Classe qui rempli une fonction (responsabilité).

Elle est enregistrée en tant que service par configuration.
 

 

Le conteneur de services organise et instancie tous vos services, grâce à leur configuration.

L'injection de dépendances est assurée par le conteneur, qui connaît les arguments (TypeHint) dont a besoin un service pour fonctionner, et les lui donne donc à sa création.

Plus de détail OpenClassRoom

7. CRUD

Create

Read

Update

Delete

Persister, modifier et supprimer les entités

public function createAction()
{
    $entityManager = $this->getDoctrine()->getManager();

    $category = new Category();
    $category->setName('Keyboard');

    // tells Doctrine you want to (eventually) save the Category (no queries yet)
    $entityManager->persist($category);

    // actually executes the queries (i.e. the INSERT query)
    $entityManager->flush();

    return new Response('Saved new category with id ' . $category->getId());
}

Persister

public function updateAction($categoryId)
{
    $entityManager = $this->getDoctrine()->getManager();
    $product = $entityManager->getRepository(Category::class)->findOneById($categoryId);

    // comme la category provient de l'entityManager 
    // elle est déjà référencée par Doctrine (pas besoin de persist() )
    $product->setName('New Name');
    $entityManager->flush();
}

Mettre à jour

// on en profite pour utiliser les paramConverter :D
/**
 * @Route("/category/{id}")
 */
public function deleteAction(Category $category)
{
    $entityManager = $this->getDoctrine()->getManager();
    $entityManager->remove($category)
    $entityManager->flush();

    return $this-redirectToRoute('category_index');
}

Supprimer

8. Formulaires

Améliorons notre CRUD

class CategoryType extends AbstractType
{
  public function buildForm(FormBuilderInterface $builder, array $options)
  {
    $builder
        // On ne permet pas de modifier l'id

        // On permet de modifier l'attribut name, et ce sera un widget text
        ->add('name', TextType::class, array(
            'required' => true,
        ))
        // Ce champ n'est pas mappé. 
        ->add('save', SubmitType::class)
    ;
  }

  public function configureOptions(OptionsResolver $resolver)
  {
    parent::configureOptions($resolver);
    $resolver->setDefaults([
        'data_class' => Category::class,
    ]);
  }
}

FormType

  {{ form_start(form, {'attr': {'class': 'form-horizontal'}}) }}

  {# Les erreurs générales du formulaire. #}
  {{ form_errors(form) }}

  {# Génération du form_label + form_errors + form_widget pour un champ. #}
  {{ form_row(form.date) }}

  {# Génération manuelle du form_row: #}
  <div class="form-group">
    {{ form_label(form.title, "Titre de l'annonce", {'label_attr': {'class': 'col-sm-2'}}) }}
    {{ form_errors(form.title) }}
    <div class="col-sm-10">
      {{ form_widget(form.title, {'attr': {'class': 'form-control'}}) }}
    </div>
  </div>

Affichage

sans  personnalisation

{{ form(form) }}
{# ou #}
{{ form_start(form, {'attr': {'novalidate': 'novalidate'}}) }}
{{ form_widget(form) }}
{{ form_end(form) }}

Création

public function create(Request $request)
{
    $category = new Category();
    // Créé le formulaire
    $form = $this->createForm(CategoryType::class, $category);
    
    // Hydrate l'objet lié au formulaire avec les données de la request
    $form->handleRequest($request);

    if ($form->isSubmitted()) {
        if ($form->isValid()) {
            $em = $this->getDoctrine()->getManager();
            $em->persist($category));
            $em->flush());

            return $this->redirectToRoute("category_index");
        }
    }

    return $this->render('category/create.html.twig', [
        // /!\ we render a view instance of the form
        "form" => $form->createView(),
    ]);
}

Validation

//src/entity/Category.php
// on importes les annotations Constraints/XXX
use Symfony\Component\Validator\Constraints as Assert;
// [...]
class Category
{
    // [...]
    /**
     * @Assert\Length(min=2)
     * @Assert\NotBlank()
     * @ORM\Column(name="name", type="string", length=255)
     */
    private $name;

On définit nos règles de validation

puis un service viendra valider les données

@Assert\Contrainte(option1="valeur1", option2="valeur2", …)

Syntaxe:

ATTENTION de ne pas confondre validation coté serveur et validation html5 coté navigateur (pas sécurisé)

Fichier (1/4)

// class src/Enttiy/Article 

   /**
     * Contiendra le nom de l'image en bdd
     * @ORM\Column(type="string")
     */
    private $image;

    /**
     * Contiendra l'objet image du formulaire
     * @Assert\Image()
     */
    private $imageFile;
    // [...] Pensez aux getter et setter

On stocke seulement le chemin relatif en bdd : le fichier, publique, ira dans le dossier public/uploads

 

// class src/Form/ArticleFormType
$builder->add('imageFile', FileType::class);

On ajoute le champ au formulaire

Fichier (2/4)

if ($form->isSubmitted() && $form->isValid()) {
    // $file stores the uploaded Image file
    /** @var Symfony\Component\HttpFoundation\File\UploadedFile $file */
    $file = $article->getImageFile();

    $fileName = md5(uniqid()) . '.' . $file->guessExtension();
    
    // moves the file to the directory where image are stored
    $file->move(
        $this->getParameter('kernel.project_dir') . '/public/uploads/article',
        $fileName
    );

    // updates the 'image' property to store the filename
    $article->setImage($fileName);

On déplace le fichier et on ne stocke que le nom en bdd

Maintenant pour afficher l'image dans les templates

<img src="{{ asset('uploads/article/' ~ article.image) }}"/>

Fichier (3/4)

// src/Uploader/FileUploader.php
class FileUploader
{
    private $targetDirectory;

    public function __construct($targetDirectory)
    {
        $this->targetDirectory = $targetDirectory;
    }

    public function upload(UploadedFile $file)
    {
        $fileName = md5(uniqid()).'.'.$file->guessExtension();

        $file->move($this->getTargetDirectory(), $fileName);

        return $fileName;
    }
}

On va alléger le contrôleur en factorisant le code dans un service.

Fichier (4/4)

# config/services.yml
services:
    # ...
    App\Uploader\FileUploader:
        arguments:
            $targetDirectory: '%kernel.project_dir%/public/uploads/article'

Notre controlleur

if ($form->isSubmitted() && $form->isValid()) {
    // on remplace tout le code de l'upload de fichier par:
    $fileName = $this->articleFileUploader->upload($article->getImageFile())
    $article->setImage($fileName);
private $articleFileUploader;

public function __construct(FileUploader $articleFileUploader)
{
    $this->articleFileUploader = articleFileUploader;
}

9. Sécurité

Gestion des droits et utilisateurs

Authentification : firewall

Différencier un utilisateur ou un anonyme

But du firewall: Comment identifier un utilisateur

Autorisation : roles

L'Utilisateur a plusieurs rôles qui lui sont attachés, lors de l'authentification.

 

On code pour faire en sorte qu'une ressource (URL/Controller...) soit accessible qu'à certains rôles.

Firewall, Authentication, Authorization

Zone protégés:                                             Zone libre accès:

Bureaux, Dancefloor, Salle 25+                Parking, jardin extérieur

 

On rentre pas si on est trop pauvre.
On rentre gratuitement,  coup de
tampon sur la main.
On montre sa carte d'identité pour aller dans la salle 25+.
Les bureaux uniquement pour le Staff
 

Le cas "Vieux Manoir"

Firewall, Authentication, Authorization

 

 /dancefloor                      Anonymes
 /admin                              Employés only
 /exterieur                         Pas sous firewall

dancefloor                        via un tampon
StaffOnly                           via une clé

Salle des vieux                 via Carte d'identité

On peut accéder à la salle des vieux (car dans le Firewall du dancefloor) mais on va nous demander de nous identifier: 403 (forbidden)

On ne peut pas accéder au staff: 401 (Unauthorized)

Fonctionnement

Fonctionnement

Notre sécurité http

security:
    firewalls:
        main:
            anonymous: ~
            http_basic: ~ 

Firewall: authentification http

security:
    access_control:
        - { path: ^/hello, roles: ROLE_ADMIN } # routes qui commencent par hello

Contrôle d'accès

# Sécuriser un controller ou une action
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
/** @Security("has_role('ROLE_ADMIN')") */

Nos utilisateurs

# config/packages/security.yaml
security:
    providers:
        in_memory:
            memory:
                users:
                    user:  { password: pass, roles: [ 'ROLE_USER' ] }
                    admin: { password: pass, roles: [ 'ROLE_ADMIN' ] }
    encoders:
        # Pour tous nos utilisateur, le mot de passe est en clair (BEURK)
        Symfony\Component\Security\Core\User\User: plaintext

    role_hierarchy:
        ROLE_ADMIN:     ROLE_USER

Le rôle admin hérite du ROLE_USER

Verifier les droits dans TWIG


                {% if is_granted('ROLE_ADMIN') %}
                    <li class="nav-item">
                        <a class="nav-link" href="{{ path('hello_name', { 'name' : 'Renaud' }) }}">Hello</a>
                    </li>
                {% endif %}

Installer un bundle

# installe la librairie dans vendor.
# dans la console, tapez:
composer require friendsofsymfony/user-bundle "~2.0"

Configurer FOSUserBundle

framework:
    templating:
        engines: ['twig']

fos_user:
    db_driver: orm
    firewall_name: main 
    user_class: App\Entity\User
    from_email:
        address: noreply@codeblog.com
        sender_name: Code Blog

Dans ... app/config/config.yml 

On veut la configuration des routes aussi.
app/config/routing.yml

fos_user:
    resource: "@FOSUserBundle/Resources/config/routing/all.xml"

Crééer notre user

Configurer le firewall

security:
    encoders:
        FOS\UserBundle\Model\UserInterface: bcrypt

    providers:
        fosuser_provider:
            id: fos_user.user_provider.username

    firewalls:
        main:
            pattern: ^/
            form_login:
                provider: fosuser_provider
                csrf_token_generator: security.csrf.token_manager
            logout:       true
            anonymous:    true

    access_control:
        - { path: ^/login$, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/register, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/resetting, role: IS_AUTHENTICATED_ANONYMOUSLY }

Dernière ligne droite!

bin/console doctrine:schema:update --force

Allons Sur l'url /register   ... C'est moche!

{% extends "base.html.twig" %}
{# Le block fos_user_content du vendor doit être affiché dans notre block body #}
{% block body %}
    {% block fos_user_content %}{% endblock %}
{% endblock %}

pour surcharger n'importe quel template d'un bundle

app/Resources/{BUNDLE_NAME}/views/{PATH/TEMPLATE.html.twig}

app/Resources/FOSUserBundle/views/layout.html.twig

10. Travail noté

Système de commentaire

Système de commentaires

Ajoutez un système de commentaire pour vos articles

Scénario d'implémentation:

  1. Sous les articles en consultation, nous affichons la liste des commentaires
  2. Un utilisateur identifié clique sur le lien "commenter" sous les articles
  3. Il renseigne son commentaire dans un formulaire et valide. Le commentaire est sauvegardé et affiché dans  la liste des commentaires.

Surtout faites vous plaisir !

Fonctionnalités bonus

  • Les utilisateurs identifiés n'ont pas besoin de saisir leur adresse email et nom lorsqu'ils créent un commentaire
  • Les utilisateurs peuvent supprimer leur propre commentaire
  • L'administrateur peut supprimer un commentaire
  • N'importe quelle idée qui vous passe par la tête

Critères d'évaluation

Le code fonctionne

Le code est compréhensible

Le code n'est pas copié sur le voisin

Le projet est rendu à temps : avant dimanche 22 décembre à 23h59m59s.

 

Envoyez votre projet en zip (ou un tar) à mon adresse email renaud.bredy@kibatic.com

Utilisez weTransfer si vous avez des problèmes de format ou taille de dossier

11. API - Rest

EPSI

By Goto Rahoutan

EPSI

Symfony beginner course

  • 616