Introduction
PHP-framework Symfony2 has a very nice component ParamConverter
, which allows to convert parameters from URL into PHP variables. When the functionality that comes out of the box isn’t enough, you should extend it manually. In this post, I'll show how to write your custom ParamConverter
at Symfony2.
Let's suppose we have two entities, which are related as «one-to-many». For example:
- Countries and cities
- Districts and villages
The key is that the name of a town or a village is not unique. After all, there are cities with the same name in different countries. For example, Odesa is not only in Ukraine, but also in Texas (USA). The popular name for the ex-USSR village, Pervomayskoe, is found in many regions of Ukraine, villages with the same name exist in Russia, Moldova, and Kazakhstan. To identify a specific village, we need to specify the full address:
- Ukraine, Crimea, Simferopol district, Pervomayskoe village;
- Kazakhstan, Aktobe region, Kargalinsky district, Pervomayskoe village;
- Russia, Moscow region, Istra district, Pervomayskoe village.
For example, let's take just two levels: district and village. So, it will be easier and more intuitive, the rest can be extended in the same way. Let's imagine, that for some region you need to make a website with information about each district and each village of each district. Information about each village is displayed on a separate page. The main requirement is that the address of the page should be readable and consist of a "slug" of the district and a "slug" of the village (slug is human-readable representation of the entity in URL). Here are examples of URLs that should work under the "Site Address / district slug / village slug" pattern:
- example.com/yarmolynetskyi/antonivtsi
- example.com/yarmolynetskyi/ivankivtsi
- example.com/vinkovetskyi/ivankivtsi
yarmolynetskyi — slug of Yarmolyntsi district of my region. vinkovetskyi — Vinkivtsi district. ivankivtsi — Ivankivtsi village, which is in both disctricts. antonivtsi — Antonivtsi is another village.
Let's describe two entities with the minimum required number of fields for this example.
namespace AppBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints as DoctrineAssert;
class District
{
private $id;
private $villages;
private $slug;
}
namespace AppBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints as DoctrineAssert;
class Village
{
private $id;
private $district;
private $slug;
}
Add an action inside the DistrictController
to show a page of a village.
namespace AppBundle\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
class DistrictController extends Controller
{
public function villageAction()
{
}
}
Now let's consider how our task can be done. The first option: you need to create a new method in VillageRepository
, which will perform a JOIN between tables `districts
` and `villages
` and find the village by its slug and slug of its district.
namespace AppBundle\Repository;
use AppBundle\Entity\Village;
use Doctrine\ORM\EntityRepository;
class VillageRepository extends EntityRepository
{
public function findBySlugAndDistrictSlug($districtSlug, $villageSlug)
{
$qb = $this->createQueryBuilder('v');
return $qb->join('v.district', 'd')
->where($qb->expr()->eq('v.slug', ':village_slug'))
->andWhere($qb->expr()->eq('d.slug', ':district_slug'))
->setParameters([
'district_slug' => $districtSlug,
'village_slug' => $villageSlug
])
->getQuery()
->getOneOrNullResult();
}
}
Method in the controller will look like this:
public function villageAction($districtSlug, $villageSlug)
{
$villageRepository = $this->getDoctrine()->getRepository('AppBundle:Village');
$village = $villageRepository->findBySlugAndDistrictSlug($districtSlug, $villageSlug);
if (null === $village) {
throw $this->createNotFoundException('No village was found');
}
return $this->render('district/show_village.html.twig', [
'village' => $village
]);
}
Part of the code for searching the desired item in the database can be replaced by using Symfony annotation — @ParamConverter
. One of the features of this annotation is that exception will be called automatically, if the entity not found. We don't need anything extra to check, which means less code, which means cleaner code.
public function villageAction($district, $village)
{
return $this->render('district/show_village.html.twig', [
'village' => $village
]);
}
By default, ParamConverter
makes mapping of parameters from URL to the ID field of the specified class. If the input parameter is not ID, it is necessary to further specify it through mapping option. But, in fact, the above code will not do what we need! The first converter will find correct district and save it into the variable $district
. The second converter will find first village with specified slug and save it into the variable $village
. In the query (which ParamConverter
executes to find entity) is a LIMIT 1, that's why only one object with the smallest ID will be found. It's not what we need. For villages with the same name, slugs are also the same. And in this case, every time only the first village with the actual slug from the database will be found.
Moving on. ParamConverter
out of the box allows to map several fields into one entity, e.g. @ParamConverter("village", options={"mapping": {"code": "code", "slug": "slug"}})
, but only if these fields belong to this entity. In our case, two slugs belong to different entities. I wish this construction worked out of the box:
public function villageAction($village)
{
return $this->render('district/show_village.html.twig', [
'village' => $village
]);
}
It would be nice, if ParamConverter
detected that we want to map districtSlug
to the slug field of District
entity, which is mapped to the district
field of entity Village
, i.e., to join both tables villages
and districts
. But, unfortunately, this cannot be done out of box right now. But there is an opportunity to write a custom ParamConverter
. Here's a complete converter, which we need, with comments between the lines.
namespace AppBundle\Request\ParamConverter;
use AppBundle\Entity\Village;
use Doctrine\Common\Persistence\ManagerRegistry;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Sensio\Bundle\FrameworkExtraBundle\Request\ParamConverter\ParamConverterInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class DistrictVillageParamConverter implements ParamConverterInterface
{
private $registry;
public function __construct(ManagerRegistry $registry = null)
{
$this->registry = $registry;
}
public function supports(ParamConverter $configuration)
{
if (null === $this->registry || !count($this->registry->getManagers())) {
return false;
}
if (null === $configuration->getClass()) {
return false;
}
$em = $this->registry->getManagerForClass($configuration->getClass());
if ('AppBundle\Entity\Village' !== $em->getClassMetadata($configuration->getClass())->getName()) {
return false;
}
return true;
}
public function apply(Request $request, ParamConverter $configuration)
{
$districtSlug = $request->attributes->get('districtSlug');
$villageSlug = $request->attributes->get('villageSlug');
if (null === $districtSlug || null === $villageSlug) {
throw new \InvalidArgumentException('Route attribute is missing');
}
$em = $this->registry->getManagerForClass($configuration->getClass());
$villageRepository = $em->getRepository($configuration->getClass());
$village = $villageRepository->findBySlugAndDistrictSlug($districtSlug, $villageSlug);
if (null === $village || !($village instanceof Village)) {
throw new NotFoundHttpException(sprintf('%s object not found.', $configuration->getClass()));
}
$request->attributes->set($configuration->getName(), $village);
}
}
Custom ParamConverter
should implement ParamConverterInterface
. Should be implemented two methods:
supports
— checks, if current request can be processed by converter apply
— does all necessary transformation
There is a minimum information about custom ParamConverter
on the Symfony site. There is an advice to take DoctrineParamConverter
as basic class for your need.
To make it work, it is also necessary to declare converter as a service with tag request.param_converter
:
services:
app.param_converter.district_village_converter:
class: AppBundle\Request\ParamConverter\DistrictVillageParamConverter
tags:
- { name: request.param_converter, converter: district_village_converter }
arguments:
- @?doctrine
Argument @?doctrine
means that if the entity manager is not configured, then it's ignored. To control the sequence of converters, we can use the option priority (about this in the documentation on the Symfony site). Or you can specify a name for the converter converter: district_village_converter
. If you want to run only our converter, then prescribe its name in the annotation, other converters will be ignored for this request.
Now the code of our action looks like this:
public function villageAction(Village $village)
{
return $this->render('district/show_village.html.twig', [
'village' => $village
]);
}
As you can see, we have moved the code which was responsible for finding objects in the database from the controller to the converter. The code has become cleaner and more pleasant.