Categories


Archives


Recent Posts


Categories


Symfony Routes and Stand Alone Controllers

astorm

Frustrated by Magento? Then you’ll love Commerce Bug, the must have debugging extension for anyone using Magento. Whether you’re just starting out or you’re a seasoned pro, Commerce Bug will save you and your team hours everyday. Grab a copy and start working with Magento instead of against it.

Updated for Magento 2! No Frills Magento Layout is the only Magento front end book you'll ever need. Get your copy today!

Today we’re going to talk about Symfony’s routing system. A framework’s routing system is the code that takes the URL you request via your browser, and decides which code in the system should run next. Put another way, it’s the code that examines the URL and decides what a program’s main entry point should be.

Sylius hews closely to what routing in a modern Symfony application should look like, but modern Symfony routing may present a few head scratchers for folks coming from more traditional PHP MVC frameworks.

In this article we’ll cover setting up a traditional MVC route with Symfony, and then strip it down until it’s using modern conventions.

We’ll be working in a system that’s been installed using the Sylius Standard package with PHP 7.2+ — but these techniques should apply to any Symfony application.

Setup

Before we begin, let’s comment out the following in our servies.yaml file.

#File: config/services.yaml
parameters:
    locale: en_US

#services:
#    # Default configuration for services in *this* file
#    _defaults:
#        # Automatically injects dependencies in your services
#        autowire: true
#
#        # Automatically registers your services as commands, event subscribers, etc.
#        autoconfigure: true
#
#        # Allows optimizing the container by removing unused services; this also means
#        # fetching services directly from the container via $container->get() won't work
#        public: false
#
#    # Makes classes in src/ available to be used as services;
#    # this creates a service per class whose id is the fully-qualified class name
#    App\:
#        resource: '../src/*'
#        exclude: '../src/{Entity,Migrations,Tests,Kernel.php}'
#
#    # Controllers are imported separately to make sure services can be injected
#    # as action arguments even if you don't extend any base controller class
#    App\Controller\:
#        resource: '../src/Controller'
#        tags: ['controller.service_arguments']

There’s a few aspects of this default configuration that will complicate what we’re trying to cover today. We’ll explain why we did this later in the article, but for now just try not to think about it.

Also, this article assumes you’ve started Sylius’s built-in development server with the following command.

$ php bin/console server:start 127.0.0.1:8001
 [OK] Server listening on http://127.0.0.1:8001

If you’re running Sylius in a different way you’ll need to adjust your URLs accordingly.

A Traditional Symfony Route

When you want to add a new URL to the Symfony system, you need to add

  1. Configuration for the route
  2. A class file with a defined action method

While route configuration is similar to service configuration, route configuration is completely separate from service configuration. The routes.xml files that Symfony loads are not service configurations.

Let’s create a simple route in our Sylius application. Add the following to the end of your config/routes.yaml file

#File: config/routes.yaml

pulsestorm_helloworld:
    path: /helloworld
    methods: [GET]
    controller: App\Controller\Pulsestorm\HelloworldController::main

Then add in the controller class mentioned above

#File: src/Controller/Pulsestorm/HelloworldController.php

<?php
namespace App\Controller\Pulsestorm;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
class HelloworldController extends AbstractController {
    public function main() {
        return new \Symfony\Component\HttpFoundation\Response(
            "Hello World");
    }
}

Finally, load the URL in your system

http://127.0.0.1:8001/helloworld

Huzzah! You should see the Hello World message displayed. We have our first Symfony route.

Anatomy of a Symfony Route

Let’s take a look at the routes configuration again

#File: config/routes.yaml

pulsestorm_helloworld:
    path: /helloworld
    methods: [GET]
    controller: App\Controller\Pulsestorm\HelloworldController::main

The top level configuration key (pulsestorm_helloworld) is a unique identifier for the route. It does not (typically) matter what this is named, so long as it’s unique. We’ve used a common pattern of combining a company name (pulsestorm, purveyor of fine knowledge products) with the path of the URL (helloworld).

Speaking of the URL’s path, that’s our first field nested under the pulsestorm_helloworld identifier.

#File: config/routes.yaml

path: /helloworld

This is the path for the URL we’re trying to create. There’s a whole system for doing things like adding identifiers and other variables to URLs. If you’re interested in going deep here the Symfony routing docs are a pretty good place to start.

Next up is the methods field.

#File: config/routes.yaml

methods: [GET]

The methods here are not PHP methods — they’re the HTTP methods (GET, POST, PUT, etc.) that our URL should respond to. If you’d rather your route respond to any HTTP method, just use the ANY keyword here.

#File: config/routes.yaml

methods: [ANY]

Finally, we have the controller field

#File: config/routes.yaml

controller: App\Controller\Pulsestorm\HelloworldController::main

This is where we tell Symfony which PHP class (App\Controller\Pulsestorm\HelloworldController::main) it should instantiate to handle a particular URL, as well as which action method on that object (main) it should call. This action method will be the main entry point for your request.

If you’re coming from a framework with older roots, you may be surprised that your class name and action methods can be anything you like. As long as it’s a class in your src folder or otherwise available to composer’s autoloader, you can use it as a controller class.

Anatomy of a Symfony Controller

Let’s take a closer look at our controller

#File: src/Controller/Pulsestorm/HelloworldController.php

<?php
namespace App\Controller\Pulsestorm;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
class HelloworldController extends AbstractController {
    public function main() {
        return new \Symfony\Component\HttpFoundation\Response(
            "Hello World");
    }
}

A traditional Symfony controller extends the Symfony\Bundle\FrameworkBundle\Controller\AbstractController controller class. By extending this base, abstract controller, we get access to helper methods and objects. We’ll explore these momentarily, but first, let’s take a look at our action method

#File: src/Controller/Pulsestorm/HelloworldController.php

public function main() {
    return new \Symfony\Component\HttpFoundation\Response(
        "Hello World");
}

A Symfony controller’s action method is responsible for performing any needed business logic and/or persistence logic and then returning a Symfony response object.

Returning the response object can be done directly, (as we have above), by returning a redirect, or by rendering a template.

Our code currently uses a response object directly. Let’s change that up to use a template. If we change our action method like this

#File: src/Controller/Pulsestorm/HelloworldController.php

public function main() {
    return $this->render("hello-world.twig", [
        'message'=>"Hello World"]);
}

and then add a template file

#File: templates/hello-world.twig
<h1>{{message}}</h1>

and then reload our page, we’ll see a rendered twig template.

The render method is one of those helper methods we mentioned previously. We’re spared from needing to know how to render a twig template — all we need to do is pass in a template path and a list of variables for that template.

Twig is Symfony’s default templating engine — again, the official docs are a great place to start if you want to learn more about twig.

Modern Symfony Controllers

What we’ve outlined so far is the traditional Symfony approach to the controller part of a Model/View/Controller system. Times are, however, changing. Let’s take a look at a Sylius controller.

#File: vendor/sylius/sylius/src/Sylius/Bundle/AdminBundle/Controller/DashboardController.php
<?php
declare(strict_types=1);
namespace Sylius\Bundle\AdminBundle\Controller;

//...

final class DashboardController
{
    //...
}

This controller handles loading the dashboard page of Sylius’s backend admin system.

There’s two big things to notice here. First — this class is marked with PHP’s final keyword. This means no other class can extend this class. The other thing you’ll notice is the controller does not extend a base controller file. It’s a stand alone class.

Sylius embraces the current fashion in object oriented design and avoids class inheritance when it can. You can’t create child controllers with a Sylius controller as the parent, and Sylius themselves rely on no base controller functionality.

You don’t need to follow this pattern in your own code, but I’ve always found it’s best (at least at the start) to hew closely to how a project wants you to do things. By programming like a Sylius developer, you’ll start to think about the code like a Sylius developer, and you’ll understand better why they’ve done things the way they have.

Let’s try bringing our controller in line with a Sylius controller. First, let’s mark our controller as final (leaving everything else in place)

#File: src/Controller/Pulsestorm/HelloworldController.php

<?php
namespace App\Controller\Pulsestorm;
/* ... */
final class HelloworldController extends AbstractController {
    /* ... */
}

If we reload the page — everything is fine. Phew! This no inheritance thing is a breeze. To finish, let’s remove our reliance on the base Symfony controller by deleting the text extends AbstractController

#File: src/Controller/Pulsestorm/HelloworldController.php

<?php
namespace App\Controller\Pulsestorm;
/* ... */
final class HelloworldController {
    /* ... */
}

Now if we reload that page

Attempted to call an undefined method named “render” of class “App\Controller\Pulsestorm\HelloworldController”.

Oh No! An error.

Maybe no inheritance is trickier than we thought.

Dependency Injection for Controllers

The problem we’re running into

Attempted to call an undefined method named “render” of class “App\Controller\Pulsestorm\HelloworldController”.

is our controller object no longer has a render method. This method was defined on the parent controller class, and without a parent class we can’t call render. If we want to keep our functionality AND not have a parent class, we’re going to need another way to render a template using a different object.

If you’ve been following along with this series, you might suspect this means dependency injection. Your suspicions would be correct but dependency injection presents another problem.

Our controller object isn’t a service. It’s a generic PHP class/object. If we want to use dependency injection in our controller we’ll need to define a service, and then configure Symfony to use that service as a controller.

So, let’s create the service by adding the following to our services.yaml file.

#File: config/services.yaml

services:
    pulsestorm_helloworld_controller_service:
        autowire: true
        class: App\Controller\Pulsestorm\HelloworldController
        public: true

and then configure the route to use this service in place of the controller.

#File: config/routes.yaml

pulsestorm_helloworld:
    path: /helloworld
    methods: [GET]
    controller: pulsestorm_helloworld_controller_service::main

Notice the syntax here is identical to configuring a PHP class for our route object — we just use the service identifier in place of the PHP class.

If you reload the page, you’ll still see the same error

Attempted to call an undefined method named “render” of class “App\Controller\Pulsestorm\HelloworldController”.

but that’s a good sign. It means Symfony is still trying to use that same controller class.

To finish up, let’s change our controller to inject the dependencies we need

#File: src/Controller/Pulsestorm/HelloworldController.php

<?php
/* ... */
use \Symfony\Bundle\FrameworkBundle\Templating\EngineInterface;

final class HelloworldController {
    /* ... */

    private $templateEngine;
    public function __construct(
        EngineInterface $templateEngine
    ) {
        $this->templateEngine = $templateEngine;
    }
    /* ... */
}

and change our calling code to use these new dependencies.

#File: src/Controller/Pulsestorm/HelloworldController.php

<?php
/* ... */
final class HelloworldController {
    /* ... */
    public function main() {
        return $this->templateEngine->renderResponse("hello-world.twig", [
            'message'=>"Hello World"]);
    }
    /* ... */
}

What we’ve done above is inject the Symfony\Bundle\FrameworkBundle\Templating\EngineInterface service into our controller, and then used its renderResponse method to render a template.

Reload the page, and you should see your template rendered.

Huzzah! We’re part of the inherit-less revolution.

Tradeoffs with No Inheritance

Programming without parent classes is certainly in fashion right now, but it does present one major tradeoff. As a client programmer, there’s no hints as to what your service dependencies need to be.

#File: src/Controller/Pulsestorm/HelloworldController.php
<?php

/* ... */

use \Symfony\Bundle\FrameworkBundle\Templating\EngineInterface;

/*...*/

public function __construct(
    EngineInterface $templateEngine
) {
    $this->templateEngine = $templateEngine;
}

With a parent controller, methods and properties on the parent class (like render) can help folks new to a framework both discover that framework’s functionality, as well as understand the intended usage pattern for a controller class. With this dependency injection approach, you’re on your own. You’ll need to know which interfaces to use in a constructor, as well as know what sorts of jobs a controller is meant to do.

Whatever benefits no-inheritance brings, it does put more of a burden on the client programmer, particularly folks just starting out. What’s great about Sylius using Symfony though is you’re not locked into this pattern. You can use old-fashioned Symfony controllers if you like.

Whatever side of this debate you fall on, you will need to be ready for this no-inheritance pattern as Sylius uses it extensively.

Sylius Storefront Templates

So far we’ve stuck to un-styled pages. It’s time to graduate to a full, Sylius page with a rendered design. All we need to do is modify our twig template to use the base Sylius storefront layout template, and then move our code to that template’s content block.

#File: templates/hello-world.twig

{% extends '@SyliusShop/layout.html.twig' %}

{% block content %}
<h1>{{message}}</h1>
{% endblock %}

Reload the page, and you’ll see the entire Sylius design loaded. Covering everything you can do in a twig template is beyond the scope of this article, but Symfony has an entire sub-domain dedicated to evangelizing and learning how to use the twig template engine.

This is one of the positive tradeoffs no-inheritance gets us — there’s no extending a specific controller to get the right design — all we need to do is use the right templates.

Some Routing Essentials NOT used

Before we wrap up for today, there’s two bits of Symfony’s routing system we didn’t touch on.

First — remember when we told you to comment the following out of your services.yaml?

#File: config/services.yaml

#    App\:
#        resource: '../src/*'
#        exclude: '../src/{DependencyInjection,Entity,Migrations,Tests,Kernel.php}'

If you’ve worked your way through this series, you know that this configuration makes every class inside your src folder a service (minus the glob patterns in exclude, of course).

When this configuration is active (as it needs to be for Sylius), this means you don’t need to explicitly configure a service for every controller. That said — this “every class is a service” pattern is relatively new to Symfony, and we wanted you to be aware of the explicit service configuration for controllers.

Annotations

The other big topic we didn’t cover is route annotations. In addition to the usual yaml (used above), XML, and PHP configuration formats, there’s a fourth configuration format for routes in Symfony called annotations. Annotations aren’t a traditional configuration format, instead they let you add specially formatted PHP DocBlocks to methods in controller files and have Symfony automatically use those methods as entry points.

Sylius doesn’t (as of this writing) use annotations for routes, so we didn’t mention them. Like most things in Sylius though, there’s nothing stopping you from using annotations for your own routes. The Symfony Routing documentation contains plenty of examples and instructions along these lines.

Wrap Up

That’s where we’ll leave it for today. We ended up talking more about core Symfony features than I originally thought we would, but that’s blogging for you. That trend will continue next time, when we’ll have a round up of all of the Symfony routing configuration keys. Then, we’ll jump into how Sylius loads its routes, and learn how to find the controller file you’re looking for.

Series Navigation<< A Brief Look at Every Symfony Service ConfigurationSymfony Routing Configuration Keys >>

Copyright © Alan Storm 1975 – 2019 All Rights Reserved

Originally Posted: 25th March 2019