Урок 1. Начало проекта (Установка)

Установка фреймворка

Самый простой способ установки фреймворки это воспользоваться менеджером зависимостей Composer.

Для установки выполните следующую команду:

php composer.phar create-project butterfly/example-web-application path/

Настройка веб вервера

При установке мы будем использовать веб сервер Apache 2.4.7

Сначало необходимо дать права доступа на дирректорию var:

chmod 777 path/var/

Затем необходимо создать новый файл конфигуации виртуального хоста.
Он должен выглядеть примерно так:

# /etc/apache2/sites-available/myproject.conf

<VirtualHost *:80>
    ServerName myproject
    DocumentRoot 'path/web'
    DirectoryIndex index.php

    <Directory 'path/web'>
    Require all granted
    Allowoverride ALL
    </Directory>
</VirtualHost>
        

Затем необходимо активировать виртуальный хост

sudo a2ensite myproject

и перезагрузить apache2

sudo service apache2 restart

После этого добавьте имя сервера в файл hosts

sudo echo "127.0.0.1 myproject" >> /etc/hosts

Настройка веб-сервера закончена

Перейдите в браузере на страницу http://myproject/ и у вас должно появится сообщение

Hello, World

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

После установки в корне проекта (path/) появится следующуя структура дирректорий:

  • bin/ - файлы для запуска консоли приложения
  • config/ - конфигурация приложения
  • src/ - код приложения
    • src/ButterflyAddition/ - дополнения к фреймворка для удобства работы
    • src/Project/ - код проекта
      • src/Project/Command - консольные команды
      • src/Project/Controller - контоллеры
      • src/Project/Entity - сущности приложения
      • src/Project/Repository - репозитории
      • src/Project/Service - сервисы
  • tests/ - дирректория для юнит тестов
  • var/ - временные файлы приложения, такие как логи и кэш
  • vendor/ - дирректория с компонентами фреймворка и другими библиотеками
  • view/ - шаблоны страниц
  • web/ - корневая папка с веб-файлами

День 2. Собственно проект (Пользовательские истории)

Начнем разработку с проработки идеи проекта.

В основе будет новостной сайт.

Роли проекта

С проектом Newsbeet будут работать следующие пользователи:

  1. Модератор - наполняет сайт новостями;
  2. Администратор - управляет аккаунтами журналистов и работой сайта в целом;
  3. Пользователь - обычный читатель

История 1 - Главная страница

Когда пользователь попадает на сайт Newsbeet, то он видит список из 5 последних новостей. Новости сортируются по дате добавления, последние отображаются в самом верху.

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

Также пользователю доступна ссылка "посмотреть все новости" при нажатии на которых он попадает на страницу с полным списком новостей.

История 2 - Страница со списком новостей

На данной странице пользователю представлен список новостей разделенный на 2 колонки. Снизу находится пагинатор в случае если новостей больше определенного количества. Пользователь может выбрать определенную категорию новости или выбрать пункт "Все категории".

История 3 - Страница с новостью

Когда пользователь переходит по ссылке "читать...", он попадает на страницу с полным текстом новости.

История 4 - Вход на сайт

При нажатии на кнопку "вход" пользователь попадает на страницу аутентификации. Он вводит e-mail и пароль и в случае успеха попадает в личный кабинет.

У модератора имеются функции:

  • создание, редактирование, публикация и просмотр новостей
  • редактирования профиля

После того как пользователь аутентифицирован, сверху появляется меню, через которое видно имя e-mail, а также есть возможность перейти по ссылке "выход" и разлогинится.

История 5 - Модератор. Создание новой новости

При нажатии на кнопку "вход" пользователь попадает на страницу аутентификации. Он вводит e-mail и пароль и в случае успеха попадает в личный кабинет.

На данной странице располагаются поля для создания новой новости. После их заполния и нажатии кнопки "создать" появляется новая новость.

История 7 - Модератор. Редактирование новости

Интерфейс редактирования очень сильно схож с интерфейсом создания новости.

История 9 - Консоль. Создание и удаление модератора

Создание и удаление модератора происходит через консоль.

День 3. Модель данных

Теперь когда известны данные о том как будет работать сайт мы подготовим модель данных.
По умолчанию в Butterfly Framework используется ORM Doctrine.

В начале необходимо настроить соединение с СУБД и создать пустую базу.
Для этого необходимо отредактировать файл конфигурации:

# config/local.yml

bfy_adapter.doctrine.db_parameters:
  driver:   'pdo_mysql'
  charset:  'UTF8'
  user:     'user'
  password: 'password'
  dbname:   'newsbeet'

Затем в MySQL необходимо создать базу:

    mysql -u root -p root_pass
    mysql> CREATE DATABASE newsbeet CHARACTER SET utf8 COLLATE utf8_general_ci;

При работе с ORM Doctrine порядок работы с архитектурой базы данных таков:

  1. Планирование какие таблицы мы хотим создать или отредактировать
  2. Создаем или вносим изменения в классы сущности. Так мы конфигурируем архитектуру базы
  3. Применяем изменения на базу данных.

Шаг 1. Состав таблиц

users

  • id (идентификатор пользователя)
  • type (тип пользователя: администратор, модератор)
  • email (в качестве логина используется email)
  • password (пароль)
  • is_active (флаг, что пользователь активировал свою анкету)
  • created_at (дата регистрации пользователя)
  • deleted_at (в случае если пользователь удален, устанавливается данная дата)

categories

  • id (идентификатор категории)
  • name (наименование категории)

news_items

  • id (идентификатор новости)
  • moderator_id (идентификатор модератора, добавившего новость)
  • category_id (идентификатор категории)
  • caption (заголовок новости)
  • short_body (краткий текст новости)
  • full_body (полный текст новости)
  • is_published (флаг, что новость опубликована)
  • created_at (дата создания новости)
  • updated_at (дата обновления новости)

Шаг 2. Создание сущностей

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

В начале нужно зайти в папку src/Project/Entity/. Здесь есть пример класса сущности User.php. Его необходимо удалить и завести новые сущности.

Прим. Существует хорошее правило, называть таблицы во множественном числе (например users), а сущность в единственном (User). Так делается потому что в таблице хранится множество записей пользователей, а в объекте сущности одна запись из таблицы. Это делать необязательно, но в наших примерах мы будем придерживаться такого стиля.

Класс User

    <?php

    namespace Project\Entity;

    use Doctrine\Common\Collections\ArrayCollection;
    use Doctrine\ORM\Mapping as ORM;

    /**
     * @ORM\Table(name="users")
     * @ORM\Entity
     */
    class User
    {
        const ALIAS = __CLASS__;

        const ADMINISTRATOR = 1;
        const MODERATOR = 2;

        /**
         * @var int
         *
         * @ORM\Id
         * @ORM\Column(name="id", type="integer", options={"comment"="идентификатор пользователя"})
         * @ORM\GeneratedValue(strategy="IDENTITY")
         */
        protected $id;

        /**
         * @var int
         *
         * @ORM\Column(name="type", type="smallint", options={"comment"="тип пользователя"})
         */
        protected $type;

        /**
         * @var string
         *
         * @ORM\Column(name="email", type="string", length=255, options={"comment"="в качестве логина используется email"})
         */
        protected $email;

        /**
         * @var string
         *
         * @ORM\Column(name="password", type="string", length=255, options={"comment"="пароль"})
         */
        protected $password;

        /**
         * @var bool
         *
         * @ORM\Column(name="is_active", type="boolean", options={"comment"="флаг что пользователь активировал свою анкет"})
         */
        protected $isActive;

        /**
         * @var \DateTime
         *
         * @ORM\Column(name="created_at", type="datetime", options={"comment"="дата регистрации пользователя"})
         */
        protected $createdAt;

        /**
         * @var \DateTime
         *
         * @ORM\Column(name="deleted_at", type="datetime", nullable=false, options={"comment"="в случае если пользователь удален, устанавливается данная дата"})
         */
        protected $deletedAt;

        /**
         * @var NewsItem[]|ArrayCollection
         *
         * @ORM\OneToMany(targetEntity="NewsItem", mappedBy="moderator")
         */
        protected $newsItems;

        public function __construct()
        {
            $this->newsItems = new ArrayCollection();
        }

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

        /**
         * @param int $id
         */
        public function setId($id)
        {
            $this->id = $id;
        }

        /**
         * @return int
         */
        public function getType()
        {
            return $this->type;
        }

        /**
         * @param int $type
         */
        public function setType($type)
        {
            $this->type = $type;
        }

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

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

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

        /**
         * @param string $password
         */
        public function setPassword($password)
        {
            $this->password = $password;
        }

        /**
         * @return boolean
         */
        public function isIsActive()
        {
            return $this->isActive;
        }

        /**
         * @param boolean $isActive
         */
        public function setIsActive($isActive)
        {
            $this->isActive = $isActive;
        }

        /**
         * @return \DateTime
         */
        public function getCreatedAt()
        {
            return $this->createdAt;
        }

        /**
         * @param \DateTime $createdAt
         */
        public function setCreatedAt($createdAt)
        {
            $this->createdAt = $createdAt;
        }

        /**
         * @return \DateTime
         */
        public function getDeletedAt()
        {
            return $this->deletedAt;
        }

        /**
         * @param \DateTime $deletedAt
         */
        public function setDeletedAt($deletedAt)
        {
            $this->deletedAt = $deletedAt;
        }

        /**
         * @return ArrayCollection|NewsItem[]
         */
        public function getNewsItems()
        {
            return $this->newsItems;
        }

        /**
         * @param ArrayCollection|NewsItem[] $newsItems
         */
        public function setNewsItems($newsItems)
        {
            $this->newsItems = $newsItems;
        }
    }

Класс Category

    <?php

    namespace Project\Entity;

    use Doctrine\Common\Collections\ArrayCollection;
    use Doctrine\ORM\Mapping as ORM;

    /**
     * @ORM\Table(name="categories")
     * @ORM\Entity
     */
    class Category
    {
        const ALIAS = __CLASS__;

        /**
         * @var int
         *
         * @ORM\Id
         * @ORM\Column(name="id", type="integer", options={"comment"="идентификатор категории"})
         * @ORM\GeneratedValue(strategy="IDENTITY")
         */
        protected $id;

        /**
         * @var string
         *
         * @ORM\Column(name="name", type="string", length=255, options={"comment"="наименование категории"})
         */
        protected $name;

        /**
         * @var NewsItem[]|ArrayCollection
         *
         * @ORM\OneToMany(targetEntity="NewsItem", mappedBy="category")
         */
        protected $newsItems;

        public function __construct()
        {
            $this->newsItems = new ArrayCollection();
        }

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

        /**
         * @param int $id
         */
        public function setId($id)
        {
            $this->id = $id;
        }

        /**
         * @return int
         */
        public function getName()
        {
            return $this->name;
        }

        /**
         * @param int $name
         */
        public function setName($name)
        {
            $this->name = $name;
        }

        /**
         * @return ArrayCollection|NewsItem[]
         */
        public function getNewsItems()
        {
            return $this->newsItems;
        }

        /**
         * @param ArrayCollection|NewsItem[] $newsItems
         */
        public function setNewsItems($newsItems)
        {
            $this->newsItems = $newsItems;
        }
    }

Класс NewsItem

    <?php

    namespace Project\Entity;

    use Doctrine\ORM\Mapping as ORM;

    /**
     * @ORM\Table(name="news_items")
     * @ORM\Entity
     */
    class NewsItem
    {
        const ALIAS = __CLASS__;

        /**
         * @var int
         *
         * @ORM\Id
         * @ORM\Column(name="id", type="integer", options={"comment"="идентификатор новости"})
         * @ORM\GeneratedValue(strategy="IDENTITY")
         */
        protected $id;

        /**
         * @var string
         *
         * @ORM\Column(name="name", type="string", length=255, options={"comment"="заголовок новости"})
         */
        protected $caption;

        /**
         * @var string
         *
         * @ORM\Column(name="short_body", type="text", options={"comment"="краткий текст новости"})
         */
        protected $shortBody;

        /**
         * @var string
         *
         * @ORM\Column(name="full_body", type="text", options={"comment"="полный текст новости"})
         */
        protected $fullBody;

        /**
         * @var bool
         *
         * @ORM\Column(name="is_published", type="boolean", options={"comment"="флаг, что новость опубликована"})
         */
        protected $isPublished;

        /**
         * @var \DateTime
         *
         * @ORM\Column(name="created_at", type="datetime", options={"comment"="дата создания новости"})
         */
        protected $createdAt;

        /**
         * @var \DateTime
         *
         * @ORM\Column(name="updated_at", type="datetime", options={"comment"="дата обновления новости"})
         */
        protected $updatedAt;

        /**
         * @var User
         *
         * @ORM\ManyToOne(targetEntity="User", inversedBy="newsItems")
         * @ORM\JoinColumn(name="moderator_id", referencedColumnName="id")
         */
        protected $moderator;

        /**
         * @var Category
         *
         * @ORM\ManyToOne(targetEntity="Category", inversedBy="newsItems")
         * @ORM\JoinColumn(name="category_id", referencedColumnName="id")
         */
        protected $category;

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

        /**
         * @param int $id
         */
        public function setId($id)
        {
            $this->id = $id;
        }

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

        /**
         * @param string $caption
         */
        public function setCaption($caption)
        {
            $this->caption = $caption;
        }

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

        /**
         * @param string $shortBody
         */
        public function setShortBody($shortBody)
        {
            $this->shortBody = $shortBody;
        }

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

        /**
         * @param string $fullBody
         */
        public function setFullBody($fullBody)
        {
            $this->fullBody = $fullBody;
        }

        /**
         * @return boolean
         */
        public function isIsPublished()
        {
            return $this->isPublished;
        }

        /**
         * @param boolean $isPublished
         */
        public function setIsPublished($isPublished)
        {
            $this->isPublished = $isPublished;
        }

        /**
         * @return \DateTime
         */
        public function getCreatedAt()
        {
            return $this->createdAt;
        }

        /**
         * @param \DateTime $createdAt
         */
        public function setCreatedAt($createdAt)
        {
            $this->createdAt = $createdAt;
        }

        /**
         * @return \DateTime
         */
        public function getUpdatedAt()
        {
            return $this->updatedAt;
        }

        /**
         * @param \DateTime $updatedAt
         */
        public function setUpdatedAt($updatedAt)
        {
            $this->updatedAt = $updatedAt;
        }

        /**
         * @return User
         */
        public function getModerator()
        {
            return $this->moderator;
        }

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

        /**
         * @return Category
         */
        public function getCategory()
        {
            return $this->category;
        }

        /**
         * @param Category $category
         */
        public function setCategory($category)
        {
            $this->category = $category;
        }
    }

Набор классов сущностей с данными описывающими их типы мы будем называть схемой данных.

После того как мы добавили сущности вернемся к консоли и введем команду:

./bin/console orm:validate-schema

Вывод должен быть таким:

    [Mapping]  OK - The mapping files are correct.
    [Database] FAIL - The database schema is not in sync with the current mapping file.

Первая строка выводит сообщение о корректности схемы данных. Если вдруг она не зеленая и FAIL будет выданы сообщения об ошибках. Необходимо руководствуясь ими проверить сущности и исправить ошибки. Для дальнейшей работы она должна быть зеленой.

Вторая строка выводит сообщение об идеентичности БД и схемы данных описанной в сущностях. Поскольку БД у нас пустая и мы только что создали сущности в ней должно быть сообщение о отсутствие идеентичности. Это нормально.

Теперь нам необходимо запустить команду по применению изменений в БД.
Для этого введите в консоли:

./bin/console orm:schema-tool:update

Вывод команды должен быть похож на следующий:

    ATTENTION: This operation should not be executed in a production environment.
               Use the incremental update to detect changes during development and use
               the SQL DDL provided to manually update your database in production.

    The Schema-Tool would execute "5" queries to update the database.
    Please run the operation by passing one - or both - of the following options:
        orm:schema-tool:update --force to execute the command
        orm:schema-tool:update --dump-sql to dump the SQL statements to the screen

Это означает, что необходимо выполнить 5 SQL запросов для того что бы привести БД в соответствие с схемой данных сущностей. Для этого повторите команду с опцией "--force":

./bin/console orm:schema-tool:update --force

Эта команда автоматически выполнит запросы, тем самым приведя БД в соответствие с схемой данных сущностей.

Повторим команду проверки:

./bin/console orm:validate-schema

чтобы убедится о том что схема идеентичная:

    [Mapping]  OK - The mapping files are correct.
    [Database] OK - The database schema is in sync with the mapping files.

Два зеленых сообщения с надписями ОК говорят, о том что можно работать дальше.

Добавим тестовый данные в проект.

Тестовые данные

# testdata.sql

INSERT INTO users (id, type, email, password, is_active, created_at) VALUES
  (1, 2, 'example@gmail.com', '', TRUE, NOW());

INSERT INTO categories (id, name) VALUES
  (1, 'Животные'),
  (2, 'Наука'),
  (3, 'Техника');

INSERT INTO news_items (moderator_id, category_id, caption, short_body, full_body, is_published, created_at, updated_at)
VALUES
  (1, 1, 'Новость о животных 1', 'Краткое описание новости о животных 1', 'Полное описание новости о животных 1', TRUE, NOW(), NOW()),
  (1, 1, 'Новость о животных 2', 'Краткое описание новости о животных 2', 'Полное описание новости о животных 2', TRUE, NOW(), NOW()),
  (1, 2, 'Новость о науке 1', 'Краткое описание новости о науке 1', 'Полное описание новости о науке 1', TRUE, NOW(), NOW()),
  (1, 2, 'Новость о науке 2', 'Краткое описание новости о науке 2', 'Полное описание новости о науке 2', FALSE, NOW(), NOW()),
  (1, 2, 'Новость о науке 3', 'Краткое описание новости о науке 3', 'Полное описание новости о науке 3', TRUE, NOW(), NOW()),

  (1, 3, 'Новость о технике 1', 'Краткое описание новости о технике 1', 'Полное описание новости о технике 1', TRUE, NOW(), NOW()),
  (1, 3, 'Новость о технике 2', 'Краткое описание новости о технике 2', 'Полное описание новости о технике 2', TRUE, NOW(), NOW()),
  (1, 3, 'Новость о технике 3', 'Краткое описание новости о технике 3', 'Полное описание новости о технике 3', TRUE, NOW(), NOW()),
  (1, 3, 'Новость о технике 4', 'Краткое описание новости о технике 4', 'Полное описание новости о технике 4', TRUE, NOW(), NOW()),
  (1, 3, 'Новость о технике 5', 'Краткое описание новости о технике 5', 'Полное описание новости о технике 5', TRUE, NOW(), NOW()),
  (1, 3, 'Новость о технике 6', 'Краткое описание новости о технике 6', 'Полное описание новости о технике 6', TRUE, NOW(), NOW()),
  (1, 3, 'Новость о технике 7', 'Краткое описание новости о технике 7', 'Полное описание новости о технике 7', TRUE, NOW(), NOW()),
  (1, 3, 'Новость о технике 8', 'Краткое описание новости о технике 8', 'Полное описание новости о технике 8', TRUE, NOW(), NOW()),
  (1, 3, 'Новость о технике 9', 'Краткое описание новости о технике 9', 'Полное описание новости о технике 9', TRUE, NOW(), NOW()),
  (1, 3, 'Новость о технике 10', 'Краткое описание новости о технике 10', 'Полное описание новости о технике 10', TRUE, NOW(), NOW());

День 4: Hello, World

Схема MVC

Схема MVC это комбинация нескольких паттернов проектирования, при помощи которых модель данных приложения (Model), пользовательский интерфейс (View) и взаимодестие с пользователем (Controller) разделено на три отдельных компонента таким образом, чтобы изменение одного из них оказывала минимальное воздействие на остальные. (Википедия)

Модель данных приложения (Model) - это бизнес логика приложения. В PHP под бизнес логика выполняет следующие задачи:

  • хранение данных (СУБД, NoSQL решения)
  • изменения данных - операции по поиску, добавлению, изменению и удалению данных в хранилище данных. Основа этих операций реализованы в хранилище данных. В приложении в основном выполняется управление и конфигурирование данных задач.

В Buttefly Framework по умолчанию для этого используются ORM Doctrine.

обеспечение безопасности - аутентификация и авторизация реализованы в Butterfly и могут гибко настраиваться.

Пользовательский интерфейс (View) - это что видит пользователь на экране. В веб разработке пользовательский интерфейс строится при помощи HTML, CSS и JS. Различные шаблонизаторы упрощают работу с HTML, а также делают ее более безопасной, автоматически применяя экранирование. В Buttefly Framework по умолчанию используется шаблонизатор Twig.

Взаимодестие с пользователем (Controller) - здесь происходит маршрутизацию (поиск необходимо экшена), обработку параметров (происходит в роутинге и самом экшене), а также обновление страницы (рендеринг шаблона).

Создание главной страницы

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

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

По факту экшены - являются методами определенного класса. Такой класс называется контроллером. Он выступает в роли группы для экшенов.

Прим.
Все находящиеся в папке src/Project/Controller классы можно удалить, они являются примерами.

Создадим в папке src/Project/Controller новый контроллер, который мы назовем SiteController:

    # src/Project/Controller/SiteController.php

    <?php

    namespace Project\Controller;

    use ButterflyAddition\AbstractController;
    use Symfony\Component\HttpFoundation\Request;

    class SiteController extends AbstractController
    {
        public function indexAction(Request $request)
        {
            return new Response('Hello, World');
        }
    }

В экшен на входе передается параметр $request. Он содержит информацию о запросе. Более подробно про него будет рассказано далее.
Экшен обязан возвращать объект Response или производный от него. В данном случае мы вручную создали объект Response и записали содержимое страницы в виде строки.

Обратите внимание на наименование контроллера и экшена. Окончания Controller в названии класса и Action метода - обязательны.

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

  • getDoctrine() - возвращает сервис EntityManager
  • getRepository($entityName) - возвращает необходимый репозиторий
  • render($view, $parameters, $response) - рендерит шаблон и оборачивает его объектов Response
  • renderView($view, $parameters) - рендерит шаблон и возвращает текст
  • redirectByRoute($route, $parameters, $referenceType) - генерирует ссылку по наименованию правила роутинга с параметрами и оборачивает ее объектов RedirectResponse
  • generateUrl($route, $parameters, $referenceType) - генерирует ссылку по наименованию правила роутинга с параметрами
  • redirectByUrl($url, $status) - возвращает объект RedirectResponse

Наследование от абстрактного контроллера не обязательно.

Теперь необходимо подготовить контроллер для использования в маршрутизации. Для этого отредактируем конфиг контроллеров:

    # config/project/controllers.yml

    services:

      project.controller.site:
        class: 'Project\Controller\SiteController'
        arguments: [@service_container]

Здесь

  • project.controller.site - имя сервиса контроллера.
  • class: 'Project\Controller\SiteController' - полное наименование класса контроллера.
  • arguments: - список параметров передающихся в конструктор при создании контроллера. Если вы не наследуетесь от абстрактного контроллера, то передавать @service_container Вам не нужно.

На самом сейчас было добавлено описание нового сервиса в DI Container (Эта тема для углубленного изучения)

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

Для этого отредактируем конфиг роутинга:

    # config/project/routing.yml

    site_home:
      pattern:  /
      defaults: { _controller: project.controller.site:index }

Здесь

  • site_home - наименование правила роутинга.
  • pattern - шаблон пути по которому будет сопоставление URL
  • project.controller.site - имя сервиса контроллера (из config/project/controllers.yml)
  • index - имя экшена (имя метода экшена без окончания Action)

После этого наберите в браузере http://myproject/ и вы должны увидеть первую страницу с текстом:

Hello, World

День 5. Первая страница

В начале необходимо подготовить модель для вывода 5 новостей на главной странице.

Создадим репозиторий для NewsItem и реализуем метод для выбора последних новостей. Для этого в директории src/Project/Repository создадим класс NewsItemRepository:

    # src/Project/Repository/NewsItemRepository.php

    <?php

    namespace Project\Repository;

    use Doctrine\ORM\EntityRepository;

    class NewsItemRepository extends EntityRepository
    {
        /**
         * @param int $count
         * @return array
         */
        public function getLastNewsItems($count)
        {
            return $this->findBy(
                array('isPublished' => true),
                array('createdAt' => 'desc'),
                $count
            );
        }
    }

Затем необходимо указать что данный репозиторий относится к сущности NewsItem. Для этого необходимо добавить в конфигурации @ORM\Entity опцию repositoryClass:

    # src/Project/Entity/NewsItem.php

    /**
     * @ORM\Table(name="news_items")
     * @ORM\Entity(repositoryClass="\Project\Repository\NewsItemRepository")
     */
    class NewsItem

    ...

Теперь необходимо связать модель с шаблоном, для чего отредактируем контроллер:

    # src/Project/Controller/SiteController.php

    <?php

    namespace Project\Controller;

    use ButterflyAddition\AbstractController;
    use Project\Entity\NewsItem;
    use Project\Repository\NewsItemRepository;
    use Symfony\Component\HttpFoundation\Request;

    class SiteController extends AbstractController
    {
        public function indexAction()
        {
            $limitItemsPerPage = $this->getLimitItemsPerPage();

            $lastNews  = $this->getNewsItemRepository()->getLastNewsItems($limitItemsPerPage);
            $firstNews = array_shift($lastNews);

            return $this->render('site/index.html.twig', array(
                'firstNews' => $firstNews,
                'lastNews'  => $lastNews,
            ));
        }

        /**
         * @return int
         */
        protected function getLimitItemsPerPage()
        {
            return $this->container->getParameter('site.items_per_page');
        }

        /**
         * @return NewsItemRepository
         */
        protected function getNewsItemRepository()
        {
            return $this->getRepository(NewsItem::ALIAS);
        }
    }

Параметр site.items_per_page который указывает количество элементов на странице мы настраиваем в файле services.yml:

    # config/project/services.yml

    site.items_per_page:    5

    services:

      project.calculator:
        class: 'Project\Service\Calculator'

В начале мы получаем репозиторий для NewsItem.

        /**
         * @return NewsItemRepository
         */
        protected function getNewsItemRepository()
        {
            return $this->getRepository(NewsItem::ALIAS);
        }

В качестве параметра метод getRepository() принимает полное наименование класса сущности, в данном случае это NewsItem. При помощи магической константы __CLASS__ внутри класса сущности мы сделали хак, который позволит не запоминать полные имена.

Затем пользуясь методом выборки последних новостей извлекаем массив с новостями в переменную $lastNews;

$lastNews = $newsItemRepository->getLastNewsItems(5);

При помощи функции array_shift отделяем самую свежую новость. Это необходимо для нашей логики вывода.

В конце используется метод render абстрактного контроллера для рендеринга шаблона.

            return $this->render('site/index.html.twig', array(
                'firstNews' => $firstNews,
                'lastNews' => $lastNews,
            ));

Первый параметр это путь до шаблона. Он указывается от дирректории view/ в проекте. Вторым параметром передается массив параметров шаблона.

Внутри шаблона параметры будут доступны по наименованию ключей: firstNews и lastNews.

Шаблоны

Twig это шаблонизатор с удобной функцией наследования. Это означает ...

Создадим главный шаблон, от которого будут наследоваться все другие шаблоны и поместим его в директорию view/:

    # view/layout.html.twig

    <!DOCTYPE html>
    <html>
        <head>
            <meta charset="utf-8">

            <title>
    {% block title %}{% endblock %}
    </title>

    {% block scripts %}
        <script src="//ajax.googleapis.com/ajax/libs/jquery/1.11.2/jquery.min.js"></script>
    {% endblock %}

    {% block styles %}
        <link href="/css/style.css" rel="stylesheet" type="text/css" />
    {% endblock %}
    </head>
        <body>
    {% block content %}
    {% endblock %}

    {% block postScripts %}
    {% endblock %}
    </body>
    </html>

Он содержит глобальные блоки title, styles, content, postScripts которые можно и необходимо переопределять в шаблонах страниц. Далее создается шаблон главной страницы сайта. Его поместим в отдельную директорию site (по названию контроллера), хотя это и не обязательно, данное разделение добавляет удобство.

    # view/site/index.html.twig

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

    {% block title %}Newsbeet | Главная{% endblock %}

    {% block content %}

        <div class="page">
            <div class="page__header">
                <h1 class="site-title">Newsbeet. Новости со всего мира</h1>
            </div>

            <div class="page__first-news news">
                <h2 class="news__title">{{ firstNews.caption }}</h2>
                <div class="news__text">{{ firstNews.fullBody }}<br/><a href="/news/{{ firstNews.id }}">далее...</a></div>
                <div class="news__other news__other_author">{{ firstNews.createdAt | date('d.m.Y') }} {{ firstNews.moderator.email }}</div>
            </div>

            {% for news in lastNews %}
                <div class="page__news {% if loop.index is even %}page__news_last {% endif %}news">
                    <h2 class="news__title">{{ news.caption }}</h2>
                    <div class="news__text">{{ news.shortBody }}<br/><a href="/news/{{ news.id }}">далее...</a></div>
                    <div class="news__other news__other_author">{{ news.createdAt | date('d.m.Y') }} {{ news.moderator.email }}</div>
                </div>
            {% endfor %}

        </div>

    {% endblock %}

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

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

Далее в шаблоне переопределены блоки title и content.

Можно посмотреть результат:

[КАРТИНКА]

День 6. Страница со списком новостей

Добавим новый экшен в контроллер и назовем его newsListAction:

    # src/Project/Controller/SiteController.php

        ...

        public function newsListAction(Request $request)
        {
            $categoryId  = $request->get('category', 0);
            $currentPage = $request->get('page', 1);

            $categoryRepository = $this->getRepository(Category::ALIAS);

            /** @var Category[] $categories */
            $categories = $categoryRepository->findAll();

            /** @var Category|null $currentCategory */
            $currentCategory = $categoryRepository->find($categoryId);

            $limitItemsPerPage = $this->getLimitItemsPerPage();
            $navigatorRadius   = $this->container->getParameter('site.navigarion_radius');

            $queryBuilder = $this->getNewsItemRepository()->getQueryBuilderNewsItemsListWithCategoryFilter($currentCategory);

            $paginator = new Paginator(
                new DoctrinePaginatorAdapter($queryBuilder),
                new DefaultPageNavigator($currentPage, $limitItemsPerPage, $navigatorRadius)
            );

            return $this->render('site/newsList.html.twig', array(
                'currentCategory' => $currentCategory,
                'categories'      => $categories,
                'paginator'       => $paginator,
            ));
        }

        /**
         * @return \Doctrine\ORM\EntityRepository
         */
        protected function getCategoryRepository()
        {
            return $this->getRepository(Category::ALIAS);
        }

        ...

В экшен всегда передается в качестве параметра объект $request.

$request содержит всю необходимую информацию о запросе:

  • query - объект обертка над $_GET
  • request - объект обертка над $_POST
  • attributes - объект-массив дополнительных атрибутов запроса (добавляются фреймворком или пользователем)
  • cookies - объект обертка над $_COOKIE
  • files - объект обертка над $_FILES
  • server - объект обертка над $_SERVER
  • header - объект для работы с заголовками запроса
  • getSession() - объект для работы с параметрами сессии
  • get($key, $default) - возвращает параметр запроса по ключу из $_GET, attributes, $_POST
  • getPathInfo() - возвращает часть пути из URL после хоста (для http://my.host/site/abc вернет /site/abc)
  • getClientIp() - возращает первый IP из полученных в запросе
  • getClientIps() - возвращает все IP адреса из данных в запросе
  • getHost() - возвращает только host (для http://my.host:8080/site/abc вернет my.host)
  • getHttpHost() - возвращает host с портом если он необходим (для http://my.host:8080/site/abc вернет my.host:8080)
  • getMethod() - возращает HTTP метод запроса (например GET)
  • getQueryString() - возвращает строку параметров запроса (для http://my.host/abc?a=1&b=2 вернет a=1&b=2)
  • getScheme() - возвращает схему из запроса (для http://my.host/ вернет http)

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

Далее мы при помощи репозитория категорий получаем список всех категорий. Он будет использоваться для выпадающего списка. Также мы получаем выбранную пользователем категорию. В случае если id категории равен 0, категория найдена не будет и будет возвращен null. В таком случае будут выбраны все новости.

Далее мы получаем параметры для пагинатора: количество элементов на странице и радиус пагинатора (количество ссылок на страницы соседних с текущей). Для этого необходимо добавить параметр в файл services.yml:

    # config/project/services.yml

    site.items_per_page:    5
    site.navigarion_radius: 2

    services:

      project.calculator:
        class: 'Project\Service\Calculator'

Затем получаем билдер запроса из NewsItemRepository. Метод создания билдера в репозитории выглядит так:

    # src/Project/Repository/NewsItemRepository.php

        ...

        /**
         * @param Category $category
         * @return \Doctrine\ORM\QueryBuilder
         */
        public function getQueryBuilderNewsItemsListWithCategoryFilter(Category $category = null)
        {
            $query = $this
                ->createQueryBuilder('n')
                ->orderBy('n.createdAt', 'desc');

            if ($category) {
                $query
                    ->where('n.category = :category_id')
                    ->setParameter('category_id', $category);
            }

            return $query;
        }

        ...

Результаты запроса сортируются по дате добавления. В случае если аргумент $category не равен null, он используется для фильтрации результатов пагинатора.

В конце экшена создается пагинатор. В него передаются два параметра: адаптер пагинатора для Doctrine и навигатор страниц по умолчанию.

Адаптер пагинатора реализует интерфейс IQueryAdapter и необходим для работы со списком значений. Его главные функции это определение общего количество записей, а также выборке диапазона записей ограниченных параметрами limit, offset. DoctrinePaginatorAdapter работает на основе билдеров Doctrine. В случае необходимости нужно реализовать свой адаптер.

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

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

    # view/site/newsList.html.twig

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

    {% block title %}Newsbeet | Список новостей{% endblock %}

    {% block content %}

        <div class="page">
            <div class="page__header">
                <h1 class="site-title">Newsbeet. Новости со всего мира</h1>
                <div>
                    <form method="GET" action="" id="form">
                        <label for="category">Категория</label>

                        <select name="category" id="category">
                            <option value="0"{% if currentCategory.id == 0 %} selected="selected"{% endif %}>Все</option>
                            {% for category in categories %}
                                <option value="{{ category.id }}"{% if currentCategory.id == category.id %} selected="selected"{% endif %}>{{ category.name }}</option>
                            {% endfor %}
                        </select>
                    </form>
                </div>
            </div>

            <table>
                {% for news in paginator.items %}
                    <tr>
                        <td><a href="/news/{{ news.id }}">{{ news.caption }}</a></td>
                        <td>{{ news.shortBody }}</td>
                        <td>{{ news.moderator.email }}</td>
                        <td>{{ news.createdAt | date('d.m.Y H:i:s') }}</td>
                    </tr>
                {% endfor %}
            </table>

            <hr/>

            {% include '_paginator.html.twig' with {'pageNavigator': paginator.pageNavigator, 'baseUrl': '/news?category=' ~ currentCategory.id ~ '&page='} %}

            <div>Всего {{ paginator.count }}</div>
        </div>

    {% endblock %}

    {% block postScripts %}
        <script>
            $(document).ready(function() {
                $('#category').on('change', function() {
                    $('#form').submit();
                })
            });
        </script>
    {% endblock %}

В шаблоне используется конструкция include котороя вставляет шаблон _paginator.html.twig. Шаблон пагинатора выглядит следующим образом:

    # view/_paginator.html.twig

    {% if pageNavigator.isNeed %}
        <div>
            {% if pageNavigator.isNeedFirst %}
                <a href="{{ baseUrl }}{{ pageNavigator.firstPage }}">Первая</a>
            {% endif %}

            {% if pageNavigator.isNeedPrev %}
                <a href="{{ baseUrl }}{{ pageNavigator.prevPage }}">Предыдущая</a>
            {% endif %}

            {% for page in pageNavigator.pages %}
                {% if page == pageNavigator.currentPage %}
                    <span>{{ page }}</span>
                {% else %}
                    <a href="{{ baseUrl }}{{ page }}">{{ page }}</a>
                {% endif %}
            {% endfor %}

            {% if pageNavigator.isNeedNext %}
                <a href="{{ baseUrl }}{{ pageNavigator.nextPage }}">Следующая</a>
            {% endif %}

            {% if pageNavigator.isNeedLast %}
                <a href="{{ baseUrl }}{{ pageNavigator.lastPage }}">Последняя</a>
            {% endif %}
        </div>
    {% endif %}

День 7. Страница с новостью

Создадим экшен в контроллере:

    # src/Project/Controller/SiteController.php

        ...

        public function newsAction(Request $request)
        {
            $id = $request->get('id');

            $news = $this->getNewsItemRepository()->find($id);

            if (!$news) {
                return $this->redirectByRoute('default');
            }

            return $this->render('site/news.html.twig', array(
                'news' => $news
            ));
        }

        ...

Здесь мы получаем id новости из объекта $request и при помощи NewsItemRepository получаем объект новости NewsItem. В случае если новость не найдена происходит редирект на главную страницу сайта.

Добавим правило в конфигурацию роутинга:

    # config/project/routing.yml

    ...

    news:
      pattern:  /news/{id}
      defaults: { _controller: project.controller.site:news }

В данном случае {id} является параметром в URL.

Шаблон страницы представлен ниже:

    # view/site/news.html.twig

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

    {% block title %}Newsbeet | {{ news.caption }}{% endblock %}

    {% block content %}

        <div class="page">
            <div class="page__header">
                <h1 class="site-title">Newsbeet. Новости со всего мира</h1>
            </div>

            <div class="page__first-news news">
                <h2 class="news__title">{{ news.caption }}</h2>
                <div class="news__text">{{ news.fullBody }}</div>
                <div class="news__other news__other_author">{{ news.createdAt | date('d.m.Y') }} {{ news.moderator.email }}</div>
            </div>

            <div>
                <a href="/news">К списку новостей</a>
            </div>
        </div>

    {% endblock %}

День 8. Вход на сайт

Для начала уточним процессы которые необходимы нам для работы с пользователями.

Аутентификация - процесс проверки подлинности. В PHP чаще всего это происходит путем поиска логина и пароля введенных пользователем в базе данных. В случае успеха мы получаем идентификатор пользователя. Идентификатор пользователя это id записи пользователя в базе. В нашем случае в таблице users.

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

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

    # src/Project/Controller/AuthController.php

    <?php

    namespace Project\Controller;

    use Butterfly\Plugin\Auth\Identificator;
    use ButterflyAddition\AbstractController;
    use Project\Entity\User;
    use Project\Repository\UserRepository;
    use Symfony\Component\HttpFoundation\Request;

    class AuthController extends AbstractController
    {
        public function indexAction()
        {
            $identificator = $this->getIndetificationService()->getIdentificator();

            if (!$identificator->isNullable()) {
                return $this->redirectByRoute('site_index');
            }

            return $this->render('auth/index.html.twig');
        }

        public function processAction(Request $request)
        {
            if ($request->getMethod() != 'POST') {
                return $this->redirectByRoute('site_index');
            }

            $email    = $request->get('email');
            $password = $request->get('password');

            $user = $this->getUserRepository()->findUserByEmailAndPassword($email, $password);

            if (null === $user) {
                return $this->render('auth/index.html.twig', array(
                    'invalid' => true,
                ));
            }

            $identificator = Identificator::createIdentificator($user->getId(), array(
                'name' => $user->getEmail(),
            ));

            $this->getIndetificationService()->setIdentificator($identificator);

            return $this->redirectByRoute('auth_index');
        }

        public function exitAction()
        {
            $this->getIndetificationService()->removeIdentificator();

            return $this->redirectByRoute('site_index');
        }

        /**
         * @return UserRepository
         */
        private function getUserRepository()
        {
            return $this->getRepository(User::ALIAS);
        }
    }

Роутинг контроллера:

    # config/project/routing.yml

    ...

    auth_index:
      pattern:  /auth
      defaults: { _controller: project.controller.auth:index }

    auth_process:
      pattern:  /auth/process
      defaults: { _controller: project.controller.auth:process }

    auth_exit:
      pattern:  /auth/exit
      defaults: { _controller: project.controller.auth:exit }

indexAction используется для отображения формы входа.

При помощи сервиса IdentificationService мы получаем объект Indentificator. Это идентификатор текущего пользователя.

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

Шаблон страницы входа:

    # view/auth/index.html.twig

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

    {% block title %}Newsbeet | Главная{% endblock %}

    {% block content %}

        <h2>Форма входа</h2>

        {% if invalid %}
            <div class="error-message">Логин или пароль не верны</div>
        {% endif %}

        <form action="/auth/process" method="POST">
            <div>
                <label for="email">Email</label>
                <input type="text" name="email" id="email">
            </div>

            <div>
                <label for="password">Password</label>
                <input type="password" name="password" id="password">
            </div>

            <div>
                <input type="submit" value="Войти">
            </div>
        </form>

    {% endblock %}

В экшене processAction в начале происходит проверка HTTP метода. Затем мы пытаемся найти пользователя соответствующего параметрам переданным в форме.

Метод поиска пользователя располагается в UserRepository. Его необходимо создать и прописать в сущности User:

    # src/Project/Entity/User.php

    ...

    /**
     * @ORM\Table(name="users")
     * @ORM\Entity(repositoryClass="\Project\Repository\UserRepository")
     */
    class User

    ...
    # src/Project/Repository/UserRepository.php

    <?php

    namespace Project\Repository;

    use Doctrine\ORM\EntityRepository;
    use Project\Entity\User;

    class UserRepository extends EntityRepository
    {
        /**
         * @param string $email
         * @param string $password
         * @return User|null
         */
        public function findUserByEmailAndPassword($email, $password)
        {
            return $this->findOneBy(array(
                'email'    => $email,
                'password' => $password,
            ));
        }
    }

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

    ...
    $identificator = Identificator::createIdentificator($user->getId(), array(
        'name' => $user->getEmail(),
    ));
    ...

Затем мы его сохраняем в сессии при помощи IndetificationService и редиректим пользователя на главную страницу сайта.

    ...
    $this->getIndetificationService()->setIdentificator($identificator);
    ...

Экшен exitAction необходим для процедуры выхода пользователя. В нем происходит процесс удаления идентификатора из сессии.

    ...
    $this->getIndetificationService()->removeIdentificator();
    ...

В AbstractController в методе renderView в параметры шаблона всегда передается идентификатор пользователя. Воспользуемся этим и изменим шаблон layout.html.twig для вывода инфомации пользователя:

    ...
    <div class="userblock">
        {% if identificator.isNullable %}
            <a href="/auth">Войти</a>
        {% else %}
            {{ identificator.parameters.name }} (<a href="/auth/exit">Выйти</a>)
        {% endif %}
    </div>
    ...

Теперь шаблон выглядит так:

    # view/layout.html.twig

    <!DOCTYPE html>
    <html>
        <head>
            <meta charset="utf-8">

            <title>
                {% block title %}{% endblock %}
            </title>

            {% block scripts %}
                <script src="//ajax.googleapis.com/ajax/libs/jquery/1.11.2/jquery.min.js"></script>
            {% endblock %}

            {% block styles %}
                <link href="/css/style.css" rel="stylesheet" type="text/css" />
            {% endblock %}
        </head>
        <body>
            <div class="page">
                <div class="page__header">
                    <h1 class="site-title">Newsbeet. Новости со всего мира</h1>
                    <div class="userblock">
                        {% if identificator.isNullable %}
                            <a href="/auth">Войти</a>
                        {% else %}
                            {{ identificator.parameters.name }} (<a href="/auth/exit">Выйти</a>)
                        {% endif %}
                    </div>
                </div>
                {% block content %}

                {% endblock %}
            </div>

            {% block postScripts %}
            {% endblock %}
        </body>
    </html>

День 9. Создание новости

Пользователь который зашел на сайт может создать новость

Добавим ссылку по добавлению новости в общий лайаут сайта:

    # /var/www/agregad/t4/view/layout.html.twig

    ...
    <div class="page__header">
        <h1 class="site-title">Newsbeet. Новости со всего мира</h1>
        <div class="userblock">
            {% if identificator.isNullable %}
        <a href="/auth">Войти</a>
    {% else %}
        {{ identificator.parameters.name }} <br>
        <a href="/news/add">Создать новость</a> |
        <a href="/auth/exit">Выйти</a>
    {% endif %}
        </div>
    </div>
    ...

Затем добавим для правила роутинга (переименовать роутинги чтоб небыло проблем с последовательностью):

    # /var/www/agregad/t4/config/project/routing.yml

    ...
    news_add:
      pattern:  /news/add
      defaults: { _controller: project.controller.news:add }

    news_add_process:
      pattern:  /news/add-process
      defaults: { _controller: project.controller.news:addProcess }
      methods:  [POST]
    ...

После этого создадим два экшена в NewsController контроллере.

    # /var/www/agregad/t4/src/Project/Controller/NewsController.php

    <?php

    namespace Project\Controller;

    use Butterfly\Component\Form\ArrayConstraint;
    use Butterfly\Component\Transform\String\StringMaxLength;
    use Butterfly\Component\Transform\String\StringTrim;
    use Butterfly\Component\Transform\Type\ToInt;
    use Butterfly\Component\Transform\Type\ToString;
    use Butterfly\Component\Validation\IsNull;
    use Butterfly\Component\Validation\String\StringLengthGreat;
    use ButterflyAddition\AbstractController;
    use Project\Entity\Category;
    use Project\Entity\NewsItem;
    use Project\Entity\User;
    use Project\Repository\NewsItemRepository;
    use Project\Repository\UserRepository;
    use Symfony\Component\HttpFoundation\Request;

    class NewsController extends AbstractController
    {
        public function addAction()
        {
            $categories = $this->getCategoryRepository()->findAll();

            return $this->render('news/add.html.twig', array(
                'categories' => $categories,
            ));
        }

        public function addProcessAction(Request $request)
        {
            $newsData = $request->get('news');

            $form = $this->getForm();
            $form->filter($newsData);

            if (!$form->isValid()) {
                $categories = $this->getCategoryRepository()->findAll();

                return $this->render('news/add.html.twig', array(
                    'categories' => $categories,
                    'form'       => $form,
                    'object'     => $form->getOldValue(),
                ));
            }

            $newsItem = new NewsItem();

            $newsItem->setIsPublished(true);
            $newsItem->setCreatedAt(new \DateTime());
            $newsItem->setUpdatedAt(new \DateTime());

            $newsItem->setModerator($this->getCurrentUser());
            $newsItem->setCategory($form->get('category')->getValue());
            $newsItem->setCaption($form->get('caption')->getValue());
            $newsItem->setShortBody($form->get('short_body')->getValue());
            $newsItem->setFullBody($form->get('full_body')->getValue());

            $this->getDoctrine()->persist($newsItem);
            $this->getDoctrine()->flush();

            return $this->redirectByRoute('site_index');
        }

        /**
         * @return ArrayConstraint
         */
        private function getForm()
        {
            return ArrayConstraint::create()
                ->addScalarConstraint('category')
                    ->addTransformer(new ToInt())
                    ->addCallableTransformer([$this->getCategoryRepository(), 'find'])
                    ->addValidator(new IsNull(), 'Категория не выбрана', true)
                ->end()
                ->addScalarConstraint('caption')
                    ->addTransformer(new ToString())
                    ->addTransformer(new StringTrim())
                    ->addTransformer(new StringMaxLength(50))
                    ->addValidator(new StringLengthGreat(0), 'Заголовок не может быть пустым')
                ->end()
                ->addScalarConstraint('short_body')
                    ->addTransformer(new ToString())
                    ->addTransformer(new StringTrim(StringTrim::TRIM_RIGTH))
                    ->addTransformer(new StringMaxLength(250))
                    ->addValidator(new StringLengthGreat(0), 'Краткий текст новости не может быть пустым')
                ->end()
                ->addScalarConstraint('full_body')
                    ->addTransformer(new ToString())
                    ->addTransformer(new StringTrim(StringTrim::TRIM_LEFT))
                    ->addTransformer(new StringMaxLength(4000))
                    ->addValidator(new StringLengthGreat(0), 'Полный текст новости не может быть пустым')
                ->end()
                ;
        }

        /**
         * @return User|null
         */
        protected function getCurrentUser()
        {
            $identificator = $this->getIndetificationService()->getIdentificator();

            return !$identificator->isNullable()
                ? $this->getUserRepository()->find($identificator->getId())
                : null;
        }

        /**
         * @return \Doctrine\ORM\EntityRepository
         */
        private function getCategoryRepository()
        {
            return $this->getRepository(Category::ALIAS);
        }

        /**
         * @return UserRepository
         */
        private function getUserRepository()
        {
            return $this->getRepository(User::ALIAS);
        }

        /**
         * @return NewsItemRepository
         */
        private function getNewsItemRepository()
        {
            return $this->getRepository(NewsItem::ALIAS);
        }
    }

В addAction происходит простой рендеринг шаблона с формой добавления новости. В шаблон передается список всех категорий для отображения списка возможных тем.

В addProcessAction происходит процесс обработки параметров введеных пользователем и сохранение новой новости. Мы сделали форму в шаблоне таким образом чтобы вся информация передавалась как ассоциативный массив news.

Форму мы подготовили в отдельном методе getForm.

Формы

В Butterfly формы строятся на 3 основных понятиях: transform, validation и constraint.

Constraint - ограничение накладываемое на входной параметр или набор параметров, с целью их привести к необходимому виду и/или провалидировать.

Есть два ограничения:

  • ScalarConstraint - накладывает ограничение на одно значение.
  • ArrayConstraint - накладывает ограничения на массив параметров.

Transform - компонент для преобразования входных данных.
Например пользователь вводит заголовок новости и перед сохранением необходимо убрать пробелы сначала и сконца. Другой более сложный пример преобразования когда в качестве параметра приходит идентификатор объекта в базе, например темы новости, и нам необходимо выполнить поиск объекта в базе.

Все классы преобразования основываются на интерфейсе ITransformer. В нем есть один метод transform($value), который должен выполнить необходимые манипуляции над объектом и вернуть результат.

Validation - компонент для проверки значения. В основе лежит интерфейс IValidator с методом check($value). Метод должен вернуть true если значение корректное и false в противном случае.

Рассмотрим нашу форму:

    ...
    return ArrayConstraint::create()
        ->addScalarConstraint('category')
            ->addTransformer(new ToInt())
            ->addCallableTransformer([$this->getCategoryRepository(), 'find'])
            ->addValidator(new IsNull(), 'Категория не выбрана', true)
        ->end()
        ->addScalarConstraint('caption')
            ->addTransformer(new ToString())
            ->addTransformer(new StringTrim())
            ->addTransformer(new StringMaxLength(50))
            ->addValidator(new StringLengthGreat(0), 'Заголовок не может быть пустым')
        ->end()
        ->addScalarConstraint('short_body')
            ->addTransformer(new ToString())
            ->addTransformer(new StringTrim())
            ->addTransformer(new StringMaxLength(250))
            ->addValidator(new StringLengthGreat(0), 'Краткий текст новости не может быть пустым')
        ->end()
        ->addScalarConstraint('full_body')
            ->addTransformer(new ToString())
            ->addTransformer(new StringTrim())
            ->addTransformer(new StringMaxLength(4000))
            ->addValidator(new StringLengthGreat(0), 'Полный текст новости не может быть пустым')
        ->end()
        ;
    ...

На входе будет ассоциативный массив:

    array(
        'category'   => 123,
        'caption'    => 'captionValue',
        'short_body' => 'shortBodyValue',
        'full_body'  => 'fullBodyValue',
    );

Здесь в ArrayConstraint добавляются скалярные ограничения. В каждое ограничение добавляются преобразователи и валидаторы. Они будут выполнятся в порядке добавления. Описание преобразователей:

  • ToInt - приводит значение к типу int
  • ToString - приводит значение к типу string
  • StringTrim - обрезает пробельные символы
  • StringMaxLength($length) - обрезает строку до $length символов

Описание валидаторов:

  • IsNull - то, что объект должен быть null
  • StringLengthGreat($length) - строка должна быть больше

Мы получаем данную форму в addProcessAction и помещаем параметры полученные из формы в метод filter. Метод isValid вернет false если какая либо из проверок не прошли. В таком случае выводится шаблон с ошибками формы.

В случае успеха мы создаем объект NewsItem, наполняем его данными с формы и сохраняем. Затем происходит редирект на страницу отображения новости.

Ниже представлены шаблоны для добавление новости:

    # /var/www/agregad/t4/view/news/add.html.twig

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

    {% block title %}Newsbeet | Добавить новость{% endblock %}

    {% block content %}

        <h2>Создать новость</h2>

        {% include 'news/_form.html.twig' with {
        'processAction': '/news/add-process',
        'submitCaption': 'Создать',
        'categories': categories,
        'form': form

        } %}

        <div><a href="/news">К списку новостей</a></div>

    {% endblock %}

Сама форма выделена в отдельный шаблон для переиспользования на странице редактирования.

    # /var/www/agregad/t4/view/news/_form.html.twig

    <form action="{{ processAction }}" method="POST">
        <input type="hidden" name="id" value="{{ objectId }}">
        <div>
            <label for="category">Категория</label>
            <select name="news[category]" id="category">
                <option>Не выбрано</option>
                {% for category in categories %}
        <option value="{{ category.id }}" {% if object.category == category.id %}selected="selected"{% endif %}>{{ category.name }}</option>
    {% endfor %}
            </select>
            {% if not form.category.isValid %}
        <div class="error-message">{{ form.category.firstErrorMessage }}</div>
    {% endif %}
        </div>
        <div>
            <label for="caption">Заголовок</label>
            <input type="text" name="news[caption]" id="caption" value="{{ object.caption }}">
            {% if not form.caption.isValid %}
        <div class="error-message">{{ form.caption.firstErrorMessage }}</div>
    {% endif %}
        </div>
        <div>
            <label for="short_body">Краткий текст</label>
            <textarea name="news[short_body]" id="short_body">{{ object.short_body }}</textarea>
            {% if not form.short_body.isValid %}
        <div class="error-message">{{ form.short_body.firstErrorMessage }}</div>
    {% endif %}
        </div>
        <div>
            <label for="full_body">Полный текст новости</label>
            <textarea name="news[full_body]" id="full_body">{{ object.full_body }}</textarea>
            {% if not form.full_body.isValid %}
        <div class="error-message">{{ form.full_body.firstErrorMessage }}</div>
    {% endif %}
        </div>
        <input type="submit" value="{{ submitCaption }}">
    </form>

День 10. Редактирование новости

В начале необходимо добавить ссылку на редактирование новости на странице вывода новости:

    # /var/www/agregad/t4/view/site/news.html.twig

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

    {% block title %}Newsbeet | {{ news.caption }}{% endblock %}

    {% block content %}

        <div class="page__first-news news">
            <h2 class="news__title">{{ news.caption }}</h2>
            <div class="news__text">{{ news.fullBody }}</div>
            <div class="news__other news__other_author">{{ news.createdAt | date('d.m.Y') }} {{ news.moderator.email }}</div>
        </div>

        <div>
            <a href="/news">К списку новостей</a>
            {% if not identificator.isNullable %}
                | <a href="/news/edit?id={{ news.id }}">Редактировать</a>
            {% endif %}
        </div>

    {% endblock %}

В редактировании новости учавствует два экшена - экшен вывода страницы редактирования новости и экшен где происходит валидации и обновление базы.

Добаляем два правила роутинга для экшенов редактирования.

    # /var/www/agregad/t4/config/project/routing.yml

     ...
     news_edit:
       pattern:  /news/edit
       defaults: { _controller: project.controller.news:edit }

     news_edit_process:
       pattern:  /news/edit-process
       defaults: { _controller: project.controller.news:editProcess }
       methods:  [POST]

Добавим также два экшена:

    # /var/www/agregad/t4/src/Project/Controller/NewsController.php

    ...
        public function editAction(Request $request)
        {
            $newsItemId = (int)$request->get('id');

            /** @var NewsItem|null $newsItem */
            $newsItem = $this->getNewsItemRepository()->find($newsItemId);

            if (!$newsItem) {
                return $this->redirectByRoute('site_index');
            }

            $categories = $this->getCategoryRepository()->findAll();

            return $this->render('news/edit.html.twig', array(
                'categories' => $categories,
                'objectId'   => $newsItem->getId(),
                'object'     => array(
                    'category'   => $newsItem->getCategory()->getId(),
                    'caption'    => $newsItem->getCaption(),
                    'short_body' => $newsItem->getShortBody(),
                    'full_body'  => $newsItem->getFullBody(),
                ),
            ));
        }

        public function editProcessAction(Request $request)
        {
            $newsItemId = (int)$request->get('id');
            $newsData = $request->get('news');

            /** @var NewsItem|null $newsItem */
            $newsItem = $this->getNewsItemRepository()->find($newsItemId);

            if (!$newsItem) {
                return $this->redirectByRoute('site_index');
            }

            $form = $this->getForm();
            $form->filter($newsData);

            if (!$form->isValid()) {
                $categories = $this->getCategoryRepository()->findAll();

                return $this->render('news/edit.html.twig', array(
                    'categories' => $categories,
                    'form'       => $form,
                    'objectId'   => $newsItem->getId(),
                    'object'     => $form->getOldValue(),
                ));
            }

            $newsItem->setUpdatedAt(new \DateTime());

            $newsItem->setCategory($form->get('category')->getValue());
            $newsItem->setCaption($form->get('caption')->getValue());
            $newsItem->setShortBody($form->get('short_body')->getValue());
            $newsItem->setFullBody($form->get('full_body')->getValue());

            $this->getDoctrine()->flush();

            return $this->redirectByRoute('site_index');
        }

    ...

Экшены редактирование не сильно отличаются от экшенов добавления новости. В начале только происходит проверка на существование новости и редирект, если новость не найдена.

Шаблон для редактирования новости выглядит так:

    # /var/www/agregad/t4/view/news/edit.html.twig

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

    {% block title %}Newsbeet | Добавить новость{% endblock %}

    {% block content %}

        <h2>Редактировать новость</h2>

        {% include 'news/_form.html.twig' with {
        'processAction': '/news/edit-process',
        'submitCaption': 'Сохранить',
        'categories': categories,
        'objectId': objectId,
        'object': object,
        'form': form
        } %}

        <div><a href="/news">К списку новостей</a></div>

    {% endblock %}

День 11. Создание пользователя

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

    # /var/www/agregad/t4/src/Project/Command/UserCreateCommand.php

    <?php

    namespace Project\Command;

    use Butterfly\Component\Form\ArrayConstraint;
    use Butterfly\Component\Transform\Type\ToString;
    use Butterfly\Component\Validation\String\StringLengthGreat;
    use Butterfly\Component\Validation\String\StringLengthGreatOrEqual;
    use Doctrine\ORM\EntityManager;
    use Project\Entity\User;
    use Project\Repository\UserRepository;
    use Symfony\Component\Console\Command\Command;
    use Symfony\Component\Console\Input\InputArgument;
    use Symfony\Component\Console\Input\InputInterface;
    use Symfony\Component\Console\Output\OutputInterface;

    class UserCreateCommand extends Command
    {
        /**
         * @var EntityManager
         */
        protected $doctrine;

        /**
         * @param EntityManager $doctrine
         */
        public function setDoctrine($doctrine)
        {
            $this->doctrine = $doctrine;
        }

        protected function configure()
        {
            $this
                ->setName('user:create')
                ->setDescription('Create user')
                ->addArgument('email', InputArgument::REQUIRED, 'Email')
                ->addArgument('password', InputArgument::REQUIRED, 'Password');
        }

        protected function execute(InputInterface $input, OutputInterface $output)
        {
            $data = array(
                'email'    => $input->getArgument('email'),
                'password' => $input->getArgument('password'),
            );

            $form = $this->getForm();
            $form->filter($data);

            if (!$form->isValid()) {
                foreach ($form->getErrorMessages() as $message) {
                    $output->writeln($message, OutputInterface::VERBOSITY_VERBOSE);
                }

                return 1;
            }

            $user = $this->getUserRepository()->findUserByEmail(
                $form->get('email')->getValue()
            );

            if ($user) {
                $output->writeln('Пользователь с таким email уже существует');

                return 1;
            }

            $newUser = new User();
            $newUser->setCreatedAt(new \DateTime());
            $newUser->setEmail($form->get('email')->getValue());
            $newUser->setPassword($form->get('password')->getValue());

            $this->doctrine->persist($newUser);
            $this->doctrine->flush();

            $output->writeln(sprintf('Пользователь %s удачно создан', $newUser->getEmail()));

            return 0;
        }

        /**
         * @return UserRepository
         */
        protected function getUserRepository()
        {
            return $this->doctrine->getRepository(User::ALIAS);
        }

        /**
         * @return ArrayConstraint
         */
        protected function getForm()
        {
            return ArrayConstraint::create()
                ->addScalarConstraint('email')
                    ->addTransformer(new ToString())
                    ->addValidator(new StringLengthGreat(0), 'Не корректный email')
                ->end()
                ->addScalarConstraint('password')
                    ->addTransformer(new ToString())
                    ->addValidator(new StringLengthGreatOrEqual(4), 'Слишком короткий пароль')
                ->end()
                ;
        }
    }

Метод setDoctrine необходим для внедрения доктрины в команду. Метод configure используется для настройки имени, описания и входных параметров команды.

Метод execute реализует логику выполнения команды. В начале происходит получение параметров введенных пользователем. После этого мы получаем форму и валидируем значения. В случае если проверка прошла корректно, будет создан пользователь и сохранен в базу.

Теперь необходимо отредактировать файл конфигурации комманд:

    # /var/www/agregad/t4/config/project/commands.yml

    services:

      project.command.calculator_sum_command:
        class: 'Project\Command\CalculatorSumCommand'
        calls:
          - [setCalculator, [@project.calculator]]
        tags:  'bfy_app.sf2_console.commands'

      project.command.user_create:
        class: 'Project\Command\UserCreateCommand'
        calls:
          - [setDoctrine, [@bfy_adapter.doctrine.entity_manager]]
        tags:  'bfy_app.sf2_console.commands'

Теперь убедимся что в консоле стала доступна новая команда:

$ ./app/console
      ...
      orm:schema-tool:drop             Drop the complete database schema of EntityManager Storage Connection or generate the corresponding SQL output.
      orm:schema-tool:update           Executes (or dumps) the SQL needed to update the database schema to match the current mapping metadata.
      orm:validate-schema              Validate the mapping files.
    project
      project:calculator:sum           Summation: A + B
    user
      user:create                      Create user

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

$ ./app/console user:create my_email@example.com 1234

Вы должны получить следующий вывод:

Пользователь my_email@example.com удачно создан