Créer une application web complète avec Symfony 5 et PHP 8

Tutoriel Symfony 5 - Créer une application web complète avec PHP 8

À propos de ce tutoriel

Je vous montre comment créer une application web avec PHP 8 et Symfony 5. Cette article vous explique une manière de réaliser un site web de type « raccourcisseur d’url » avec un système d’inscription et de connexion.

Création du projet

symfony new UrlShortener --full

Installation des dépendances

composer require symfony/webpack-encore-bundle
npm install bootstrap sass sass-loader chart.js @fortawesome/fontawesome-free

Création des contrôleurs

php bin/console make:controller
HomeController
php bin/console make:controller
UrlController
<?php

namespace App\Controller;

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

class HomeController extends AbstractController
{
    #[Route('/', name: 'app_homepage')]
    public function index(): Response
    {
        return $this->render('home/index.html.twig', [
            'controller_name' => 'HomeController',
        ]);
    }
}
<?php

namespace App\Controller;

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

class UrlController extends AbstractController
{
    #[Route('/', name: 'app_homepage')]
    public function index(): Response
    {
        return $this->render('url/index.html.twig', [
            'controller_name' => 'UrlController',
        ]);
    }
}

Importation du template Bootstrap

Vous pouvez récupérer le template Bootstrap en cliquant ici.

Création de la page d’accueil

{% extends 'base.html.twig' %}

{% block title %}Accueil{% endblock %}

{% block body %}
    <main>

        <div id="myCarousel" class="carousel slide" data-bs-ride="carousel">
            <div class="carousel-inner">
                <div class="overlay-image" style="background-image: url('images/slide.jpg');"></div>
                <div class="carousel-item active">
                    <div class="container">
                        <div class="carousel-caption text-start">
                            <h1>Le meilleur raccourcisseur d'URL.</h1>
                            <p>Un raccourcisseur d'URL conçu avec des outils puissants pour vous aider à développer et à protéger votre marque.</p>

                            {% if is_granted('IS_ANONYMOUS') %}
                                <p><a class="btn btn-lg btn-primary" href="#">Inscription</a></p>
                            {% endif %}

                        </div>
                    </div>
                </div>

            </div>
        </div>

        <!-- Marketing messaging and featurettes
        ================================================== -->
        <!-- Wrap the rest of the page in another container to center all the content. -->

        <div class="container marketing">

            <!-- Three columns of text below the carousel -->
            <div class="row">

                <div class="col-lg-4">
                    <img loading="lazy" width="320" height="180" src="{{ asset('images/illustration-1.png') }}" />
                    <h2 class="fw-bold">Inspirer la confiance</h2>
                    <p class="text-start">Avec plus de clics, la reconnaissance de la marque et la confiance des consommateurs dans vos communications augmentent, ce qui à son tour inspire encore plus d'engagement avec vos liens.</p>
                </div>

                <div class="col-lg-4">
                    <img loading="lazy" width="320" height="180" src="{{ asset('images/illustration-2.png') }}" />

                    <h2 class="fw-bold">Boostez vos résultats</h2>
                    <p class="text-start">En plus d'une meilleure délivrabilité et d'un meilleur taux de clics, des statistiques au niveau des liens vous donnent un aperçu crucial de votre engagement afin que votre équipe puisse prendre des décisions plus judicieuses concernant son contenu et ses communications.</p>

                </div><!-- /.col-lg-4 -->

                <div class="col-lg-4">
                    <img loading="lazy" width="320" height="180" src="{{ asset('images/illustration-3.png') }}" />
                    <h2 class="fw-bold">Prenez le contrôle</h2>
                    <p class="text-start">Prenez le crédit de votre contenu et apprenez-en plus sur la façon dont il est consommé en activant l'auto-branding, une fonctionnalité qui garantit que votre marque reste dans tout lien raccourci par quelqu'un pointant vers votre site Web.</p>

                </div>

            </div><!-- /.row -->


            <hr class="featurette-divider">


        </div><!-- /.container -->

        <section class="col col-lg-10 mx-auto mb-5">
            <div class="card" id="shortenCard">
                <div class="card-body bg-light">
                    <form id="shortenForm">
                        <div class="col">
                            <div class="input-group">
                                <input type="text" class="form-control form-control-lg" name="url" id="url" placeholder="Votre adresse à réduire" autocomplete="off" required>
                                <button type="submit" class="btn btn-lg btn-success" id="btnShortenUrl">Réduire l'URL</button>
                            </div>
                        </div>
                    </form>
                </div>
            </div>
        </section>

        <!-- FOOTER -->
        <footer class="container">
            <p>© Pentiminax 2021</p>
        </footer>
    </main>
{% endblock %}

{% block javascripts %}
    {{ encore_entry_script_tags('home') }}
{% endblock %}

Création du script home.js

const form = document.querySelector('#shortenForm');
const shortenCard = document.querySelector('#shortenCard');
const inputUrl = document.querySelector('#url');
const btnShortenUrl = document.querySelector('#btnShortenUrl');

const URL_SHORTEN = '/ajax/shorten';

const errorMessages = {
    'INVALID_ARG_URL': "Impossible de raccourcir ce lien. Ce n'est pas une URL valide",
    'MISSING_ARG_URL': "Veuillez fournir une URL valide"
}

form.addEventListener('submit', function(e) {
    e.preventDefault();

    fetch(URL_SHORTEN, {
        method: 'POST',
        body: new FormData(e.target)
    })
        .then(response => response.json())
        .then(handleData);
});

const handleData = function(data) {
    if (data.statusCode >= 400) {
        return handleError(data);
    }

    inputUrl.value = data.link;
    btnShortenUrl.innerText = "Copier";

    btnShortenUrl.addEventListener('click', function(e) {
       e.preventDefault();

       inputUrl.select();
       document.execCommand('copy');

       this.innerText = "Réduire l'URL";
    }, { once: true });
}

const handleError = function(data) {
    const alert = document.createElement('div');
    alert.classList.add('alert', 'alert-danger', 'mt-2');
    alert.innerText = errorMessages[data.statusText];

    shortenCard.after(alert);
}

Création de l’entité Url

L’entité Url comporte les propriétés suivantes :

  • id (« integer »)
  • hash (« string »)
  • link (« string »)
  • longUrl (« string »)
  • domain (« string »)
  • createdAt (« datetime »)
php bin/console make:entity

Création du premier service

<?php

namespace App\Service;

use App\Entity\Url;
use App\Repository\UrlRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Security;

class UrlService
{
    private EntityManagerInterface $em;
    private UrlRepository $urlRepo;
    private Security $security;

    public function __construct(EntityManagerInterface $em, UrlRepository $urlRepo, Security $security)
    {
        $this->em = $em;
        $this->security = $security;
        $this->urlRepo = $urlRepo;
    }

    public function addUrl(string $longUrl, string $domain): Url
    {
        $url = new Url();

        $hash = $this->generateHash();
        $link = $_SERVER['HTTP_ORIGIN'] . "/$hash";
        $user = $this->security->getUser();

        $url->setLongUrl($longUrl);
        $url->setDomain($domain);

        $url->setHash($hash);
        $url->setLink($link);
        $url->setUser($user);
        $url->setCreatedAt(new \DateTime);

        $this->em->persist($url);
        $this->em->flush();

        return $url;
    }

    public function deleteUrl(string $hash)
    {
        $url = $this->urlRepo->findOneBy(['hash' => $hash]);

        if (!$url) {
            return new JsonResponse([
                'statusCode' => 'URL_NOT_FOUND',
                'statusText' => "Le lien n'a pas été trouvé !"
            ]);
        }

        $this->em->remove($url);
        $this->em->flush();

        return new JsonResponse([
            'statusCode' => 'DELETE_SUCCESSFUL',
            'statusText' => 'Le lien a bien été supprimé !'
        ]);
    }

    public function parseUrl(string $url): string|bool
    {
        $domain = parse_url($url, PHP_URL_HOST);

        if (!$domain) {
            return false;
        }

        if (!filter_var(gethostbyname($domain), FILTER_VALIDATE_IP)) {
            return false;
        }

        return $domain;
    }

    public function generateHash(int $offset = 0, int $length = 8): string
    {
        return substr(md5(uniqid(mt_rand(), true)), $offset, $length);
    }
}

Création de la fonction add

Il faut ajouter le code suivant dans le fichier UrlController.php :

private UrlService $urlService;
private UrlStatisticService $urlStatisticService;

public function __construct(UrlService $urlService, UrlStatisticService $urlStatisticService)
{
    $this->urlService = $urlService;
    $this->urlStatisticService = $urlStatisticService;
}

#[Route('/ajax/shorten', name: 'url_add')]
public function add(Request $request): Response
{
    $longUrl = $request->request->get('url');

    if (!$longUrl) {
        return  $this->json([
            'statusCode' => 400,
            'statusText' => 'MISSING_ARG_URL'
        ]);
    }

    $domain = $this->urlService->parseUrl($longUrl);

    if (!$domain) {
        return $this->json([
            'statusCode' => 500,
            'statusText' => 'INVALID_ARG_URL'
        ]);
    }

    $url = $this->urlService->addUrl($longUrl, $domain);

    return $this->json([
        'link' => $url->getLink(),
        'longUrl' => $url->getLongUrl()
    ]);
}

Création de la fonction view

Il faut ajouter le code suivant dans le fichier UrlController.php :

#[Route('/{hash}', name: 'url_view')]
public function view(string $hash, UrlRepository $urlRepo): Response
{
    $url = $urlRepo->findOneBy(['hash' => $hash]);

    if (!$url) {
        return $this->redirectToRoute('app_homepage');
    }

    if (!$url->getUser()) {
        return $this->redirect($url->getLongUrl());
    }

    $urlStatistic = $this->urlStatisticService->findOneByUrlAndDate($url, new \DateTime);
    $this->urlStatisticService->incrementUrlStatistic($urlStatistic);

    return $this->redirect($url->getLongUrl());
}

Création de l’entité User

symfony console make:User
# The name of the security user class (e.g. User) [User]: User
# Do you want to store user data in the database (via Doctrine) (yes/no) [yes]: yes
# Enter a property name that will be the unique "display" name for the user (e.g. email, username, uuid) [email]: email
# Does this app need to hash/check user passwords? (yes/no) [yes] : yes

Création du système de connexion

symfony console make:auth
# What style of authentication do you want? [Empty authenticator]: 1
# The class name of the authenticator to create (e.g. AppCustomAuthenticator): LoginAuthenticator
# Choose a name for the controller class: SecurityController 
# Do you want to generate a "/logout" URL? (yes/no): yes
<?php

namespace App\Security;

use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface;
use Symfony\Component\Security\Http\Util\TargetPathTrait;

class LoginAuthenticator extends AbstractLoginFormAuthenticator
{
    use TargetPathTrait;

    public const LOGIN_ROUTE = 'app_login';

    private UrlGeneratorInterface $urlGenerator;

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

    public function authenticate(Request $request): PassportInterface
    {
        $email = $request->request->get('email', '');

        $request->getSession()->set(Security::LAST_USERNAME, $email);

        return new Passport(
            new UserBadge($email),
            new PasswordCredentials($request->request->get('password', '')),
            [
                new CsrfTokenBadge('authenticate', $request->get('_csrf_token')),
            ]
        );
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) {
            return new RedirectResponse($targetPath);
        }

        return new RedirectResponse($this->urlGenerator->generate('app_homepage'));
    }

    protected function getLoginUrl(Request $request): string
    {
        return $this->urlGenerator->generate(self::LOGIN_ROUTE);
    }
}
<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;

class SecurityController extends AbstractController
{
    /**
     * @Route("/connexion", name="app_login")
     */
    public function login(AuthenticationUtils $authenticationUtils): Response
    {
        if ($this->getUser()) {
             return $this->redirectToRoute('app_homepage');
        }

        $error = $authenticationUtils->getLastAuthenticationError();
        $lastUsername = $authenticationUtils->getLastUsername();

        return $this->render('security/login.html.twig', ['last_username' => $lastUsername, 'error' => $error]);
    }

    /**
     * @Route("/logout", name="app_logout")
     */
    public function logout()
    {
        throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.');
    }
}

Modification de la page de connexion

{% extends 'base.html.twig' %}

{% block title %}Log in!{% endblock %}

{% block body %}

    <div class="container mt-5">
        <form method="post">
            {% if error %}
                <div class="alert alert-danger">{{ error.messageKey|trans(error.messageData, 'security') }}</div>
            {% endif %}

            {% if app.user %}
                <div class="mb-3">
                    You are logged in as {{ app.user.username }}, <a href="{{ path('app_logout') }}">Logout</a>
                </div>
            {% endif %}

            <h1 class="h3 mb-3 font-weight-normal">Ravi de vous revoir !</h1>
            <label for="inputEmail">Adresse e-mail</label>
            <input type="email" value="{{ last_username }}" name="email" id="inputEmail" class="form-control" autocomplete="email" required autofocus>
            <label for="inputPassword">Mot de passe</label>
            <input type="password" name="password" id="inputPassword" class="form-control" autocomplete="current-password" required>

            <input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">

            <button class="btn btn-primary mt-2" type="submit">Connexion</button>
        </form>
    </div>

{% endblock %}

Création du système d’inscription

symfony console make:registration-form
# @UniqueEntity: yes
# Send an email to verify the user's email address after registration: yes
# Include the user id in the verification link: no
# email address used to send registration confirmations: example@gmail.com
# Page redirection: app_login
composer require symfonycasts/verify-email-bundle
<?php

namespace App\Controller;

use App\Entity\User;
use App\Form\RegistrationFormType;
use App\Security\EmailVerifier;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mime\Address;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
use SymfonyCasts\Bundle\VerifyEmail\Exception\VerifyEmailExceptionInterface;

class RegistrationController extends AbstractController
{
    private $emailVerifier;

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

    #[Route('/inscription', name: 'app_register')]
    public function register(Request $request, UserPasswordEncoderInterface $passwordEncoder): Response
    {
        $user = new User();
        $form = $this->createForm(RegistrationFormType::class, $user);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            // encode the plain password
            $user->setPassword(
                $passwordEncoder->encodePassword(
                    $user,
                    $form->get('plainPassword')->getData()
                )
            );

            $entityManager = $this->getDoctrine()->getManager();
            $entityManager->persist($user);
            $entityManager->flush();

            // generate a signed url and email it to the user
            $this->emailVerifier->sendEmailConfirmation('app_verify_email', $user,
                (new TemplatedEmail())
                    ->from(new Address('pentiminax.bot@gmail.com', 'Pentiminax Bot'))
                    ->to($user->getEmail())
                    ->subject('Bienvenue chez nous !')
                    ->htmlTemplate('registration/confirmation_email.html.twig')
            );
            // do anything else you need here, like send an email

            return $this->redirectToRoute('app_login');
        }

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

    #[Route('/verify/email', name: 'app_verify_email')]
    public function verifyUserEmail(Request $request): Response
    {
        $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');

        // validate email confirmation link, sets User::isVerified=true and persists
        try {
            $this->emailVerifier->handleEmailConfirmation($request, $this->getUser());
        } catch (VerifyEmailExceptionInterface $exception) {
            $this->addFlash('verify_email_error', $exception->getReason());

            return $this->redirectToRoute('app_register');
        }

        // @TODO Change the redirect on success and handle or remove the flash message in your templates
        $this->addFlash('success', 'Votre adresse e-mail a bien été vérifiée.');

        return $this->redirectToRoute('app_homepage');
    }
}
<h1>Salut !  Veuillez confirmez votre adresse e-mail</h1>

<p>
    Veuillez cliquer sur le lien ci-dessous : <br><br>
    <a href="{{ signedUrl }}">Confirmer mon adresse e-mail</a>.
    Ce lien dans expire dans {{ expiresAtMessageKey|trans(expiresAtMessageData, 'VerifyEmailBundle') }}.
</p>

Modification de la page d’inscription

{% extends 'base.html.twig' %}

{% block title %}Inscription{% endblock %}

{% block body %}
    <div class="container  mt-5">
        {% for flashError in app.flashes('verify_email_error') %}
            <div class="alert alert-danger" role="alert">{{ flashError }}</div>
        {% endfor %}

        <h1 class="h3 mb-3 font-weight-normal">Inscription</h1>

        {{ form_start(registrationForm) }}
        {{ form_row(registrationForm.email, {
            label: 'Adresse e-mail'
        }) }}
        {{ form_row(registrationForm.plainPassword, {
            label: 'Mot de passe'
        }) }}

        <button type="submit" class="btn btn-primary mt-2">Inscription</button>
        {{ form_end(registrationForm) }}
    </div>
{% endblock %}

Création de la fonction list

Il faut ajouter le code suivant dans le fichier UrlController.php :

#[Route('/user/links', name: 'url_list')]
public function list(): Response
{
    $user = $this->getUser();

    if (!$user || !$user->getUrls()->count()) {
        return $this->redirectToRoute('app_homepage');
    }

    return $this->render('url/list.html.twig', [
        'urls' => $user->getUrls()
    ]);
}

Il faut créer un nouveau fichier de template :

{% extends 'base.html.twig' %}

{% block title %}Mes liens{% endblock %}

{% block body %}

    <div class="container mt-5">

        <div class="row">
            <section class="col-lg-6 col-md-12 mx-lg-auto" id="linksSection">
                <div class="list-group">
                    {% for url in urls %}
                        <div class="list-group-item list-group-item-action" id="link_{{ url.hash }}" data-hash="{{ url.hash }}">

                            <div class="d-flex w-100 justify-content-between">
                                <h5 class="mb-1">{{ url.domain }}</h5>
                                <small>{{ url.createdAt|date('d/m/Y') }}</small>
                            </div>

                            <p class="float-start mb-1 fw-bold">
                                {{ url.getAllClicks() }}
                                <i class="far fa-chart-bar"></i>
                            </p>

                            <div class="d-flex w-100 justify-content-between">
                                <a class="text-danger fw-bold" id="anchor_{{ url.hash }}" href="{{ url.link }}" target="_blank">{{ url.link }}</a>
                            </div>

                        </div>
                    {% endfor %}
                </div>
            </section>
        </div>

        <div class="row mt-2">
            <div class="col-lg-4 col-md-12 justify-content-between mx-lg-auto">

                <div class="card">
                    <div class="card-body">
                        <div class="d-flex w-100 justify-content-between" id="actions">
                            <button class="btn btn-sm btn-primary" id="btnCopy" disabled>Copier</button>
                            <button class="btn btn-sm btn-success" id="btnStats" disabled>Statistiques</button>
                            <button class="btn btn-sm btn-danger" id="btnDelete" disabled>Supprimer</button>

                        </div>
                    </div>
                </div>

            </div>
        </div>

        <div class="position-fixed bottom-0 end-0 p-3">
            <div class="toast" id="copyToast" role="alert" aria-live="assertive" aria-atomic="true">
                <div class="toast-header">
                    <strong class="me-auto">Raccourcisseur d'URL</strong>
                    <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
                </div>
                <div class="toast-body">
                    Le lien a bien été copié !
                </div>
            </div>
        </div>
    </div>

{% endblock %}

{% block javascripts %}
    {{ encore_entry_script_tags('list') }}
{% endblock %}