A free and open-source book on ZF3 for beginners


12.14. Implementing Tag Cloud

Another major feature we implement in the Blog sample will be the tag cloud. The tag cloud appears on the Home page. The tag cloud contains most popular tags, and tag's font size varies depending on popularity of the tag: most popular tags appear larger than less popular ones. Clicking the tag in the tag cloud results in filtering posts by this tag.

For example of what we are trying to achieve, please look at the right side of the figure 12.11 below:

Figure 12.11. Tag cloud Figure 12.11. Tag cloud

For this feature, we need the following things:

12.14.1. Adding Custom Post Repository

Earlier we mentioned that by default Doctrine uses the Doctrine\ORM\EntityRepository as the default repository class. Custom repository is a class extended from EntityRepository class. It is typically used when you need to encapsulate complex DQL queries and search logic in a single place in your code.

It is also possible to put the DQL queries to controller class, but that would make controllers "fat". Since we use MVC pattern, we strive to avoid that.

DQL is similar to SQL in sense that it allows to write and execute queries to database, but the result of a query is an array of objects rather than an array of table rows. For more information on DQL and its usage examples, please refer to this page.

For our Blog sample web application, we need a custom repository which allows to find published posts having at least one tag (to calculate total count of tagged posts), and, to find published posts filtered by particular tag. We plan to encapsulate this search logic into the custom PostRepository repository.

Doctrine works with custom repositories transparently. This means, that you retrieve the repository from EntityManager as usual and still can use its findBy(), findOneBy() and other methods.

Create the PostRepository.php file inside the Repository directory under the module's source directory. Below, you can find the code of PostRepository class that has two public methods:

<?php
namespace Application\Repository;

use Doctrine\ORM\EntityRepository;
use Application\Entity\Post;

// This is the custom repository class for Post entity.
class PostRepository extends EntityRepository
{
  // Finds all published posts having any tag.
  public function findPostsHavingAnyTag()
  {
    $entityManager = $this->getEntityManager();
        
    $queryBuilder = $entityManager->createQueryBuilder();
    
    $queryBuilder->select('p')
        ->from(Post::class, 'p')
        ->join('p.tags', 't')
        ->where('p.status = ?1')
        ->orderBy('p.dateCreated', 'DESC')
        ->setParameter('1', Post::STATUS_PUBLISHED);
    
    $posts = $queryBuilder->getQuery()->getResult();
    
    return $posts;
  }
    
  // Finds all published posts having the given tag.
  public function findPostsByTag($tagName)
  {
    $entityManager = $this->getEntityManager();
        
    $queryBuilder = $entityManager->createQueryBuilder();
    
    $queryBuilder->select('p')
        ->from(Post::class, 'p')
        ->join('p.tags', 't')
        ->where('p.status = ?1')
        ->andWhere('t.name = ?2')
        ->orderBy('p.dateCreated', 'DESC')
        ->setParameter('1', Post::STATUS_PUBLISHED)
        ->setParameter('2', $tagName);
    
    $posts = $queryBuilder->getQuery()->getResult();
            
    return $posts;
  }        
}

In the code above, we use the query builder to conveniently create complex DQL queries.

In lines 17-22, we create a query which selects all published posts ordering them by date created in descending order. Because we join posts with tags, here we only select posts which have at least one tag. In line 24, we execute the query. If you are curious what DQL the query builder creates, here it is:

SELECT p FROM \Application\Entity\Post p JOIN p.tags t 
WHERE p.status=?1 ORDER BY p.dateCreated DESC

In lines 36-43, we create a query that filters posts by tag name. An analogous DQL is presented below:

SELECT p FROM \Application\Entity\Post p JOIN p.tags t 
WHERE p.status=?1 AND t.name=?2 ORDER BY p.dateCreated DESC

To learn more about Doctrine query builder, please refer to this page.

To let Doctrine know that it should use the custom repository for Post entity, modify the Post entity's annotation as follows:

<?php
//...

/**
 * This class represents a single post in a blog.
 * @ORM\Entity(repositoryClass="\Application\Repository\PostRepository")
 * @ORM\Table(name="post")
 */
class Post 
{
  //...
}

Above, in line 6, we use the repositoryClass parameter of the @ORM\Entity tag to tell Doctrine that it should use PostRepository repository.

12.14.2. Calculating Tag Cloud

Business logic for the tag cloud feature will be stored inside of the PostManager::getTagCloud() method, as follows:

<?php
//...
class PostManager 
{
  //...
	
  // Calculates frequencies of tag usage.
  public function getTagCloud()
  {
    $tagCloud = [];
               
    $posts = $this->entityManager->getRepository(Post::class)
                    ->findPostsHavingAnyTag();
    $totalPostCount = count($posts);
        
    $tags = $this->entityManager->getRepository(Tag::class)
                ->findAll();
    foreach ($tags as $tag) {
            
      $postsByTag = $this->entityManager->getRepository(Post::class)
                    ->findPostsByTag($tag->getName());
            
      $postCount = count($postsByTag);
      if ($postCount > 0) {
        $tagCloud[$tag->getName()] = $postCount;
      }
    }
        
    $normalizedTagCloud = [];
        
    // Normalize
    foreach ($tagCloud as $name=>$postCount) {
      $normalizedTagCloud[$name] =  $postCount/$totalPostCount;
    }
        
    return $normalizedTagCloud;
  }
}

In the code above, we have the getTagCloud() method that selects all post having at least one tag attached and calculates the "frequency" of each available tag (how often the tag appears). Then it normalizes the frequency values (makes them to be between 0 and 1.0).

12.14.3. Modifying Controller Action

Here we will modify the IndexController to implement tag filter.

<?php
//...
class IndexController extends AbstractActionController 
{
    /**
     * Post manager.
     * @var Application\Service\PostManager 
     */
    private $postManager;
    
    // Constructor is used for injecting dependencies into the controller.
    public function __construct($entityManager, $postManager) 
    {
        $this->entityManager = $entityManager;
        $this->postManager = $postManager;
    }
    
    public function indexAction() 
    {
        $tagFilter = $this->params()->fromQuery('tag', null);
        
        if ($tagFilter) {
         
            // Filter posts by tag
            $posts = $this->entityManager->getRepository(Post::class)
                    ->findPostsByTag($tagFilter);
            
        } else {
            // Get recent posts
            $posts = $this->entityManager->getRepository(Post::class)
                    ->findBy(['status'=>Post::STATUS_PUBLISHED], 
                             ['dateCreated'=>'DESC']);
        }
        
        // Get popular tags.
        $tagCloud = $this->postManager->getTagCloud();
        
        // Render the view template.
        return new ViewModel([
            'posts' => $posts,
            'postManager' => $this->postManager,
            'tagCloud' => $tagCloud
        ]);
    }
}

The action method will retrieve the tag from the GET variable tag if the variable doesn't present in HTTP request, all posts are retrieved as usual. If the variable present, we use our custom repository's findPostsByTag() method to filter posts.

In line 36, we call the PostManager::getTagCloud() that returns array of tags and their frequencies. We use this information for rendering the cloud.

Please note that we are now using the PostManager service in our controller and have to inject it into the constructor. Do not forget to modify the controller factory to do that.

12.14.4. Rendering Tag Cloud

Finally, modify the index.phtml file to make it look like follows:

<h1>Posts</h1>

<div class="row">
    
    <div class="col-md-8">

    <?php foreach($posts as $post): ?>

    <h3>
        <a href="<?= $this->url('posts', ['action'=>'view', 'id'=>$post->getId()]); ?>">
            <?= $this->escapeHtml($post->getTitle()); ?>
        </a>    
    </h3>
    
    <p>
        Published: <?= $this->escapeHtml(date('jS \of F Y', strtotime($post->getDateCreated()))); ?> 
        | Tags: <?= $this->escapeHtml($postManager->convertTagsToString($post)); ?>   
    </p>    
        
    <p class="comments-header">
        <?= $this->escapeHtml($postManager->getCommentCountStr($post)); ?> | 
        <a href="<?= $this->url('posts', ['action'=>'view', 'id'=>$post->getId()],
                ['fragment'=>'comment']); ?>">
            Add new comment
        </a>
    </p>

    <p>    
        <?= $this->escapeHtml($post->getContent()); ?>
    </p>

    <?php endforeach; ?>

    </div>
    
    <div class="col-md-4">
        <div class="panel panel-default">
            <div class="panel-heading">
                <h3 class="panel-title">Popular Tags</h3>
            </div>
            <div class="panel-body">
                <?php foreach($this->tagCloud as $tagName=>$frequency): ?>
                
                <a href="<?= $this->url('application', ['action'=>'index'],
                    ['query'=>['tag'=>$tagName]]); ?>">                   
        
                    <span style="font-size:<?= $this->escapeHtml(0.9 + $frequency*3) ?>em">
                        <?= $this->escapeHtml($tagName); ?>
                    </span>
                
                </a>    
                    
                <?php endforeach; ?>
            </div>
        </div>
    </div>
</div>

Top