Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

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!

17
Login or Register to join the conversation
gazzatav Avatar
gazzatav Avatar gazzatav | posted 2 months ago

Hi @weaverryan , I see quite some questions on this one. Just spent a few hours trying to get this working by updating the original code. Don't really want to use stimulus yet. I have the javascript creating the correct route using a data field in the twig 'data-answer_id="{{ answer.id}}". However, even though I see the correct route in dev tools in the browser I always get a 500 response. My code in the suddenly appeared AnswerController.php is the same as yours. Unfortunately I get an exception caused by a warning:
'Warning: Use of undefined constant data - assumed 'data' (this will throw an Error in a future version of PHP)'.
Well it didn't need to throw an error in the future cause it killed the application! It was the line '$direction = data['direction'] ?? 'up';' that broke it. Any suggestions?

Reply

Hey Gary,

Is that the PHP code? If so, you probably mean "$direction = $data['direction'] ?? 'up';" ? It seems like you missed a dollar sign for the $data var :)

I hope this helps!

Cheers!

Reply
gazzatav Avatar

@Victor Bocharsky , hi and thanks, yes that stops the 500 error/reason. For some reason I think I understand but don't know how to stop all the votes are changed to the same value. I'm using a modified version of the older Javascript without stimulus because I want to understand what I'm using. It also looks like yarn is not necessarily behaving correctly, I just don't know what's going on anymore. In public/build and assets I have:

$(document).ready(function(){
var $container = $('.js-vote-arrows');
console.log("This is debug", $container);
$container.find('a').on('click', function(e){
e.preventDefault();
var $link = $(e.currentTarget);
console.log("On 20220713 Current target", e.currentTarget);
console.log("On 20220713 id", $link.data('answer_id'));

$.ajax({
url: '/answers/'+ $link.data('answer_id') +'/vote/'+ $link.data('direction'),
method: 'POST'

}).then(function(data){
console.log(data);
$link('.js-vote-total').text(data.votes);
});
});
});


notice the date in the call to console.log. In the console in the browser I have:

$(document).ready(function(){
var $container = $('.js-vote-arrows');
console.log("This is debug", $container);
$container.find('a').on('click', function(e){
e.preventDefault();
var $link = $(e.currentTarget);
console.log("Current target", e.currentTarget);
console.log("id", $link.data('answer_id'));

$.ajax({
url: '/answers/'+ $link.data('answer_id') +'/vote/'+ $link.data('direction'),
method: 'POST'

}).then(function(data) {
console.log(data);
$container.find('.js-vote-total').text(data.votes);
});
});
});

I have restarted yarn, done cache:clear, assets:install, refreshed the page many times and the browser cache. Any ideas?

Reply
gazzatav Avatar

Figured it out:


$($link).parent().find('.js-vote-total').text(data.votes) sets the vote on the correct element.
Reply

Hey Gary,

I'm glad to hear you were able to figured it out yourself, well done!

Cheers!

Reply
Marco Avatar

You completely lost me in this chapter when it comes to the votes for the answers. There is quite some information missing, which you did when you refactored the voting off-screen. It's not just the JS part, it's also the AnswerController. I've ran into various issues here, where for the last ~60 videos it was super easy to follow the videos and code along, without downloading any pre-existing code. I highly suggest you update this chapter video to include the missing information.
Thank you very much and cheers!

Reply
Bard R. Avatar

You lost me on the "saving answer votes" section of this lesson. I have gone through all the courses, coding along.. but when I got here I saw you have implemented "stimulus_controller", but going back to the courses I do not see this implementation explained anywhere. What you did in the first course was to add a questionVote method to the QuestionController and up/downvote the question, but its using plain form POST action, not stimulus. Did I miss something?

Reply

Hey Bard Ronningen!

Ah, sorry about confusing you - that was my fault! Before this tutorial (so between the Doctrine tutorial and this Doctrine relations tutorial), I refactored the JavaScript to use Stimulus. The JavaScript - whether it's written in Stimulus or not - is not a topic that I wanted to go into here. We DID need to write a tiny bit of code in Twig (stimulus_controller) to activate that JavaScript... which is what we did here. And yes, you're right, in the original Doctrine tutorial, I had a plain form POST for voting (though I "hijacked" that with JavaScript and submitted it via Ajax to be fancy).

So... this is a long way of saying that I refactored the JavaScript behind the scenes... but my hope was that this didn't cause problems, as the JavaScript itself isn't an important piece. But if you have any questions about it or how it works, I'd be happy to elaborate.

Cheers!

1 Reply
MattHB Avatar

a tiny problem is that the code in the download finish directory is still the un-refactored code, which adds to the confusion quite a bit as it doesnt match the docs or video.

Reply

Hey Matt Holbrook-Bull!

Hmm, the finish directory still has the un-refactored code? I just downloaded it (specifically, the course download from this course), and I see the Stimulus controller in assets/ as well as stimulus_controller() inside of the template. Do you see something different? Or are you referring to some other bit of code that's still un-refactored in the final code? We definitely want to get this right :). The "final" code is built automatically from the real code we use in the video.

Cheers!

Reply
MattHB Avatar

you were right! (as usual :D ).. It was pilot error.

Reply
MattHB Avatar

oh weird!!! I could easily be me having a brain-f@art..

Reply
MikkoP Avatar

Thanks for the awesome videos, I love your courses!

Upvoting works fine (probably because it's the default...), but clicking down also increases the vote count (probably because it's the default...).
Is there something wrong with my configuration/browser/head, or is there something missing in the code?

The problem to me seems to be that the POST parameters is partly escaped {"data":"{\"direction\":\"down\"}"}, which results to "data" => "{"direction":"down"}" when json_decoding in AnswerController (array with json still in it, not pure array)

Adding second json_decode $data = json_decode($data['data'], true); does work, but it cannot be proper solution?

Another working solution is modifying the answer-vote_controller.js:

axios.post(this.urlValue, {
direction: button.value
})
.then((response) => {
this.voteTotalTarget.innerHTML = response.data.votes;
})
;

That is to remove 'data' key and json.stringify, and send post request like in axios documentation
But would this break something else?

Reply

Hey Mikko

I believe Axios changed and it's now automatically encoding your data. So, if I'm correct, you don't need to do JSON.stringify anymore

> That is to remove 'data' key and json.stringify, and send post request like in axios documentation
But would this break something else?
I don't think so, it just seems to me an another way to send your POST parameters

Cheers!

1 Reply
Ek24 Avatar

$data = json_decode($request->toArray()['data'],true);

if ($data['direction'] === 'up') {
$answer->setVotes($answer->getVotes() + 1);
} else {
$answer->setVotes($answer->getVotes() - 1);
}

Should work. urgs .. ugly code.

Reply

Hey Jens W

Or, you can implement two routes, one for each direction (up/down). Cheers!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial also works great for Symfony 6!

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.2.1
        "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.17.5
        "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/validator": "5.3.*", // v5.3.14
        "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
    }
}