Tutorial: Controllers and Routes

Introduction

phpBB 3.1 introduced Symfony’s HttpKernel, Controller and Routing systems which allow extensions to handle custom “front-facing” pages that users are able to view and interact with.

This tutorial explains how to create user-facing pages:

Controllers

A controller is a class with a collection of functions that serve content and handle interactions from the user, based on what is requested in the URL. For example, a blog extension that displays one or more blog entries.

Currently our Acme Demo extension has a link in the navigation bar without a target. We will use a controller to create a page the user will see, and use routing to manage the URL to our controller.

Controller files can be placed anywhere inside an extension’s directory structure, although to keep things organised it is best to place them in a separate directory named controller/. Controller files may also be given any name. For the Acme Demo extension, we will use ext\acme\demo\controller\main.php.

Every controller should contain at least two methods:

  1. A public __construct() method. This is optional if your controller has no dependencies.

  2. A public handle() method. This method can take any name, but “handle” is common. This method must return a Symfony Response object.

<?php

namespace acme\demo\controller;

use \Symfony\Component\HttpFoundation\Response;

class main
{
    /* @var \phpbb\config\config */
    protected $config;

    /* @var \phpbb\controller\helper */
    protected $helper;

    /* @var \phpbb\language\language */
    protected $language;

    /* @var \phpbb\template\template */
    protected $template;

    /**
     * Constructor
     *
     * @param \phpbb\config\config      $config
     * @param \phpbb\controller\helper  $helper
     * @param \phpbb\language\language  $language
     * @param \phpbb\template\template  $template
     */
    public function __construct(\phpbb\config\config $config, \phpbb\controller\helper $helper, \phpbb\language\language $language, \phpbb\template\template $template)
    {
        $this->config   = $config;
        $this->helper   = $helper;
        $this->language = $language;
        $this->template = $template;
    }

    /**
     * Demo controller for route /demo/{name}
     *
     * @param string $name
     * @throws \phpbb\exception\http_exception
     * @return \Symfony\Component\HttpFoundation\Response A Symfony Response object
     */
    public function handle($name)
    {
        if ($name === 'bertie')
        {
            throw new \phpbb\exception\http_exception(403, 'NO_AUTH_SPEAKING', [$name]);
        }

        $l_message = !$this->config['acme_demo_goodbye'] ? 'DEMO_HELLO' : 'DEMO_GOODBYE';
        $this->template->assign_var('DEMO_MESSAGE', $this->language->lang($l_message, $name));

        return $this->helper->render('@acme_demo/demo_body.html', $name);
    }
}

Dependencies

Now let’s have a look at the constructor of the controller, before looking at the actual controller code in the handle() method.

Our controller has several dependencies on phpBB objects. We must tell phpBB about our controller and it’s dependencies by defining it as a service in our config/services.yml file which was introduced in Tutorial: Events and Listeners. The complete services.yml file should look like:

services:
    acme.demo.controller:
        class: acme\demo\controller\main
        arguments:
            - '@config'
            - '@controller.helper'
            - '@language'
            - '@template'
    acme.demo.listener:
        class: acme\demo\event\main_listener
        tags:
            - { name: event.listener }

Caution

Remember that the order of arguments must match the order of parameters in the constructor method definition.

Request handling

The handle() method is responsible for handling the request to display pages. Notice it accepts the argument $name. This is a variable that is passed in from a URL parameter, as defined in the Routing configuration file.

The handle method has a special condition that checks if the user tries to use bertie. We do not want to authorise this, because people should not interact with Bertie. So we throw an http_exception with a 403 error code, which will display a nice “unauthorised” error message to the user.

With a valid name, the handle method will create a simple message to display to the user and assign it to the controller’s template variables array.

Then we use the phpbb\controller\helper Helper object to render our page with the render() method. It takes the template filename, the page title, and the status code as its arguments. The page title defaults to an empty string and the status code defaults to 200. We are using the Controller template demo_body.html.

Tip

When calling a template file from PHP using phpbb\controller\helper:render() template files are searched for in two places (and in this order):

  1. phpBB/styles/style_name/template/

  2. phpBB/ext/all_active_extensions/styles/style_name/template/

The following code will load a template that could be located in any of the above locations, i.e., in any phpBB style or active extension:

$this->helper->render('demo_body.html', $name);

If you only need to load a template file from within your own extension, we recommend using the @vendor_extension/ prefix:

$this->helper->render('@acme_demo/demo_body.html', $name);

It is also recommended to always use unique names for your templates to avoid possible conflicts with phpBB’s templates or other extensions.

Note

The phpbb\controller\helper:render() method returns a Symfony Response object for us. If you choose to not use the Helper object, you will need to manually return a Symfony Response object. The Reponse object takes two arguments:

  1. Response message - This should be the full, rendered page source that will be output on the screen.

  2. Status code - This defaults to 200, which is the status code “OK”. If you are sending a response about being unable to find some information, you would use the 404 (“Not Found”) status. 403 would be used if the user lacks the appropriate permissions, and 500 would be for an unknown error.

return new \Symfony\Component\HttpFoundation\Response($template_file, 200);

Warning

A controller should never call trigger_error() to generate output. Instead it should always return Symfony Response or JsonResponse objects, or throw a phpBB http_exception.

Controller template

Every controller requires an HTML template file. The Acme Demo extension uses demo_body.html located in the styles/prosilver/template/ directory, with the following content including the phpBB header and footer:

{% include 'overall_header.html' %}

<h2>{{ DEMO_MESSAGE }}</h2>

{% include 'overall_footer.html' %}

Note

A template file this simple could be stored in the all/ style folder because it clearly has no HTML markup specific to the prosilver style.

Routing

At this point, we now have a controller that can create and serve a user-facing page, but we don’t yet have a URL through which to access the page.

To solve this, each controller must define a route in a config/routing.yml file of the extension. This file is responsible for associating a controller’s access name (i.e. what is typed in the URL) with its service (i.e. what we covered in Dependencies).

Recall that our controller expects a URL parameter to be passed to it as the $name variable. Therefore, we want our URL to look like: /app.php/demo/<name>.

Note

All extension controller files are accessed via app.php. However, boards can turn on the Enable URL rewriting feature in the ACP to hide the app.php/ component of the URL.

Our routing.yml file should look like:

acme_demo_route:
    path: /demo/{name}
    defaults: { _controller: acme.demo.controller:handle, name: "world" }

The above routing definition says that when the user goes to the URL /app.php/demo/<name> it should load the acme.demo.controller service and call the handle method, giving the value of the {name} “slug” to the $name argument (the names of the slug and argument must match). If no value is given for {name} (i.e. the URL is /app.php/demo) it will pass the default value of “world” to the handle method.

As you can see, slugs offer a powerful way to interact with your controller through URL parameters. You must specify a slug for every required parameter in your method. Optional parameters do not have to be provided in the Routing definition, in which case they will take the default value given in the method definition.

You can also specify regular expressions for the slugs, to more tightly control the type of data being passed to the method. For example, if we want to ensure that name is an integer, we would append the following code to our route definition:

requirements:
    name: \d+

Item

Description

route

The route name is a unique name and must be prefixed with the vendor and extension names. Use only lowercase letters and underscores.

path

The path of the URL component, including slugs wrapped in curly braces. If a path does not match any route a 404 error is returned.

defaults

The service name of the controller and the name of the method to call, separated by a colon. Optionally, default values for slugs can be defined.

requirements

Used to make a specific route only match under specific conditions.

The routing.yml can hold multiple route definitions for multiple URLs, as may be required by the needs of the extension. Routes are compared in the order of their declaration in the routing.yml file, which is important to consider when defining routes. For example:

acme_blog_home:
    path: /blog
    defaults: { _controller: acme.blog.controller:handle }

acme_blog_entry:
    path: /blog/{id}
    defaults: { _controller: acme.blog.controller:handle }
    requirements:
        id: \d+

acme_blog_edit:
    path: /blog/{id}/edit
    defaults: { _controller: acme.blog.controller:handle }
    requirements:
        id: \d+