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: User facing files
Routing: URLs to user facing files
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:
A public
__construct()
method. This is optional if your controller has no dependencies.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):
phpBB/styles/style_name/template/
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:
Response message - This should be the full, rendered page source that will be output on the screen.
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+
Generating links to routes¶
Now that we are able to access our user-facing page from a URL, we need to add that URL to the nav-bar link we created earlier with the template listener.
Recall that our template listener has a U_DEMO_PAGE
variable. We will now
revisit our PHP event listener and update it to generate a URL for our route
and assign it to U_DEMO_PAGE
.
First, we will use the core.page_header
event. This is an ideal event
to use when you want to manipulate code when the header of a phpBB page is
generated. We must update the getSubscribedEvents()
method in the
event/main_listener.php
as follows:
static public function getSubscribedEvents()
{
return [
'core.user_setup' => 'load_language_on_setup',
'core.page_header' => 'add_page_header_link',
];
}
Next we will add a new method to the event listener which creates our link and assigns it to our template variable:
/**
* Add a page header nav bar link
*
* @param \phpbb\event\data $event The event object
*/
public function add_page_header_link($event)
{
$this->template->assign_vars([
'U_DEMO_PAGE' => $this->helper->route('acme_demo_route', ['name' => 'world']),
]);
}
In this new method we use the Controller Helper object’s route()
method to create the link to our controller. Note that it takes two
arguments:
The name of the route, as defined in the
routing.yml
. In this case,acme_demo_route
.An optional array of parameters. In this case, we are passing the value
world
to thename
parameter as a default value.
Note
The URL generated will look like ./app.php/demo/world
which is
equivalent to ./app.php/demo?name=world
.
Notice that our new method add_page_header_link()
requires the
Controller Helper and Template objects from phpBB. Therefore, we must
also add a new constructor to our event listener in order to
inject these dependencies. Putting everything together, the complete
event listener should look like:
namespace acme\demo\event;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class main_listener implements EventSubscriberInterface
{
/* @var \phpbb\controller\helper */
protected $helper;
/* @var \phpbb\template\template */
protected $template;
/**
* Constructor
*
* @param \phpbb\controller\helper $helper
* @param \phpbb\template\template $template
*/
public function __construct(\phpbb\controller\helper $helper, \phpbb\template\template $template)
{
$this->helper = $helper;
$this->template = $template;
}
/**
* Assign functions defined in this class to event listeners in the core
*
* @return array
*/
static public function getSubscribedEvents()
{
return [
'core.user_setup' => 'load_language_on_setup',
'core.page_header' => 'add_page_header_link',
];
}
/**
* Load the Acme Demo language file
* acme/demo/language/en/demo.php
*
* @param \phpbb\event\data $event The event object
*/
public function load_language_on_setup($event)
{
$lang_set_ext = $event['lang_set_ext'];
$lang_set_ext[] = [
'ext_name' => 'acme/demo',
'lang_set' => 'demo',
];
$event['lang_set_ext'] = $lang_set_ext;
}
/**
* Add a page header nav bar link
*
* @param \phpbb\event\data $event The event object
*/
public function add_page_header_link($event)
{
$this->template->assign_vars([
'U_DEMO_PAGE' => $this->helper->route('acme_demo_route', ['name' => 'world']),
]);
}
}
Remember to also update the event listener’s service definition in
config/services.yml
with the new dependencies:
acme.demo.listener:
class: acme\demo\event\main_listener
arguments:
- '@controller.helper'
- '@template'
tags:
- { name: event.listener }
Note
Remember to purge the cache every time you change something in
the *.yml
files.
Now our link in the nav-bar should open a new user-facing page that says “Hello world!” If we temporarily replace “world” with some other string, for example “foo” the page should say “Hello foo!”. And if we use “bertie” then we should be shown a 403 error page.
We have completed our user-facing controller page. Continue on to the next section to learn how to add an ACP module to our extension so we can give it some configuration settings.