If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeSo 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!
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.
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.
// 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
}
}