Symfony Controllers - Making Your App Work For You


We've taken our standalone Twig template about as far as I'd like to go without using a custom controller method.

Let's now create our second custom controller method, and replicate - then enhance - the functionality we've seen so far.

I'm going to start by removing the hello_page route defined in config/routes.yaml.

# config/routes.yaml

- hello_page:
-     path:         /hello
-     controller:   Symfony\Bundle\FrameworkBundle\Controller\TemplateController::templateAction
-     defaults:
-          template: hello_page.html.twig

This momentarily breaks our /hello page.

Before the helpdesk warning klaxon starts blaring, let's quickly fix this by creating a new method inside our WelcomeController:

<?php

namespace App\Controller;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

class WelcomeController extends AbstractController
{
    /**
     * @Route("/", name="welcome")
     */
    public function index()
    {
        return $this->render('welcome/index.html.twig');
    }

    /**
     * @Route("/hello-page", name="hello_page")
     */
    public function hello()
    {
        return $this->render('hello_page.html.twig');
    }
}

Ok, so some interesting points to cover:

We could have generated a new Controller class for this new route. It wouldn't have mattered. Feel free to do that, if you'd like.

I've changed the route path from /hello to /hello-page. This is to illustrate that changing the path makes no difference, so long as the route name remains the same.

On that front, our route name has stayed the same - it's still hello_page. This keeps Twig happy.

Our method name - hello - can really be anything. Anything, that is, except index, which is already in use.

Finally, I haven't moved the hello_page.html.twig template.

I haven't moved this template to illustrate the point that templates can live anywhere in the templates directory. It would be better, in my personal opinion, to move this template under the welcome subdirectory, simply to maintain order as our site grows. For now, I'm going to leave this as is.

 php bin/console debug:router
 -------------------------- -------- -------- ------ ----------------------------------- 
  Name                       Method   Scheme   Host   Path                               
 -------------------------- -------- -------- ------ ----------------------------------- 
  welcome                    ANY      ANY      ANY    /                                  
  hello_page                 ANY      ANY      ANY    /hello-page                        
  _twig_error_test           ANY      ANY      ANY    /_error/{code}.{_format}           
  _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_phpinfo          ANY      ANY      ANY    /_profiler/phpinfo                 
  _profiler_search_results   ANY      ANY      ANY    /_profiler/{token}/search/results  
  _profiler_open_file        ANY      ANY      ANY    /_profiler/open                    
  _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   
 -------------------------- -------- -------- ------ ----------------------------------- 

After changing routes like this it's always a good idea to validate the new configuration has taken. I'm doing this using bin/console debug:router.

Browsing around the site now shows that everything still works, and our "Hello Page" now uses the updated path (/hello-page).

Passing In Variables

Let's pass in a variable from our controller, and update our hello_page.html.twig template to display this variable:

{% extends 'base.html.twig' %}

{% block title %}Some custom title{% endblock title %}

{% block body %}

+    <div>
        Hello, {{ app.request.query.get('name') | default('CodeReviewVideos') }}!
+    </div>

+    <div>
+        My lovely variable: {{ some_variable_name }}
+    </div>

{% endblock %}

I've added the extra div tags here to make the output look a bit better.

Given that I am using a variable by the name of some_variable_name in my Twig template, it stands that I need to pass in this variable - with this exact name - from my controller method.

If I don't, I'm going to see a big red 500 error:

a big red 500 error

As we're in development mode currently, I do see a nice error page explaining what went wrong, and why:

"Variable "some_variable_name" does not exist."

We saw a solution to this in the previous video: set a default:

{% extends 'base.html.twig' %}

{% block title %}Some custom title{% endblock title %}

{% block body %}

    <div>
        Hello, {{ app.request.query.get('name') | default('CodeReviewVideos') }}!
    </div>

    <div>
        My lovely variable: {{ some_variable_name | default('I am a lovely default value') }}
    </div>

{% endblock %}

This "fixes" the problem. Refreshing now we see:

"My lovely variable: I am a lovely default value"

We haven't really solved the problem though.

Fortunately, doing so is easy.

Our controller method return's the outcome of a call to $this->render(...):

    /**
     * @Route("/hello-page", name="hello_page")
     */
    public function hello()
    {
        return $this->render('hello_page.html.twig');
    }

If you're using an IDE like PhpStorm, ctrl+clicking render will take you to the Symfony ControllerTrait.php file where the render method is defined.

From here, we can see the render method's expected arguments:

    /**
     * Renders a view.
     *
     * @final since version 3.4
     */
    protected function render(string $view, array $parameters = array(), Response $response = null): Response

The only mandatory argument is the first argument: string $view.

We fulfill this criteria by passing in our template name, e.g. hello_page.html.twig.

The second argument is an array of $parameters.

This is how we pass our variables into the template.

Knowing this, we can easily pass in our some_variable_name parameter:

    /**
     * @Route("/hello-page", name="hello_page")
     */
    public function hello()
    {
        return $this->render('hello_page.html.twig', [
            'some_variable_name' => 'whatever we want here'
        ]);
    }

Now refresh once again, and we see:

"My lovely variable: whatever we want here"

Easy, right?

These variables can be as simple or as complex as you need.

Repeating What We Had

In the previous video we pulled the name query parameter from the URL, and displayed the contents in our template.

If you followed along you'll remember we used app.request.query.get('name').

We can also get access to the Request object from a controller method. We simply need to inject it. In order to do this, we provide the correct typehint, and set a variable name - usually something vary similar to the typehint:

<?php

namespace App\Controller;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+ use Symfony\Component\HttpFoundation\Request;

class WelcomeController extends AbstractController
{
    /**
     * @Route("/", name="welcome")
     */
    public function index()
    {
        return $this->render('welcome/index.html.twig');
    }

    /**
     * @Route("/hello-page", name="hello_page")
     */
-    public function hello()
+    public function hello(Request $request)
    {
        return $this->render('hello_page.html.twig', [
            'some_variable_name' => 'whatever we want here'
        ]);
    }
}

We can then use this object in our code.

Symfony is all about the journey from Request to Response.

It therefore stands that Symfony's Request object is one of the most useful, and most frequently used objects you will work with. Injecting the Request object in this way is commonplace in many of my controllers.

A decent IDE like PhpStorm will help you massively in understanding what the Request object offers in terms of methods and properties.

We're going to start by accessing the query public property. From this query property we can get access to the any query parameters we set on the current request. As a quick reminder, query parameters are those things on the URL after the ?, like ?name=bob&age=25.

This is very similar to what we did in the previous video, only with slightly different syntax:

    /**
     * @Route("/hello-page", name="hello_page")
     */
    public function hello(Request $request)
    {
        $someVar = $request->query->get('someVar');

        return $this->render('hello_page.html.twig', [
            'some_variable_name' => $someVar,
        ]);
    }
}

Now if you refresh your page, you should either see the default value from your template, or if you browse to http://127.0.0.1:8000/hello-page?someVar=whatever+goes+here then:

"My lovely variable: whatever goes here"

If you followed along with the extra example in the previous video, then because the name property is used directly in our Twig template, we could also set that via the query parameter still, too:

http://127.0.0.1:8000/hello-page?someVar=whatever+goes+here&name=Peter

And then we see:

" Hello, Peter! My lovely variable: whatever goes here "

One last thing before we move on.

The $someVar local variable is redundant. We can inline the call to $request->query->get('someVar'):

    /**
     * @Route("/hello-page", name="hello_page")
     */
    public function hello(Request $request)
    {
        return $this->render('hello_page.html.twig', [
            'some_variable_name' => $request->query->get('someVar'),
        ]);
    }

And everything should behave the same.

In a real application we would need to validate any provided input such as shown using these query parameters. We could add in this logic to our controller method. Or we could use functionality provided to us out of the box as part of Symfony's routing annotations.

Route Placeholders

We've been using query parameters so far. It not only looks nicer, as well as offering us a bunch of extra functionality if we make use of routing placeholders.

What I mean here is instead of having:

http://127.0.0.1:8000/hello-page?name=Peter

We might switch to:

http://127.0.0.1:8000/hello/CodeReviewVideos

Let's make this change:

    /**
     * @Route("/hello/{name}", name="hello_page")
     * @param string $name
     *
     * @return \Symfony\Component\HttpFoundation\Response
     */
    public function hello(string $name)
    {
        return $this->render('hello_page.html.twig', [
            'person_name' => $name,
        ]);
    }

And update the templates/hello_page.html.twig template:

{% extends 'base.html.twig' %}

{% block title %}Some custom title{% endblock title %}

{% block body %}

    <div>
        Hello, {{ person_name }}!
    </div>

{% endblock %}

Now if we try and visit e.g. http://127.0.0.1:8000/hello/chris, all should be good here, right?

exception-has-been-thrown-during-the-rendering-of-a-template-mandatory-parameters-missing

Bzzzzt. Wrong, sir! Wrong.

Everything we were doing was technically correct. However:

We have our hello_page as a link on our Navbar.

In order to visit the hello_page, we need to provide a name to meet the placeholder requirements. This is a mandatory piece of data, and we haven't provided a default.

Fortunately, fixing this is as easy as providing a default value in the hello method's $name argument:

- public function hello(string $name)
+ public function hello(string $name = 'CodeReviewVideos')

Cool. Now try refreshing and boom, our page loads as expected.

If we browse to:

http://127.0.0.1:8000/hello/chris

We see "Hello, Chris!"

If we browse to:

http://127.0.0.1:8000/hello

We see "Hello, CodeReviewVideos!"

And if we browse to:

http://127.0.0.1:8000/hello/ (note the trailing slash)

We get a 404 error: 'No route found for "GET /hello/"'

Ack. Well, I show you this because this is a real world problem. It's all well and good going through tutorials that show you the happy path. I prefer a more realistic approach. There is a solution to this problem. Feel free to add it in to your project.

As a side note here, there are often multiple ways to achieve the same goal when programming. Rather than provide a default value to your method's $name argument, you could also use the defaults annotation, e.g.:

    /**
     * @Route("/hello/{name}", name="hello_page", defaults={"name" = "CodeReviewVideos"})
     * @param string $name
     *
     * @return \Symfony\Component\HttpFoundation\Response
     */
    public function hello(string $name)
    {
        return $this->render('hello_page.html.twig', [
            'person_name' => $name,
        ]);
    }

There's no real difference here, to the best of my knowledge. However, across your project it's best to pick one approach and stick to it.

Routing Restrictions

Right now it's possible to browse to:

http://127.0.0.1:8000/hello/chris%20123

Try it.

What do you see?

Well, I will tell you anyway, even if you haven't.

As before we pass in chris, but then also the URL encoded representation of a space (%20), and then some numbers (123).

Maybe we don't want to allow spaces, or numbers.

Symfony has us covered here, too.

    /**
-    * @Route("/hello/{name}", name="hello_page")
+    * @Route("/hello/{name}", name="hello_page", requirements={"name"="[A-Za-z]+"})
     * @param string $name
     *
     * @return \Symfony\Component\HttpFoundation\Response
     */
    public function hello(string $name = 'CodeReviewVideos')
    {
        return $this->render('hello_page.html.twig', [
            'person_name' => $name,
        ]);
    }

By specifying our route requirements we can restrict what routes this controller method will match on.

In this instance I've stated that the name placeholder of our route must be one or more character in capitals or lowercase, A to Z.

The tricky part of the requirements section is that you must use a Regular Expression for your match. I like sites like regexr to help me come up with regular expression patterns that work. Often examples already exist to solve the vast majority of circumstances you will encounter. My advice: keep it simple.

There's more to learn about Routing in Symfony 4, but what we've covered in this and the previous video are more than enough to get you up and running.

Code For This Course

Get the code for this course.

Episodes