Building a Web App with Symfony 2: Finalizing

Taylor Ren
Share

In Part 1 and Part 2 of this series, I have covered the basics of using Symfony 2 to develop a functioning web site.

In this part of the tutorial, I will cover some more advanced techniques and finish the project with pagination, image watermarks and NativeQuery.

The code we'll be using is identical to the code from Part 2 – the features are already there, they just weren't discussed.

Image watermarks

Seeing as there's already plenty of image manipulation and watermarking through Imagick tutorials available on SitePoint, and owing to the fact that we don't do any advanced image manipulation in this particular project, we'll stick to PHP's default image mainpulation library – GD.

In general, when there is "image processing", we are talking about an action or actions applied to an image before we eventually show it using the <img> tag. In my application, this is done in two steps:

  • Create a route to capture the "image display request" and map it to an action in a controller.
  • Implement the processing in the action.

Route configuration

The route for displaying an image after processing is simple:

    cover: 
        pattern: /books/cover/{id}_{title}_{author}_{width}.png
        defaults: {_controller: trrsywxBundle:Default:cover, width: 300}

In a template, we can invoke the call to display the processed image by:

    <img src="{{path("cover", {'id':book.id, 'author':author, 'title':book.title}) }}" alt="{{book.title}}'s cover" title="{{book.title}}'s cover"/>

So every time this <img> tag is encountered, a processed image will be displayed.

Image processing

In this application, we do two things before displaying the image:

  1. Depending on whether or not a book cover image exists, display it, or display the default cover image;
  2. Add a watermark and adjust the size (300px wide in book detail view and 200px wide in reading list view).

The complete code is in src/tr/rsywxBundle/Controller/DefaultController.php in the function coverAction. The code is simple and straightforward and I'll just show you the output in Detail View for both a real book cover and a default cover:

Note that I have created a "cover" folder under "web" to hold the default cover and the scanned book covers. I also used a Chinese TTF to display the watermark texts. Please feel free to use your own font (and copy that font to the "cover" folder).

On a higher-traffic site, the correct course of action would be to cache the autogenerated images much like Lukas White did in his article, but I'll leave that up to you to play around with.

Pagination

There are also a lot of articles on paginating a big dataset. In this tutorial, I will show you how I did it in this app, and we'll test it.

The source code for the class is in src/tr/rsywxBundle/Utility/Paginator.php.

The code itself is easy to read and does not involve anything particularly advanced, so I will just discuss the process.

There are two fundamental values in a Pagination class:

  1. How many records in total and how many pages in total?
  2. What is the current page and how to construct an easily accessible page list for further processing ?

Many pagination classes often deal with data retrieval too, but this is not good practice: pagination should only deal with things related to pagination, not the data itself. The data is the domain of the Entity/Repository.

If we go back to the implementation of getting the books matching certain criteria, you will notice how the two steps (get data and do pagination) are separated in the controller:

File location: src/tr/rsywxBundle/Controller/BookController.php

    public function listAction($page, $key)
    {
        $em = $this->getDoctrine()->getManager();
        $rpp = $this->container->getParameter('books_per_page');

        $repo = $em->getRepository('trrsywxBundle:BookBook');

        list($res, $totalcount) = $repo->getResultAndCount($page, $rpp, $key);
        //Above to retrieve data and counts
        //Below to instantiate the paginator

        $paginator = new \tr\rsywxBundle\Utility\Paginator($page, $totalcount, $rpp);
        $pagelist = $paginator->getPagesList();

        return $this->render('trrsywxBundle:Books:List.html.twig', array('res' => $res, 'paginator' => $pagelist, 'cur' => $page, 'total' => $paginator->getTotalPages(), 'key'=>$key));
    }

The constructor of my paginator takes 3 parameters:

  • page: to tell what is the current page. This will be used to return a list for pages to show as clickable links in the template;
  • totalcount: to tell the count of the results. This will be used to calculate the total pages together with the rpp parameter;
  • rpp: short for records per page.

In my current implementation, I used a simple version of pagination showing only "First", "Previous", "Next", and "Last" page links, but you can try out different types by using the getPagesList function.

Take for example a page list like this:

    1 2 3 4 5

The key here is in the getPagesList function which makes sure that the current page is always in the middle, or if there aren't enough pages, it makes sure it's in the correct position.

    public function getPagesList()
    {
        $pageCount = 5;
        if ($this->totalPages <= $pageCount) //Less than total 5 pages
            return array(1, 2, 3, 4, 5);

        if($this->page <=3)
            return array(1,2,3,4,5);

        $i = $pageCount;
        $r=array();
        $half = floor($pageCount / 2);
        if ($this->page + $half > $this->totalPages) // Close to end
        {
            while ($i >= 1)
            {
                $r[] = $this->totalPages - $i + 1;
                $i--;
            }
            return $r;
        } else
        {
            while ($i >= 1)
            {
                $r[] = $this->page - $i + $half + 1;
                $i--;
            }
            return $r;
        }
    }

To make sure this function really works, we'll have to test it before using it. We'll use PHPUnit as the test bench. Please refer to the official site for detailed instructions on how to install it. I used the phpunit.phar way to download the package and place it in my project root folder.

To test the Paginator class we just created, firstly we need to create a folder Utility under src/tr/rsywxBundle/Tests. All tests in Symfony should go under src/tr/rsywxBundle/Tests. In the Utility folder, create a PHP file named PaginatorTest.php:

    namespace tr\rsywxBundle\Tests\Utility;

    use tr\rsywxBundle\Utility\Paginator;

    class PaginatorTest extends \PHPUnit_Framework_TestCase
    {
        public function testgetPageList()
        {
            $paginator=new Paginator(2, 101, 10);
            $pages=$paginator->getTotalPages();
            $this->assertEquals($pages, 11);
            $list=$paginator->getPagesList();
            $this->assertEquals($list, array(1,2,3,4,5));

            $paginator=new Paginator(7, 101, 10);
            $list=$paginator->getPagesList();
            $this->assertEquals($list, array(5,6,7,8,9));

            $paginator=new Paginator(10, 101, 10);
            $list=$paginator->getPagesList();
            $this->assertEquals($list, array(7,8,9,10,11));
        }
    }

This kind of test is called a Unit Test. It tests a particular unit of the program.

In testgetPageList function, we basically instantiate the object we want to test (a paginator) with virtually any combination of parameters we can think of. We then call some methods of that object and test the validity of the result by using assertions. Here we only use the method assertEquals.

In the example $this->assertEquals($list, array(7,8,9,10,11)) from the $paginator object we created, we know there should be a total of 11 pages (with 101 records in total and 10 records per page), and page 10 as the current page will return a page list 7,8,9,10,11 as page 10 is very close to the end. We assert this and if that assertion fails, there must be something wrong in the function logic.

In our command line/terminal, we run the following command:

php phpunit.phar -c app/

This reads the configuration file for PHPUnit from the app/ folder (phpunit.xml.dist is generated by the Symfony installation. DON'T CHANGE IT!)

Note: Please delete all other test files auto-generated by Symfony (like Controller folder under Tests). Otherwise, you will see at least one error.

The above command will parse all test files under Tests and make sure all assertions pass. In the above example, you will see a prompt saying something like OK, 1 test, 4 assertions. This means all the tests we created have passed and thus proved the function behaves properly. If not, there must be something wrong in the code (in the implementation or in the test).

Feel free to expand the test file for the Paginator class.

It is always a good practice to test a home-made module before it is used in your program.

For a more in-depth look at PHPUnit and testing in PHP, see any of SitePoint's numerous PHPUnit articles.

NativeQuery

Our database has a table called book_visit, we use timestamp as the data type to log the time of a visit to the book detail page. We need to do some statistics aggregation on the visits and one of them is to get the total visit count by day (my "day" is in the +8 hours timezone).

In SQL, this is easy:

    select count(v.bid) vc, date(from_unixtime(v.visitwhen+15*60*60)) vd from book_visit v group by vd order by vd

In the above, 15*60*60 is there to adjust my server time to my timezone.

However, if you try to use similar grammar in Symonfy (changing the table name to its FQN, of course), an error prompt will tell you something like date function is not supported. To solve this, one way is to use pure SQL:

    $q = $em->getConnection()->prepare('select count(v.bid) vc, date(from_unixtime(v.visitwhen+8*60*60)) vd from book_visit v group by vd order by vd');
    $q->execute();
    return $q->fetchAll();

Or as recommended by Symfony and Doctrine, we can (and should) use createNativeQuery and ResultSetMapping.

    public function getVisitCountByDay()
    {
        $em = $this->getEntityManager();

        $rsm=new \Doctrine\ORM\Query\ResultSetMapping;

        $rsm->addScalarResult('vc', 'vc');
        $rsm->addScalarResult('vd', 'vd');

        $q=$em->createNativeQuery('select count(v.bid) vc, date(from_unixtime(v.visitwhen+15*60*60)) vd from book_visit v group by vd order by vd', $rsm);

        $res=$q->getResult();

        return $res;

    }

In the example above, the most critical statements are to create a ResultSetMapping and add results to that mapping.

vc (visit count) and vd (visit date) both appeared twice in the addScalarResult call. The first is a column name that will be returned from the query and the second is an alias for that column. To prevent the complication of creating more names, we just use the same names.

A scalar result describes the mapping of a single column in an SQL result set to a scalar value in the Doctrine result. Scalar results are typically used for aggregate values but any column in the SQL result set can be mapped as a scalar value.

The above functionality is not implemented in the final code. Take it as home work.

Conclusion

This is far from a complete tutorial for Symfony. There's plenty not covered (forms, security, functional testing, i18n, etc), which could easily take another 10-12 parts. I highly recommend you read the full official documentation provided by Symfony, which can be downloaded here.

This being my first time writing a series in PHP and for Sitepoint, I would appreciate any constructive criticism and general feedback you could throw my way.