[WebShake] Курс по фреймворку Symfony 4 - Часть 1

Зачем мне изучать фреймворк?

Многие задаются этим вопросом и немногие находят правильный ответ на него. Какими бы разными ни казались задачи разных проектов, в их основе лежат шаблонные решения, являющиеся результатом продолжительного развития всего сообщества разработчиков в целом. Фреймворк - это экосистема, а в некоторых случаях (например, простой блог) - это даже наполовину готовый продукт. Фреймворк предоставляет ряд готовых инструментов (от простых до достаточно сложных), отдавая нам основное время на решение действительно важных и нестандартных (лишь иногда) задач текущего проекта. Если вас это не убедило, представьте, что вам придётся доделывать проект, написанный с нуля, за другого разработчика. Вы не можете быть уверены ни в качестве продукта, ни в знаниях разработчика. Также вы не можете быть уверены, что проект будет расположен для будущих изменений в коде, не сразу будут очевидны ошибки, паттерны или места, где можно было бы сделать лучше. Таким образом, фреймворк предлагает общие правила и одну замкнутую систему, в рамках которой команда разработчиков понимает друг друга, употребляя общий словарь терминов, предлагаемый фреймворком.

Почему Symfony?

Конечно, фреймворк - это не панацея и даже иногда это не лучшее решение для поставленной задачи. На фреймворке так же можно писать плохой код, как и на чистом PHP, другое дело, что такой код легче рефакторится и, соответственно, меньше вредит бизнес-задачам. Symfony - это пример хорошего кода. Ну, скажем, не такого плохого, как в других фреймворках. Да, он непростой, но чем выше вы ставите задачу, тем быстрее вы растёте. В этом курсе вы познакомитесь с важными компонентами фреймворка, среди которых ORM Doctrine, шаблонизатор Twig, аннотации, Dependency Injection, Routing, Security, HttpFoundation и многие другие. Курс рассчитан на крепких новичков в разработке, хорошо понимающих ООП (на уровне композиции и агрегации, задач интерфейсов и абстрактных классов), работу пространств имён и композера, работу HTTP протокола и прочие основы веба.

Установка фреймворка Symfony 4: обзор структуры и конфигурация

Symfony, как и другие библиотеки и фреймворки на PHP, устанавливается с помощью пакетного менеджера Composer командой

composer create-project symfony/website-skeleton blog

где blog — имя вашего проекта, оно может быть любым.

Также на официальном сайте можно найти упрощённую версию фреймворка для разработки API. После установки вы получите актуальную на сегодняшний день версию Symfony — 4.2. Она во многом отличается от предыдущих версий, основная идея которых состояла в наличии бандлов (bundle, это слово вы встретите не раз). Бандлом было ваше приложение внутри фреймворка, оно могло называться BlogBundle, ApiBundle и легко переносилось между проектами, что, сами понимаете, в некоторых случаях было невероятно полезно. Сейчас же структура фреймворка выглядит следующим образом:

Структура проекта

Почти весь свой код вы будете писать в папке src, где после установки по умолчанию находятся папки Controller, Entity, Migrations и Repository. Если папка Controller не нуждается в представлении (по крайней мере, из курса по MVC вы должны о ней знать), то на остальных остановимся поподробнее. Для начала замечу, что автор Symfony считает, что этот фреймворк — это не MVC фреймворк, что бы там ни говорили. Он реализует модель Request-Response (или по-русски: запрос-ответ), это значит, что вы должны представлять, какой жизненный цикл проходит запрос от пользователя к серверу и как возвращать ответ обратно.

Таким образом, у нас нет привычных вам моделей, однако есть сущности (Entity), которые представляют из себя объектное отображение таблиц в базе данных. Также есть папка миграций (Migrations), где, как вы уже, наверно, догадались, будет храниться информация о миграциях базы данных. Чтобы подробнее познакомиться с темой миграций, советую прочитать следующую статью. Если коротко, идея миграций состоит в обеспечении актуальности структуры базы данных в любой момент разработки.

В папке Repository находятся классы-репозитории, каждый из которых соответствует одной конкретной сущности (Entity) и содержит методы, делающие запросы в базу данных. Таким образом, когда вам нужно будет достать данные, связанные с этой сущностью, вы будете использовать репозиторий, а не модель, как в случае с ActiveRecord.

Также у нас есть папка templates, где хранятся все наши шаблоны twig (о котором позже), и ничего больше. В папке config находится всё, что связано с конфигурацией проекта: роуты, сервисы, настройка пакетов вроде doctrine и switft_mailer и многое другое, чем мы будем пользоваться по ходу изучения фреймворка.

Чтобы запустить проект, вам даже не нужен веб-сервер, достаточно иметь php и mysql-клиент для работы с базой данных. Дело в том, что в Symfony есть встроенный веб-сервер, который вы можете запустить, выполнив в корне проекта следующую команду:

php bin/console server:run

Консоль отдаст нам актуальную ссылку, перейдя по которой мы увидим следующее:

Первое, что бросается в глаза (ну или должно бросаться), это замечательный debug bar внизу экрана. Он показывает статус-код (сейчас там 404, поскольку такого маршрута нет, но Symfony отдаёт стандартную страницу), время загрузки страницы, ошибки, авторизованы вы или нет, количество запросов в базу и многое другое. Debug bar будет работать только в dev режиме для помощи в разработке.

Вы могли заметить, что после команды server:run вы не можете пользоваться консолью, так как она отдаёт результаты работы проекта. Если вам это не нужно, выполните server:start и продолжайте работать с консолью дальше.

В завершение статьи настроим соединение с базой. Найдите файл .env в корне проекта. Он должен выглядеть так:

В строке DATABASE_URL замените db_user, db_password и db_name на актуальные значения вашего подключения к базе. При этом даже необязательно, чтобы база данных (db_name) существовала на данный момент. Symfony создаст её сама: для этого в терминале выполните команду:

php bin/console doctrine:database:create

Если все данные введены правильно, фреймворк создаст вам базу данных под именем, которое вы указали.

В данном уроке вы познакомились со структурой фреймворка и базовой конфигурацией. В следующем уроке мы создадим первый контроллер, поработаем с шаблонизатором Twig и многое другое.

Пишем первый контроллер на Symfony и работаем с шаблонами

Итак, настроив соединение с базой данных, разобравшись по верхам в структуре папок фреймворка, давайте начнём работать с ним. Первое, с чем я предлагаю вам познакомиться, будет папка с контроллерами, где, как ни странно, и будут находиться наши контроллеры.

Контроллер можно создать вручную, а можно воспользоваться компонентом maker для генерирования контроллера через терминал. Для этого выполните следующую команду в корне проекта (прим.: все команды надо выполнять в корне проекта):

php bin/console make:controller PostsController

Когда вы выполните эту команду, в папке Controller у вас появится класс PostsController.php с одним единственным методом (action) index. Также в папке templates появится папка posts с шаблоном index.html.twig. Использование команд удобно для генерирования простых контроллеров, сущностей, связей между ними, запуска миграций и многого другого.

Когда вы откроете файл PostsController.php, вы увидите, что он наследуется от AbstractController.php. Это базовый контроллер, дающий нам множество полезных методов вроде рендеринга шаблона, редиректа, создания форм и т.д.

В целом наш класс выглядит следующим образом:

<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;
class PostsController extends AbstractController
{
 /**
 * @Route("/posts", name="posts")
 */
 public function index()
 {
     return $this->render('post/index.html.twig', [
         'controller_name' => 'PostsController',
     ]);
 }
}

Что здесь необычного? Например, аннотация над методом index(). Дело в том, что именно так (чаще всего) настраиваются маршруты в Symfony. Route первым аргументом принимает маршрут, он может быть вида /posts, /posts/{id}, где id - плейсхолдер, то есть принимает различные значения. Второй аргумент - это название маршрута. Раньше в ссылках вы привыкли писать полный путь до файла-обработчика, в Symfony вы используете имя маршрута, что обеспечивает код гибкостью, так как независимо от того, где будет находиться файл-обработчик, ссылка всегда будет указывать на него правильно.

Пока наш action рендерит простой базовый шаблон, что не очень интересно. Давайте добавим простой массив данных и попробуем вывести его в шаблоне. Для этого измените ваш код следующим образом:

$posts = [
     'post_1' => [
     'title' => 'Заголовок первого поста',
     'body' => 'Тело первого поста'
    ],
     'post_2' => [
     'title' => 'Заголовок второго поста',
     'body' => 'Тело второго поста'
    ]
  ];
   return $this->render('posts/index.html.twig', [
     'posts' => $posts,
 ]);

Передавая данные через шаблон, вы должны помнить, что именно ‘posts’, а не $posts, шаблонизатор Twig будете видеть в качестве переменной. Таким образом, вы должны давать осмысленное имя и во множественном числе, если работаете с массивом данных.

Перейдите в шаблон templates/posts/index.html.twig. Вы увидите там несложную вёрстку вперемешку со стилями. Удалите всё, что между тегами {% block body %} и {% endblock %}, чтобы ваш файл выглядел так:

{% extends 'base.html.twig' %}
{% block title %} {% endblock %}
{% block body %}
{% endblock %}

Шаблонизатор Twig предлагает всего два типа тегов: {% %} и {{ }}. Первый тег означает, что мы что-то делаем, второй - что-то выводим. Когда мы получаем массив, обычно мы проходимся по нему foreach, чтобы вывести его в понятной форме. В twig нет foreach, но есть цикл for. Чтобы вывести название каждого поста, пройдёмся циклом for так, как это можно делать в twig:

{% for post in posts %}
 {{ post.title }}
{% endfor %}

Вы можете обрамлять твиговские теги тегами простого html, например:

<p>{{ post.title }} </p>

Цикл twig выглядит иначе: если в обычном php мы из множественного числа превращаем в единственное (foreach $posts as $post), то тут мы как бы говорим, что для каждого поста в массиве постов (for post in posts) нужно сделать следующее - {{ post.title }}. Через точку мы получаем доступ к ключу ассоциативного массива.

Также в начале файла вы могли заметить следующую строку:

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

А вот и главная особенность шаблонизаторов: они позволяют наследоваться друг от друга! Другими словами, в шаблоне base.html.twig вы можете определить нужные стили и javascript скрипты, а также блоки, которые унаследуют все остальные шаблоны. Это удобно, ведь позволяет не писать в каждом шаблоне все ненужные html теги, а сосредоточиться только на выводе информации. В base.html.twig вы могли увидеть блоки {% block title %}, {% block body %} и другие блоки. Глубже с twig мы познакомимся несколько позже, а сейчас вам достаточно знать, что блоки можно переопределять, но писать код нужно только внутри них.

Итак, если вы всё сделали правильно, можете перейти по маршруту /posts и увидеть, что названия двух наших постов вывелись. Таким образом, вы увидели, как работает основной жизненный цикл Symfony: пользователь переходит по конкретному маршруту, который обрабатывает определённый action, он же и отдаёт шаблон с данными для пользователя.

В следующем уроке мы глубже познакомимся с работой контроллеров, создадим первую сущность, наполним базу фейковыми данными, выведем список постов и сделаем красивые ЧПУ по slug!

Первая сущность. Создаём миграции и загружаем фикстуры. Работаем с шаблонизатором. Часть 1.

Итак, начиная с этого урока, мы приступаем изучать концепции, которые вы не использовали при создании собственного MVC фреймворка, а именно - сущности, миграции и фикстуры. Как я говорил в одном из прошлых уроков, Symfony - не MVC фреймворк. Пусть вас не пугает этот факт, это совсем не значит, что, изучив Symfony, вы не сможете писать на Laravel или, упаси бог, на Yii2. Нет, на раннем этапе вы будете обращаться с Symfony как с MVC фреймворком, и только потом, с опытом, поймёте фундаментальную разницу. Например, вы можете попробовать вернуть из action что-то другое, не объект Response, а массив данных, и сразу словите ошибку

The controller must return a "Symfony\Component\HttpFoundation\Response" object

Поскольку Symfony - это HTTP фреймворк, он получает данные из Request’a и отправляет в виде Response.

Сущность в Symfony - это одна конкретная таблица. Если вы будете работать с постами, очевидно, вам нужна сущность Post.

Сущность можно создать двумя способами: через терминал, выполнив команду make:entity, ну или же просто вручную в папке Entity. На раннем этапе предлагаю использовать make:entity, ведь команда создаст не только сущность, но и класс-репозиторий этой сущности по работе с базой данных. Таким образом, если в том же Laravel Eloquent ORM использует паттерн ActiveRecord для работы с базой данных, то Symfony использует паттерн Репозиторий, позволяющий абстрагироваться от конкретного типа базы данных. По умолчанию, репозиторий для каждой сущности предлагает несколько простых методов выборки: findAll, find, findBy и другие.

Итак, выполните в корне проекта bin/console make:entity. Вы сразу можете передавать в качестве ключа название сущности, как это сделал я

php bin/console make:entity Post 

Если вам будет проще, пока можете думать о сущности в качестве модели. Выполнив команду, вы заметите, что на этом консоль продолжает работать и предлагает ввести имя поля таблицы, тип данных поля, размер и проч. Мы так делать не будем, а взамен познакомимся с аннотациями. Создав сущность, просто прервите операцию с помощью Ctrl+C.

Дальше зайдите в класс Entity/Post.php, вы должны увидеть следующее:

По умолчанию, Doctrine генерирует id. Вы могли заметить нечто необычное - обширные аннотации над классом и свойствами класса. Да, именно так выглядит маппинг Doctrine ORM. Над свойствами класса, которые, я напоминаю, являются полями вашей таблицы Post, вы пишете тип данных, размер, может ли быть null и многое другое, с чем мы позже познакомимся. Над самим классом стоит следующая аннотация:

/**
 * @ORM\Entity(repositoryClass="App\Repository\PostRepository")
 */

Она указывает, какой репозиторий обрабатывает нашу сущность.
Давайте начнём заполнять наш класс. Для начала определимся, какие у нас будут поля: title, body, slug и created_at. Этих полей будет достаточно, чтобы познакомиться с разными типами аннотаций. Тут вам не обойтись без автокомплита PhpStorm, так что советую скачать его немедленно.

Аннотация Column принимает тип данных, можно ещё указать, может ли быть поле nullable. В аннотации Length мы указываем длину, также можно указать с помощью minMessage и maxMessage, какое сообщение об ошибке будет получать пользователь, если его title не будет соответствовать допустимой длине. Над id вы могли заметить ORM\Id и ORM\GeneratedValue(), это специальные аннотации для первичного ключа.

/**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\Column(type="string", nullable=false)
     * @Assert\Length(min=10, max=255)
     */
    private $title;

    /**
     * @ORM\Column(type="text", nullable=false)
     */
    private $body;

    /**
     * @ORM\Column(type="string", nullable=false)
     */
    private $slug;

    /**
     * @ORM\Column(type="datetime")
     */
    private $created_at;

Также вы должны сделать сеттеры и геттеры для каждого свойства класса, кроме id - для него нужен только геттер. В конце наш класс должен выглядеть следующим образом:

<?php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\DBAL\Types\DateType;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * @ORM\Entity(repositoryClass="App\Repository\PostRepository")
 */
class Post
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\Column(type="string", nullable=false)
     * @Assert\Length(min=10, max=255)
     */
    private $title;

    /**
     * @ORM\Column(type="text", nullable=false)
     */
    private $body;

    /**
     * @ORM\Column(type="string", nullable=false)
     */
    private $slug;

    /**
     * @ORM\Column(type="datetime")
     */
    private $created_at;

    public function getId(): ?int
    {
        return $this->id;
    }

    /**
     * @return mixed
     */
    public function getTitle()
    {
        return $this->title;
    }

    /**
     * @param mixed $title
     */
    public function setTitle($title): void
    {
        $this->title = $title;
    }

    /**
     * @return mixed
     */
    public function getBody()
    {
        return $this->body;
    }

    /**
     * @param mixed $body
     */
    public function setBody($body): void
    {
        $this->body = $body;
    }

    /**
     * @return mixed
     */
    public function getSlug()
    {
        return $this->slug;
    }

    /**
     * @param mixed $slug
     */
    public function setSlug($slug): void
    {
        $this->slug = $slug;
    }

    /**
     * @return \DateTimeInterface
     */
    public function getCreatedAt(): ?\DateTimeInterface
    {
        return $this->created_at;
    }

    /**
     * @param \DateTimeInterface $created_at
     */
    public function setCreatedAt(\DateTimeInterface $created_at): void
    {
        $this->created_at = $created_at;
    }
}

На данном этапе мы закончили работу с сущностью. Теперь мы должны выполнить миграцию, чтобы у нас появилась таблица в нашей базе! Открываем терминал и выполняем следующую команду:

php bin/console make:migration

У вас сгенерируется миграция в папке Migrations. Вот как она выглядит:

Внизу вы могли заметить, как Symfony вам подсказывает, что нужно сделать, чтобы запустить миграцию, а именно выполнить команду

php bin/console doctrine:migrations:migrate

Также вы должны будете подтвердить её, введя y. Вуаля, у вас появилась первая таблица! Однако, у вас нет данных. Давайте же введём вручную пару десятков значений. Шучу конечно, ничего вручную мы вводить не будем, ведь у нас есть фикстуры (fixtures)! Фикстуры - это фейковые данные, которые используются в dev-режиме, чтобы заполнить базу и проверить её работоспособность. Однако для начала нам надо скачать DoctrineFixturesBundle следующей командой:

composer require --dev doctrine/doctrine-fixtures-bundle

Ещё скачаем популярную библиотеку Faker для генерирования рандомных данных.

composer require fzaninotto/faker

Объясню разницу: DoctrineFixturesBundle отправляет фейковые данные, позволяет выбирать, в какой последовательности они будут генерироваться (допустим, сначала пользователи, а потом посты, если у нас связь один-ко-многим), а Faker только создаёт случайные данные. Так же работают сиды в Laravel’е, если вы с ними знакомы.

Если помните, у нас есть поле slug, обычно slug генерируется на основе названия статьи. На packagist можно найти библиотеку cocur/slugify, делающая slug как из английских слов, так и из русских. Давайте скачаем её тоже:

composer require cocur/slugify

После установки DoctrineFixturesBundle у вас появится папка DataFixtures и в ней класс AppFixtures.php. В нём-то мы и будем загружать наши фейковые данные. Поначалу файл выглядит следующим образом:

ObjectManager - это класс, подготавливающий и сохраняющий данные в базе, к нему мы вернёмся позже. Мы не будем загружать фикстуры в методе load, в нём мы будем вызывать приватные методы, которые сейчас создадим. Поскольку мы работаем с постами, создадим приватный метод loadPosts, который вызовем в методе load. Для начала определимся с зависимостями: нам нужен класс Factory из библиотеки Faker и Slugify. Создадим для них свойства и заинжектим в конструктор:

    private $faker;

    private $slug;

    public function __construct(Slugify $slugify)
    {
        $this->faker = Factory::create();
        $this->slug = $slugify;
    }

Дальше я приведу реализацию метода loadPosts и объясню, что мы делаем:

    public function loadPosts(ObjectManager $manager)
    {
        for ($i = 1; $i < 20; $i++) {
            $post = new Post();
            $post->setTitle($this->faker->text(100));
            $post->setSlug($this->slug->slugify($post->getTitle()));
            $post->setBody($this->faker->text(1000));
            $post->setCreatedAt($this->faker->dateTime);

            $manager->persist($post);
        }
        $manager->flush();
    }

Итак, мы запускаем обычный цикл, из которого понятно, что мы создадим 20 записей. Создаём класс Post. Сначала сеттим title, куда отправляем значения, сгенерированные для нас faker. Далее генерируем slug с помощью метода slugify класса Slugify, куда передаём title с помощью метода getTitle(). Поскольку сейчас мы работаем с первой записью ($i = 1), то именно title под id, равному 1, у нас и попадёт в setSlug. Это нам и нужно. Дальше мы просто сеттим body и created_at. Метод persist готовит данные, а flush всё сохраняет в конце цикла. Таким образом, наш класс выглядит следующим образом:

Осталось запустить наши фикстуры, для этого в терминале в корне проекта выполните следующую команду:

php bin/console doctrine:fixtures:load

Подтвердите действие с помощью Y и бегите смотреть базу. Вы увидите, что все поля заполнены правильно и в поле slug у нас находится title, но с маленькими буквами и разделителями. Собственно, всё работает так, как мы этого и хотели. Во второй части статьи мы выведем все данные в шаблоне, данные по id или slug и поработаем с репозиториями!

Первая сущность. Создаём миграции и загружаем фикстуры. Работаем с шаблонизатором. Часть 2.

В прошлом уроке мы создали первую сущность, познакомились с аннотациями, миграциями, скачали несколько дополнительных библиотек и загрузили фикстуры. Теперь в базе есть данные, которые нужно вывести в шаблоне. В этом уроке мы наконец-то сделаем полезный контроллер, которые будет выводить все данные и одну конкретную запись по slug.

Для начала создадим action posts(), который будет выводить все посты. Нам понадобится класс PostRepository, отвечающий за работу с таблицей постов. Однако в Symfony есть как минимум два способа работы с репозиторием. Поскольку мы унаследовались от AbstractController, мы можем достать репозиторий следующим образом и это будет первый способ:

$repo = $this->getDoctrine()->getRepository(Post::class);

То есть мы передаём в getRepository() в качестве переменной ссылку на сущность Post. Теперь $repo является объектом класса PostRepository, ему доступны все методы, предоставляемые стандартными репозиториями. Есть и другой способ, знакомый вам из курса по ООП, - внедрение зависимостей через конструктор. Я предпочитаю именно этот способ, однако вы вольны выбрать любой. На данном этапе класс контроллера выглядит так:

<?php

declare(strict_types=1);

namespace App\Controller;

use App\Entity\Post;
use App\Repository\PostRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;

class PostsController extends AbstractController
{
    /** @var PostRepository $postRepository */
    private $postRepository;

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

Пока ничего нового, обычное ООП. Но приступим же к первому экшену posts. Что нам нужно? Получить все записи и отправить их в шаблон. Создаём переменную $posts, в которой будем хранить результата выполнения запроса $this->postRepository->findAll(). Проще говоря, это массив записей, по которым мы пройдёмся в цикле в нашем шаблоне и достанем только названия статей. Наш action становится действительно полезным, а самое главное - ничего лишнего:

   /**
     * @Route("/posts", name="blog_posts")
     */
    public function posts()
    {
        $posts = $this->postRepository->findAll();

        return $this->render('posts/index.html.twig', [
           'posts' => $posts
        ]);
    }

И не забудьте настроить маршрут, по которому мы будем видеть все посты. Думаю, этот экшен вы поймёте без проблем: получаем записи из репозитория и отправляем их в шаблон, который создался после создания контроллера.
Работать с циклами twig мы уже научились, так что делаем то же самое, что и на первом уроке:

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

{% block title %} {% endblock %}

{% block body %}
    {% for post in posts %}
        <a href="#">{{ post.title }}</a><br><br>
    {% endfor %}
{% endblock %}

Запускаем сервер командой php bin/console server:start и… видим ошибку. Да, Symfony ругается на то, что ему незнаком сервис Slugify из нашего прошлого урока. И правда, такого сервиса во фреймворке не существует. Но Symfony не был бы таким популярным, если бы легко не решал эту проблему. Для этого найдите файл config/services.yaml. В самом низу, на уровне с этой строкой App\Controller\ напишите следующее:

App\DataFixtures\AppFixtures:
        $slugify: 'Cocur\Slugify\Slugify'

Это значит, что мы прямо указали, что класс AppFixtures принимает переменную $slugify, которая является объектом класса Cocur\Slugify\Slugify. Кто не понял, как это должно выглядеть, прилагаю скриншот:

Теперь запустите сервер ещё раз и перейдите по адресу, указанному в терминале, по маршруту /posts. Вы должны увидеть список наших постов. Теперь мы должны сделать возможность нажимать на ссылки и переходить к отдельной записи по её slug, чтобы увидеть контент. На самом деле, это делается даже легче, чем первый экшен, серьёзно. Я приведу его реализацию и объясню, что происходит:

   /**
     * @Route("/posts/{slug}", name="blog_show")
     */
    public function post(Post $post)
    {
        return $this->render('posts/show.html.twig', [
            'post' => $post
        ]);
    }

В первую очередь обратите внимание на роутинг. Я уже упоминал в одном из первых уроков, что значения, заключенные в фигурные скобки, называются плейсхолдерами - они меняются в зависимости от запроса пользователя. А ещё, Symfony достаточно умный, чтобы понимать, по какому критерию вы достаёте данные, так что если вы напишите вместо slug id, title или body, он отдаст вам нужные данные конкретного поста! Всё это происходит благодаря аннотации ParamConverter, которую в нашем случае использовать необязательно.

Теперь переходим шаблон и пишем следующее:

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

{% block body %}
    <p>{{ post.body }}</p>
{% endblock %}

Осталось только починить наши ссылки, чтобы можно было по ним переходить. Возвращаемся в шаблон posts/index.html.twig и меняем ссылку на новую:

<a href="{{ path('blog_show', {'slug': post.slug}) }}">{{ post.title }}</a>

Обратите внимание на href, мы использовали хелпер twig path(), который принимает в качестве аргументов имя маршрута (это те самые имена, которые мы указывали в аннотации Route, помните?) и второй аргумент для генерации динамического урла. В нашем случае это slug. В фигурных скобках мы пишем, что наш slug равен post.slug. Слишком много фигурных скобок, да, но вы к этому привыкните. Теперь вы можете обновить страницу и кликнуть на любую из ссылок. Если вы всё сделали правильно, вы увидите тело статьи.

Ну что же, мы сделали почти всё, чтобы написать своё первое CRUD приложение. Осталось познакомиться с формами, что мы и сделаем на следующем уроке.

Формы в Symfony

В предыдущем уроке мы наконец-то сделали кое-что полезное, а именно - заполнили базу фейковыми данными, вывели все записи и одну запись по её slug. Но давайте приблизимся к реальности ещё на шаг - и создадим форму добавления записи, как это происходит в реальных приложениях.

В Symfony каждая форма принадлежит конкретной сущности, что даёт нам удобство её обработки. Формы, как и многое другое во фреймворке, можно создавать через консоль. Название формы должно состоять из названия сущности и слова Type (т.е. PostType, UserType). Форму можно создать с помощью компонента maker следующей командой:

php bin/console make:form 

Symfony попросит вас ввести имя сущности, с которой связана форма. Введите Post. Дальше у вас сгенерируется папка Form, где вы найдёте класс PostType. Перейдя в него, вы должны увидеть следующий код:

В методе configureOptions() Symfony указывает, какая сущность будет источником данных для нашей формы. На данный момент нас интересует метод buildForm, который, собственно, и строит нашу форму из полей нашей сущности. Мы смело можем удалить поля slug и created_at, поскольку они будут генерироваться автоматически. Кроме того, нам доступны некоторые настройки для нашей формы: так, например, метод в add() мы можем указать, какой тип данных будет принимать то или иное поле - TextType или TextareaType, а также установить label, если он нужен. Вот так будет выглядеть наш метод теперь:

public function buildForm(FormBuilderInterface $builder, array $options)
{
    $builder
        ->add('title', TextType::class, [
               'label' => ''
           ])
        ->add('body', TextareaType::class, [
                'label' => ' '
           ])
        ;
}

Обратите внимание на то, что классы TextType и TextareaType должны быть загружены по следующим неймспейсам:

use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;

Вы могли заметить, что в нашей форме не хватает одной важной детали. Да, я говорю о кнопке. Добавлять её напрямую в билдер - плохая практика. Дело в том, что мы используем одну форму как для сохранения сущности, так и для её обновления, отсюда нам потребуется менять название кнопки, поэтому её мы будем добавлять непосредственно в вёрстку.

Итак, закончив, с созданием формы (да, на этом всё), используем же её для создания первой нашей публикации! Для этого перейдите в класс PostsController.php и создайте метод addPost(). Также не забудьте назначить маршрут нашему методу в аннотации Route и добавить имя.

Для начала нужно понять, где хранятся данные, передаваемые пользователем через форму. Как я уже упоминал в одной из предыдущих статьях, Symfony - HTTP фреймворк, данные он получает из класса Request и отдаёт в виде Response. Поэтому одним из параметров нашего метода будет объект $request, а вторым - знакомый нам класс Slugify. Прежде чем начнём писать наш метод, нам надо зарегистрировать класс Slugify как сервис в файле config/services.yaml. Вот как это будет выглядеть:

Строка autowire: true позволяет переложить на Symfony автоматическую загрузку нашего сервиса.

Кстати говоря, эту запись можно убрать

App\DataFixtures\AppFixtures:
        $slugify: 'Cocur\Slugify\Slugify'

Теперь мы зарегистрировали класс как сервис, а не как аргумент другого класса, в результате чего он стал нам доступен глобально.

Итак, приступим к созданию нашего метода.
Сначала мы создаём объект нашей сущности, которую и будем сохранять.

$post = new Post();

Дальше мы создаём форму с помощью метода createForm, который обязательным параметром принимает наш класс PostType и объект класса Post.

$form = $this->createForm(PostType::class, $post);

Далее мы обрабатываем наш $request с помощью метода handleRequest, который стал нам доступен после создания инстанса формы.

$form->handleRequest($request);

Теперь мы проверяем, нажата ли кнопка под формой и является ли она валидной в соответствии с теми правилами валидации, которые мы применили к нашей сущности. Если оба этих условия выполняются, мы устанавливаем slug с помощью уже известного нам метода slugify, куда передаём title статьи. Время для поля created_at берём текущее, которое возвращает нам объект класса DateTime() по умолчанию.

if ($form->isSubmitted() && $form->isValid()) {
            $post->setSlug($slugify->slugify($post->getTitle()));
            $post->setCreatedAt(new \DateTime());

Вызываем наш Manager, подготавливаем (persist) и сохраняем пост (flush). После сохранения редиректим пользователя на страницу со всеми постами.

$em = $this->getDoctrine()->getManager();
            $em->persist($post);
            $em->flush();

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

А сам метод addPosts() возвращает шаблон, куда в качестве аргумента передаём $form->createView(). Этот метод создаст саму форму в нашей вёрстке.

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

Вот как полностью выглядит наш метод:

/**
     * @Route("/posts/new", name="new_blog_post")
     */
    public function addPost(Request $request, Slugify $slugify)
    {
        $post = new Post();
        $form = $this->createForm(PostType::class, $post);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $post->setSlug($slugify->slugify($post->getTitle()));
            $post->setCreatedAt(new \DateTime());

            $em = $this->getDoctrine()->getManager();
            $em->persist($post);
            $em->flush();

            return $this->redirectToRoute('blog_posts');
        }
        return $this->render('posts/new.html.twig', [
            'form' => $form->createView()
        ]);
    }

Теперь создайте шаблон в папке templates/posts под именем new.html.twig. Всё, что он будет делать, это рендерить форму. Вот как будет выглядеть наш шаблон:

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

{% block body %}
    {{ form_start(form) }}
        {{ form_row(form.title) }}
        {{ form_row(form.body, { 'attr': {
            'rows' : '10',
            'cols' : '10' }}) }}
    <button class="btn btn-default" type="submit">Сохранить</button>
    {{ form_end(form) }}
{% endblock %}

Twig позволяет по-разному рендерить форму: можно сразу вывести всю форму с помощью такой записи {{ form(form) }}, однако в этом случае будет сложнее управлять каждым полем отдельно - его стилями или названием. Поэтому мы используем теги form_start и form_end, между которыми выводим каждое поле в отдельности. Записи form.title и form.body соответствует реальным полям в нашей таблице и в свойствах нашей сущности. Также мы добавляем rows и cols для form.body (вы же помните, как мы указали, что это TextareaType?), чтобы форма не выглядела маленькой. До тегов form_end вы должны не забыть добавить кнопку. Теперь наша форма и метод-handler, которые её обрабатывает, готовы. Вы можете перейти по указанному адресу и попробовать написать и сохранить первую запись.

Ну что же, на этом уроке мы освоили очередной важный компонент фреймворка - формы. На следующем уроке мы напишем первое CRUD приложение, в результате чего вы научитесь создавать, редактировать и удалять записи.

Полноценный CRUD в Symfony

На прошлых уроках мы научились создавать сущности, контроллеры, отображения, формы. Познакомились с репозиториями и с тем, как выводить все данные и одну конкретную запись. Настало время объединить эти знания и написать первое CRUD приложение. Другими словами, мы научимся создавать, удалять, редактировать и просматривать одну запись.

Создавать и отображать мы уже умеем, но давайте пройдёмся по нашему контроллеру ещё раз.

<?php

declare(strict_types=1);

namespace App\Controller;

use App\Entity\Post;
use App\Form\PostType;
use App\Repository\PostRepository;
use Cocur\Slugify\Slugify;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;

class PostsController extends AbstractController
{
    /** @var PostRepository $postRepository */
    private $postRepository;

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

Инжектим наш репозиторий, который нам нужен для чтения данных, в конструктор. Ещё раз напоминаю вам, что репозиторий в Symfony работает с конкретной сущностью, позволяя нам выполнять различные выборки.

Вывод всех данных

Как выводить все данные - вы уже знаете. Если нет, напоминаю вам, как выглядит код:

   /**
     * @Route("/posts", name="blog_posts")
     */
    public function posts()
    {
        $posts = $this->postRepository->findAll();

        return $this->render('posts/index.html.twig', [
           'posts' => $posts
        ]);
    }

Методы findAll, find, findBy и другие довольно часто используются разработчиками, поэтому предоставляются фреймворком по умолчанию (мы ещё научимся с вами писать собственные запросы). Вы обращаетесь к репозиторию и просите его достать все данные, которые потом передаёте в шаблон.

Вывод одной записи

   /**
     * @Route("/posts/{slug}", name="blog_show")
     */
    public function show(Post $post)
    {
        return $this->render('posts/show.html.twig', [
            'post' => $post
        ]);
    }

Тут Symfony сравнивает {slug} со свойством slug в сущности Post. Если есть - возвращает нам данные. Теперь важное: этот метод вы должны написать ниже всех остальных! Мы уже создали метод для создания записи, роутинг которого выглядит следующим образом:

   /**
     * @Route("/posts/new", name="new_blog_post")
     */

Когда вы переходите по какому-то маршруту на сайте, Symfony начинает искать совпадение сверху вниз. Вы переходите по маршруту /posts/new, чтобы создать новую запись, но метод show() у нас находится вторым по счёту и его роутинг выглядит так:

   /**
     * @Route("/posts/{slug}", name="blog_show")
     */

Как вы думаете, что произойдёт, когда вы перейдёте по маршруту /posts/new? Правильно, Symfony начнёт искать его среди slug’ов нашей сущности и, не найдя совпадения, выкинет ошибку. Почему именно по slug? Потому что вы поставили этот action вторым по счёту, и Symfony именно с ним и найдёт совпадение, так как слово new ничем не отличается от обычного slug. Поэтому метод show() вы должны поставить самым последним в нашем контроллере.

Создание записи

Мы буквально на прошлом уроке научились создавать запись и научились работать с формами, но я ещё раз напомню вам код нашего метода:

   /**
     * @Route("/posts/new", name="new_blog_post")
     */
    public function addPost(Request $request, Slugify $slugify)
    {
        $post = new Post();
        $form = $this->createForm(PostType::class, $post);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $post->setSlug($slugify->slugify($post->getTitle()));
            $post->setCreatedAt(new \DateTime());

            $em = $this->getDoctrine()->getManager();
            $em->persist($post);
            $em->flush();

            return $this->redirectToRoute('blog_posts');
        }
        return $this->render('posts/new.html.twig', [
            'form' => $form->createView()
        ]);
    }

Как вы помните из основ ООП, если наш метод от чего-то зависит, это можно передать или в качестве его аргументов, или в конструктор класса. Поскольку классы Request и Slugify нам нужны не во всех методах нашего класса, нет смысла перегружать конструктор ими. Собственно, что мы делаем: создаём объект класса Post, форму на основе его полей. Дальше мы проверяем, отправлена ли форма и валидна ли она. Если всё это выполняется, мы производим некоторые действия над созданием slug’а и текущего времени, потом подготавливаем данные и сохраняем. Делаем редирект на роут со всеми постами и рендерим нашу форму.

Редактирование записи

Метод edit(), который мы сейчас создадим, будет выглядеть несколько иначе, но в целом принцип ничем не отличается от создания.

   /**
     * @Route("/posts/{slug}/edit", name="blog_post_edit")
     */
    public function edit(Post $post, Request $request, Slugify $slugify)
    {
         $form = $this->createForm(PostType::class, $post);
         $form->handleRequest($request);

         if ($form->isSubmitted() && $form->isValid()) {
            $post->setSlug($slugify->slugify($post->getTitle()));
            $this->em->flush();

            return $this->redirectToRoute('blog_show', [
                'slug' => $post->getSlug()
            ]);
         }

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

Начнём с первого - роутинга. Да, именно так нужно делать правильные ендпоинты: действие, которое мы выполняем в данный момент - удаление или редактирование - должно стоять в конце.

/posts/{slug}/edit
/posts/{slug}/delete

Объект Post нам создавать не нужно, поскольку он уже есть в форме. Мы просто создаём новый slug, если он изменился, и сохраняем данные. Поскольку мы обновляем данные, а не сохраняем их в первый раз, делать persist() нам не нужно. Обратите внимание, что мы делаем дальше, мы редиректим на роут blog_show с параметрами slug. То есть просматривая запись и решив её отредактировать, вы возвращаетесь туда же, но уже по новому slug. В конце мы рендерим ту же форму, которую использовали для создания. Вы вольны создать другую.

Также не забудьте в шаблоне, где вы отображаете данные (т.е., например, в show.html.twig), сделать ссылку на редактирование:

<a href="{{ path('blog_post_edit', {'slug': post.slug}) }}">Редактировать</a>

Удаление данных

В менеджере Doctrine уже есть готовые методы по удалению данных. Нам нужно всего лишь ими воспользоваться следующим образом:

   /**
     * @Route("/posts/{slug}/delete", name="blog_post_delete")
     */
    public function delete(Post $post)
    {     
        $em = $this->getDoctrine()->getManager();
        $em->remove($post);
        $em->flush();

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

Опять передаём в качестве аргументов наш объект Post. В остальном здесь всё понятно: удаляем и делаем редирект на страницу всех постов. Также не забудьте сделать кнопку, аналогичную редактированию:

<a href="{{ path('blog_post_delete', {'slug': post.slug}) }}">Удалить</a>

На это всё, мы сделали CRUD. Но какое приложение существует без стилей? Наверняка вы уже втайне подключили bootstrap к своему проекту. В следующем уроке мы познакомимся с таким инструментом как вебпак.

Вебпак и загрузка стилей

Почти любое приложение использует стили и JavaScript скрипты. Однако если в приложениях без использования фреймворков вы знаете, как подключать стили, то в Symfony и в том же Laravel есть свои методы по подключению css и js файлов на проект. Давайте разберемся, что это за инструменты.

Установка и конфигурация

Для начала вы должны запомнить, что хорошей практикой считается хранить все стили в папке public, где чаще всего также находится точка входа в наше приложение. В Symfony есть функция asset, которая всегда смотрит в папку public, однако напрямую класть стили туда мы не будем. Вместо этого мы воспользуемся пакетом encore, webpack и npm. Если вы не слышали про webpack, то знайте, что это модуль, предназначенный для упаковки JavaScript и CSS файлов в один единственный файл, увеличивающий скорость загрузки стилей на проекте.

Для начала мы должны установить бандл Encore следующей командой:

composer require encore

После установки у вас появятся файлы package.json и webpack.config.js, а также папки assets, куда мы и будем класть наши стили, и node_modules - vendor для JavaScript библиотек.

Когда вы установите Encore, компилятор (если вы его используете) предложит установить пакеты из файла package.json. Для этого надо выполнить команду npm install. Если у вас ещё нет npm, вам надо будет его скачать.

Теперь мы установили все нужные пакеты для работы со стилями. Осталось сконфигурировать файл webpack.config.js, который будет брать стили из папки assets и компилировать их в папку public. Если вы посмотрите в файл webpack.config.js сейчас, то ничего не поймёте. Там и правда много лишнего кода. Мы напишем свой:

Во-первых, объявляем переменную Encore, куда включаем симфоневский пакет webpack-encore.

var Encore = require('@symfony/webpack-encore');

Дальше указываем директории, где будут храниться скомпилированные стили, а именно public/build:

Encore

    .setOutputPath('public/build/')
    .setPublicPath('/build')

Очищаем вывод перед компиляцией и указываем, что мы не на продакшене:

    .cleanupOutputBeforeBuild()
    .enableSourceMaps(!Encore.isProduction())

В следующих двух методах мы указываем, откуда брать стили и js-файлы и куда их класть. А именно, поскольку стили компилируются в public, как мы указали в setOutputPath, нам нет необходимости её указывать: мы указываем папку js и css, а также имя файлов без расширения (app), куда в итоге скомпилируется весь наш код.

    .addEntry('js/app', './assets/js/app.js')
    .addStyleEntry('css/app', './assets/css/app.css');

В addEntry мы указываем js-файлы, в addStyleEntry - угадайте что.

Экспортируем модуль и готово:

module.exports = Encore.getWebpackConfig();

В конечном итоге так будет выглядеть наш webpack.config.js:

var Encore = require('@symfony/webpack-encore');

Encore

    .setOutputPath('public/build/')
    .setPublicPath('/build')

    .cleanupOutputBeforeBuild()
    .enableSourceMaps(!Encore.isProduction())

    .addEntry('js/app', './assets/js/app.js')
    .addStyleEntry('css/app', './assets/css/app.css');

module.exports = Encore.getWebpackConfig();

Webpack на практике

Теперь вы можете попробовать добавить свои стили в assets/css/app.css. Когда вы это сделаете, вам нужно будет запустить webpack, но как это сделать? В файле package.json в корне проекта вы можете найти секцию scripts. Чаще всего вы будете использовать encore dev или encore dev --watch. Ключ watch означает, что вебпак будет следить за изменением стилей и сразу их компилировать. Если же запускать без ключа, вам придется каждый раз, когда вы измените стили, запускать команду encore dev.

Однако в корне проекта запустить эти команды не получится, потому что encore находится в node_modules/.bin/encore. Поэтому запускать вы можете так:

./node_modules/.bin/encore dev

В консоли вы увидите надпись Running Webpack и сообщение об успешной компиляции. Теперь в папке public у вас появится папка build с папками css и js. И только после всего этого вы можете перейти в файл base.html.twig или любой другой шаблон и попробовать загрузить ваши стили:

<link rel="stylesheet" href="{{ asset('build/css/app.css') }}">

Используем Bootstrap

Писать свои стили скучно, давайте будем использовать bootstrap, к тому же его возможностей хватит с головой для нашего проекта. Забудьте про CDN и скачивание вручную, просто выполните в корне проекта команду

npm install bootstrap

Теперь в папке node_modules у вас появится папка bootstrap. Она будет подсвечена серым цветом в отличие от других, не особо нас интересующих папок и библиотек. Чтобы webpack увидел bootstrap, нам надо ему показать, куда смотреть. Для этого давайте отредактируем наш webpack.config.js:

.addEntry('js/app', [
        './assets/js/app.js',
        './node_modules/bootstrap/dist/js/bootstrap.min.js'
    ])
    .addStyleEntry('css/app', [
        './assets/css/app.css',
        './node_modules/bootstrap/dist/css/bootstrap.min.css'
    ])

Вторым аргументом методов addEntry и addStyleEntry идет простой массив, где мы через запятую перечисляем файлы, которые webpack должен будет скомпилировать в папку public. Запускаем ещё раз наш encore:

./node_modules/.bin/encore dev

Все готово, теперь вы можете подключить один файл со стилями и один - c js, которые у вас лежат в public/build.

<link rel="stylesheet" href="{{ asset('build/css/app.css') }}">
...
<script src="{{ asset('build/js/app.js') }}"></script>

Как видите, это достаточно удобно: вам не надо подключать сотни файлов, все ваши стили компилируются в один!

Форма поиска на Symfony

Теперь когда у нас есть стили и мы можем сделать наш блог красивым, сделаем форму поиска. Поиск есть почти в каждом приложении, так почему бы и нам не иметь свой?

Для начала нам нужна верстка формы поиска. Раз у нас уже есть bootstrap, возьмем готовую. Вы можете взять целое меню bootstrap и вставить в неё нашу форму или же только значения action и name у тега input.

<form class="form-inline my-2 my-lg-0" action="{{ path('blog_search') }}" method="get">
    <input class="form-control mr-sm-2" type="search" aria-label="Search" name="q">
    <button class="btn btn-default my-2 my-sm-0" type="submit">Ищем</button>
</form>

Во-первых, необязательно для поиска использовать форму Symfony, мы можем в теге action написать имя нашего экшена, который мы назвали blog_search. Туда будут уходить данные, отправленные методом get, то есть они будут такого вида:

127.0.0.1/posts/search?q=наш_запрос 

q - потому что так мы назвали наш input. search - наш будущий роут, который будет обрабатывать наш экшен blog_search, куда мы отправляем наш запрос. Поиск мы будем делать только по названию статьи. Вы же помните, что все запросы мы пишем в PostsRepository? Так что давайте откроем этот файл и напишем следующий метод:

public function searchByQuery(string $query)
{
    return $this->createQueryBuilder('p')
                ->where('p.title LIKE :query')
                ->setParameter('query', '%'. $query. '%')
                ->getQuery()
                ->getResult();
}

Все запросы в ваших репозиториях будут начинаться с метода createQueryBuilder, куда вы передаёте ссылку (alias) на вашу таблицу. Можно писать как post, так и p, при условии что у вас только одна таблица начинается на букву p. Дальше мы указываем, что хотим все данные, где название (p.title) похоже (LIKE) на наш запрос (:query). Вам это может напомнить работу с PDO - все эти плейсхолдеры и биндинги. Поскольку мы хотим, чтобы слово, которое мы ищем в названии, могло находиться и в начале, и в середине, и в конце, обрамляем знаками процент полностью наш запрос с двух сторон. Потом получаем наш результат. Теперь этот метод мы вызовем в нашем экшене в контроллере PostsController.

Переходим в PostsController и пишем метод search().

   /**
     * @Route("/posts/search", name="blog_search")
     */
    public function search(Request $request)
    {
        $query = $request->query->get('q');
        $posts = $this->postRepository->searchByQuery($query);

        return $this->render('blog/query_post.html.twig', [
            'posts' => $posts
        ]));
    }

Как мы уже договорились, запросы будут идти на роут /posts/search, а называться он будет blog_search. Определяем это в аннотациях к методу. В качестве аргумента передаем наш Request, где хранится наш запрос. Для этого мы должны вызвать метод query() и у него метод get(), куда передаем q - имя нашего инпута, откуда мы хотим брать данные. Присваиваем переменной posts результат выполнения нашего метода searchByQuery и рендерим шаблон, где в цикле перебираем наш массив (массив, потому что результатов поиска может быть много).

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

{% block title %}Результаты поиска{% endblock %}

{% block body %}
    {% for post in posts %}
       <div class="media text-muted pt-3">
         <p class="media-body pb-3 mb-0 small lh-125 border-bottom border-gray">
           <a href="{{ path('blog_show', {id: post.id}) }}">{{ post.title }}</a><
              {{ post.body | slice(0, 255) ~ '...'  }}
         </p>
       </div>
    {% endfor %}
    <small class="d-block text-right mt-3">
        <a href="{{ path('blog_home') }}">На главную</a>
    </small>
{% endblock %}

В шаблоне мы также использовали функцию twig slice, которая обрезает исходный текст до 255 символов и проставляет многоточие. Теперь вы можете попробовать ввести что-то в поиск и получить результат!

На этом первая часть курса закончилась. Мы научились всему, чтобы перейти к более серьёзным вещам: аутентификации на сайте, комментариям к постам, сложным связям между тегами и постами и многому другому, что вас ждёт во второй части курса!

Регистрация на Symfony. Создание сущности пользователя

Вторую часть курса мы начнем с общей детали тысячи приложений - авторизации. У нас будет все: регистрация, вход-выход, кнопка “Запомнить меня” и отправка писем подтверждения регистрации на почту!

Чтобы реализовать регистрацию встроенными инструментами фреймворка, для начала нам нужно создать сущность User и имплементировать UserInterface, который даст нам стандартные методы вроде getRoles(), getUsername(), getSalt() и другие. Итак, через консоль или руками создайте сущность User, имплементируйте интерфейс UserInterface и добавьте методы, которые он требует. Вот как должен выглядеть ваш код на данном этапе:

Теперь давайте добавим поля для сущности. В первую очередь нам нужен id. Также добавим email, password, roles и plainPassword. PlainPassword не сохраняется в базе, он нужен для временного сохранения пароля из формы и валидации. Поле roles будет обычным json, в котором мы будем хранить роли, которыми владеет пользователь. Id у нас будет таким же, как и у сущности Post:

   /**
     * @var int
     *
     * @ORM\Id()
     * @ORM\GeneratedValue(strategy="AUTO")
     * @ORM\Column(type="integer")
     */
    private $id;

Я думаю, не стоит объяснять эти аннотации, они достаточно красноречивы и без того. Давайте создадим остальные поля:

    /**
     * @var string
     *
     * @ORM\Column(type="string", unique=true)
     * @Assert\NotBlank()
     * @Assert\Email()
     */
    private $email;

    /**
     * @var string
     *
     * @ORM\Column(type="string")
     */
    private $password;

    /**
     * @var string
     *
     * @Assert\NotBlank()
     */
    private $plainPassword;

    /**
     * @var array
     *
     * @ORM\Column(type="json_array")
     */
    private $roles = [];

Аннотация @Assert для вас новая, она предоставляет базовую валидацию: NotBlank() - поле не может быть пустым, Email() - введенные данные должны быть электронным адресом, то есть содержать @ и точку (как минимум). Также поле email у нас будет уникальным, что мы определили, указав unique=true. Над полем plainPassword нет аннотации Orm\Column(), потому что нам не надо сохранять это боле в базе, оно нужно только при регистрации пользователя.

Теперь давайте реализуем методы, которые нам дал UserInterface. Метод getRoles() пока будет возвращать обычный массив:

   /**
     * @return array
     */
    public function getRoles()
    {
        return [
            'ROLE_USER'
        ];
    }

Это значит, что любой пользователь, по умолчанию, будет обладать ролью User, что логично. Метод getPassword, собственно, будет обычным геттером пароля:

   /**
     * @return string
     */
    public function getPassword(): string
    {
        return $this->password;
    }

Соль нам не нужна, поэтому возвращаем null или можете вообще оставить метод пустым:

   /**
     * @return null
     */
    public function getSalt()
    {
        return null;
    }

А вот getUsername() может возвращать не только username (который я специально не добавил, чтобы показать особенность этого метода), он возвращает то, что для вас является достаточным правом для аутентификация на сайте - в нашем случае это email.

   /**
     * @return string
     */
    public function getUsername(): string
    {
        return $this->email;
    }

Метод eraseCredentials удаляет конфиденциальную информацию о пользователе, например, простой текстовый пароль, которым у нас является plainPassword:

    public function eraseCredentials()
    {
        $this->plainPassword = null;
    }

Не забудьте добавить геттеры и сеттеры для всех полей. В конечном итоге так будет выглядеть наша сущность:

<?php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * @ORM\Entity(repositoryClass="App\Repository\UserRepository")
 * @ORM\Table(name="user")
 * @UniqueEntity(fields={"email"}, message="У вас уже есть аккаунт")
 */
class User implements UserInterface
{
    /**
     * @var int
     *
     * @ORM\Id()
     * @ORM\GeneratedValue(strategy="AUTO")
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @var string
     *
     * @ORM\Column(type="string", unique=true)
     * @Assert\NotBlank()
     * @Assert\Email()
     */
    private $email;

    /**
     * @var string
     *
     * @ORM\Column(type="string")
     */
    private $password;

    /**
     * @var string
     *
     * @Assert\NotBlank()
     */
    private $plainPassword;

    /**
     * @var array
     *
     * @ORM\Column(type="json_array")
     */
    private $roles = [];

    /**
     * @return array
     */
    public function getRoles()
    {
        return [
            'ROLE_USER'
        ];
    }

    /**
     * @param $roles
     *
     * @return $this
     */
    public function setRoles($roles): self
    {
        $this->roles = $roles;

        return $this;
    }

    /**
     * @return string
     */
    public function getPassword(): string
    {
        return $this->password;
    }

    /**
     * @param string $password
     *
     * @return User
     */
    public function setPassword(string $password): self
    {
        $this->password = $password;

        return $this;
    }

    /**
     * @return null
     */
    public function getSalt()
    {
        return null;
    }

    /**
     * @return string
     */
    public function getUsername(): string
    {
        return $this->email;
    }

    public function eraseCredentials()
    {
        $this->plainPassword = null;
    }

    /**
     * @return string
     */
    public function getEmail()
    {
        return $this->email;
    }

    /**
     * @param string $email
     *
     * @return $this
     */
    public function setEmail(string $email): self
    {
        $this->email = $email;

        return $this;
    }

    /**
     * @return int
     */
    public function getId(): int
    {
        return $this->id;
    }

    /**
     * @return string
     */
    public function getPlainPassword()
    {
        return $this->plainPassword;
    }

    /**
     * @param string $plainPassword
     *
     * @return User
     */
    public function setPlainPassword(string $plainPassword): self
    {
        $this->plainPassword = $plainPassword;

        return $this;
    }
}

В сеттерах вы могли заметить return $this, то есть сеттер устанавливает значение и возвращает текущий объект. Это позволит нам выполнять цепочку методов без прерывания:

$user
   ->setEmail('youremail@gmail.com')
   ->setPassword($password);

Для больших сущностей это крайне удобно, также это удобно, когда вы загружаете фикстуры в базу, где валидация не требуется и поэтому можно непрерывно добавлять (set) данные.

Вверху сущности вы могли заметить следующую аннотацию:

/**
 * @ORM\Entity(repositoryClass="App\Repository\UserRepository")
 * @ORM\Table(name="user")
 * @UniqueEntity(fields={"email"}, message="У вас уже есть аккаунт")
 */

С первой вы уже знакомы: мы указываем, какой репозиторий отвечает за работу с этой сущностью. Если вы создавали сущности через команды, то у вас уже есть репозиторий:

php bin/console make:entity User

Если же нет, создайте его сами по примеру репозитория PostRepository.
Вторая аннотация показывает, как будет называться таблица в базе. Третья аннотация нужна для валидации конкретного поля сущности: в нашем случае это поле email. Также вы можете написать сообщение (message), которое покажется над полем email при регистрации, если пользователь уже существует.

Когда мы все сделали, нам нужно обновить наши миграции следующими командами:

php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate

Если вы все сделали правильно, в базе у вас появится таблица user. На этом мы закончили первую часть большой темы по созданию регистрации. До новых встреч!

Создание формы регистрации и отправка писем на почту

На прошлом уроке мы приступили с вами к созданию регистрации на сайте. Мы сделали не так много: создали сущность User и обновили базу. Сегодня мы начнем с того, что немного отредактируем код из прошлого урока, добавив туда еще два поля - confirmationCode и enabled - и конструктор. Первое поле нужно для кода подтверждения, который мы будем отправлять на почту, а второе - это булев тип, который принимает состояние false, если аккаунт еще не подтвержден, и true - когда подтвержден.

Итак, как мы уже сказали, нам нужны два новых поля для сущности User, давайте создадим их:

    /**
     * @var string
     *
     * @ORM\Column(type="string", nullable=true)
     */
    private $confirmationCode;

    /**
     * @var bool
     *
     * @ORM\Column(type="boolean")
     */
    private $enabled;

Также нам понадобятся геттеры и сеттеры для них, которые вы можете написать сами или позволить сгенерировать их за вас IDE:

   /**
     * @return string
     */
    public function getConfirmationCode(): string
    {
        return $this->confirmationCode;
    }

    /**
     * @param string $confirmationCode
     *
     * @return User
     */
    public function setConfirmationCode(string $confirmationCode): self
    {
        $this->confirmationCode = $confirmationCode;

        return $this;
    }

    /**
     * @return bool
     */
    public function getEnabled(): bool
    {
        return $this->enabled;
    }

    /**
     * @param bool $enabled
     *
     * @return User
     */
    public function setEnable(bool $enabled): self
    {
        $this->enabled = $enabled;

        return $this;
    }

Пока ничего необычного, правда? Поле confirmationCode будет содержать рандомную строку, которую мы будем отправлять на почту для подтверждения пользователем. Также после подтверждения поле enabled примет true и сделает человека полноправным пользователем нашего приложения. Не забудем сделать конструктор для сущности:

    public function __construct()
    {
        $this->roles = [self::ROLE_USER];
        $this->enabled = false;
    }

ROLE_USER - это константа, которую надо добавить в начале сущности User:

public const ROLE_USER = 'ROLE_USER';

Делайте так в любой ситуации, чтобы избежать магических переменных.

Теперь, кажется, мы закончили работу над нашей сущностью. По крайней мере, на данном этапе ее хватит для работы регистрации. Выполняем знакомые нам команды, чтобы доктрина применила изменения:

php bin/console doctrine:migrations:diff &&
php bin/console doctrine:migrations:migrate

Как вы уже знаете, многие настройки, которые дает нам Symfony, содержатся в файлах формата yaml. Любой пароль нужно шифровать, и Symfony предоставляет различные алгоритмы шифрования на выбор. Для этого вам нужно открыть файл config/security.yaml и после тега security уровнем ниже и чуть правее написать, какой алгоритм вы собираетесь использовать. В нашем случае это будет стандартный и надежный bcrypt. Так будет выглядеть файл:

security:
    encoders:
        App\Entity\User: bcrypt

Стандартная система безопасности фреймворка достаточно гибкая: она позволяет вам управлять данными, которые будут доставаться из сессии, а также данными, доставаемыми по разным признакам - например, из базы. Повторю код с новыми настройками:

security:
    encoders:
        App\Entity\User: bcrypt
    providers:
        database_users:
            entity:
                class: App\Entity\User,
                property: email

Здесь мы создали так называемый User провайдер, который знает, как доставать пользователя из базы по его email. Провайдер больше нужен для формы логина, но мы напишем его уже сейчас, а как он работает - узнаем в следующей статье.

Теперь нам нужно создать форму для регистрации пользователя:

<?php

declare(strict_types=1);

namespace App\Form;

use App\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class UserType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('email', EmailType::class)
            ->add('plainPassword', RepeatedType::class, [
                'type' => PasswordType::class
            ]);
    }

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

С формами мы уже работали, так что многое вам покажется знакомым. Мы используем EmailType, который требует от пользователя обязательного ввода электронного адреса, иначе форма не пройдет валидацию. RepeatedType - это парные поля, которые должны быть идентичны, чтобы валидация прошла успешно. Остальная конфигурация должна быть интуитивно понятна: поле типа PasswordType скроет ваш ввод.

Прежде чем писать регистрацию, предлагаю написать нужные нам сервисы: это Mailer и CodeGenerator - первый будет отправлять письма на почту, второй генерировать случайный хэш. В папке src/Service создайте класс Mailer.php. Symfony использует популярную и простую в использовании библиотеку Swift_Mailer, которая принимает объект Swift_Message, предварительно заполненный данными, и отправляет его на почту. Для создания сервиса Mailer нам нужны компоненты Swift_Mailer и Twig для рендеринга приветственного сообщения, которое отправится на почту пользователю. Инжектим их через конструктор и пишем наш метод sendConfirmation, который принимает в качестве аргумента сущность User. Вот так будет выглядеть наш класс:

<?php

declare(strict_types=1);

namespace App\Service;

use App\Entity\User;
use Swift_Mailer;
use Swift_Message;
use Twig_Environment;

class Mailer
{
    public const FROM_ADDRESS = 'kafkiansky@webshake.ru';

    /**
     * @var Swift_Mailer
     */
    private $mailer;

    /**
     * @var Twig_Environment
     */
    private $twig;

    public function __construct(
        Swift_Mailer $mailer,
        Twig_Environment $twig

    )  {
        $this->mailer = $mailer;
        $this->twig = $twig;

    }

    /**
     * @param User $user
     *
     * @throws \Twig_Error_Loader
     * @throws \Twig_Error_Runtime
     * @throws \Twig_Error_Syntax
     */
    public function sendConfirmationMessage(User $user)
    {
        $messageBody = $this->twig->render('security/confirmation.html.twig', [
            'user' => $user
        ]);

        $message = new Swift_Message();
        $message
            ->setSubject('Вы успешно прошли регистрацию!')
            ->setFrom(self::FROM_ADDRESS)
            ->setTo($user->getEmail())
            ->setBody($messageBody, 'text/html');

        $this->mailer->send($message);
    }
}

Для начала рендерим форму, куда передаем юзера и возвращаем результат в переменную $messageBody. Дальше создаем объект класса Swift_Message и по цепочке заполняем данными: тема, откуда отправлено (это может быть корпоративный ящик вашего ресурса), кому отправляем (тут достаем ящик пользователя, который он только что ввел) и само тело сообщения - это будет красиво оформленный в html разметку текст со ссылкой для подтверждения аккаунта. И под конец отправляем сообщение методом send() класса Swift_Mailer. Как видим, у нас получился достаточно простой сервис, мы могли бы написать его даже стандартными функциями php, но зачем, когда есть готовый компонент Swift_Mailer.

Также нам нужно написать шаблон (security/confirmation.html.twig), который отправится на почту пользователю. Вы можете сделать его красивым, мне хватит такого:

<!doctype html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Подтверждение регистрации!</title>
</head>
<body>
<div class="container">
    <p>Добро пожаловать, {{ user.username }}!</p>
    <p>Чтобы завершить регистрацию, подтвердите <a href="{{ path('email_confirmation', {'code': user.confirmationCode }) }}">электронный адрес</a></p>
</div>
</body>
</html>

Теперь займемся другим сервисом - CodeGenerator. В этом классе будет всего один метод, генерирующий нам случайный код. Этот сервис вы можете написать сами, подойдет любая реализация. В любом случае вот готовый пример:

<?php

declare(strict_types=1);

namespace App\Service;

class CodeGenerator
{
    public const RANDOM_STRING = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';

    /**
     * @return string
     */
    public function getConfirmationCode()
    {
        $stringLength = strlen(self::RANDOM_STRING);
        $code = '';

        for ($i = 0; $i < $stringLength; $i++) {
            $code .= self::RANDOM_STRING[rand(0, $stringLength - 1)];
        }

        return $code;
    }
}

Думаю, объяснять не нужно, здесь нет ничего от фреймворка, это чистый PHP.

Итак, займемся, наконец, классом RegisterController. Вы можете создать контроллер следующей командой:

php bin/console make:controller RegisterController. 

В этот раз я не буду использовать агрегацию, а покажу, как, используя наследование, работать с методами родительского класса для решения поставленной задачи. Вы можете полностью отказаться от наследования в пользу агрегации или композиции, неважно, в нашем случае это никак не влияет на работу приложения.

Что нам нужно передать в аргументы метода register()? Конечно, объект Request, наши сервисы Mailer и CodeGenerator, а также симфоневский компонент UserPasswordEncoderInterface, который зашифрует наш пароль: он принимает объект пользователя и plainPassword.

При регистрации происходит такой же порядок действий, как и при добавлении обычного поста: создаем объект сущности, резолвим форму и выполняем сохранение. Генерация случайного кода и хэширование пароля - это единственные особенности в данном случае. Также не забудем отправить сообщение на почту. Вот как в итоге будет выглядеть наш код:

<?php

declare(strict_types=1);

namespace App\Controller;

use App\Entity\User;
use App\Form\UserType;
use App\Repository\UserRepository;
use App\Service\CodeGenerator;
use App\Service\Mailer;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;

class RegisterController extends AbstractController
{
    /**
     * @Route("/register", name="register")
     */
    public function register(
        UserPasswordEncoderInterface $passwordEncoder,
        Request $request,
        CodeGenerator $codeGenerator,
        Mailer $mailer
    ) {
        $user = new User();
        $form = $this->createForm(
            UserType::class,
            $user
        );

        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $user = $form->getData();

            $password = $passwordEncoder->encodePassword(
                $user,
                $user->getPlainPassword()
            );
            $user->setPassword($password);
            $user->setConfirmationCode($codeGenerator->getConfirmationCode());

            $em = $this->getDoctrine()->getManager();

            $em->persist($user);
            $em->flush();

            $mailer->sendConfirmationMessage($user);
        }

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

Не забудем реализовать шаблон security/register.html.twig:

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

{% block title %} Регистрация {% endblock %}

{% block body %}

    <div class="container">
        <h3 class="text-center">Регистрация</h3>
        {{ form_start(form) }}
        {{ form_row(form.email, {
            'label': ' '
        }) }}
        {{ form_row(form.plainPassword.first, {
            'label': ' ', 'class': 'form-control'
        }) }}
        {{ form_row(form.plainPassword.second, {
            'label': ' ', 'help': 'Введите пароль повторно'
        }) }}
        <button type="submit" class="btn btn-success" formnovalidate>Зарегистрироваться</button>
        {{ form_end(form) }}

    </div>
{% endblock %}

Уже сейчас вы можете пойти в браузер и посмотреть, что получилось. После регистрации можете посмотреть в дебаг бар и увидеть значок письма

Нажав на него, вы перейдете в профайлер Symfony и увидите письмо:

Так будет выглядеть письмо, отправленное на почту. Как я уже говорил, вы можете сделать его красивым.

Осталось написать action для подтверждения регистрации. Он будет доставать пользователя по его confirmationCode. Если такого пользователя нет, обрываем выполнение кода и возвращаем 404 (вы со своей стороны можете возвращать красивую страницу с такой же ошибкой). Если пользователь есть, ставим true в поле enable и ‘’ в поле confirmationCode. Обновляем сущность и рендерим шаблон. Вот так будет выглядеть наш метод:

   /**
     * @Route("/confirm/{code}", name="email_confirmation")
     */
    public function confirmEmail(UserRepository $userRepository, string $code)
    {
        /** @var User $user */
        $user = $userRepository->findOneBy(['confirmationCode' => $code]);

        if ($user === null) {
          return new Response('404');
        }

        $user->setEnable(true);
        $user->setConfirmationCode('');

        $em = $this->getDoctrine()->getManager();

        $em->flush();

        return $this->render('security/account_confirm.html.twig', [
            'user' => $user,
        ]);
    }

Шаблон же будет выглядеть следующим образом:

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

{% block body %}
        <h5>Поздравляем!</h5>
        <p>Ваш аккаунт подтвержден, теперь вы можете
            <a href="{{ path('login') }}">войти</a></p>
{% endblock %}

Все, на данном этапе форма регистрации закончена. В следующем уроке мы сделаем вход/выход и кнопку “Запомнить меня”.

Знакомство с Event и EventSubsriber в Symfony

В этом уроке мы закончим регистрацию в нашем приложении, познакомившись и применив на практике такой компонент Symfony как события. Если простым языком, события - это любые действия, которые происходят в системе вашего приложения по “вине” пользователя (чаще всего) или чего-то другого. Этими действиями могут быть регистрация, авторизация, новый комментарий, новый лайк, создание поста и проч.

Конечно, по большей части, можно написать какой-нибудь приватный метод или сервис, который применить в конкретном экшене, реагируя на заранее известное вам событие (та же регистрация пользователя), но компонент Symfony, называемый EventDispatcher, позволяет реагировать на абсолютно любые события, выполнять их в определенном порядке (по приоритету) и даже несколько сразу.

События работают следующим образом: у нас есть Event - в нашем случае это зарегистрированный пользователь; и есть EventSubscriber, который подписывается на это событие и отрабатывает, когда оно произошло. Таким образом, когда у нас появится новый зарегистрированный пользователь, подписчик на данное событие отправит письмо на почту для подтверждения регистрации. Давайте приступим!

События, или Events, практически всегда выглядят крайне просто. Например, вот так:

<?php

declare(strict_types=1);

namespace App\Event;

use App\Entity\User;
use Symfony\Component\EventDispatcher\Event;

class RegisteredUserEvent extends Event
{
    public const NAME = 'user.register';

    /**
     * @var User
     */
    private $registeredUser;

    /**
     * @param User $registeredUser
     */
    public function __construct(User $registeredUser)
    {
        $this->registeredUser = $registeredUser;
    }

    /**
     * @return User
     */
    public function getRegisteredUser(): User
    {
        return $this->registeredUser;
    }
}

Создайте папку Event и положите этот класс туда. Итак, разберем его подробнее:

  1. Событие должно наследоваться от класса Event;
  2. У события есть имя в константе NAME: обычно оно составляется по имени класса (сущность User) и конкретного события (register, deleted, loggedIn, etc);
  3. Событие принимает в конструктор имя сущности и возвращает ее через геттер.

Как видите, ничего сложного. Теперь давайте напишем подписчик на событие. Для этого создайте папку EventSubscriber и создайте там класс UserSubscriber. Данный класс подписывается на любые события, совершенные пользователем, но пока он отслеживает только его регистрацию. Вы не поверите, но выглядит он даже еще проще (пока, по крайней мере), чем само событие:

<?php

namespace App\EventSubscriber;

use App\Event\RegisteredUserEvent;
use App\Service\Mailer;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Twig_Error_Loader;
use Twig_Error_Runtime;
use Twig_Error_Syntax;

class UserSubscriber implements EventSubscriberInterface
{
    /**
     * @var Mailer
     */
    private $mailer;

    /**
     * @param Mailer $mailer
     */
    public function __construct(Mailer $mailer)
    {
        $this->mailer = $mailer;
    }

    /**
     * @return array
     */
    public static function getSubscribedEvents()
    {
        return [
            RegisteredUserEvent::NAME => 'onUserRegister'
        ];
    }

    /**
     * @param RegisteredUserEvent $registeredUserEvent
     * @throws Twig_Error_Loader
     * @throws Twig_Error_Runtime
     * @throws Twig_Error_Syntax
     */
    public function onUserRegister(RegisteredUserEvent $registeredUserEvent)
    {
        $this->mailer->sendConfirmationMessage($registeredUserEvent->getRegisteredUser());
    }
}

Подписчик должен имплементить интерфейс EventSubscriberInterface, который по контракту требует реализовать метод getSubscribedEvents, в комментарии к которому (если у вас IDE PhpStorm, вы можете легко найти этот интерфейс и сами убедиться) можно увидеть следующее:

  array('eventName' => 'methodName')
  array('eventName' => array('methodName', $priority))
  array('eventName' => array(array('methodName1', $priority), array('methodName2')))

Так создатели Symfony предлагают реализовать этот метод: он должен возвращать простой массив, ключами которого являются имена событий, а значения - реализованные в этом подписчике методы. Обычно они должны формироваться как “on” + имя события. Именем у нас является “user.register”, а значит, метод будет называться onUserRegister. Напомню код еще раз:

public static function getSubscribedEvents()
    {
        return [
            RegisteredUserEvent::NAME => 'onUserRegister'
        ];
    }

А метод, собственно, всего лишь вызывает наш сервис по отправке сообщений:

public function onUserRegister(RegisteredUserEvent $registeredUserEvent)
    {
        $this->mailer->sendConfirmationMessage($registeredUserEvent->getRegisteredUser());
    }

Скажите, просто? Осталось совсем немного: запустить событие в контроллере. На прошлом уроке мы написали следующий экшен:

   /**
     * @Route("/register", name="register")
     */
    public function register(
        UserPasswordEncoderInterface $passwordEncoder,
        Request $request,
        CodeGenerator $codeGenerator,
        Mailer $mailer
    ) {
        $user = new User();
        $form = $this->createForm(
            UserType::class,
            $user
        );

        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $user = $form->getData();

            $password = $passwordEncoder->encodePassword(
                $user,
                $user->getPlainPassword()
            );
            $user->setPassword($password);
            $user->setConfirmationCode($codeGenerator->getConfirmationCode());

            $em = $this->getDoctrine()->getManager();

            $em->persist($user);
            $em->flush();

            $mailer->sendConfirmationMessage($user);
        }

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

Нам надо его переписать, используя события. Для начала удаляем сервис Mailer из аргументов метода: он нам больше не нужен. Также удаляем строку

$mailer->sendConfirmationMessage($user);

Вместо Mailer инжектим следующее EventDispatcherInterface $eventDispatcher и добавляем следующие две строки в конец:

$userRegisteredEvent = new RegisteredUserEvent($user);
$eventDispatcher->dispatch(RegisteredUserEvent::NAME, $userRegisteredEvent);

Создаем эвент, который принимает пользователя, потом вызываем метод dispatch встроенного класса EventDispatcher, принимающий имя события и само событие. Вот так выглядит весь код:

   /**
     * @Route("/register", name="register")
     */
    public function register(
        UserPasswordEncoderInterface $passwordEncoder,
        Request $request,
        CodeGenerator $codeGenerator,
        EventDispatcherInterface $eventDispatcher
    ) {
        $user = new User();
        $form = $this->createForm(
            UserType::class,
            $user
        );

        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $user = $form->getData();

            $password = $passwordEncoder->encodePassword(
                $user,
                $user->getPlainPassword()
            );
            $user->setPassword($password);
            $user->setConfirmationCode($codeGenerator->getConfirmationCode());

            $em = $this->getDoctrine()->getManager();

            $em->persist($user);
            $em->flush();

            $userRegisteredEvent = new RegisteredUserEvent($user);
            $eventDispatcher->dispatch(RegisteredUserEvent::NAME, $userRegisteredEvent);
        }

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

Некоторые правки с прошлого урока

На прошлом уроке мы сделали шаблон для отправки на почту при регистрации. Там есть небольшая ошибка: путь на страницу регистрации неполный, а содержит только path часть без хоста. Вот так он выглядит сейчас:

<!doctype html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Подтверждение регистрации!</title>
</head>
<body>
<div class="container">
    <p>{{ user.username }}, добро пожаловать!</p>
    <p>Чтобы завершить регистрацию, подтвердите <a href="{{ path('email_confirmation', {'code': user.confirmationCode }) }}">электронный адрес</a></p>
</div>
</body>
</html>

Строку со ссылкой на подтверждение надо заменить на следующую:

<p>Чтобы завершить регистрацию, подтвердите <a href="{{ app.request.schemeAndHttpHost }}{{ path('email_confirmation', {'code': user.confirmationCode }) }}">электронный адрес</a></p>

app.request.schemeAndHttpHost - в этой переменной хранится полное имя вашего сайта (http://127.0.0.1:8000).

Итого

Теперь, когда все готово, зарегистрируйтесь в вашем приложении. В tool-bar вы увидите письмо, вы можете нажать на него и выбрать Rendered Content. Собственно, вот и все. Мы познакомились с новым компонентом фреймворка, который может быть необычайно полезным и обязательно будет! В следующем уроке мы сделаем формы входы и выхода.

Создание формы логина на Symfony

Итак, мы уже сделали с вами регистрацию, научились отправлять письма на почту. Осталось реализовать формы входа и выхода, чтобы наше приложение было достаточно защищенным.

Пожалуй, это будет самая короткая статья из этой серии, ведь Symfony предлагает крайне простой механизм создания Login и Logout действий.

Для начала создайте контроллер SecurityController. Он будет достаточно простым и содержать два действия login и logout, поэтому нет необходимости наследоваться от Controller. Все нужно мы заинжектим через конструктор, а нужен нам только twig для рендера формы. Вот как будет выглядеть наш контроллер в самом начале:

<?php

declare(strict_types=1);

namespace App\Controller;

use Twig_Environment;

class SecurityController
{
    /**
     * @var Twig_Environment $twig
     */
    private $twig;

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

Теперь реализуем первый action - login.

   /**
     * @Route("/login", name="login")
     */
    public function login(AuthenticationUtils $authenticationUtils)
    {
        $lastUsername = $authenticationUtils->getLastUsername();
        $error = $authenticationUtils->getLastAuthenticationError();

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

На самом деле, это все. Вам не нужно писать действия по проверке схожести паролей, логина, доставать пользователя из базы или стартовать сессию. Если вы помните, в уроке по созданию сущности (первая статья из серии по реализации аутентификации) мы уже подготовили конфигурацию для логина в файлу config/packages/security.yaml. Она выглядела следующим образом:

providers:
        database_users:
            entity:
                class: App\Entity\User
                property: email

Так мы создали простой провайдер, который знает, как достать пользователя по определенному полю (в нашем случае этим полем является email).

Компонент AuthenticationUtils отдает нам пользователя либо из сессии, если он уже вошел, или из базы. Также отдает ошибку при входе. Оба этих поля мы передаем в следующий шаблон:

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

{% block body %}
    {% if error %}
        <div class="alert alert-danger">
            {{ error.message }}
        </div>
    {% endif %}

    <div class="container" style="padding-top: 160px ">
    <form method="post">
        <div class="form-group">
            <input type="text" name="_username" required="required" class="form-control" id="exampleInputEmail1">
            <small id="emailHelp" class="form-text text-muted">Введите электронный адрес</small>
        </div>
        <div class="form-group">
            <input type="password" name="_password" required="required" class="form-control" id="exampleInputPassword1">
        </div>
        <button type="submit" class="btn btn-primary">Войти</button>
        <input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
    </form>
    </div>
{% endblock %}

Нам даже не нужно указывать атрибут “action” в теге form, достаточно указать поля name="_username" и name="_password".

Не забудем про CSRF-защиту от автоподбора, которая генерирует уникальный хэш при каждой авторизации:

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

Также выведем ошибки в блоке сверху:

{% if error %}
    <div class="alert alert-danger">
         {{ error.message }}
    </div>
{% endif %}

Теперь напишем action - logout. Выглядеть он будет следующим образом:

   /**
     * @Route("/logout", name="logout")
     */
    public function logout()
    {
    }

Вы поверите, если я скажу, что на этом все? Но это правда так. Вернее, не совсем. Symfony уже знает, как разлогинить пользователя, достаточно правильно настроить config/packages/security.yaml.

firewalls:
    main:
        form_login:
           check_path: login
           login_path: login
           default_target_path: homepage
        logout:
           path: logout

Это стандартная настройка, которая, на мой взгляд, достаточно красноречива: login_path - это страница входа, куда перенаправляется пользователь, когда хочет получить доступ к страницам с определенными правами доступа; default_target_path - это роут, куда перенаправляется пользователь после входа; path: logout - это имя экшена, который разлогинивает пользователя; check_path - это роут, который ловит login-запросы.

На этом все. Вот так просто реализуется авторизация в Symfony.

7 симпатий

Admin там еще CRUD часть есть . Здесь нет (

Обновлено, курс не полный и будет обновлятся периодически после обновлений у автора курса.

2 симпатии

добавте там уже 7,8 пункты появились пожалуйста

Обновлено, + две главы

3 симпатии

9 и 10 части добавьте плз. И 11 уже.

Обвнолено. + 4 главы

2 симпатии

@Andrew.Kaluba добавьте, пожалуйста, новые главы.

2 симпатии

Обновите!! Пожалуйста

1 симпатия

(сообщение отозвано автором и будет автоматически удалено в течение 24 часов, если только на сообщение не поступит жалоба)

2 симпатии

Спасибо мужик есть еще что то

Обновите курс. пожалуйста.

1 симпатия

@Andrew.Kaluba добавьте, пожалуйста, новые главы.

Тут тип очень толково объясняет, кому интересно daniil-solovyev.github. io