Adding Routes & Controllers

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $8.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

If you watch a lot of KnpU tutorials, you know that I love to talk about how the whole point of a bundle is that it adds services to the container. But, even I have to admit that a bundle can do a lot more than that: it can add routes, controllers, translations, public assets, validation config and a bunch more!

Find your browser and Google for "Symfony bundle best practices". This is a really nice document that talks about how you're supposed to build re-usable bundles. We're following, um, most of the recommendations. It tells you the different directories where you should put different things. Some of these directories are just convention, but some are required. For example, if your bundle provides translations, they need to live in the Resources/translations directory next to the bundle class. If you follow that rule, Symfony will automatically load them.

Adding a Route + Controller

Here's our new goal: I want to add a route & controller to our bundle. We're going to create an optional API endpoint that returns some delightful lorem ipsum text.

Before we start, I'll open my PhpStorm preferences and, just to make this more fun, search for "Symfony" and enable the Symfony plugin. Also search for "Composer" and select the composer.json file so that PhpStorm knows about our autoload namespaces.

Back to work! In src/, create a Controller directory and inside of that, a new PHP class called IpsumApiController. We don't need to make this extend anything, but it's OK to extend AbstractController to get some shortcuts... except what!? AbstractController doesn't exist!

That's because the class lives in FrameworkBundle and... remember! Our bundle does not require that! Ignore this problem for now. Instead, find our app code, open AbstractController, copy its namespace, and use it to add the use statement manually to the controller.

... lines 1 - 5
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
... line 7
class IpsumApiController extends AbstractController
... lines 9 - 25

Next, add a public function called index. Here, we're going to use the KnpUIpsum class to return a JSON response with some dummy text. When you create a controller in a reusable bundle, the best practice is to register your controller as a proper service and use dependency injection to get anything you need.

... lines 1 - 16
public function index()
{
... lines 19 - 22
}
... lines 24 - 25

Add public function __construct() and type-hint the first argument with KnpUIpsum. I'll press Alt+Enter and choose Initialize Fields so that PhpStorm creates and sets a property for that.

... lines 1 - 9
private $knpUIpsum;
... line 11
public function __construct(KnpUIpsum $knpUIpsum)
{
$this->knpUIpsum = $knpUIpsum;
}
... lines 16 - 25

Down below, return $this->json() - we will not have auto-complete for that method because of the missing AbstractController - with a paragraphs key set to $this->knpUIpsum->getParagraphs() and a sentences key set to $this->knpUIpsum->getSentences()

... lines 1 - 16
public function index()
{
return $this->json([
'paragraphs' => $this->knpUIpsum->getParagraphs(),
'sentences' => $this->knpUIpsum->getSentences(),
]);
}
... lines 24 - 25

Excellent!

Registering your Controller as a Service

Next, we need to register this as a service. In services.xml, copy the first service, call this one ipsum_api_controller, and set its class name. For now, don't add public="true" or false: we'll learn more about this in a minute. Pass one argument: the main knpu_lorem_ipsum.knpu_ipsum service.

... lines 1 - 6
<services>
... lines 8 - 13
<service id="knpu_lorem_ipsum.controller.ipsum_api_controller" class="KnpU\LoremIpsumBundle\Controller\IpsumApiController">
<argument type="service" id="knpu_lorem_ipsum.knpu_ipsum" />
</service>
... lines 17 - 19
</services>
... lines 21 - 22

Tip

In Symfony 5, you'll need a bit more config to get your controller service working:

<service id="knpu_lorem_ipsum.ipsum_api_controller" class="KnpU\LoremIpsumBundle\Controller\IpsumApiController" public="true">
    <call method="setContainer">
        <argument type="service" id="Psr\Container\ContainerInterface"/>
    </call>
    <tag name="container.service_subscriber"/>
    <argument type="service" id="knpu_lorem_ipsum.knpu_ipsum"/>
</service>

For a full explanation, see this thread: https://bit.ly/abstract-controller-tag

Perfect!

Routing

Finally, let's add some routing! In Resources/config, create a new routes.xml file. This could be called anything because the user will import this file manually from their app.

To fill this in, as usual, we'll cheat! Google for "Symfony Routing" and, just like we did with services, search for "XML" until you find a good example.

Copy that code and paste it into our file. Let's call the one route knpu_lorem_ipsum_api. For controller, copy the service id, paste, and add a single colon then index.

<?xml version="1.0" encoding="UTF-8" ?>
<routes xmlns="http://symfony.com/schema/routing"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/routing
http://symfony.com/schema/routing/routing-1.0.xsd">
<route id="knpu_lorem_ipsum_api" controller="knpu_lorem_ipsum.controller.ipsum_api_controller:index" path="/" >
<!-- settings -->
</route>
</routes>

Fun fact: in Symfony 4.1, the syntax changes to a double :: and using a single colon is deprecated. Keep a single : for now if you want your bundle to work in Symfony 4.0.

Finally, for path, the user will probably want something like /api/lorem-ipsum. But instead of guessing what they want, just set this to /, or at least, something short. We'll allow the user to choose the path prefix.

And that's it! But... how can we make sure it works? In a few minutes, we're going to write a legitimate functional test for this. But, for now, let's just test it in our app!

In the config directory, we have a routes.yaml file, and we could import the routes.xml file from here. But, it's more common to go into the routes/ directory and create a separate file: knpu_lorem_ipsum.yaml.

Add a root key - _lorem_ipsum - this is meaningless, then resources set to @KnpULoremIpsumBundle and then the path to the file: /Resources/config/routes.xml. Then, give this a prefix! How about /api/ipsum.

_lorem_ipsum:
resource: '@KnpULoremIpsumBundle/Resources/config/routes.xml'
prefix: /api/ipsum

Did it work? Let's find out: find your terminal tab for the application, and use the trusty old:

php bin/console debug:router

There it is! /api/ipsum/. Copy that, find our browser, paste and.... nope. Error!

Controller ipsum_api_controller cannot be fetched from the container because it is private. Did you forget to tag the service with controller.service_arguments.

The error is not entirely correct for our circumstance. First, yes, at this time, controllers are the one type of service that must be public. If you're building an app, you can give it this tag, which will automatically make it public. But for a reusable bundle, in services.xml, we need to set public="true".

... lines 1 - 6
<services>
... lines 8 - 13
<service id="knpu_lorem_ipsum.controller.ipsum_api_controller" class="KnpU\LoremIpsumBundle\Controller\IpsumApiController" public="true">
... line 15
</service>
... lines 17 - 19
</services>
... lines 21 - 22

Try that again! Now it works. And... you might be surprised! After all, our bundle references a class that does not exist! This is a problem... at least, a minor problem. But, because FrameworkBundle is included in our app, it does work.

But to really make things solid, let's add a proper functional test to the bundle that guarantees that this route and controller work. And when we do that, it'll become profoundly obvious that we are, yet again, not properly requiring all the dependencies we need.

Leave a comment!

This tutorial is built using Symfony 4, but most of the concepts apply fine to Symfony 5!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-iconv": "*",
        "doctrine/annotations": "^1.8", // v1.8.0
        "knplabs/knp-markdown-bundle": "^1.7", // 1.7.0
        "nexylan/slack-bundle": "^2.0", // v2.0.0
        "php-http/guzzle6-adapter": "^1.1", // v1.1.1
        "sensio/framework-extra-bundle": "^5.1", // v5.1.6
        "symfony/asset": "^4.0", // v4.0.6
        "symfony/console": "^4.0", // v4.0.6
        "symfony/flex": "^1.0", // v1.2.7
        "symfony/framework-bundle": "^4.0", // v4.0.6
        "symfony/lts": "^4@dev", // dev-master
        "symfony/twig-bundle": "^4.0", // v4.0.6
        "symfony/web-server-bundle": "^4.0", // v4.0.6
        "symfony/yaml": "^4.0", // v4.0.6
        "weaverryan_test/lorem-ipsum-bundle": "^1.0" // v1.0.0
    },
    "require-dev": {
        "easycorp/easy-log-handler": "^1.0.2", // v1.0.4
        "sensiolabs/security-checker": "^4.1", // v4.1.8
        "symfony/debug-bundle": "^3.3|^4.0", // v4.0.6
        "symfony/dotenv": "^4.0", // v4.0.6
        "symfony/maker-bundle": "^1.0", // v1.1.1
        "symfony/monolog-bundle": "^3.0", // v3.2.0
        "symfony/phpunit-bridge": "^3.3|^4.0", // v4.3.3
        "symfony/profiler-pack": "^1.0", // v1.0.3
        "symfony/var-dumper": "^3.3|^4.0" // v4.0.6
    }
}