Straightforward Routing Requirements


In the previous video we looked at ways to get the request parameters in Symfony websites.

Along the way we covered various properties of the Request object, including the request, query, and attributes public properties, and also accepting JSON via the request body content.

The thing is, whilst we can accept this data, we were doing so in a way that left ourselves open for mistakes... at the very least.

What we ideally want to do is ensure only some specific values are acceptable.

Let's take an example route:

class DemoController extends Controller
{
    /**
     * @Route("/demo/{userId}", name="demo")
     */
    public function demoAction($userId)
    {
        dump($userId);

        return $this->render('::base.html.twig');
    }

As we saw in the previous video, this will allow us to accept a parameter (or multiple parameters) via our URL that Symfony will convert for us (via the RouterListener) into variable(s) such as $userId.

The problem we have is that we haven't explicitly stated what kind of information is acceptable. By default, any character sequence that can come via the URL would therefore be 'valid'.

That's Numberwang

Thinking about it, a $userId is commonly only going to be a number. Well, we may be using GUIDs, but for the moment, let's make life simple for ourselves and say in our application, user id data is always numerical.

Thankfully, Symfony makes restricting this value to a number very easy. We just need to add in the appropriate requirements annotation:

class DemoController extends Controller
{
    /**
     * @Route(
     *      "/demo/{userId}",
     *      name="demo",
     *      requirements={
     *          "userId": "\d+"
     *      }
     * )
     */
    public function demoAction($userId)
    {
        dump($userId);

        return $this->render('::base.html.twig');
    }

I've stretched this annotation out over multiple lines, purely for the sake of readability, but you are free to make it a one-liner.

Be careful with annotations. As they aren't code, it is really easy to make trivial mistakes, and the errors are not exactly beginner friendly:

[Syntax Error] Expected Doctrine\Common\Annotations\DocLexer::T_CLOSE_PARENTHESIS, got 'defaults' at position 82 in method AppBundle\Controller\DemoController::demoAction() in /var/www/twig.symfony-3.dev/src/AppBundle/Controller/ (which is being imported from "/var/www/twig.symfony-3.dev/app/config/routing.yml").

Translation: you missed a comma.

Meeting Your Requirements

Adding in requirements is easy enough. The syntax is always the same. You just need to match up the routing placeholder (i.e. userId) with the same key under your requirements section.

The harder part is figuring out the correct regular expression to use.

Now, regular expressions (regex for short) are, frankly, not beginner friendly.

However, they are powerful, and largely universal, so at least spending a little time learning the basics is information that you can put to use in a whole slew of programming languages, not to mention config files, and debugging painful .htaccess issues.

Fortunately, as regex are so common and so tricky, a whole bunch of websites have sprung up to make working with them that little bit less difficult. One really nice site is regexr.com, but plenty of other alternatives exist

When using a site like regexr, you can either build up your own, or if you are like me, find a decent looking starting point on a site like StackOverflow, then paste that in and hit the 'explain' tab, which will handily break down what initially looks like hieroglyphics, into something even my simple brain can comprehend.

Common Regex Patterns

There are some common regex patterns that, with a bit of use, will stick in your head.

Once you know a handful, it's really just a case of smushing a few patterns together until they meet your needs.

Of course, good tests will validate you didn't make unexpected mistakes, and again, sites like regexr will help 'explain' the patterns.

Here are a few common regex patterns I use:

  • \d+ - matches one or more digits
  • \w+ - matches one or more alphanumerical characters and underscores
  • [a-z0-9]+ - matches one ore more letters or numbers
  • this|that - matches either this or that, the pipe being the or part

And then you can combine them, such as [a-z0-9\-]+ which will match one or more alphanumerical characters and hyphens. The \ part before the - is called an escape character, and won't be matched directly.

No Matches

The next question you may have might be: "what happens if we don't meet the requirements?"

Good question, even if I did put the words into your mouth.

Simply put, the route won't match.

And as a result, the user would see a 404 page.

This can be a little confusing as the output of your php bin/console debug:router won't actually reveal this to you:

 -------------------------- -------- -------- ------ -----------------------------------
  Name                       Method   Scheme   Host   Path
 -------------------------- -------- -------- ------ -----------------------------------
  demo                       ANY      ANY      ANY    /demo/{userId}

You would need to look at the routing annotations directly to figure this out.

Or, well, there is another way of doing this - but it's better illustrated with a more complex example.

Example++

Chances are you commonly need to accept more than just a single parameter.

Let's take a more advanced example:

class DemoController extends Controller
{
    /**
     * @Route(
     *      "/demo/{userId}/{pageName}",
     *      name="demo",
     *      requirements={
     *          "userId": "\d+",
     *          "pageName": "this|that"
     *      }
     * )
     */
    public function demoAction($userId, $pageName)
    {
        dump($userId);
        dump($pageName);

        return $this->render('::base.html.twig');
    }

Here we have two routing placeholders - userId and pageName.

As it stands, neither are optional. We must supply a value for both.

  • http://mysite.com/demo - 404
  • http://mysite.com/demo/123 - 404
  • http://mysite.com/demo/123/other - 404
  • http://mysite.com/demo/123/that - 200 (only one that works)

We can provide some default values here.

class DemoController extends Controller
{
    /**
     * @Route(
     *      "/demo/{userId}/{pageName}",
     *      name="demo",
     *      requirements={
     *          "userId": "\d+",
     *          "pageName": "this|that"
     *      },
     *      defaults={
     *          "userId": "987",
     *          "pageName": "this"
     *      }
     * )
     */
    public function demoAction($userId, $pageName)
    {
        dump($userId);
        dump($pageName);

        return $this->render('::base.html.twig');
    }

And now we can hit the following URLs:

  • http://mysite.com/demo - 200 > outputs 987, and this
  • http://mysite.com/demo/123 - 200 > outputs 123, and this
  • http://mysite.com/demo/123/that - 200 > outputs 123, and that
  • http://mysite.com/demo/123/other - 404 (other is still invalid)
  • http://mysite.com/demo/some-string - 404 (some-string is not a number)

But there's one way to spoil your fun.

We can set a default to an 'invalid' value:

class DemoController extends Controller
{
    /**
     * @Route(
     *      "/demo/{userId}/{pageName}",
     *      name="demo",
     *      requirements={
     *          "userId": "\d+",
     *          "pageName": "this|that"
     *      },
     *      defaults={
     *          "userId": "tim",
     *          "pageName": "anything-we-like"
     *      }
     * )
     */
    public function demoAction($userId, $pageName)
    {
        dump($userId);
        dump($pageName);

        return $this->render('::base.html.twig');
    }

And now, should we hit the base URL:

  • http://mysite.com/demo - 200 > outputs tim, and anything-we-like

So just be careful.

There are valid use cases for the above, but you can also inadvertently torpedo your good intentions.

Again, robust tests should catch such unexpected / unwanted outcomes.

Debugging

Lastly I did mention that there is an approach to debugging your routes that is better illustrated with a more complex example.

php bin/console debug:router

Is a really common one. It's useful for many reasons.

You can also test your routes more specifically.

The command would be:

php bin/console router:match {your route here}

Let's try that in our case, and see the various output:

php bin/console router:match /demo/999/this

 [OK] Route "demo" matches

+--------------+--------------------------------------------------------------+
| Property     | Value                                                        |
+--------------+--------------------------------------------------------------+
| Route Name   | demo                                                         |
| Path         | /demo/{userId}/{pageName}                                    |
| Path Regex   | #^/demo(?:/(?P<userId>\d+)(?:/(?P<pageName>this|that))?)?$#s |
| Host         | ANY                                                          |
| Host Regex   |                                                              |
| Scheme       | ANY                                                          |
| Method       | ANY                                                          |
| Requirements | pageName: this|that                                          |
|              | userId: \d+                                                  |
| Class        | Symfony\Component\Routing\Route                              |
| Defaults     | _controller: AppBundle:Demo:demo                             |
|              | pageName: anything-we-like                                   |
|              | userId: tim                                                  |
| Options      | compiler_class: Symfony\Component\Routing\RouteCompiler      |
+--------------+--------------------------------------------------------------+

Plenty of useful output here.

It shows our 'bad' defaults, the regex that Symfony will use behind the scenes, our routing requirements and more.

php bin/console router:match /demo/765

 [OK] Route "demo" matches

+--------------+--------------------------------------------------------------+
| Property     | Value                                                        |
+--------------+--------------------------------------------------------------+
| Route Name   | demo                                                         |
| Path         | /demo/{userId}/{pageName}                                    |
| Path Regex   | #^/demo(?:/(?P<userId>\d+)(?:/(?P<pageName>this|that))?)?$#s |
| Host         | ANY                                                          |
| Host Regex   |                                                              |
| Scheme       | ANY                                                          |
| Method       | ANY                                                          |
| Requirements | pageName: this|that                                          |
|              | userId: \d+                                                  |
| Class        | Symfony\Component\Routing\Route                              |
| Defaults     | _controller: AppBundle:Demo:demo                             |
|              | pageName: this                                               |
|              | userId: 123                                                  |
| Options      | compiler_class: Symfony\Component\Routing\RouteCompiler      |
+--------------+--------------------------------------------------------------+

When we don't supply a value (pageName in this case), we don't get any immediate notification. Instead, we must manually consult the defaults. In my opinion, this could be a little more user friendly.

php bin/console router:match /demo

 [OK] Route "demo" matches

+--------------+--------------------------------------------------------------+
| Property     | Value                                                        |
+--------------+--------------------------------------------------------------+
| Route Name   | demo                                                         |
| Path         | /demo/{userId}/{pageName}                                    |
| Path Regex   | #^/demo(?:/(?P<userId>\d+)(?:/(?P<pageName>this|that))?)?$#s |
| Host         | ANY                                                          |
| Host Regex   |                                                              |
| Scheme       | ANY                                                          |
| Method       | ANY                                                          |
| Requirements | pageName: this|that                                          |
|              | userId: \d+                                                  |
| Class        | Symfony\Component\Routing\Route                              |
| Defaults     | _controller: AppBundle:Demo:demo                             |
|              | pageName: this                                               |
|              | userId: 123                                                  |
| Options      | compiler_class: Symfony\Component\Routing\RouteCompiler      |
+--------------+--------------------------------------------------------------+

Again, our route still matches here, but it's not perhaps immediately obvious why. You know, because you've been through this video, but imagine if you hadn't - it does require a few brain cycles to figure it out.

Of course it sounds trivial during a tutorial, but remember that this sort of thing is usually done whilst debugging - and you usually have a few things in your head already when tracking down problems.

php bin/console router:match /demo/bad

 Route "demo" almost matches but requirement for "userId" does not match (\d+)

 [ERROR] None of the routes match the path "/demo/bad"

Lastly when we pass in bad data, we will see the standard Symfony big red [ERROR] command line output. Pity that I can't copy the formatting in, as without it, a lot of the impact is lost.

Episodes

# Title Duration
1 How to Get The Request / Query Parameters in Symfony? 07:06
2 Straightforward Routing Requirements 04:36
3 Can Query Parameters Use Annotations? 06:42