Most Popular Answers Page

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Let's build a "top answers" page where we list the answers with the most votes for all questions on our site.

Creating the Route, Controller & Template

Open AnswerController and create a new public function called popularAnswers().

... lines 1 - 11
class AnswerController extends AbstractController
{
... lines 14 - 16
public function popularAnswers()
{
... line 19
}
... lines 21 - 43
}

Add an @Route() above this - or use the Route attribute if you're on PHP 8 - with the URL /answers/popular. Immediately give this a name so we can link to it: app_popular_answers.

... lines 1 - 11
class AnswerController extends AbstractController
{
/**
* @Route("/answers/popular", name="app_popular_answers")
*/
public function popularAnswers()
{
... line 19
}
... lines 21 - 43
}

Inside, render a template: answer/popularAnswers.html.twig.

... lines 1 - 11
class AnswerController extends AbstractController
{
/**
* @Route("/answers/popular", name="app_popular_answers")
*/
public function popularAnswers()
{
return $this->render('answer/popularAnswers.html.twig');
}
... lines 21 - 43
}

Now, copy that template name and, down in the templates/ directory, create the new answer/ folder... and inside, the new file: popularAnswers.html.twig. I'll paste in a little structure to get us started.

{% extends 'base.html.twig' %}
{% block title %}Popular Answers{% endblock %}
{% block body %}
... lines 6 - 10
{% endblock %}

This extends base.html.twig, overrides the title block to customize the title... and in the body block, adds some basic structure. Let's put an <h1> that says "Most Popular Answers".

{% extends 'base.html.twig' %}
{% block title %}Popular Answers{% endblock %}
{% block body %}
<div class="container my-md-4">
<div class="row">
<h1>Most Popular Answers</h1>
</div>
</div>
{% endblock %}

Before we try this, open up base.html.twig so we can link to this. Scroll down a little. Inside of the navbar, we have an empty <ul> that's just waiting for a link. Add an <li> with class="nav-item"... and an a tag inside with href set to our new page: path('app_popular_answers'). Say "Answers" for the link text... and this needs class="nav-link".

... line 1
<html>
... lines 3 - 14
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-light px-1" style="height: 60px;">
... lines 17 - 20
<div class="collapse navbar-collapse">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link" href="{{ path('app_popular_answers') }}">Answers</a>
</li>
</ul>
</div>
... lines 28 - 29
</nav>
... lines 31 - 35
</body>
</html>

Now let's try this thing. Refresh... and click the link. Hello normal, boring, but functional page.

To get the most popular answers, we need a custom query. Well, technically we could use the findBy() method on AnswerRepository and use its "order by" argument. But let's add a full custom repository method instead: that will be nice and descriptive.

Open up AnswerRepository. At the bottom, add the method. Let's call it findMostPopular() and set the return type to an array. Like normal, I'll use PHPDoc to advertise that, more specifically, this will return an array of Answer objects.

... lines 1 - 15
class AnswerRepository extends ServiceEntityRepository
{
... lines 18 - 40
/**
* @return Answer[] Returns an array of Answer objects
*/
public function findMostPopular(): array
{
... lines 46 - 51
}
}

Inside, it's a simple query: return $this->createQueryBuilder('answer'), ->addCriteria() and reuse self::createApprovedCriteria() so that this only returns approved answers.

... lines 1 - 15
class AnswerRepository extends ServiceEntityRepository
{
... lines 18 - 40
/**
* @return Answer[] Returns an array of Answer objects
*/
public function findMostPopular(): array
{
return $this->createQueryBuilder('answer')
->addCriteria(self::createApprovedCriteria())
... lines 48 - 51
}
}

Then ->orderBy('answer.votes', 'DESC'), ->setMaxResults(10) to only return the top 10 answers, ->getQuery(), ->getResult().

... lines 1 - 15
class AnswerRepository extends ServiceEntityRepository
{
... lines 18 - 40
/**
* @return Answer[] Returns an array of Answer objects
*/
public function findMostPopular(): array
{
return $this->createQueryBuilder('answer')
->addCriteria(self::createApprovedCriteria())
->orderBy('answer.votes', 'DESC')
->setMaxResults(10)
->getQuery()
->getResult();
}
}

Beautiful! Back in the controller, autowire AnswerRepository $answerRepository, and then we can say $answers = $answerRepository->findMostPopular().

... lines 1 - 5
use App\Repository\AnswerRepository;
... lines 7 - 12
class AnswerController extends AbstractController
{
... lines 15 - 17
public function popularAnswers(AnswerRepository $answerRepository)
{
$answers = $answerRepository->findMostPopular();
... lines 21 - 24
}
... lines 26 - 48
}

Add a second argument to render() so that we can pass an answers variable to Twig set to this array of answers.

... lines 1 - 5
use App\Repository\AnswerRepository;
... lines 7 - 12
class AnswerController extends AbstractController
{
... lines 15 - 17
public function popularAnswers(AnswerRepository $answerRepository)
{
$answers = $answerRepository->findMostPopular();
return $this->render('answer/popularAnswers.html.twig', [
'answers' => $answers
]);
}
... lines 26 - 48
}

In the template, add a ul and loop over answers with {% for answer in answers %}. Let's start real simple: render answer.votes so we can at least make sure that we have the most popular on top.

... lines 1 - 4
{% block body %}
<div class="container my-md-4">
<div class="row">
... lines 8 - 9
<ul class="list-unstyled">
{% for answer in answers %}
<li>{{ answer.votes }}</li>
{% endfor %}
</ul>
</div>
</div>
{% endblock %}

Spin over to your browser, refresh and... got it! 10 answers with the most highly voted on top.

Reusing the Answer Templates

So on the question show page, we already have a nice structure for rendering answers. I want to reuse this on our new popular answers page. Open question/show.html.twig. Select everything inside the for loop - the entire <li> that renders a single answer - and copy it. Then, in the templates/answer/ directory, create a new file called _answer.html.twig... and paste!

<li class="mb-4">
<div class="row">
<div class="col-1">
<img src="{{ asset('images/tisha.png') }}" width="50" height="50" alt="Tisha avatar">
</div>
<div class="col-9">
{{ answer.content|parse_markdown }}
<p>-- {{ answer.username }}</p>
</div>
<div class="col-2 text-end">
<small>{{ answer.createdAt|ago }}</small>
<div
class="vote-arrows"
{{ stimulus_controller('answer-vote', {
url: path('answer_vote', {
id: answer.id
})
}) }}
>
<button
class="vote-up btn btn-link"
name="direction"
value="up"
{{ stimulus_action('answer-vote', 'clickVote') }}
><i class="far fa-arrow-alt-circle-up"></i></button>
<button
class="vote-down btn btn-link"
name="direction"
value="down"
{{ stimulus_action('answer-vote', 'clickVote') }}
><i class="far fa-arrow-alt-circle-down"></i></button>
<span><span {{ stimulus_target('answer-vote', 'voteTotal') }}>{{ answer.votes }}</span></span>
</div>
</div>
</div>
</li>

Back in show.html.twig, delete all of this and replace it with {{ include('answer/_answer.html.twig') }}.

... lines 1 - 4
{% block body %}
<div class="container">
... lines 7 - 54
<ul class="list-unstyled">
{% for answer in question.approvedAnswers %}
{{ include('answer/_answer.html.twig') }}
{% endfor %}
</ul>
</div>
{% endblock %}

Now copy that line and, in the popular answers template, repeat this! The new template includes the <li> element... so this will fit perfectly inside of our ul.

... lines 1 - 4
{% block body %}
<div class="container my-md-4">
<div class="row">
... lines 8 - 9
<ul class="list-unstyled">
{% for answer in answers %}
{{ include('answer/_answer.html.twig') }}
{% endfor %}
</ul>
</div>
</div>
{% endblock %}

Conditionally Rendering the Answer's Question

Phew! Let's check it! Refresh and... very nice! But hmm, in this context, we really need to render which question this answer is answering. We don't want to do that on the question show page - that would be redundant - but we do want it here.

To allow that, in popularAnswers.html.twig, add a second argument to include() and pass in a new variable called showQuestion set to true.

... lines 1 - 4
{% block body %}
<div class="container my-md-4">
<div class="row">
... lines 8 - 9
<ul class="list-unstyled">
{% for answer in answers %}
{{ include('answer/_answer.html.twig', {
showQuestion: true
}) }}
{% endfor %}
</ul>
</div>
</div>
{% endblock %}

In _answer.html.twig, we can use that: if showQuestion|default(false) and endif. Thanks to the default filter, if this variable is not passed, instead of an error, it'll default to false.

<li class="mb-4">
{% if showQuestion|default(false) %}
... lines 3 - 11
{% endif %}
<div class="row">
... lines 14 - 45
</div>
</li>

Inside, add an <a> tag with href="" set to {{ path('app_question_show') }}: the route to the question show page. This route needs a slug parameter set to answer.question.slug. Also give this some classes: mb-1 and link-secondary". For the text, say <strong> "Question" and then print the question text: answer.question.question.

<li class="mb-4">
{% if showQuestion|default(false) %}
<a
href="{{ path('app_question_show', {
slug: answer.question.slug
}) }}"
class="mb-1 link-secondary"
>
<strong>Question:</strong>
{{ answer.question.question }}
</a>
{% endif %}
<div class="row">
... lines 14 - 45
</div>
</li>

That does look funny, but... it's correct: answer.question gives us the Question object... then the last part reads its question property.

Back at our browser, refresh and... yikes! That technically works but these questions are way too long! We need to shorten them!

Next, let's learn about Twig's powerful u filter and add a method to our Answer class that will make our code a whole lot more readable.

Leave a comment!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.4.1 || ^8.0.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "babdev/pagerfanta-bundle": "^3.3", // v3.3.0
        "composer/package-versions-deprecated": "^1.11", // 1.11.99.3
        "doctrine/doctrine-bundle": "^2.1", // 2.4.2
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.1.1
        "doctrine/orm": "^2.7", // 2.9.5
        "knplabs/knp-markdown-bundle": "^1.8", // 1.9.0
        "knplabs/knp-time-bundle": "^1.11", // v1.16.1
        "pagerfanta/doctrine-orm-adapter": "^3.3", // v3.3.0
        "pagerfanta/twig": "^3.3", // v3.3.0
        "sensio/framework-extra-bundle": "^6.0", // v6.1.5
        "stof/doctrine-extensions-bundle": "^1.4", // v1.6.0
        "symfony/asset": "5.3.*", // v5.3.4
        "symfony/console": "5.3.*", // v5.3.7
        "symfony/dotenv": "5.3.*", // v5.3.7
        "symfony/flex": "^1.3.1", // v1.15.1
        "symfony/framework-bundle": "5.3.*", // v5.3.7
        "symfony/monolog-bundle": "^3.0", // v3.7.0
        "symfony/runtime": "5.3.*", // v5.3.4
        "symfony/stopwatch": "5.3.*", // v5.3.4
        "symfony/twig-bundle": "5.3.*", // v5.3.4
        "symfony/webpack-encore-bundle": "^1.7", // v1.12.0
        "symfony/yaml": "5.3.*", // v5.3.6
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.1
        "twig/string-extra": "^3.3", // v3.3.1
        "twig/twig": "^2.12|^3.0" // v3.3.2
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.4.0
        "symfony/debug-bundle": "5.3.*", // v5.3.4
        "symfony/maker-bundle": "^1.15", // v1.33.0
        "symfony/var-dumper": "5.3.*", // v5.3.7
        "symfony/web-profiler-bundle": "5.3.*", // v5.3.5
        "zenstruck/foundry": "^1.1" // v1.13.1
    }
}