PHP
Article

Building a Custom Twig Filter the TDD Way

By Taylor Ren

Twig is a powerful, yet easy to master template engine. It is also my personal favorite as all my web development is based on either Symfony or Silex.

Twig Header

Apart from its core syntax ({{ ... }} and {% ... %}), Twig has built-in support for various filters. A filter is like a “converter”. It receives certain original data (a string, a number, a date, etc) and by applying a conversion, outputs the data in a new form (as a string, a number, a date, etc). For example, the number_format filter can convert a number into a more readable one:

{{price|number_format(2, '.', ',')}}

Assuming price is a variable with value 1234567.12345, after the filter operation, the output in the page will be 1,234,567.12: 2 decimal places, “.” as the decimal point and “,” as the thousands separator. This makes it much more readable.

As another example, capitalize will make every first letter of a word in a sentence uppercase and others lowercase:

{{title|capitalize}}

Assuming title is a variable with the value this tutorial is nice, after the filter operation, the output will be This Tutorial Is Nice.

Twig has a number of built-in filters. The full list can be found in its official documentation.

Why Choose a Filter?

Some may argue that the above functionality is also doable in PHP; that is true. We also often find the functionality provided by built-in filters quite limited and insufficient. So, why use a filter?

In an MVC environment, the model layer is responsible for providing data (a book price or an article title, for example). The view layer is responsible for displaying the data. Doing the data conversion, filter-style, in a controller is not advisable because it is against the design role of a controller, and doing it in a model effectively changes the data, which is not good. In my opinion, the view is the only viable option.

Besides, as a particular transformation of data may be requested in many places in a template (as well as in various templates) on the same data from various sources, it is better to call that filter in the template every time such a conversion is required, than to call a function in the controller. The code will be much tidier.

Let’s consider the following code segments comparing using a filter and a PHP function call (using Symfony 2 + Doctrine). We can easily see the differences in elegance and usability.

Filter version:

...
<em>{{ book.title|capitalize }}</em> has {{book.pages|number_format(2, ".", ",")}}
and costs ${{ book.price|number_format(2, ".", ",")}}.  
...

And for this approach, what we do in the controller will be:

$book=$repo->findById('00666');
...
return $this->render('A_Template', ['book'=>$book]);

Find the book (the data) and pass it to the view to display.

But if we use PHP function calls, the code may look like this:

//Using PHP function within a Symfony framework and Doctrine
$book=$repo->findById('00666');
$book['tmpTitle'] = ucwords($book->getTitle);
$book['tmpPage'] = number_format($book->getPages(), 2, ".", ",");
$book['tmpPrice'] = number_format($book->getPrice(), 2, ".", ",");
...
return $this->render('A_Template', ['book'=>$book]);

.. and then in the template

<em>{{ book.tmpTitle }}</em> has {{book.tmpPages}}
and costs ${{ book.tmpPrice}}.

You can see that the filter approach is much cleaner and easier to manage, with no clumsy temp variables in between.

Let’s build a filter

We’ll build a filter to display the publication date/time of a post in a fancier way. That is, instead of saying something like “Posted at 2015-03-14 13:34“, this timestamp will be transformed into something like “Just now“, “A few hours earlier“, “A few days back“, “Quite some time ago“, “Long, long ago“, etc.

We’ll build it in a TDD way. To get introduced to TDD, see this post and the links within it, but the approaches we take in this tutorial should be easy enough to understand even without looking into TDD beforehand.

First, install PHPUnit by executing the following Composer command:

composer global require phpunit/phpunit

This will install the most recent version of PHPUnit globally, making it accessible on your entire machine from any folder. We will use PHPUnit to run the tests and assert that all the expectations are met.

Set expectations

The use case is clear: we want to convert a date/time object (2014-03-19 12:34) into something like “Just now“, “A few hours ago“, “A few days back“, “Quite some time ago“, “Long, long ago“, etc, depending on how far the date/time is from the current moment.

There is no set rule to determine how far away a date/time should be so that it can be displayed as “Quite some time ago“. This is a subjective matter so we will define a customized rule set for our app and these rules will be reflected in our expectations:

How long ago To be regarded as
< 1 minute Just now
< 10 minutes Minutes ago
< 1 hour Within an hour
< 16 hours A few hours ago
< 24 hours Within one day
< 3 days Some time back
< 10 days Ages ago
> 10 days From Mars

Let’s translate these expectations into a test script so that we can test it with PHPUnit. This script is saved in src/AppBundle/Tests/Twig/timeUtilTest.php:

<?php

namespace AppBundle\Tests\Twig;
use AppBundle\Twig\AppExtension;

class timeUtilTest extends \PHPUnit_Framework_TestCase
{
    /**
     * @dataProvider tsProvider
     */
    public function testtssFilter($testTS, $expect)
    {
        $tu=new AppExtension();
        $output=$tu->tssFilter($testTS);
        $this->assertEquals($output, $expect);
    }
    
    public static function tsProvider()
    {
        return [
            [date_sub(new \DateTime(), new \DateInterval("PT50S")), "Just now"],
            [date_sub(new \DateTime(), new \DateInterval("PT2M")), "Minutes ago"],
            [date_sub(new \DateTime(), new \DateInterval("PT57M")), "Within an hour"],
            [date_sub(new \DateTime(), new \DateInterval("PT13H1M")), "A few hours ago"],
            [date_sub(new \DateTime(), new \DateInterval("PT21H2M")), "Within one day"],
            [date_sub(new \DateTime(), new \DateInterval("P2DT2H2M")), "Some time back"],
            [date_sub(new \DateTime(), new \DateInterval("P6DT2H2M")), "Ages ago"],
            [date_sub(new \DateTime(), new \DateInterval("P13DT2H2M")), "From Mars"],
        ];
    }
}

If we run this test now:

phpunit -c app/

The test won’t run because we have not defined the AppBundle\Twig\AppExtension yet. We can quickly create a skeleton file: src/AppBundle/Twig/AppExtension.php. It can be as simple as this:

namespace AppBundle\Twig;

class AppExtension extends \Twig_Extension
{
    public function getFilters()
    {
        return [
            new \Twig_SimpleFilter('tss', [$this, 'tssFilter']),
        ];
    }
    
    public function getName()
    {
        return 'app_extension';
    }

	public function tssFilter(\DateTime $timestamp)
    {
    	// to be implemented 
    }
}

Now we can run the test script. All tests (expectations) will fail because we have not done anything to implement the tssFilter function.

NOTE: Symfony2 works very well with PHPUnit. With the default Symfony2 setup, there is a phpunit.xml.dist file in the project’s app folder. The above command will automatically use that file as the configuration file for PHPUnit. Normally, no further adjustment is needed.

The full code of the tssFilter function is listed below:

public function tssFilter(\DateTime $timestamp)
    {
        $TSS=['Just now','Minutes ago','Within an hour','A few hours ago','Within one day','Some time back','Ages ago', 'From Mars'];

        $i=-1;
        $compared = new \DateTime();
        
        $ts1=$timestamp->getTimestamp();
        $co1=$compared->getTimestamp();
        
        $diff=$ts1-$co1;
        if($diff<0 ) // Diff is always <0, so always start from index 0
        {
            $i++;
        }
        
        if($diff<-1*60 ) //within one minute
        {
            $i++;
        }
        
        if($diff<-10*60) // within ten minues
        {
            $i++;
        }
        if($diff<-60*60)
        {
            $i++;
        }
        
        if($diff<-16*60*60)
        {
            $i++;
        }
        
        if($diff<-24*60*60)
        {
            $i++;
        }
        
        if($diff<-3*24*60*60)
        {
            $i++;
        }
        
        if($diff<-10*24*60*60)
        {
            $i++;
        }
        
        return $TSS[$i];
    }

The code will reside in tssFilter. It accepts a DateTime object so that the program can determine which string in $TSS should be returned based on far timestamp is from now.

That’s it! Run the test, and everything should pass!

Integrate it into Symfony

The tssFilter is still isolated from the Symfony framework. To use it in our template, we need to register it in the services.yml file:

services:
    app.twig_extension:
        class: AppBundle\Twig\AppExtension
        tags:
            - { name: twig.extension }

We must provide the fully qualified name of the filter class: AppBundle\Twig\AppExtension.

Finally, we can use it like this in our Twig template:

{{post.author|capitalize}} posted "{{post.title|capitalize}}" (posted {{post.creation|tss}})

The filter name (tss) is derived from src/AppBundle/Twig/AppExtension.php file’s tssFilter() function name and like with other Symfony components, “Filter” is stripped.

Wrapping up

In this quick tutorial, we covered a few things:

  1. Twig filters and why it is better to use them than pure PHP calls.
  2. How to build a custom filter the TDD way with PHPUnit.
  3. How to integrate a filter into the Symfony framework.

Leave your comments and thoughts below and share your achievements!

  • Janek Kowalski

    hmm.. where was TDD?

    • http://harikt.com/ Hari K T

      Test driven development . There is unit tests. May be you missed ?

Recommended

Learn Coding Online
Learn Web Development

Start learning web development and design for free with SitePoint Premium!

Get the latest in PHP, once a week, for free.