Symfony 2.0
Недавно вышедшая версия фреймворка Symfony 2 включает в себя много интересных фич. В данной статье хочу рассказать про создание CRUD приложений — очень часто встречающейся задачи создания веб-интерфейса для создания, чтения, обновления и удаления записей в БД.
Про архитектуру и установку Symfony 2 уже было на хабре, поэтому считаем что Symfony 2 SE уже установлена и основные используемые понятия (бандлы, формы, шаблоны и т.д) вам знакомы.
Основные задачи, стоящие при разработке стандартного CRUD-приложения на Symfony 2
- Разработка модели данных
- Разработка контроллеров, форм и шаблонов позволяющих создавать, читать, обновлять и удалять сущности модели данных
В этой статье в качестве примера создадим приложение для редактирования новостей, каждая из которых привязана к категории, к новости можно привязывать ссылки.
Разработка модели данных
Создание описаний сущностей посредством ручного написания yml или xml достаточно утомительное занятие (хотя конечно можно воспользоваться плагином к Mysql Workbench или специализированным ПО). Для ускорения процесса в Symfony 2 есть удобное средство генерации описания модели данных путем реверс-инжиниринга существующей БД.
Для начала создаем бандл TestNewsBundle:
php app/console generate:bundle --namespace=Test/NewsBundle --format=annotation --structure
Создаем схему БД в Mysql Workbench (или любом другом средстве проектирования БД):

SET FOREIGN_KEY_CHECKS=0;
CREATE TABLE `news` (
`id` INT NOT NULL AUTO_INCREMENT ,
`news_category_id` INT NOT NULL ,
`title` VARCHAR(255) NULL ,
`announce` TEXT NULL ,
`text` TEXT NULL ,
`pub_date` DATE NULL ,
PRIMARY KEY (`id`) ,
INDEX `pub_date` (`pub_date` ASC) ,
INDEX `fk_news_news_category` (`news_category_id` ASC) ,
CONSTRAINT `fk_news_news_category`
FOREIGN KEY (`news_category_id` )
REFERENCES `news_category` (`id` )
ON DELETE NO ACTION
ON UPDATE NO ACTION)
ENGINE = InnoDB;
CREATE TABLE `news_category` (
`id` INT NOT NULL AUTO_INCREMENT ,
`name` VARCHAR(255) NULL ,
PRIMARY KEY (`id`) )
ENGINE = InnoDB
CREATE TABLE `news_link` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`news_id` INT NOT NULL ,
`url` varchar(255) DEFAULT NULL,
`text` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`) ,
INDEX `fk_news_link_news1` (`news_id` ASC) ,
CONSTRAINT `fk_news_link_news1`
FOREIGN KEY (`news_id` )
REFERENCES `news` (`id` )
ON DELETE NO ACTION
ON UPDATE NO ACTION)
ENGINE = InnoDB;
Создаем классы doctrine:
php app/console doctrine:mapping:import TestNewsBundle annotation
В результате выполнения этой команды в поддиректории Test/NewsBundle/Entity будут созданы по одному классу для каждой таблицы, при этом конфигурация маппинга объектов в реляционные таблицы для Doctrine ORM описаны в аннотациях классов, например класс News:
<?php
namespace Test\NewsBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* Test\NewsBundle\Entity\News
*
* @ORM\Table(name="news")
* @ORM\Entity
*/
class News
{
/**
* @var integer $id
*
* @ORM\Column(name="id", type="integer", nullable=false)
* @ORM\Id
* @ORM\GeneratedValue(strategy="IDENTITY")
*/
private $id;
/**
* @var string $title
*
* @ORM\Column(name="title", type="string", length=255, nullable=true)
*/
private $title;
/**
* @var text $announce
*
* @ORM\Column(name="announce", type="text", nullable=true)
*/
private $announce;
/**
* @var text $text
*
* @ORM\Column(name="text", type="text", nullable=true)
*/
private $text;
/**
* @var date $pubDate
*
* @ORM\Column(name="pub_date", type="date", nullable=true)
*/
private $pubDate;
/**
* @var NewsCategory
*
* @ORM\ManyToOne(targetEntity="NewsCategory")
* @ORM\JoinColumns({
* @ORM\JoinColumn(name="news_category_id", referencedColumnName="id")
* })
*/
private $newsCategory;
}
Для дополнения классов геттерами и сеттерами выполняем команду:
php app/console doctrine:generate:entities TestNewsBundle
Создание заготовки для CRUD — приложения
Cоздаем заготовку для работы с новостями с использованием команды doctrine:generate:crud. Формат роутинга — аннотации в файле контроллера (Test/NewsBundle/Controller/NewsController). Роутинг через аннотации в файле контроллера работает через бандл SensioFrameworkExtra (в поставке Symfony 2 SE он есть).
php app/console doctrine:generate:crud --entity=TestNewsBundle:News --route-prefix=news --with-write --format=annotation
Теперь при заходе по указанному при генерации пути (если Symfony 2 распакован в wwwroot — http://localhost/Symfony/web/app_dev.php/news/) показывается пустой список новостей, а при нажатии на ссылку «Create new entry» — открывается форма создания записи по умолчанию.
Создадим такую же заготовку для работы с категориями новостей и занесем несколько категорий:
php app/console doctrine:generate:crud --entity=TestNewsBundle:NewsCategory --route-prefix=newscategory --with-write --format=annotation
Для того чтобы у нас в дальнейшем была заготовка для формы добавления ссылки к новости аналогично сгенерируем заготовку для сущности NewsLink:
php app/console doctrine:generate:crud --entity=TestNewsBundle:NewsLink --route-prefix=newslink --with-write --format=annotation
Чтобы при выводе списка категорий в форме добавления новости система знала какое поле показывать в селекте, в класс Test/NewsBundle/Entity/NewsCategory нужно добавить метод:
function __toString()
{
return $this->getName();
}
Модификация класса формы
Теперь подправим сгенерированную форму Test/NewsBundle/Form/NewsType. Добавим заголовки к полям (label), проставим нужные типы полей — text (отображается input type=text), textarea. В полях pubDate и newsCategory оставим тип поля null — в этом случае Symfony Form Component сам «угадывает» какой тип поля показать.
<?php
namespace Test\NewsBundle\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilder;
class NewsType extends AbstractType
{
public function buildForm(FormBuilder $builder, array $options)
{
$builder->add('title', 'text', array('label' => 'Заголовок'))
->add('announce', 'textarea', array('label' => 'Анонс'))
->add('text', 'textarea', array('label' => 'Текст'))
->add('pubDate', null, array('label' => 'Дата новости'))
->add('newsCategory', null, array('label' => 'Категория'));
}
public function getName()
{
return 'news';
}
}
Модификация шаблона формы
Подробно про формы и шаблонизацию в формах можно почитать разделы руководства Формы и Кастомизация форм. В создаваемой форме заменим отображение формы на табличное, для этого в шаблон нужно добавить указание использовать табличную верстку (верстку по умолчанию также можно изменить в настройках).
{% form_theme form 'form_table_layout.html.twig' %}
Однако нам в дальнейшем нужно будет переопределить стандартное отображение поля, поэтому шаблон занесения новости Test/NewsBundle/Resources/views/News/new.html.twig выглядит так:
{% use 'form_table_layout.html.twig' %}
{% form_theme form _self %}
<h1>Занесение новости</h1>
<form action="{{ path('news_create') }}" method="post" {{ form_enctype(form) }}>
{{ form_widget(form) }}
<p>
<button type="submit">Создать новость</button>
</p>
</form>
<ul class="record_actions">
<li>
<a href="{{ path('news') }}">
Назад к списку
</a>
</li>
</ul>
Форма отображается в табличной верстке:

Редактирование связанных записей
Теперь займемся самым интересным — редактированием ссылок к новости. При реверс-инжиниринге в классы сущностей была автоматически добавлена только связь «Ссылка -> Новость». Для того чтобы добавить связь «Новость -> Cсылки» нужно в классе Test\NewsBundle\Entity\News добавить:
/**
* @ORM\OneToMany(targetEntity="NewsLink", mappedBy="news", cascade={"all"})
*/
protected $newsLinks;
function __construct()
{
$this->newsLinks = new \Doctrine\Common\Collections\ArrayCollection();
}
После чего выполнить команду (будут сгенерированы геттер и сеттер для атрибута $newsLinks):
php app/console doctrine:generate:entities TestNewsBundle
Теперь в класс Test/NewsBundle/Form/NewsType добавляем поле типа «Collection», позволяющее редактировать набор связанных сущностей:
$builder->add('title', 'text', array('label' => 'Заголовок'))
....
->add('newsLinks', 'collection', array(
'label' => 'Ссылки к новости',
'type' => new NewsLinkType(),
'allow_add' => true,
'allow_delete' => true,
'prototype' => true
));
В классе Test\NewsBundle\Form\NewsLinkType обязательно нужно указать название класса редактируемой сущности (опция data_class):
<?php
namespace Test\NewsBundle\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilder;
class NewsLinkType extends AbstractType
{
public function buildForm(FormBuilder $builder, array $options)
{
$builder->add('url')
->add('text');
}
public function getName()
{
return 'newsLinkType';
}
public function getDefaultOptions(array $options)
{
return array(
'data_class' => 'Test\NewsBundle\Entity\NewsLink',
);
}
}
Однако если сейчас посмотреть форму — то будет отображен только заголовок поля «Ссылки к новости». Чтобы форма заработала нужно еще модифицировать шаблон. Заодно и вынесем определение формы в отдельный шаблон. Для этого создаем файл Test/NewsBundle/Resources/views/News/form.html.twig:
{% use 'form_table_layout.html.twig' %}
{% form_theme form _self %}
<script language="JavaScript" src="https://ajax.googleapis.com/ajax/libs/jquery/1.6.2/jquery.min.js"></script>
<form action="{{ entity.id ? path('news_update', { 'id': entity.id }) : path('news_create') }}" method="post" {{ form_enctype(form) }}>
{{ form_errors(form) }}
<table>
{{ form_row (form.title) }}
{{ form_row (form.announce) }}
{{ form_row (form.text) }}
{{ form_row (form.pubDate) }}
{{ form_row (form.newsCategory) }}
<tr>
<td valign="top">Ссылки к новости</td>
<td>
<!-- Шаблон вывода строки с полем занесения/редактирования ссылки к новости -->
{% macro linkRow(link) %}
<tr>
<td>{{ form_widget(link.url) }}</td>
<td>{{ form_widget(link.text) }}</td>
<td><a href="#" class="deleteRowLink">X</a></td>
</tr>
{% endmacro %}
<!-- В этом контейнере находится шаблон строки занесения/редактирования ссылки к новости -->
<!-- При нажатии на кнопку #addLink он добавляется к таблице -->
<script type="text/html" id="nl">{{ _self.linkRow (form.newsLinks.get('prototype')) }} </script>
<!-- Таблица в которой будет выводиться список занесенных ссылок -->
<table id="linksTable">
<tr><td>Url</td><td>Название ссылки</td></tr>
{% for key, link in form.newsLinks %}
{{ _self.linkRow(link) }}
{% endfor %}
</table>
<input type="button" id="addLink" value="Добавить ссылку">
<script>
$(function() {
$("#addLink" ).click(function() {
$('#linksTable tbody').append($('#nl').html().replace(/\$\$name\$\$/g, $('#linksTable tbody tr').length)); });
$("form a.deleteRowLink").live('click', function() {
$(this).closest('tr').remove(); });
});
</script>
</td>
</tr>
</table>
{{ form_rest(form) }}
<p><button type="submit">Сохранить</button></p>
</form>
Шаблон занесения записи Test/NewsBundle/Resources/views/News/new.html.twig:
<h1>Занесение новости</h1>
{% include 'TestNewsBundle:News:form.html.twig' %}
<ul class="record_actions">
<li>
<a href="{{ path('news') }}">
Назад к списку
</a>
</li>
</ul>
Шаблон редактирования записи Test/NewsBundle/Resources/views/News/edit.html.twig:
<h1>Редактирование новости</h1>
{% include 'TestNewsBundle:News:form.html.twig' with { 'form' : edit_form } %}
<ul class="record_actions">
<li>
<a href="{{ path('news') }}">
Back to the list
</a>
</li>
<li>
<form action="{{ path('news_delete', { 'id': entity.id }) }}" method="post">
{{ form_widget(delete_form) }}
<button type="submit">Delete</button>
</form>
</li>
</ul>
Можно конечно оставить один шаблон для занесения и редактирования записей, но в данном примере оставим структуру шаблонов и методов контроллера, которую сгенерировал CRUD-генератор, без изменений.
Далее нужно модифицировать класс котроллера Test/NewsBundle/Controller/NewsController. Хотя мы и указали в параметрах связи «Новость — Ссылка» опцию cascade = all (при сохранении сущности сохраняются связанные сущности), однако все равно требуется определить привязку объектов NewsLink к родительскому объекту News:
public function createAction()
{
$entity = new News();
$request = $this->getRequest();
$form = $this->createForm(new NewsType(), $entity);
$form->bindRequest($request);
if ($form->isValid()) {
$em = $this->getDoctrine()->getEntityManager();
// Нужно указать родительский объект
foreach ($entity->getNewsLinks() as $link)
{
$link->setNews($entity);
}
$em->persist($entity);
$em->flush();
return $this->redirect($this->generateUrl('news_show', array('id' => $entity->getId())));
}
return array(
'entity' => $entity,
'form' => $form->createView());
}
Для метода updateAction нужно еще предусмотреть возможно удаления связанных записей. В html-форме есть удаление строк таблицы с использованием JQuery, однако этого недостаточно для удаления записей — это нужно явно реализовать в контроллере:
public function updateAction($id)
{
$em = $this->getDoctrine()->getEntityManager();
$entity = $em->getRepository('TestNewsBundle:News')->find($id);
if (!$entity) {
throw $this->createNotFoundException('Unable to find News entity.');
}
$beforeSaveLinks = $currentLinkIds = array();
foreach ($entity->getNewsLinks() as $link)
$beforeSaveLinks [$link->getId()] = $link;
$editForm = $this->createForm(new NewsType(), $entity);
$deleteForm = $this->createDeleteForm($id);
$request = $this->getRequest();
$editForm->bindRequest($request);
if ($editForm->isValid()) {
foreach ($entity->getNewsLinks() as $link)
{
$link->setNews($entity);
//Если ссылка - не только что занесенная (у нее есть id)
if ($link->getId()) $currentLinkIds[] = $link->getId();
}
$em->persist($entity);
//Если ссылка которая была до сохранения отсутствует в текущем наборе - удаляем ее
foreach ($beforeSaveLinks as $linkId => $link)
if (!in_array( $linkId, $currentLinkIds)) $em->remove($link);
$em->flush();
return $this->redirect($this->generateUrl('news_edit', array('id' => $id)));
}
return array(
'entity' => $entity,
'edit_form' => $editForm->createView(),
'delete_form' => $deleteForm->createView(),
);
}
Форма с возможностью добавления связанных записей:

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