Rendering Answer Data & Saving Votes

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

So let's render some answer data! Delete the old, hardcoded $answers and the foreach. Perfect: we're now passing this collection of Answer objects into the template:

... lines 1 - 15
class QuestionController extends AbstractController
{
... lines 18 - 50
public function show(Question $question)
{
... lines 53 - 56
$answers = $question->getAnswers();
return $this->render('question/show.html.twig', [
'question' => $question,
'answers' => $answers,
]);
}
... lines 64 - 83
}

Let's go open this template... because it'll probably need a few tweaks: templates/question/show.html.twig.

If you scroll down a bit - here it is - we loop over the answers variable. That will still work: the Doctrine collection is something that we can loop over. But the answer variable will now be an Answer object. So, to get the content, use answer.content:

... lines 1 - 4
{% block body %}
... lines 6 - 54
<ul class="list-unstyled">
{% for answer in answers %}
<li class="mb-4">
<div class="row">
... lines 59 - 61
<div class="col-9">
{{ answer.content|parse_markdown }}
... line 64
</div>
... lines 66 - 89
</div>
</li>
{% endfor %}
</ul>
... line 94
{% endblock %}

We can also remove the hardcoded username and replace it with answer.username:

... lines 1 - 4
{% block body %}
... lines 6 - 54
<ul class="list-unstyled">
{% for answer in answers %}
<li class="mb-4">
<div class="row">
... lines 59 - 61
<div class="col-9">
{{ answer.content|parse_markdown }}
<p>-- {{ answer.username }}</p>
</div>
... lines 66 - 89
</div>
</li>
{% endfor %}
</ul>
... line 94
{% endblock %}

And there's... one more spot. The vote count is hardcoded. Change that to answer.votes:

... lines 1 - 4
{% block body %}
... lines 6 - 54
<ul class="list-unstyled">
{% for answer in answers %}
<li class="mb-4">
<div class="row">
... lines 59 - 61
<div class="col-9">
{{ answer.content|parse_markdown }}
<p>-- {{ answer.username }}</p>
</div>
<div class="col-2 text-end">
<div
... lines 68 - 73
>
... lines 75 - 86
<span><span {{ stimulus_target('answer-vote', 'voteTotal') }}>{{ answer.votes }}</span></span>
</div>
</div>
</div>
</li>
{% endfor %}
</ul>
... line 94
{% endblock %}

Ok! Let's see how it looks. Refresh and... alright! We have dynamic answers!

Fetching the Answers Directly in Twig

But... we're still doing too much work! Head back to the controller and completely remove the $answers variable:

... lines 1 - 15
class QuestionController extends AbstractController
{
... lines 18 - 50
public function show(Question $question)
{
if ($this->isDebug) {
$this->logger->info('We are in debug mode!');
}
return $this->render('question/show.html.twig', [
'question' => $question,
]);
}
... lines 61 - 80
}

Why are we doing this? Well, we know that we can say $question->getAnswers() to get all the answers for a question. And since we're passing a $question object into the template... we can call that method directly from Twig!

In show.html.twig, we don't have an answers variable anymore. That's ok because we can say question.answers:

... lines 1 - 4
{% block body %}
... lines 6 - 54
<ul class="list-unstyled">
{% for answer in question.answers %}
... lines 57 - 91
{% endfor %}
</ul>
... line 94
{% endblock %}

As reminder, when we say question.answers, Twig will first try to access the $answers property directly. But because it's private, it will then call the getAnswers() method. In other words, this is calling the same code that we were using a few minutes ago in our controller.

Back in the template, we need to update one more spot: the answer|length that renders the number of answers. Change this to question.answers:

... lines 1 - 4
{% block body %}
... lines 6 - 47
<div class="d-flex justify-content-between my-4">
<h2 class="">Answers <span style="font-size:1.2rem;">({{ question.answers|length }})</span></h2>
... line 50
</div>
... lines 52 - 94
{% endblock %}

Refresh now and... we're still good! If you open the Doctrine profiler, we have the same 2 queries. But now this second query is literally being made from inside of the Twig template.

Saving Answer Votes

While we're here, in the first Symfony 5 tutorial, we wrote some JavaScript to support this answer voting feature. When we click, it... well... sort of works? It makes an Ajax call: we can see that down on the toolbar. But since there were no answers in the database when we built this, we... just "faked" it and returned a new random vote count from the Ajax call. Now we can make this actually work!

Before I recorded this tutorial, I refactored the JavaScript logic for this into Stimulus. If you want to check that out, it lives in assets/controllers/answer-vote_controller.js:

import { Controller } from 'stimulus';
import axios from 'axios';
... lines 3 - 12
export default class extends Controller {
static targets = ['voteTotal'];
static values = {
url: String,
}
clickVote(event) {
event.preventDefault();
const button = event.currentTarget;
axios.post(this.urlValue, {
data: JSON.stringify({ direction: button.value })
})
.then((response) => {
this.voteTotalTarget.innerHTML = response.data.votes;
})
;
}
}

The important thing for us is that, when we click the vote button, it makes an Ajax call to src/Controller/AnswerController.php: to the answerVote method. Inside, yup! We're grabbing a random number, doing nothing with it, and returning it.

To make the voting system truly work, start in show.html.twig. The way that our Stimulus JavaScript knows what URL to send the Ajax call to is via this url variable that we pass into that controller. It's generating a URL to the answer_vote route... which is the route above the target controller. Right now, for the id wildcard... we're passing in a hardcoded 10. Change that to answer.id:

... lines 1 - 4
{% block body %}
... lines 6 - 54
<ul class="list-unstyled">
{% for answer in question.answers %}
<li class="mb-4">
<div class="row">
... lines 59 - 65
<div class="col-2 text-end">
<div
class="vote-arrows"
{{ stimulus_controller('answer-vote', {
url: path('answer_vote', {
id: answer.id
})
}) }}
>
... lines 75 - 87
</div>
</div>
</div>
</li>
{% endfor %}
</ul>
... line 94
{% endblock %}

Back in the controller, we need to take this id and query for the Answer object. The laziest way to do that is by adding an Answer $answer argument. Doctrine will see that entity type-hint and automatically query for an Answer where id equals the id in the URL.

Remove this TODO stuff... and for the "up" direction, say $answer->setVotes($answer->getVotes() + 1). Use the same thing for the down direction with minus one.

... lines 1 - 11
class AnswerController extends AbstractController
{
... lines 14 - 16
public function answerVote(Answer $answer, LoggerInterface $logger, Request $request, EntityManagerInterface $entityManager)
{
... lines 19 - 21
// use real logic here to save this to the database
if ($direction === 'up') {
$logger->info('Voting up!');
$answer->setVotes($answer->getVotes() + 1);
$currentVoteCount = rand(7, 100);
} else {
$logger->info('Voting down!');
$answer->setVotes($answer->getVotes() - 1);
}
... lines 31 - 34
}
}

If you want to create fancier methods inside Answer so that you can say things like $answer->upVote(), you totally should. We did that in the Question entity in the last tutorial.

At the bottom, return the real vote count: $answer->getVotes(). The only thing left to do now is save the new vote count to the database. To do that, we need the entity manager. Autowire that as a new argument - EntityManagerInterface $entityManager - and, before the return, call $entityManager->flush().

... lines 1 - 11
class AnswerController extends AbstractController
{
... lines 14 - 16
public function answerVote(Answer $answer, LoggerInterface $logger, Request $request, EntityManagerInterface $entityManager)
{
... lines 19 - 21
// use real logic here to save this to the database
if ($direction === 'up') {
$logger->info('Voting up!');
$answer->setVotes($answer->getVotes() + 1);
$currentVoteCount = rand(7, 100);
} else {
$logger->info('Voting down!');
$answer->setVotes($answer->getVotes() - 1);
}
$entityManager->flush();
return $this->json(['votes' => $answer->getVotes()]);
}
}

Ok team! Test drive time! Refresh. Everything still looks good so... let's vote! Yes! That made a successful Ajax call and the vote increased by 1. More importantly, when we refresh... the new vote count stays! It did save to the database!

Next: we've already learned that any one relationship can have two sides, like the Question is a OneToMany to Answer... but also Answer is ManyToOne to Question. It turns out, in Doctrine, each side is given a special name and has an important distinction.

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
    }
}