Example below is based on one-to-many relationship (one Country to many League), uses webforms and all the process are handled in controller although it is not a "good practise". It just gives you a basic idea. The better way is to move logic into custom services. "Thin controller, fat service" is considered as "good practice" when developing applications.


Facts



Country entity


namespace Football\FrontendBundle\Entity;

use DateTime;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;

/**
* @ORM\Entity(repositoryClass="Football\FrontendBundle\Repository\CountryRepository")
* @ORM\Table(name="country")
* @UniqueEntity(fields="code", message="Code is already taken.")
* @UniqueEntity(fields="name", message="Name is already taken.")
* @ORM\HasLifecycleCallbacks
*/
class Country
{
/**
* @var int
*
* @ORM\Id
* @ORM\Column(type="smallint")
* @ORM\GeneratedValue(strategy="AUTO")
*/
protected $id;

/**
* @var string
*
* @ORM\Column(name="name", type="string", length=100, unique=true)
* @Assert\NotBlank(message="Name is required.")
* @Assert\Length(
* max=100,
* exactMessage="Name cannot be longer than {{ limit }} characters."
* )
*/
protected $name;

/**
* @var object
*
* @ORM\OneToMany(
* targetEntity="League",
* mappedBy="country",
* cascade={"persist", "remove"}
* )
*/
protected $league;

/**
* Constructor
*/
public function __construct()
{
$this->league = new ArrayCollection();
}

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

/**
* Set name
*
* @param string $name
* @return Country
*/
public function setName($name)
{
$this->name = $name;

return $this;
}

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

/**
* Add league
*
* @param League $league
* @return Country
*/
public function addLeague(League $league)
{
$this->league[] = $league;

return $this;
}

/**
* Remove league
*
* @param League $league
*/
public function removeLeague(League $league)
{
$this->league->removeElement($league);
}

/**
* Get league
*
* @return Collection
*/
public function getLeague()
{
return $this->league;
}
}

League entity


namespace Football\FrontendBundle\Entity;

use DateTime;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;

/**
* @ORM\Entity(repositoryClass="Football\FrontendBundle\Repository\LeagueRepository")
* @ORM\Table(
* name="league",
* uniqueConstraints={@ORM\UniqueConstraint(columns={"name", "country_id"})}
* )
* @UniqueEntity(
* fields={"name","country"},
* message="League for given country already exists in database."
* )
* @ORM\HasLifecycleCallbacks
*/
class League
{
/**
* @var int
*
* @ORM\Id
* @ORM\Column(type="smallint")
* @ORM\GeneratedValue(strategy="AUTO")
*/
protected $id;

/**
* @var string
*
* @ORM\Column(name="name", type="string", length=100)
* @Assert\NotBlank(message="Name is required.")
* @Assert\Length(
* max=100,
* exactMessage="Name cannot be longer than {{ limit }} characters."
* )
*/
protected $name;

/**
* @var datetime
*
* @ORM\Column(name="created_at", type="datetime", nullable=false)
*/
protected $createdAt;

/**
* @var datetime
*
* @ORM\Column(name="updated_at", type="datetime", nullable=true)
*/
protected $updatedAt;

/**
* @var object
*
* @Assert\NotBlank(message="Country is required.")
* @ORM\ManyToOne(
* targetEntity="Country",
* inversedBy="league"
* )
* @ORM\JoinColumn(
* name="country_id",
* referencedColumnName="id",
* onDelete="CASCADE",
* nullable=false
* )
*/
protected $country;

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

/**
* Set name
*
* @param string $name
* @return Country
*/
public function setName($name)
{
$this->name = $name;

return $this;
}

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

/**
* @ORM\PrePersist
*/
public function onPrePersist()
{
$this->createdAt = new DateTime('now');
}

/**
* @ORM\PreUpdate
*/
public function onPreUpdate()
{
$this->updatedAt = new DateTime('now');
}

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

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

/**
* Set country
*
* @param Country $country
* @return League
*/
public function setCountry(Country $country)
{
$this->country = $country;

return $this;
}

/**
* Get country
*
* @return Country
*/
public function getCountry()
{
return $this->country;
}
}

FormType


Country field is represented as an embedded entity.


namespace Football\FrontendBundle\Form\Type;

use Doctrine\ORM\EntityRepository;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;

class LeagueType extends AbstractType
{
private $country;

public function __construct()
{
$this->country = [
'error_bubbling' => true,
'class' => 'FootballFrontendBundle:Country',
'property' => 'name',
'multiple' => false,
'expanded' => false,
'required' => false,
'empty_value' => '',
'query_builder' => function (EntityRepository $repo)
{
return $repo->createQueryBuilder('c')->orderBy('c.name', 'ASC');
}
];
}

public function buildForm(FormBuilderInterface $builder, array $options = [])
{
// Update page should not have empty option for country because it is FK to country entity.
if ($options['method'] == 'PATCH') {
$this->country['empty_value'] = false;
}

$builder
->setMethod($options['method'])
->setAction($options['action'])
->add('name', 'text', ['required' => false, 'error_bubbling' => true])
->add('country', 'entity', $this->country);
}

public function getName()
{
return 'league';
}

public function setDefaultOptions(OptionsResolverInterface $resolver)
{
$resolver->setDefaults(
['data_class' => 'Football\FrontendBundle\Entity\League']
);
}
}

League repository class


Read facts section above to find out why we're using a repository class although we could simply use built-in find methods.


namespace Football\FrontendBundle\Repository;

use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query;

class LeagueRepository extends EntityRepository
{
/**
* Built-in findAll() would run additional queries for Country because
* we are interacting with Country info as parent.
*/
public function findAll()
{
return
$this
->createQueryBuilder('l')
->select('l.id, l.name, l.createdAt, l.updatedAt, c.name as country')
->leftJoin('l.country', 'c')
->getQuery()
->getResult();
}

/**
* Built-in findAll() would run additional queries for Country because
* we are interacting with Country info as parent.
*/
public function findOneById($id, $hydrator = Query::HYDRATE_OBJECT)
{
return
$this
->createQueryBuilder('l')
->select('l.id, l.name, l.createdAt, l.updatedAt, c.name as country')
->leftJoin('l.country', 'c')
->where('l.id = :id')
->setParameter('id', $id)
->getQuery()
->getOneOrNullResult($hydrator);
}

/**
* Removing an entity must return whole objects graph hence reason we must
* select l and c.
*/
public function findOneByIdAsObject($id)
{
return
$this
->createQueryBuilder('l')
->select('l, c')
->leftJoin('l.country', 'c')
->where('l.id = :id')
->setParameter('id', $id)
->getQuery()
->getOneOrNullResult();
}
}

Controller


namespace Football\FrontendBundle\Controller;

use Doctrine\DBAL\DBALException;
use Doctrine\ORM\ORMException;
use Doctrine\ORM\Query;
use Exception;
use Football\FrontendBundle\Entity\League;
use Football\FrontendBundle\Exception\LeagueException;
use Football\FrontendBundle\Form\Type\LeagueType;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Symfony\Component\Form\Form;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

/**
* @Route("/league")
*/
class LeagueController extends Controller
{
const ROUTER_PREFIX = 'football_frontend_league_';

/**
* Landing page.
*
* @Route("")
* @Method({"GET"})
*
* @return Response
*/
public function listAction()
{
$repo = $this->getDoctrine()->getRepository('FootballFrontendBundle:League');

return $this->getFormView('list', ['leagueArray' => $repo->findAll()]);
}

/**
* Create page.
*
* @Route("/create")
* @Method({"GET"})
*
* @return Response
*/
public function createAction()
{
$form = $this->getForm(
new League(),
'POST',
$this->generateUrl(self::ROUTER_PREFIX . 'create')
);

return $this->getFormView('create', ['form' => $form->createView()]);
}

/**
* Creates processing.
*
* @param Request $request
*
* @Route("/create")
* @Method({"POST"})
*
* @return RedirectResponse|Response
* @throws LeagueException
*/
public function createProcessAction(Request $request)
{
if ($request->getMethod() != 'POST') {
throw new LeagueException('League create: only POST method is allowed.');
}

$form = $this->getForm(
new League(),
'POST',
$this->generateUrl(self::ROUTER_PREFIX . 'create')
);
$form->handleRequest($request);

if (!$form->isSubmitted()) {
throw new LeagueException('League create: form is not submitted.');
}

if ($form->isValid() !== true) {
return $this->getFormView('create', ['form' => $form->createView()]);
}

try {
$data = $form->getData();

$league = new League();
$league->setName($data->getName());
$league->setCountry($data->getCountry());

$em = $this->getDoctrine()->getManager();
$em->persist($league);
$em->flush();
} catch (DBALException $e) {
$message = sprintf('DBALException [%s]: %s', $e->getCode(), $e->getMessage());
} catch (ORMException $e) {
$message = sprintf('ORMException [%s]: %s', $e->getCode(), $e->getMessage());
} catch (Exception $e) {
$message = sprintf('Exception [%s]: %s', $e->getCode(), $e->getMessage());
}

if (isset($message)) {
throw new LeagueException($message);
}

return $this->redirect($this->generateUrl(self::ROUTER_PREFIX . 'list'));
}

/**
* Update page.
*
* @Route("/update/{id}", requirements={"id"="\d+"})
* @Method({"GET"})
*/
public function updateAction($id)
{
$repo = $this->getDoctrine()->getRepository('FootballFrontendBundle:League');
$league = $repo->findOneByIdAsObject($id);
if (!$league instanceof League) {
throw new LeagueException(sprintf('League update: league [%s] cannot be found.', $id));
}

$form = $this->getForm(
$league,
'PATCH',
$this->generateUrl(self::ROUTER_PREFIX . 'updateprocess', ['id' => $id])
);

return $this->getFormView(
'update',
[
'form' => $form->createView(),
'id' => $league->getId()
]
);
}

/**
* Update processing.
*
* @param Request $request
* @param int $id
*
* @Route("/update/{id}", requirements={"id"="\d+"})
* @Method({"PATCH"})
*
* @return RedirectResponse|Response
* @throws LeagueException
*/
public function updateProcessAction(Request $request, $id)
{
if ($request->getMethod() != 'PATCH') {
throw new LeagueException('League update: only PATCH method is allowed.');
}

$repo = $this->getDoctrine()->getRepository('FootballFrontendBundle:League');
$league = $repo->findOneByIdAsObject($id);
if (!$league instanceof League) {
throw new LeagueException(sprintf('League update: league [%s] cannot be found.', $id));
}

$form = $this->getForm(
$league,
'PATCH',
$this->generateUrl(self::ROUTER_PREFIX . 'updateprocess', ['id' => $id])
);
$form->handleRequest($request);

if (!$form->isSubmitted()) {
throw new LeagueException('League update: form is not submitted.');
}

if ($form->isValid() !== true) {
return $this->getFormView('update', ['form' => $form->createView()]);
}

try {
$data = $form->getData();

$league->setName($data->getName());
$league->setCountry($data->getCountry());

$em = $this->getDoctrine()->getManager();
$em->flush();
} catch (DBALException $e) {
$message = sprintf('DBALException [%s]: %s', $e->getCode(), $e->getMessage());
} catch (ORMException $e) {
$message = sprintf('ORMException [%s]: %s', $e->getCode(), $e->getMessage());
} catch (Exception $e) {
$message = sprintf('Exception [%s]: %s', $e->getCode(), $e->getMessage());
}

if (isset($message)) {
throw new LeagueException($message);
}

return $this->redirect($this->generateUrl(self::ROUTER_PREFIX . 'list'));
}

/**
* Fetches country.
*
* @param int $id
*
* @Route("/{id}", requirements={"id"="\d+"})
* @Method({"GET"})
*
* @return RedirectResponse|Response
* @throws LeagueException
*/
public function readAction($id)
{
try {
$repo = $this->getDoctrine()->getRepository('FootballFrontendBundle:League');
$league = $repo->findOneById($id, Query::HYDRATE_SCALAR);
if (!count($league)) {
throw new LeagueException(sprintf('League read: league [%s] cannot be found.', $id));
}

return $this->getFormView('read', ['league' => $league]);
} catch (DBALException $e) {
$message = sprintf('DBALException [%s]: %s', $e->getCode(), $e->getMessage());
} catch (ORMException $e) {
$message = sprintf('ORMException [%s]: %s', $e->getCode(), $e->getMessage());
} catch (Exception $e) {
$message = sprintf('Exception [%s]: %s', $e->getCode(), $e->getMessage());
}

if (isset($message)) {
throw new LeagueException($message);
}

return $this->redirect($this->generateUrl(self::ROUTER_PREFIX . 'list'));
}

/**
* Deletes country.
*
* @param int $id
*
* @Route("/delete/{id}", requirements={"id"="\d+"})
* @Method({"GET"})
*
* @return RedirectResponse|Response
* @throws LeagueException
*/
public function deleteAction($id)
{
try {
$em = $this->getDoctrine()->getEntityManager();
$repo = $em->getRepository('FootballFrontendBundle:League');

$league = $repo->findOneByIdAsObject($id);
if (!$league instanceof League) {
throw new LeagueException(sprintf('League read: league [%s] cannot be found.', $id));
}

$em->remove($league);
$em->flush();
} catch (DBALException $e) {
$message = sprintf('DBALException [%s]: %s', $e->getCode(), $e->getMessage());
} catch (ORMException $e) {
$message = sprintf('ORMException [%s]: %s', $e->getCode(), $e->getMessage());
} catch (Exception $e) {
$message = sprintf('Exception [%s]: %s', $e->getCode(), $e->getMessage());
}

if (isset($message)) {
throw new LeagueException($message);
}

return $this->redirect($this->generateUrl(self::ROUTER_PREFIX . 'list'));
}

/**
* Creates form object.
*
* @param League $league
* @param string $method
* @param string $action
*
* @return Form
*/
private function getForm(League $league, $method, $action)
{
return $this->createForm(
new LeagueType(),
$league,
[
'method' => $method,
'action' => $action
]
);
}

/**
* Creates webform.
*
* @param string $template
* @param array $parameters
*
* @return Response
*/
private function getFormView($template, array $parameters = [])
{
return $this->render(
sprintf('FootballFrontendBundle:League:%s.html.twig', $template),
$parameters
);
}
}

Routers


inanzzz-MBP:sport inanzzz$ php app/console router:debug
[router] Current routes
Name Method Scheme Host Path
_wdt ANY ANY ANY /_wdt/{token}
_profiler_home ANY ANY ANY /_profiler/
_profiler_search ANY ANY ANY /_profiler/search
_profiler_search_bar ANY ANY ANY /_profiler/search_bar
_profiler_purge ANY ANY ANY /_profiler/purge
_profiler_info ANY ANY ANY /_profiler/info/{about}
_profiler_phpinfo ANY ANY ANY /_profiler/phpinfo
_profiler_search_results ANY ANY ANY /_profiler/{token}/search/results
_profiler ANY ANY ANY /_profiler/{token}
_profiler_router ANY ANY ANY /_profiler/{token}/router
_profiler_exception ANY ANY ANY /_profiler/{token}/exception
_profiler_exception_css ANY ANY ANY /_profiler/{token}/exception.css
_configurator_home ANY ANY ANY /_configurator/
_configurator_step ANY ANY ANY /_configurator/step/{index}
_configurator_final ANY ANY ANY /_configurator/final
_twig_error_test ANY ANY ANY /_error/{code}.{_format}
football_frontend_default_index GET ANY ANY /
football_frontend_league_list GET ANY ANY /league
football_frontend_league_create GET ANY ANY /league/create
football_frontend_league_createprocess POST ANY ANY /league/create
football_frontend_league_update GET ANY ANY /league/update/{id}
football_frontend_league_updateprocess PATCH ANY ANY /league/update/{id}
football_frontend_league_read GET ANY ANY /league/{id}
football_frontend_league_delete GET ANY ANY /league/delete/{id}

List template


{% extends 'FootballFrontendBundle:League:index.html.twig' %}

{% block body %}
{% spaceless %}
{{ parent() }}
LEAGUE - List
<hr />
{% if leagueArray is defined and leagueArray|length != 0 %}
<table border="1px">
<tr>
<td>#</td>
<td>ID</td>
<td>NAME</td>
<td>CREATED</td>
<td>UPDATED</td>
<td>COUNTRY</td>
<td> </td>
<td> </td>
<td> </td>
</tr>
{% for league in leagueArray %}
<tr>
<td class="head">{{ loop.index }}</td>
<td>{{ league.id }}</td>
<td>{{ league.name }}</td>
<td>{{ league.createdAt|date('d/m/Y H:i:s') }}</td>
<td>{{ league.updatedAt is not null ? league.updatedAt|date('d/m/Y H:i:s') : '' }}</td>
<td>{{ league.country }}</td>
<td><a href="{{ path('football_frontend_league_read', {'id':league.id}) }}">view</a></td>
<td><a href="{{ path('football_frontend_league_delete', {'id':league.id}) }}">delete</a></td>
<td><a href="{{ path('football_frontend_league_update', {'id':league.id}) }}">update</a></td>
</tr>
{% endfor %}
</table>
{% else %}
No league found in database!
{% endif %}
{% endspaceless %}
{% endblock %}

Read template


{% extends 'FootballFrontendBundle:League:index.html.twig' %}

{% block body %}
{% spaceless %}
{{ parent() }}
LEAGUE - Read
<hr />
<table border="1px">
<tr>
<td>ID</td>
<td>NAME</td>
<td>CREATED</td>
<td>UPDATED</td>
<td>* COUNTRY</td>
</tr>
<tr>
<td>{{ league.id }}</td>
<td>{{ league.name }}</td>
<td>{{ league.createdAt|date('d/m/Y H:i:s') }}</td>
<td>{{ league.updatedAt is not null ? league.updatedAt|date('d/m/Y H:i:s') : '' }}</td>
<td>{{ league.country }}</td>
</tr>
</table>
{% endspaceless %}
{% endblock %}

Create template


{% extends 'FootballFrontendBundle:League:index.html.twig' %}

{% block body %}
{% spaceless %}
{{ parent() }}
LEAGUE - Create
<hr />
{{ form_start(form) }}
{% if form_errors(form) != '' %}

{{ form_errors(form) }}

{% endif %}

<p>NAME: {{ form_widget(form.name) }}</p>
<p>COUNTRY: {{ form_widget(form.country) }}</p>

<p><button name="button">Submit</button></p>
{{ form_end(form) }}
{% endspaceless %}
{% endblock %}

Update template


{% extends 'FootballFrontendBundle:League:index.html.twig' %}

{% block body %}
{% spaceless %}
{{ parent() }}
LEAGUE - Update
<hr />
{{ form_start(form) }}
{% if form_errors(form) != '' %}

{{ form_errors(form) }}

{% endif %}

<p>NAME: {{ form_widget(form.name) }}</p>
<p>COUNTRY: {{ form_widget(form.country) }}</p>

<p><button name="button">Submit</button></p>
{{ form_end(form) }}
{% endspaceless %}
{% endblock %}