Writing custom ParamConverter in Symfony2

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 box isn’t enough, you should extend it manually. In this post I’ll show how to write your custom Symfony2 ParamConverter.

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 a name of a town or a village is not unique. After all, there are cities with same name in different countries. For example, there’s Odesa not only in Ukraine, but also in Texas (USA). A popular name of an 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 only two levels: district and village. 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 create 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 districts. 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;
 
/**
 * @ORM\Entity()
 * @ORM\Table(name="districts")
 * @DoctrineAssert\UniqueEntity("slug")
 */class District
{/**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue
     */private$id;
 
    /**
     * @ORM\OneToMany(targetEntity="Village", mappedBy="district", cascade={"persist", "remove"}, orphanRemoval=true)
     * @ORM\JoinColumn(onDelete="CASCADE")
     */private$villages;
 
    /**
     * @ORM\Column(type="string", unique=true, length=100, nullable=false)
     */private$slug;
 
    // Setters and getters for fields}
namespace AppBundle\Entity;
 
use Doctrine\ORM\Mapping as ORM;use Symfony\Bridge\Doctrine\Validator\Constraints as DoctrineAssert;
 
/**
 * @ORM\Entity(repositoryClass="AppBundle\Repository\VillageRepository")
 * @ORM\Table(name="villages",
 *   uniqueConstraints={@ORM\UniqueConstraint(name="unique_village_slug_for_district", columns={"district_id", "slug"})}
 * )
 * @DoctrineAssert\UniqueEntity(fields={"district", "slug"}, errorPath="slug")
 */class Village
{/**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue
     */private$id;
 
    /**
     * @ORM\ManyToOne(targetEntity="District", inversedBy="villages")
     * @ORM\JoinColumn(nullable=false, onDelete="CASCADE")
     */private$district;
 
    /**
     * @ORM\Column(type="string", length=100, nullable=false)
     */private$slug;
 
    // Setters and getters for fields}

Add an action inside the DistrictController to show a village page.

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
{/**
     * Show village page of some district
     *
     * @Route("/{districtSlug}/{villageSlug}", name="district_village_show")
     * @Method({"GET"})
     */publicfunction 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
{/**
     * Find village by its slug and slug of its district
     *
     * @param string $districtSlug District slug
     * @param string $villageSlug  Village slug
     *
     * @return Village|null
     */publicfunction 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:

/**
 * @param string $districtSlug District slug
 * @param string $villageSlug  Village slug
 *
 * @return Response
 *
 * @Route("/{districtSlug}/{villageSlug}", name="district_village_show")
 */publicfunction 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.

/**
 * @param District $district District
 * @param Village  $village  Village
 *
 * @return Response
 *
 * @Route("/{districtSlug}/{villageSlug}", name="district_village_show")
 *
 * Just an example, but it will not do what we need!!!
 * @ParamConverter("district", class="AppBundle:District", options={"mapping": {"districtSlug" = "slug"}})
 * @ParamConverter("village", class="AppBundle:Village", options={"mapping": {"villageSlug" = "slug"}})
 */publicfunction 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:

/**
 * @param Village $village Village
 *
 * @return Response
 *
 * Just an example, but this code doesn't work!!!
 * @Route("/{districtSlug}/{villageSlug}", name="district_village_show")
 * @ParamConverter("village", class="AppBundle:Village", options={"mapping": {"villageSlug" = "slug", "districtSlug" = "district.slug"}})
 */publicfunction 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. Unfortunately, this cannot be done out of box right now. But there is an opportunity to write a Symfony2 custom param converter. 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
{/**
     * @var ManagerRegistry $registry Manager registry
     */private$registry;
 
    /**
     * @param ManagerRegistry $registry Manager registry
     */publicfunction __construct(ManagerRegistry $registry=null){$this->registry=$registry;}
 
    /**
     * {@inheritdoc}
     *
     * Check, if object supported by our converter
     */publicfunction supports(ParamConverter $configuration){// If there is no manager, this means that only Doctrine DBAL is configured// In this case we can do nothing and just returnif(null===$this->registry||!count($this->registry->getManagers())){returnfalse;}
 
        // Check, if option class was set in configurationif(null===$configuration->getClass()){returnfalse;}
 
        // Get actual entity manager for class$em=$this->registry->getManagerForClass($configuration->getClass());
 
        // Check, if class name is what we needif('AppBundle\Entity\Village'!==$em->getClassMetadata($configuration->getClass())->getName()){returnfalse;}
 
        returntrue;}
 
    /**
     * {@inheritdoc}
     *
     * Applies converting
     *
     * @throws \InvalidArgumentException When route attributes are missing
     * @throws NotFoundHttpException     When object not found
     */publicfunction apply(Request $request, ParamConverter $configuration){$districtSlug=$request->attributes->get('districtSlug');$villageSlug=$request->attributes->get('villageSlug');
 
        // Check, if route attributes existsif(null===$districtSlug||null===$villageSlug){thrownew \InvalidArgumentException('Route attribute is missing');}
 
        // Get actual entity manager for class$em=$this->registry->getManagerForClass($configuration->getClass());
 
        /** @var \AppBundle\Repository\VillageRepository $villageRepository Village repository */$villageRepository=$em->getRepository($configuration->getClass());
 
        // Try to find village by its slug and slug of its district$village=$villageRepository->findBySlugAndDistrictSlug($districtSlug,$villageSlug);
 
        if(null===$village||!($village instanceof Village)){thrownew NotFoundHttpException(sprintf('%s object not found.',$configuration->getClass()));}
 
        // Map found village to the route's parameter$request->attributes->set($configuration->getName(),$village);}}

Custom ParamConverter development 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 how to create param converter 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 (read about it in Symfony documentation available on the official website). 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:

/**
 * @param Village $village Village
 *
 * @return Response
 *
 * @Route("/{districtSlug}/{villageSlug}", name="district_village_show")
 * @Method({"GET"})
 *
 * @ParamConverter("village", class="AppBundle:Village", converter="district_village_converter")
 */publicfunction 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 easier to read.