Создание собственного вендорного бандла в Symfony2

  • Tutorial
Часто возникает необходимость использовать одинаковый код в разных проектах. Чтобы не было повторения кода, такой код обычно помещают в библиотеку. В фреймворке Symfony2 весь код должен быть помещён в так называемые бандлы (bundle). Уже сейчас существует огромное количество бандлов, решающих совершенно разные задачи, но всё-таки часто возникает необходимость создания своего бандла, решающего рутинную задачу.

Это может быть обычный бандл, находящийся в папке src, и тогда при необходимости использовать его в новом проекте нужно скопировать его в новый проект. Но в таком случае возникает проблема с обновлением кода, ведь, когда код доступен для изменения, то он будет изменён (особые извращенцы изменяют даже код в папке vendor). Для удобства процедуры использования своего кода в других проектах можно оформить бандл как внешний, вендорный бандл, и управлять им через composer наравне с остальными сторонними бандлами.

Эта статья пошагово показывает, как можно с нуля создать бандл, доступный к установке через composer.

Содержание:
  1. Создание нового бандла
  2. Добавление настроек в бандл
  3. Подготовка бандла к публикации
  4. Публикация бандла

Будет рассмотрено создание бандла для управления статичными страницами сайта. Можно найти несколько готовых подобных бандлов, но они либо слишком простые, либо слишком сложные (типа SonataPageBundle). Уровень статьи — продвинутый новичок. Подразумевается, что читатель уже умеет создавать бандлы в проекте, а также пользоваться контроллерами и шаблонами.

1. Создание нового бандла


1.1. Генерация бандла и его первоначальная настройка


Установка Symfony
В этой статье я буду использовать новую установку Symfony 2.6, полученную командой 'composer create-project symfony/framework-standard-edition path/' в командной строке. Но вы свободны использовать уже готовый проект, в этой статье он изменяться не будет, только будут добавлены два новых бандла.

Создание вендорного бандла проще всего начать с обычного бандла. Поэтому воспользуемся командой generate:bundle для его создания. Тут нужно внимательно отнестись к правильному именованию, ведь именно под этим названием ваш бандл станет публично доступен. Обычно бандлы называются по имени разработчика или компании и по имени самого бандла. Поэтому при создании бандла я указал пространство имён Lexxpavlov/PageBundle — моё имя и простое понятное название. На основании пространства имён автоматически предлагается имя самого бандла, в моём случае LexxpavlovPageBundle. Это имя можно изменить, но меня оно устраивает, поэтому можно оставить так. Подробнее про именование бандла можно почитать тут.

При создании бандла особое внимание нужно уделить одному параметру — выбору типа конфигурации. Симфония предлагает четыре разных варианта — yml, xml, php, или annotation. Но реальный выбор происходит между yml и annotation, то есть между выбором конфигурации в отдельных файлах формата YAML, либо в формате аннотаций, размещаемых прямо в комментариях в самом коде контроллеров и сущностей. На этот счёт было сломано много копий в этом топике, есть аргументы в обоих вариантах. Мой выбор в данном случае — аннотации, потому что проект очень маленький, и преимущества отдельных файлов конфигурации нивелируются (по сути, только один файл будет иметь конфигурацию — доктриновская сущность). На быстродействие самого бандла в production тип конфигурации не влияет — в любом случае весь конфиг кэшируется.

Далее следует подтвердить готовность генерации нового бандла и после этого согласиться с автоматическим обновлением файла AppKernel.php и конфигурации роутов app/config/routes.yml.

1.2. Создание сущности для доктрины


Пришло время создать сущность, в которой будут находиться будущие страницы. Очевидно, что требуются поля id, title и content. Также будет полезным булевое поле published, для возможности временно отключить показ страницы, а также поля createdAt и updatedAt с датой создания и последнего изменения страницы. В целях SEO полезно добавить поле для хранения названия страницы в «урлифицированном» виде, обычно такое поле называется slug, а также поля keywords и description. Создаём папку Entity в папке бандла, и в ней создаём файл Page.php:
Класс сущности страницы Page.php
<?php
namespace Lexxpavlov\PageBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Gedmo\Mapping\Annotation as Gedmo;

/**
 * @ORM\Entity
 */
class Page
{
    /**
     * @var integer
     * @ORM\Column(type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;
    
    /**
     * @var string
     * @Gedmo\Slug(fields={"title"}, updatable=false)
     * @ORM\Column(type="string", length=100, unique=true)
     */
    protected $slug;

    /**
     * @var string
     *
     * @ORM\Column(type="string", length=255)
     */
    protected $title;

    /**
     * @var string
     * @ORM\Column(type="text")
     */
    protected $content;

    /**
     * @var string
     * @ORM\Column(type="text", name="keywords", nullable=true)
     */
    protected $keywords;

    /**
     * @var string
     * @ORM\Column(type="text", name="description", nullable=true)
     */
    protected $description;

    /**
     * @var boolean
     * @ORM\Column(type="boolean", options={"default":false})
     */
    protected $published = false;

    /**
     * @var \Datetime
     * @Gedmo\Timestampable(on="create")
     * @ORM\Column(type="datetime", name="created_at")
     */
    protected $createdAt;

    /**
     * @var \Datetime
     * @Gedmo\Timestampable(on="update")
     * @ORM\Column(type="datetime", name="updated_at")
     */
    protected $updatedAt;

    public function __toString() {
        return $this->title ?: 'n/a';
    }

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

    /**
     * Set slug
     * @param string $slug
     * @return Page
     */
    public function setSlug($slug)
    {
        $this->slug = $slug;
        return $this;
    }

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

    /**
     * Set title
     * @param string $title
     * @return Page
     */
    public function setTitle($title)
    {
        $this->title = $title;
        return $this;
    }

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

    /**
     * Set content
     * @param string $content
     * @return Page
     */
    public function setContent($content)
    {
        $this->content = $content;
        return $this;
    }

    /**
     * Get content
     * @return string 
     */
    public function getContent()
    {
        return $this->content;
    }
    
    /**
     * Set meta keywords
     * @param string $keywords
     * @return Page
     */
    public function setKeywords($mkeywords)
    {
        $this->keywords = $keywords;
        return $this;
    }
    
    /**
     * Get meta keywords
     * @return string
     */
    public function getKeywords()
    {
        return $this->keywords;
    }
    
    /**
     * Set meta description
     * @param string $description
     * @return Page
     */
    public function setDescription($description)
    {
        $this->description = $description;
        return $this;
    }
    
    /**
     * Set meta description
     * @return string
     */
    public function getDescription()
    {
        return $this->description;
    }

    /**
     * Set published
     * @param boolean $published
     * @return Page
     */
    public function setPublished($published)
    {
        $this->published = $published;
        return $this;
    }

    /**
     * Toggle published
     * @return Page
     */
    public function togglePublished()
    {
        $this->published = !$this->published;
        return $this;
    }

    /**
     * Get published
     * @return boolean 
     */
    public function getPublished()
    {
        return $this->published;
    }

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

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

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

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


Помимо известных аннотаций @ORM, поддерживаемых доктриной, здесь использованы аннотации @Gedmo, предоставленные бандлом StofDoctrineExtensionsBundle. Чтобы эти аннотации работали, нужно добавить этот бандл в систему. Для этого воспользуемся инструкцией в его документации, установив пакет изменением composer.json нашего проекта. Для работы используемых аннотаций достаточно такой конфигурации:
stof_doctrine_extensions:
    orm:
        default:
            sluggable: true
            timestampable: true

Создание таблицы в базу данных выполняется командой 'app/console doctrine:schema:update --force' (если сама БД ещё не создана, то нужно вначале выполнить команду 'app/console doctrine:database:create'). Эта команда создаст таблицу с именем Page или page, в зависимости от настройки БД. Насколько я встречал в других бандлах, чаще всего сторонние бандлы создают таблицы без заглавных букв, потому что иначе в некоторых случаях могут возникнуть проблемы. Но в данном случае это не важно, позже этот скользкий момент будет устранён.

1.3. Создание и использование формы для создания новых страниц


Следующий шаг — написание типа формы для создания страниц. Для этого требуется создать папку Form/Type в бандле и в ней создать файл PageType.php. Содержимое этого файла достаточно тривиальное — создаём класс-наследник AbstractType и указываем в методе buildForm() все поля, требуемые для заполнения:
<?php
namespace Lexxpavlov\PageBundle\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;

class PageType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('title', 'text')
            ->add('slug', 'text', array('required' => false))
            ->add('content', 'textarea')
            ->add('published', 'checkbox', array('required' => false))
            ->add('keywords', 'text', array('required' => false))
            ->add('description', 'text', array('required' => false))
            ->add('save', 'submit')
           ;
    }
    
    public function getName()
    {
        return 'lexxpavlov_page';
    }
}

Не следует забывать указать имя формы, имеющее в префиксе название бандла — `lexxpavlov_page`. Именно по этому имени нужно из типа формы создать сервис, и указание вендорного префикса позволит избежать конфликтов в проекте. Для этого создадим файл конфигурации бандла Resources/config/services.yml и добавим в него такой код:
services:
    lexxpavlov_page.form.type.page:
        class: Lexxpavlov\PageBundle\Form\Type\PageType
        tags:
            - { name: form.type, alias: lexxpavlov_page }

В папке Resources/config уже находится файл services.xml, предлагающий создавать описание сервисов в многословном формате XML. Так как мы написали описание сервиса на YAML, то нужно удалить файл services.xml и настроить подключение файла services.yml. Для этого откроем настройку инъектора зависимостей бандла — файл DependencyInjector/LexxpavlovPageExtension.php и исправим чтение файла XML на YAML:
// ...
$loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
$loader->load('services.yml');

Добавим в созданный генератором контроллер DefaultController действия для просмотра списка страниц, для просмотра одной страницы и для добавления новой страницы, не забудем добавить также и шаблоны для них:
<?php
namespace AppBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;

use Symfony\Component\HttpFoundation\Request;

use Lexxpavlov\PageBundle\Entity\Page;

/**
 * @Route("/page")
 */
class DefaultController extends Controller
{
    /**
     * @Route("/", name="page")
     * @Template()
     */
    public function indexAction()
    {
        $pages = $this->getDoctrine()->getManager()
            ->getRepository('LexxpavlovPageBundle:Page')
            ->findAll();
        
        return array(
            'pages' => $pages,
        );
    }

    /**
     * @Route("/show/{slug}", name="page_show")
     * @Template()
     */
    public function showAction(Page $page)
    {
    }

    /**
     * @Route("/new", name="page_new")
     * @Template()
     */
    public function newAction(Request $request)
    {
        $page = new Page();
    
        $form = $this->createForm('lexxpavlov_page', $page);
        
        if ($request->isMethod('POST')) {
            $form->handleRequest($request);
            if ($form->isValid()) {
                $em = $this->getDoctrine()->getManager();
                $em->persist($page);
                $em->flush();
                
                return $this->redirect($this->generateUrl('page'));
            }
        }
        
        return array(
            'form' => $form->createView(),
        );
    }
}

{# src/Lexxpavlov/PageBundle/Resources/views/Default/index.html.twig #}
<ul>
{% for page in pages %}
    <li><a href="{{ path('page_show', {slug: page.slug}) }}">{{ page.title }}</a></li>
{% endfor %}
</ul>
<a href="{{ path('page_new') }}">Добавить новую страницу</a>

{# src/Lexxpavlov/PageBundle/Resources/views/Default/show.html.twig #}
<article>
    <h1>{{ page.title }}</h1>
    <div>Дата публикации: <time datetime="{{ page.createdAt|date('Y-m-d') }}" pubdate>{{ page.createdAt|date('d.m.Y') }}</time></div>
    {{ page.content|raw }}
</article>

{# src/Lexxpavlov/PageBundle/Resources/views/Default/new.html.twig #}
<h1>Добавление страницы</h1>
{{ form(form) }}

Disclaimer о коде контроллера
Отмечу, что в дальнейшем контроллер будет убран из бандла, поэтому я не старался написать красивый и эффективный код. Текущий код контроллера не является эталонным для таких задач. По хорошему, следовало бы перенести код работы с базой данных в сервисы. Но статья не о контроллерах, поэтому я не стал раздувать и без того длинную статью. Спасибо Fesor за замечания.


1.4. Создание класса для Сонаты


Также создадим класс для админки SonataAdminBundle — популярной административной панели проектов на Symfony2. Для этого, во-первых, нужно добавить в проект саму админку SonataAdminBundle (существует старая статья про установку SonataAdminBundle, я планирую написать новую по этой теме). Далее требуется создать файл Admin/Page.php:
Класс для управления сущностью в SonataAdminBundle
<?php
namespace Lexxpavlov\PageBundle\Admin;

use Sonata\AdminBundle\Admin\Admin;
use Sonata\AdminBundle\Form\FormMapper;
use Sonata\AdminBundle\Datagrid\DatagridMapper;
use Sonata\AdminBundle\Datagrid\ListMapper;
use Sonata\AdminBundle\Show\ShowMapper;

class PageAdmin extends Admin
{
    public function configureListFields(ListMapper $listMapper)
    {
        $listMapper
            ->addIdentifier('title')
            ->add('slug')
            ->add('published', null, array('editable' => true))
            ->add('createdAt', 'datetime')
            ->add('updatedAt', 'datetime')
        ;
    }

    public function configureFormFields(FormMapper $formMapper)
    {
        $formMapper
            ->with('General')
                ->add('slug', null, array('required' => false))
                ->add('title')
                ->add('content')
                ->add('published', null, array('required' => false))
            ->end()
            ->with('SEO')
                ->add('keywords', null, array('required' => false))
                ->add('description', null, array('required' => false))
            ->end()
        ;
        $formMapper->setHelps(array(
            'slug' => 'Leave blank for automatic filling from title field',
        ));
    }

    public function configureDatagridFilters(DatagridMapper $datagridMapper)
    {
        $datagridMapper
            ->add('slug')
            ->add('title')
            ->add('published')
        ;
    }

    public function configureShowFields(ShowMapper $showMapper)
    {
        $showMapper
            ->add('slug')
            ->add('title')
            ->add('content')
            ->add('published')
            ->add('publishedAt', 'datetime')
            ->add('createdAt', 'datetime')
            ->add('updatedAt', 'datetime')
            ->add('keywords')
            ->add('description')
        ;
    }
}


Теперь, для того, чтобы Соната его увидела, нужно объявить сервис для него. Сервисы для Сонаты я обычно описываю в отдельном файле конфигурации — Resources/config/admin.yml:
services:
    sonata.admin.lexxpavlov_page:
        class: Lexxpavlov\PageBundle\Admin\Page
        tags:
            - { name: sonata.admin, manager_type: orm, group: "Content", label: "Pages", label_catalogue: "messages" }
        arguments:
            - ~
            - Lexxpavlov\PageBundle\Entity\Page
            - ~
        calls:
            - [ setTranslationDomain, [messages]]

И теперь нужно добавить загрузку этого файла в конфигураторе DependencyInjector/LexxpavlovPageExtension.php:
$loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
$loader->load('services.yml');
$loader->load('admin.yml');


1.5. Проверка работы бандла


Пора проверять проделанную работу — зайти на страницу сайта /page/new, добавить на ней страницу с заголовком «Тест», увидеть эту страницу в списке на /page/ и просмотреть её на /page/show/tiest, заодно проверив работу @Gedmo/Slug. На первой странице нужно добавить добавление новой страницы, на второй — увидеть вновь созданную страницу в списке, и на третьей — открыть и посмотреть на неё.
Особенности транслитерации русских символов
Обработчик @Gedmo/Slug умеет автоматически преобразовывать русские слова в транслит. Но делает это плохо — добавляет лишние гласные буквы, так что слово «Тест» становится словом «tiest». Поэтому нужно научить его правильному преобразованию. Делается это очень просто — создаётся специальный класс Listener\SluggableListener, умеющий совершать преобразование, и устанавливается в качестве обработчика события Sluggable:
<?php
namespace Lexxpavlov\PageBundle\Listener;

use Gedmo\Sluggable\SluggableListener as BaseSluggableListener;
use Gedmo\Sluggable\Util\Urlizer;

class SluggableListener extends BaseSluggableListener
{
    public function __construct(){
        $this->setTransliterator(array($this, 'transliterate'));
    }
    
    public function transliterate($text, $separator = '-')
    {
        $convertTable = array(
            'а' => 'a',  'б' => 'b',   'в' => 'v',  'г' => 'g',  'д' => 'd',
            'е' => 'e',  'ё' => 'e',   'ж' => 'zh', 'з' => 'z',  'и' => 'i',
            'й' => 'j',  'к' => 'k',   'л' => 'l',  'м' => 'm',  'н' => 'n',
            'о' => 'o',  'п' => 'p',   'р' => 'r',  'с' => 's',  'т' => 't',
            'у' => 'u',  'ф' => 'f',   'х' => 'h',  'ц' => 'ts', 'ч' => 'ch',
            'ш' => 'sh', 'щ' => 'sch', 'ь' => '',   'ы' => 'y',  'ъ' => '',
            'э' => 'e',  'ю' => 'yu',  'я' => 'ya'
        );
        $text = strtr(trim(mb_strtolower($text, 'UTF-8')), $convertTable);
        return Urlizer::urlize($text, $separator);
    }
}

stof_doctrine_extensions:
    orm:
        default:
            sluggable: true
            timestampable: true
    class:
        sluggable: Lexxpavlov\PageBundle\Listener\SluggableListener


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

2. Добавление настроек в бандл


2.1. Возможность расширения сущности страницы


Важным отличием вендорных бандлов является их способность подстраиваться под нужды использующего его. Например, требуется, помимо указанных в бандле полей страницы, добавить новое поле с именем автора страницы и поле, хранящее автора последних правок на странице. Как это сделать? Написать новый класс сущности? Долго и нудно. Унаследовать его от уже готового? Уже лучше, к тому же не зря в классе сущности поля отмечены как protected, а не private.
Doctrine и приватные поля в сущности
На самом деле, отметка protected нужна только для возможности доступа к полям суперкласса из методов класса-наследника. Если поля в суперклассе отметить как private, то Доктрину всё равно можно настроить на использование таких полей в классе-наследнике, для этого используется аннотация MappedSuperclass. Если суперкласс отмечен этой аннотацией, то в классы-наследники попадут даже приватные поля. Тут есть одна особенность. Часто свеженаписанную сущность оставляют без геттеров и сеттеров, с тем, чтобы попросить доктрину создать их автоматически консольной командой doctrine:generate:entities. Но если эти геттеры/сеттеры уже имеются в суперклассе, то доктрина всё равно будет пытаться создать в наследующем классе геттеры для protected-полей из суперкласса.

Хорошее решение реализовано в популярном бандле FOSUserBundle — в самом бандле нет сущностей, то есть, классов, отмеченных аннотацией @ORM\Entity. Сделаем этот же приём и в нашем бандле. Нужно удалить аннотацию @ORM\Entity перед классом Lexxpavlov/PageBundle/Entity/Page.php. А для создания сущности нужно создать класс-наследник этого класса, отметить его как @ORM\Entity, и в него же можно добавлять требуемые кастомные дополнительные поля. Этот класс будет хорошим местом для того, чтобы пользователь мог выбрать свой собственный стиль именования таблицы — либо разрешить доктрине автоматически выбрать имя таблицы для новой сущности, либо указать своё имя в аннотации @ORM\Table.

Для создания новой сущности лучше создать новый бандл. Воспользуемся генератором и разместим бандл в пространство имён AppBundle (если вы установили новый проект Symfony2, то у вас уже должен быть бандл с таким названием). Перенесём контроллер DefaultController из нашего бандла (в нём вообще больше не нужен контроллер) во вновь созданный, перенесём шаблоны (Resources/views), удалим лишнюю ссылку на бандл в файле app/config/routing.yml, а также не забудьте изменить новый класс в контроллере DefaultController (в конструкции use и в вызове метода getRepository()). Создадим новую сущность для страниц:
<?php
namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Lexxpavlov\PageBundle\Entity\Page as BasePage;

/**
 * @ORM\Entity
 */
class Page extends BasePage
{
}

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

Но у нас была задача расширить сущность новыми полями — полями для хранения автора и последнего редактора. Для этого хорошо подойдёт поведение Blameable из уже используемого бандла StofDoctrineExtensionsBundle. Добавим их во вновь созданный класс:
Обновлённый класс AppBundle\Entity\Page
<?php
namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Gedmo\Mapping\Annotation as Gedmo;
use Lexxpavlov\PageBundle\Entity\Page as BasePage;

use AppBundle\Entity\User;

/**
 * @ORM\Entity
 */
class Page extends BasePage
{
    /**
     * @var User
     * @ORM\ManyToOne(targetEntity="User")
     * @Gedmo\Blameable(on="create")
     */
    protected $createdBy;

    /**
     * @var User
     * @ORM\ManyToOne(targetEntity="User")
     * @Gedmo\Blameable(on="update")
     */
    protected $updatedBy;
    
    /**
     * Set user, that updated entity
     * @param User $updatedBy
     * @return Page
     */
    public function setUpdatedBy($updatedBy)
    {
        $this->updatedBy = $updatedBy;
        return $this;
    }

    /**
     * Get user, that updated entity
     * @return User 
     */
    public function getUpdatedBy()
    {
        return $this->updatedBy;
    }
    
    /**
     * Set user, that created entity
     * @param User $createdBy
     * @return Page
     */
    public function setCreatedBy($createdBy)
    {
        $this->createdby = $createdBy;
        return $this;
    }

    /**
     * Get user, that created entity
     * @return User 
     */
    public function getCreatedBy()
    {
        return $this->createdBy;
    }    
}


Также нам понадобится новый класс User — для хранения пользователей. Лучше всего взять его из бандла FOSUserBundle. Устанавливаем этот бандл и расширяем предложенный в нём класс для пользователей:
<?php
namespace AppBundle\Entity;

use FOS\UserBundle\Model\User as BaseUser;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity
 * @ORM\Table(name="users")
 */
class User extends BaseUser
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

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

Теперь нужно включить поведение Blameable в конфиг StofDoctrineExtensionsBundle:
stof_doctrine_extensions:
    orm:
        default:
            sluggable: true
            timestampable: true
            blameable: true

и обновить таблицы в базе данных консольной командой 'app/console doctrine:schema:update --force'.
Настройка бандла FOSUserBundle
После установки бандла FOSUserBundle и его настройки (в соответствии с его документацией) нужно добавить наши защищённые страницы в файрвол. У нас одна страница, доступ к которой требуется ограничить — это страница добавления новой страницы /page/new. Нужно добавить её в список access_control в файле app/config/security.yml:
security:
# ...
    access_control:
        - { path: ^/login$, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/register, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/resetting, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/page/new, role: ROLE_ADMIN }

Теперь требуется добавить пользователя в базу данных. Проще всего это сделать консольной командой 'app/console fos:user:create', добавлением ему прав администратора (ROLE_ADMIN) командой 'app/console fos:user:promote', а затем активировать его командой 'app/console fos:user:activate'.


2.2. Настройка конфигурации бандла


После внесённых изменений и переносе создания сущности в отдельный бандл, перестал работать класс для Сонаты, ведь в конфиге его сервиса был явно прописан класс Lexxpavlov\PageBundle\Entity\Page. Но теперь нельзя точно сказать, в каком месте и под каким названием будет создан класс сущности. Для этого лучше всего использовать стандартный способ создания настроек в Симфонии — описание сервисов указать в настройках бандла, а саму конфигурацию, относящуюся к конкретному проекту, поместить в app/config/config.yml.

Первым делом требуется создать описание конфигурации нашего бандла. Для этого служит стандартный компонент Config. Всё описание конфига заключается в последовательном вызове предопределённых методов, описывающих доступные параметры конфига. Повторять документацию здесь будет излишне, только приведу ссылку на её перевод на русский.
<?php
namespace Lexxpavlov\PageBundle\DependencyInjection;

use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;

class Configuration implements ConfigurationInterface
{
    public function getConfigTreeBuilder()
    {
        $treeBuilder = new TreeBuilder();
        $rootNode = $treeBuilder->root('lexxpavlov_page');

        $rootNode
            ->children()
                ->scalarNode('entity_class')
                    ->cannotBeEmpty()
                ->end()
            ->end()
        ;
        
        return $treeBuilder;
    }
}

Теперь нужно добавить настройку в конфиг:
lexxpavlov_page:
    entity_class: AppBundle\Entity\Page

и настроить использование этого параметра в тех местах, где он нужен — в сервисе админки сонаты и типе формы. Чтобы можно было использовать значение имени класса, требуется сохранить его в параметр DI-контейнера, это нужно сделать в конфигураторе DependencyInjection/LexxpavlovPageExtension.php, добавив следующую строчку в конец метода load():
$container->setParameter('lexxpavlov_page.entity_class', $config['entity_class']);

Теперь сохранённый параметр можно использовать в объявлениях сервисов. Сервис админки требует указать имя класса вторым параметром конструктора:
services:
    sonata.admin.lexxpavlov_page:
        class: Lexxpavlov\PageBundle\Admin\Page
        tags:
            - { name: sonata.admin, manager_type: orm, group: "Content", label: "Pages", label_catalogue: "messages" }
        arguments:
            - ~
            - %lexxpavlov_page.entity_class%
            - ~
        calls:
            - [ setTranslationDomain, [messages]]

В сервисе формы нужно не только передать параметр, но и создать конструктор в классе формы:
services:
    lexxpavlov_page.form.type.page:
        class: Lexxpavlov\PageBundle\Form\Type\PageType
        arguments: [ %lexxpavlov_page.entity_class% ]
        tags:
            - { name: form.type, alias: lexxpavlov_page }

Добавление конструктора и настройка кастомных опций формы
<?php
namespace Lexxpavlov\PageBundle\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;

class PageType extends AbstractType
{

    private $dataClass;

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

    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('title', 'text')
            ->add('slug', 'text', array('required' => false))
            ->add('content', 'textarea')
            ->add('published', 'checkbox')
            ->add('keywords', 'text')
            ->add('description', 'text')
            ->add('save', 'submit')
            ;
    }

    public function setDefaultOptions(OptionsResolverInterface $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => $this->dataClass,
        ));
    }
    
    public function getName()
    {
        return 'lexxpavlov_page';
    }
}



2.3. Дополнительные настройки бандла


Следующим шагом будет возможность отключить регистрацию сервиса для сонаты, для тех, кому этот сервис не нужен, или для тех, кто захочет изменить этот сервис (например, добавить в админку свои поля сущности). Для этого нужно добавить новый параметр в конфигурацию бандла. Заодно добавим ещё одну настройку — использование в формах добавления страницы wysiwyg-редактора CKEditor вместо обычного поля textarea (для использования этого типа поля формы потребуется бандл IvoryCKEditorBundle, позже мы отметим его рекомендуемым для установки).

Добавим два новых элемента в конфигурацию бандла (DependencyInjection/Configuration.php):
$rootNode
    ->children()
        ->scalarNode('entity_class')
            ->cannotBeEmpty()
        ->end()
        ->scalarNode('admin_class')
            ->defaultValue('Lexxpavlov\PageBundle\Admin\PageAdmin')
        ->end()
        ->scalarNode('content_type')
            ->defaultValue('ckeditor')
        ->end()
    ->end()
;

Эти параметры отмечены необязательными, и если они не указаны в конфиге, то они примут указанные значения по умолчанию. Теперь сохраним значения настроек в параметры контейнера (в файле DependencyInjection/LexxpavlovPageExtension.php):
public function load(array $configs, ContainerBuilder $container)
{
    $configuration = new Configuration();
    $config = $this->processConfiguration($configuration, $configs);

    $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
    $loader->load('services.yml');
        
    $container->setParameter('lexxpavlov_page.entity_class', $config['entity_class']);
    $container->setParameter('lexxpavlov_page.content_type', $config['content_type']);
        
    if ($config['admin_class'] && $config['admin_class'] != 'false') {
        $loader->load('admin.yml');
        $container->setParameter('lexxpavlov_page.admin_class', $config['admin_class']);
    }
}

Тут указано условное добавление файла с объявлением сервиса для админки. А если написан свой расширяющий класс для админки (который вполне можно наследовать от уже готового класса), то в параметре lexxpavlov_page.admin_class можно указать его имя.

Теперь добавим использование новых параметров в класс админки. Изменим объявление сервиса и расширим сам класс:
services:
    sonata.admin.lexxpavlov_page:
        class: %lexxpavlov_page.admin_class%
        tags:
            - { name: sonata.admin, manager_type: orm, group: "Content", label: "Pages", label_catalogue: "messages" }
        arguments:
            - ~
            - %lexxpavlov_page.entity_class%
            - ~
        calls:
            - [ setTranslationDomain, [messages]]
            - [ setContentType, [ %lexxpavlov_page.content_type% ] ]

Финальная версия класса PageAdmin
<?php
namespace Lexxpavlov\PageBundle\Admin;

use Sonata\AdminBundle\Admin\Admin;
use Sonata\AdminBundle\Form\FormMapper;
use Sonata\AdminBundle\Datagrid\DatagridMapper;
use Sonata\AdminBundle\Datagrid\ListMapper;
use Sonata\AdminBundle\Show\ShowMapper;

class PageAdmin extends Admin
{
    protected $contentType = 'ckeditor'; // or null for simple textarea field

    public function setContentType($contentType)
    {
        $this->contentType = $contentType;
    }
    
    public function configureListFields(ListMapper $listMapper)
    {
        $listMapper
            ->addIdentifier('title')
            ->add('slug')
            ->add('published', null, array('editable' => true))
            ->add('createdAt', 'datetime')
            ->add('updatedAt', 'datetime')
        ;
    }

    public function configureFormFields(FormMapper $formMapper)
    {
        $formMapper
            ->with('General')
                ->add('slug', null, array('required' => false))
                ->add('title')
                ->add('content', $this->contentType)
                ->add('published', null, array('required' => false))
            ->end()
            ->with('SEO')
                ->add('keywords', null, array('required' => false))
                ->add('description', null, array('required' => false))
            ->end()
        ;
        $formMapper->setHelps(array(
            'slug' => 'Leave blank for automatic filling from title field',
        ));
    }

    public function configureDatagridFilters(DatagridMapper $datagridMapper)
    {
        $datagridMapper
            ->add('slug')
            ->add('title')
            ->add('published')
        ;
    }

    public function configureShowFields(ShowMapper $showMapper)
    {
        $showMapper
            ->add('slug')
            ->add('title')
            ->add('content')
            ->add('published')
            ->add('publishedAt', 'datetime')
            ->add('createdAt', 'datetime')
            ->add('updatedAt', 'datetime')
            ->add('keywords')
            ->add('description')
        ;
    }
}


Для разнообразия я не стал добавлять параметр типа поля в класс формы, включив использование CKEditor-а по умолчанию и предоставив возможность его отключения через настройки формы при её создании.
Финальная версия класса PageType
<?php
namespace Lexxpavlov\PageBundle\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;

class PageType extends AbstractType
{
    private $dataClass;

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

    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('slug', 'text')
            ->add('title', 'text')
            ->add('content', $options['contentType'])
            ->add('published', 'checkbox')
            ->add('keywords', 'text')
            ->add('description', 'text')
            ->add('save', 'submit')
            ;
    }

    public function setDefaultOptions(OptionsResolverInterface $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => $this->dataClass,
            'contentType' => 'ckeditor',
        ));
    }
    
    public function getName()
    {
        return 'lexxpavlov_page';
    }
}



2.4. Удаление из бандла лишних файлов


Бандл почти закончен. Осталось выкинуть лишние файлы, которые были созданы бандлогенератором и которые не используются. Это папка Controller (если вы раньше не удалили её), содержимое папки Resources (кроме Resources/config), а также папка тестов (тестировать в бандле нечего, контроллеров нет, а всё остальное тривиально).

3. Подготовка бандла к публикации


3.1. Создание файла composer.json


Когда бандл готов к публикации, пришло время готовить его к публикации. Бандлы composer-а находятся на сайте packagist.org, а туда они попадают из какой-либо публичной системы контроля версий. Мы будем использовать Github. Но первым делом нужно создать файл composer.json — файл в формате JSON, содержащий метаинформацию о пакете, которую в будущем будет использовать как сам composer, так и packagist. Файл composer.json располагается в корне бандла и выглядит примерно так:
{
    "name" : "lexxpavlov/pagebundle",
    "description" : "Symfony2 Page bundle with meta data, predefined form type and Sonata Admin service",
    "version" : "1.0.0",
    "type" : "symfony-bundle",
    "homepage": "https://github.com/lexxpavlov/PageBundle",
    "license" : "MIT",
    "keywords" : ["page", "page bundle"],
    "authors" : [{
        "name" : "Alexey Pavlov",
        "email" : "lexx.pavlov@gmail.com"
    }],
    "require" : {
        "php": ">=5.3.2",
        "symfony/symfony": ">=2.1",
        "stof/doctrine-extensions-bundle": ">=1.1"
    },
    "suggest": {
        "egeloen/ckeditor-bundle": "Allow use ckeditor field"
    },
    "autoload" : {
        "psr-4" : { "Lexxpavlov\\PageBundle\\" : "" }
    },
    "extra" : {
        "branch-alias" : {
            "dev-master" : "1.0.x-dev"
        }
    }
}

Рассмотрим, какие поля файла за что отвечают.

name — название бандла. Формируется из неймспейса проекта переводом в нижний регистр. Именно под таким именем бандл будет размещён в папку vendor.
description — описание бандла. Короткое предложение, которое даёт исчерпывающее описание бандла, чтобы пользователи Packagist-а могли понять, что предлагает данный бандл.
version — текущая версия бандла. В дальнейшем, при развитии бандла, этот номер будет увеличиваться.
type — тип пакета. Так как у нас пакет содержит бандл Symfony, то нужно указать «symfony-bundle».
homepage — адрес страницы с описанием бандла. Может быть ссылка на ваш сайт, но вполне допускается и ссылка на страницу проекта на гитхабе.
license — лицензия, под которой доступен этот пакет. Почти всегда указывают лицензию MIT — это одна из самых свободных лицензий. (Подробнее о выборе лицензии)
keywords — список ключевых слов, описывающих пакет, размещённый в массив.
authors — список авторов пакета.
require — список ограничений, под которыми работает пакет. Сюда следует указать версию php, версию симфонии, а также добавить те бандлы, которые вы сами используете в своём коде. Composer сам проверит версии указанных ограничений, а также установит пакеты, которые требуются для работы пакета, но не были установлены ранее.
suggest — список предложений к установке (опционально). Сюда обычно размещают бандлы, использование которых расширит работу с вашим бандлом, но которые не являютя обязательными. У нас бандл умеет использовать тип формы 'ckeditor', который предоставляет бандл IvoryCKEditorBundle, поэтому добавим его в список suggests, указав поясняющий текст, показывающий, для чего используется этот бандл. Список предложений будет выведен на экран после установки пакета composer-ом.
autoload — способ автозагрузки классов пакета. Не вижу смысла не использовать PSR-4, поэтому его и укажем. Отличие от PSR-0 в том, что не будут создаваться лишние уровни вложенности в папках, в которых расположены файлы пакета — файлы будут располагаться прямо в папке vendor/lexxpavlov/pagebundle.
extra — дополнительные параметры пакета. В данном случае используется параметр branch-alias, создающий в Packagist новую версию пакета с именем dev-master, которая обычно показывает на master-ветку кода.
minimum-stability — (в примере выше не используется) Указывает, какие ветки зависимостей можно использовать. Если не указать, то будут устанавливаться только stable версии. Возможные значения: dev, alpha, beta, RC и stable. Подробнее можно почитать тут.

Также требуется создать файл readme.md с документацией бандла. Это текстовый файл в формате Markdown позволяет легко и быстро написать документацию с разметкой текста и фрагментами кода. Описание формата Markdown можно почитать здесь, или посмотреть разметку описания нашего бандла.

3.2. Создание репозитория с кодом бандла


Теперь нужно создать репозиторий Git, и поместить все файлы бандла в этот репозиторий. Для создания репозитория для проекта можно воспользоваться каким-нибудь визуальным клиентом, но здесь будет приведён консольный вариант:
$ cd /path/to/project/src/Lexxpavlov/PageBundle
$ git init
$ git add . -A
$ git commit -m "Init commit"

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

3.3. Размещение кода на Github


Идём на Github на страницу создания нового репозитория и даём имя новому репозиторию (PageBundle). Можно задать краткое описание пакета, которое будет выводиться наверху главной страницы репозитория сразу под названием. Важно! Убедитесь, что не стоит галочка «Initialize this repository with a README», а также отключено создание .gitignore и файла лицензии (стоит слово NONE в соответствующих выпадающих списках). Далее заходим во вновь созданный (пока пустой) репозиторий и копируем путь к нему в буфер (кнопка Copy to clipboard в разделе HTTPS clone URL внизу правого меню). Выполняем строчки в консоли:
$ git remote add origin remote https://github.com/yourusername/YourBundle.git
$ git push origin master

Готово! Зайдите на страницу пакета и полюбуйтесь на ваши файлы!

4. Публикация бандла


4.1. Использование бандла без регистрации на Packagist


Уже сейчас можно использовать пакет через composer, только нужно подсказать ему, где искать файлы пакета. Для этого нужно добавить в composer.json проекта, в который требуется добавить этот пакет, следующие строчки:
[...]
"require" : {
    [...]
    "lexxpavlov/pagebundle" : "dev-master"
},
"repositories" : [{
    "type" : "vcs",
    "url" : "https://github.com/lexxpavlov/PageBundle.git"
}],
[...]

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

4.2. Регистрация бандла на Packagist


Архив пакетов Packagist поддерживает множество версий пакета, определяя версии по тегам коммитов и названиям веток кода. Он просматривает все теги и ветки репозитория, и если находит названия, похожие на название версии, то применяет их как версии кода. Поэтому нужно добавить хотя бы один тег версии в репозиторий проекта. Версии задаются названиями, подходящими под шаблон 1.0.0 или v1.0.0. (Подробнее про именование версий можно почитать тут.) Если ваш бандл готов к использованию и вы не собираетесь его в ближайшее время изменять, то можно выбрать версию 1.0.0. Если вы выкладываете версию бандла, но собираетесь его дорабатывать, то лучше дать версию меньше единицы.
Создадим тег, название которого совпадает с версией, указанной в composer.json:
$ git tag 1.0.0
$ git push origin --tags

Пора выкладывать наш бандл и заканчивать этот затянувшийся туториал. Регистрируемся на Packagist.org (или проще войти через аккаунт Github) и нажимаем на зелёную кнопку Submit package. В появившееся поле вводим адрес репозитория и нажимаем кнопку Check. Если всё в порядке, то появится кнопка Submit. Смело нажимайте её!

Пакет будет размещён в архиве пакетов Packagist.org. Вы сможете увидеть страницу пакета с полями, которые были извлечены из файла composer.json, и кнопками управления пакетом. Также вы увидите предупреждение, что пакет не является автообновляемым. Автообновление пакета — это настройка гитхаба, которая автоматически сообщает Packagist-у об обновлении репозитория, и тот заново просмотрит репозиторий в поисках новых версий кода или обновления информации в файле composer.json. Для установки автообновления нужно пройти на страницу профиля на Packagist и выполнить инструкции, которые там приведены.

Теперь для использования бандла достаточно добавить название пакета в файл composer.json вашего проекта:
[...]
"require" : {
    [...]
    "lexxpavlov/pagebundle" : "1.0.0"
},
[...]

или выполнить команду для добавления пакета в консоли (в папке проекта):
$ php composer.phar require lexxpavlov/pagebundle


Заключение


Мы создали пакет для пакетного менеджера composer, который содерит написанный нами бандл для фреймвока Symfony2. Если мы будем использовать его в нескольких проектах, то при изменении пакета получить новую версию кода во все проекты можно простой командой «composer update». Также другие программисты могут использовать ваш пакет для своих проектов, и отплатить вам багрепортами и пулреквестами.

Репозиторий бандла (в статье предложена незначительно изменённая версия бандла, чтобы не увеличивать статью ещё больше)
Страница бандла на Packagist

Использованные материалы и полезные ссылки:
  1. Вопрос на StackOverflow о создании своего бандла
  2. Создание страниц в Symfony2 — подробное описание процесса создания бандлов, контроллеров и шаблонов
  3. Перевод документации компонента Config
  4. About Packagist — описание проекта Packagist и инструкции по настройке пакетов
  5. Choose a license — сайт о выборе лицензии пакета от создателей Github.com
  • +14
  • 18,6k
  • 6
Поделиться публикацией

Похожие публикации

Комментарии 6

    0
    1) В вендорных бандлах лучше не использовать аннотации для мэпинга сущностей. Лучше yaml, это позволит прописать мэпинги для Doctrine ORM и ODM к примеру.
    2) Стоило вынести всю логику по работе с доктриной в отдельный сервис-менеджер. В дальнейшем можно было бы вынести интерфейс и добавить реализации для ODM или других ORM ну или дать возможность разработку заимплементить интерфейс менеджера для своего хранилища.
    3) flush в контроллере без передачи списка того что вы хотите зафлашить это не хорошо, особенно когда речь идет о бандле, который будут использовать сторонние разработчики. Возможно в этом случае ничего страшного, но по хорошему опять же стоит это дело выносить в сервис менеджер и оставить бандл вообще без контроллеров, оставив это дело опять же на откуп разработчику. Контроллеры можно конечно добавить, в виде примера или если они реально могут реюзаться, но тогда правила маршрутизации стоит опять же выносить в yaml полностью.
    3) Считаю в вашем случае завязываться на doctrine_extensions лишним, хотя если вам так удобнее то что поделать.
    4) Раз уж реализовали класс для сонаты, стоило добавить его в рекомендуемые пакеты. Но это уже мелочи.
      +1
      1) и 3) Я когда-то сделал этот бандл для себя, и я как-то исторически использую аннотации. Уже потом я решил его использовать для демонстрации в этой статье. Если бы я изначально делал публичный бандл, то я бы, конечно, сделал бы маппинг на yaml.
      2) Вся логика вынесена из бандла, в нём вообще нет контроллера, во второй части контроллер перенесён в другой бандл. Поэтому нечего выносить в сервис. В первой части есть контроллер, но он предназначен для того, чтобы просто работало, я не стал сильно заморачиваться о нём, ведь в самом бандле его не будет.
      4) согласен.
      Основная часть статьи — третья и четвёртая, и частично вторая, а первая — чтобы было о чём говорить в остальной части. Мне бы следовало, наверное, указать, что код контроллера (и шаблоны, что уж там говорить) — не образцы для подражания. Я, например, в контроллерах своих рабочих проектов вообще отказался от ParamConverter и от аннотации Template.
      +1
      Зачем создавать конструктор формы и передавать туда `dataClass`? Разве нельзя при создании формы передать в массиве с опциями этот самый `array('data_class' => $container->getPaeameter('lexxpavlov_page.entity_class'))`?
        0
        Во-первых, в родителе класса формы нет контейнера, и его туда в любом случае нужно передавать.
        А во-вторых, передавать контейнер в сервис не очень правильно, потому что это идёт вразрез с самой идеей DI-контейнеров — уменьшать связность компонентов, а тут придётся в классе определять, где находятся нужные ему данные.
        Поэтому в сервисы нужно уже готовые данные или другие сервисы. А передача через конструктор — это простейший способ передачи зависимости.
          +1
          Естественно, что в классе формы не будет контейнера. Контейнер есть в контроллере.

          $form = $this->createForm('lexxpavlov_page', $page); // ну вот тут третьим параметром передать этот data_class же можно
            +2
            Это значение по умолчанию считайте. Вы всегда можете переопределить это дело при создании формы Другой вопрос, зачем это делать.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое