Monday 11 May 2015

PHP: inadvertently looking at a coupla refactoring tactics with Twig

G'day:
Here we have details of me unsuccessfully trying to replicate an issue we are having with one of our Twig files... it's not seeing some data being passed to it. However along the way I got myself a bit more up to speed with a coupla refactoring tactics one can use with Twig.

This article doesn't cover everything about Twig (I don't know everything about Twig, for one thing), it just covers the troubleshooting steps I took too try to replicate the issue I was having.

Spoiler: I did not solve the problem.
Spoiler spoiler: or did I?

Baseline

Twig is a templating system for PHP, provided by the same outfit - SensioLabs - that is responsible for Silex, which is the micro-framework we use. They're pretty much designed to work together, and the docs have a fair bit of crossover.

To get a controller to use a twig file to represent a view, we just call Twig's render() function, which takes the name of a Twig file, and an array of data to pass to it:

<?php
// Simple.php
namespace me\adamcameron\twig\controller;

use Silex\Application;
use Symfony\Component\HttpFoundation\Request;

class Simple {

    public static function doGet(Request $request, Application $app){
        return $app['twig']->render('simple.html.twig', ['message'=>"G'day world"]);
    }

}

Then in the Twig file:

{# simple.html.twig #}

<h1>{{ message }}</h1>



This - predictably - outputs "G'day world" all nice and big. Simple.

Extending a Twig


The next facet of the issue was that the code I was looking at wasn't in the Twig itself, we were calling one Twig file and the code in question was in the Twig the first one was extending.

This time we have two methods routed: /twigextends/master/ and /twigextends/detail/. I'll leave the routing (Application.php) and controller provision (TwigExtends.php) out, but they're linked there if you want to look at the relevant files. Here's the controller though:

<?php
// TwigExtends.php
namespace me\adamcameron\twig\controller;

use Silex\Application;
use Symfony\Component\HttpFoundation\Request;

class TwigExtends {

    public static function doMaster(Request $request, Application $app){
        $viewData = [
            'message' => 'This was set in the Master controller'
        ];
        return $app['twig']->render('extends/master.html.twig', $viewData);
    }

    public static function doDetail(Request $request, Application $app){
        $viewData = [
            'message' => 'This was set in the Detail controller'
        ];
        return $app['twig']->render('extends/detail.html.twig', $viewData);
    }

}
And the twigs:

{# master.html.twig #}

This is unblocked text in the master view<br>
<hr>
{% block detailBlock %}
    This is text within a block in the master view<br>
{% endblock %}
<hr>
{{ message }}<br>

{# detail.html.twig #}

{% extends "extends/master.html.twig" %}

{% block detailBlock %}
    This is text in the detail view which will override the content of the block in the master view<br>
{% endblock %}

What have we got here?
  • the master twig can define blocks,
  • and the detail twig extends the master twig.
  • Within the detail twig, we can override the extended twig's block by implementing a block with the same name.
  • In the master twig we output a variable value,
  • which is set both in master and detail twig.

Again, simple!

If we hit /twigextends/master/, we see the block contents as defined by the master, as well as the variable value as set in the master:

This is unblocked text in the master view


This is text within a block in the master view

This was set in the Master controller

On the other hand if we browse to /twigextends/detail, we get this:

This is unblocked text in the master view

This is text in the detail view which will override the content of the block in the master view

This was set in the Detail controller

Notes:
  • the detail twig overrides the "detailBlock" in the master.
  • and the variable correctly uses the value specified by whichever controller was used.
Another thing to note is that an extending twig cannot have any content of its own, it can only override blocks from the twig its extending. If I had this:

{# detail.html.twig #}

{% extends "extends/master.html.twig" %}

{% block detailBlock %}
    This is text in the detail view which will override the content of the block in the master view<br>
{% endblock %}

This is unblocked text in the detail view<br>

I just get an error:


This makes sense... where would Twig render content from the extending twig in the context of the extended one? Still: I got caught out by it when I was knocking-out the sample code for this article, so figured it worth mentioning.

Including a twig

Another tactic for abstracting view code is simply using an include. Here are the twigs:

{# main.html.twig #}

{{ messageForMain }}<br>
{% include("include/include.html.twig") %}

{# include.html.twig #}

{{ messageForInclude }}<br>

And the controller:

<?php
// TwigInclude.php
namespace me\adamcameron\twig\controller;

use Silex\Application;
use Symfony\Component\HttpFoundation\Request;

class TwigInclude {

    public static function doGet(Request $request, Application $app){
        $viewData = [
            'messageForMain' => 'This will be output in the main twig',
            'messageForInclude' => 'This will be output in the included twig'
        ];
        return $app['twig']->render('include/main.html.twig', $viewData);
    }

}

Note here that the same one controller controls all the data (in this case hard-coded in line, but you get the idea). We've got a fair bit of this in our code base, but are erring away from it now. The issue is that there's not really any decent decoupling between the controller and the subviews, which makes for very busy controllers. And also code repetition if the included twig is used in more than one place.

There's a better solution: using a sub request.

Sub-requests

Another tactic one can employ is to encapsulate a chunk of business logic & view together and render a sub request. This is similar to an include except instead of calling in a file, one calls in a route, and accordingly this takes the usual approach of route ➞ controller (➞ model) ➞ view. So it better compartmentalises to controlling of the logic needed for the view. Code will probably demonstrate that easier than describing it.

As the routing is relevant when using a sub request, I'll include all that code as well this time:

<?php
// Application.php

// ...

class Application extends SilexApplication {

    // ...

    function mountControllers(){
        // ...

        $this->mount('/subrequest', $this["provider.controller.subRequest"]);
    }

}


I've omitted most of the code here, but include the controller provider mount() call so you can see the base route which is /subrequest

<?php
// SubRequest.php

// ...

class SubRequest implements ControllerProviderInterface {

    public function connect(Silex\Application $app){
        $controllers = $app['controllers_factory'];

        $controllers->match('main/', 'controller.subRequest:doMain')
            ->method('GET')
            ->bind('route.subRequestMain');

        $controllers->get('sub/', 'controller.subRequest:doSub')
            ->method('GET')
            ->bind('route.subRequestSub');

        return $controllers;
    }

}


… and in the controller provider we define the rest of the routes, so we have routes: /subrequest/main/ and /subrequest/sub/, and the sub one has a route binding of route.subRequestSub

<?php
// SubRequest.php
namespace me\adamcameron\twig\controller;

use Silex\Application;
use Symfony\Component\HttpFoundation\Request;

class SubRequest {

    public static function doMain(Request $request, Application $app){
        $viewData = ['mainMessage'=>'This was set in the doMain() method'];
        return $app['twig']->render('subrequest/main.html.twig', $viewData);
    }
    public static function doSub(Request $request, Application $app){
        $viewData = [
            'mainMessage' => $request->get('mainMessage'),
            'subMessage'=>'This was set in the doSub() method'
        ];
        return $app['twig']->render('subrequest/sub.html.twig', $viewData);
    }

}


In the controller we just have the usual bumpf of setting some variables. You can see that the sub request receives a GET variable mainMessage.

{# main.html.twig #}

This is static text in the main twig<br>
This is the message passed from the main controller: {{ mainMessage }}<br>
<hr>
{{
    render(
        path(
            "route.subRequestSub",
            {"mainMessage":mainMessage}
        )
    )
}}


This is how we call a sub request. Use call path() - passing it the route binding value - which returns the internal URL to the bound route (so /subrequest/sub/ in this case), and then we render() that. We pass parameters to the sub request via the second argument of path(). Here we're passing the value from the main request into the sub request.

{# sub.html.twig #}

This is static text in the sub twig<br>
This is the message passed from the main controller: {{ mainMessage }}<br>
This is the message passed from the sub controller: {{ subMessage }}<br>

This just demonstrates we've got both values. The output of all this is unsurprising:

This is static text in the main twig
This is the message passed from the main controller: This was set in the doMain() method


This is static text in the sub twig
This is the message passed from the main controller: This was set in the doMain() method
This is the message passed from the sub controller: This was set in the doSub() method


Sit. rep.

OK, so those are the building blocks of what we're using in our codebase, and somewhere along the way something's going amiss. I'd refactored some code to get a reference to a constant out of a view (and back into the controller where it belonged), and as part of doing this refactored a view which was being included into being a sub-request instead. at this point, the sub-request's controller was not receiving the values passed to it in the path() call. However everything I'd done here in isolation worked fine, so I have not yet repro'ed the situation.

Deeper abstraction

My first question was whether some combination of include / extend / subrequests was undoing me. So I've repro'ed that too.

This time I've contrived a page which is build from the following components:

{# doc.html.twig #}
<!DOCTYPE html>
<html>
<head>
    <title>{% block title %}Default title{% endblock %}</title>
    {% block css %}{% endblock %}
</head>
<body>
    {% block layout %}Default layout{% endblock %}
</body>
</html>

{# layout.html.twig #}
{% extends "deep/doc.html.twig" %}
{% block layout %}
<div id="header">{% block header %}Default header{% endblock %}</div>
<div id="content">{% block content %}Default content{% endblock %}</div>
<div id="footer">{% block footer %}Default footer{% endblock %}</div>
{% endblock %}

{# header.html.twig #}
{% block header %}
<p>Top of header</p>
<div id="status">
{% include("deep/status.html.twig") %}
</div>
<p>Bottom of header</p>
{% endblock %}

{# status.html.twig #}
<p>Top of status</p>
<div id="user">
{{ render(path("route.deep.userSummary", {"user":user, "format":format})) }}
</div>
<p>Bottom of status</p>

{# user.html.twig #}
Full name: {{ user }}<br>

And we have this routing:

<?php
// Application.php
// [...]
class Application extends SilexApplication {

    // [...]

    function mountControllers(){
        // [...]
        $this->mount('/deep', $this["provider.controller.deep"]);
    }

}

<?php
// Deep.php
// [...]
class Deep implements ControllerProviderInterface {

    public function connect(Silex\Application $app){
        $controllers = $app['controllers_factory'];

        // [...]

        $controllers->match('actualSimple/', 'controller.deep:doActualSimple')
            ->method('GET')
            ->bind('route.deep.actualSimple');

        $controllers->match('userSummary/', 'controller.deep:doUserSummary')
            ->method('GET')
            ->bind('route.deep.userSummary');

        return $controllers;
    }

}

<?php
// Deep.php
// [...]
class Deep {

    // [...]
    public static function doActualSimple(Request $request, Application $app){
        $user = 'Bertrand Russell';
        return $app['twig']->render('deep/actual.html.twig', ['user'=>$user]);
    }

    public static function doUserSummary(Request $request, Application $app){
        $user = $request->get('user');
        return $app['twig']->render('deep/user.html.twig', ['user'=>$user]);
    }

}

And this page view for that route:

{# actual.html.twig #}
{% extends "deep/layout.html.twig" %}

{% block css %}<link rel="stylesheet" href="/lib/styles/actual.css"/>{% endblock %}

{% block header %}
    {% include("deep/header.html.twig") %}
{% endblock %}

{% block content %}Actual content{% endblock %}
{% block footer %}Actual footer{% endblock %}


So we have:

  • a route /deep/actualSimple
  • the controller for which renders a view actual.html.twig
  • actual.html.twig extends a layout.html.twig (and that extends doc.html.twig), and overrides the layout's blocks
  • the content for the header block is provided by an included header.html.twig file
  • header.html.twig itself includes status.html.twig
  • and status.html.twig calls a sub-request to get user.twig.html
  • which displays the user initially set in the controller.

And this outputs:


So that's a reasonable level of abstraction, and... it all works. Frown. So I still cannae replicate my problem.

Complex objects

The only other thing we could think of is that in out actual code we were passing around complex objects, not just strings. Whilst this should not be a problem in theory, we'd have issues with it before. And we wondered given the sub-request process uses the routing and passes values as "get" values, perhaps only simple values (as per in a URL query string) might be allowed.

So I made the example more complex. Most of the code for this example is the same as above, but the controller and user view changes, so I'll show those.

I have a new route:

$controllers->match('actualComplex/', 'controller.deep:doActualComplex')
    ->method('GET')
    ->bind('route.deep.actualComplex');

And a new controller for that:

public static function doActualComplex(Request $request, Application $app){
    $user = new Person('David', 'Hume');
    return $app['twig']->render('deep/actual.html.twig', ['user'=>$user, 'format'=>'complex']);
}

Which uses a model file this time, to represent a user object (a Person):

<?php
// https://github.com/adamcameron/scratch/blob/master/blogExamples/otherLanguages/php/php.twig.local/src/model/Person.php
namespace me\adamcameron\twig\model;

class Person {

    public $firstName;
    public $lastName;

    public function __construct($firstName, $lastName){
        $this->firstName = $firstName;
        $this->lastName = $lastName;
    }

}

Notice I'm also passing a "format" parameter now, and this is leveraged in the user view:

{# user.html.twig #}
{% if format == "simple" %}
    Full name: {{ user }}<br>
{% elseif format == "complex" %}
    First name: {{ user.firstName }}<br>
    Last name: {{ user.lastName }}<br>
{% endif %}

And this... drum roll... still works:



(all the rest of the stuff on the page was the same, so I omitted it).

Bamboozlement

OK, so I've pretty much replicated the perceived problem, so I was at my wits' end as to what the issue was. This morning I revisited the live code to see if I could find any clues as to why my code wasn't working. I checked a coupla pages, and it seems they were working: the correct info was being passed in and rendered correctly.

I re-enquired as to why we though this whole thing wasn't working, and my colleague pointed me to a different page, and - lo - it was not working. I revisited the code, back-tracked through to where the data was being set...

PEBCAC

(Problem exists between chair and colleague). But the data wasn't being set. On that particular page (and other pages like it which "weren't working") we simply weren't passing all the necessary values, so my code was picking up the defaults. As per its intent. The code was always working. Well the bits of it which had been implemented. The bits that hadn't been implemented? No, those did not work. Never had. Unsurprisingly.

Result!

This just made me laugh. I should have checked more closely initially as to what the perceived problem was, and not ass-u-me`d quite so much. I should also have not automatically decided because I had been working on code that when something about things didn't seem right once we came to look at it, that the error must lie with my own work. Fair's fair: it usually does lie with my code. But not in this instance.

Why is this a "result!". Because as part of troubleshooting this I spent a lot of time reading up on Twig and how it comes together, plus I decided to write it up here, so I took even more care than usual. Plus my examples were a lot more rigorous than usual too. So that's quite convenient.

Righto.

--
Adam