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 SubscribeNew goal team: to allow users to upvote and downvote a mix. To accomplish this, in the VinylMix
entity, when a user votes, we need to send an UPDATE query to change the $votes
integer property in the database.
Let's first focus on the user interface. Open templates/mix/show.html.twig
. To start, print {{ mix.votesString }} votes
so we can see that here.
... lines 1 - 4 | |
{% block body %} | |
<div class="container"> | |
... lines 7 - 11 | |
<div class="col-12 col-md-8 ps-5"> | |
... lines 13 - 15 | |
{{ mix.votesString }} votes | |
... lines 17 - 22 | |
</div> | |
... line 24 | |
</div> | |
{% endblock %} |
And... perfect! To add the upvote and downvote functionality, we could use some fancy JavaScript. But we're going to keep it simple by adding a button that posts a form. Well this will actually be fancier than it sounds. In the first tutorial, we installed the Turbo JavaScript library. So even though we'll use a normal <form>
tag and button, Turbo will automatically submit it via AJAX for a smooth experience.
By the way, Symfony does have a form component and we'll talk about that in a future tutorial. But this form is going to be so simple that we don't really need it anyway. Add a beautifully boring <form>
tag with action
set to the path()
function.
The form will submit to a new controller that... we still need to create!
Head over to MixController
and add a new public function
called vote()
. Give this the #[Route()]
attribute with the URL /mix/{id}/vote
. And because we need to link to this, add a name: app_mix_vote
.
... lines 1 - 11 | |
class MixController extends AbstractController | |
{ | |
... lines 14 - 42 | |
'/mix/{id}/vote', name: 'app_mix_vote', methods: ['POST']) ( | |
public function vote(VinylMix $mix): Response | |
... lines 45 - 47 | |
} |
The {id}
route wildcard will hold the id of the specific VinylMix
that the user is voting on. To query for that, use the trick we learned earlier: add an argument type-hinted with VinylMix
and call it $mix
. Oh, and while we don't need to, I'll add the Response
return type. Adding this is just a good practice.
Inside, to make sure things are working, dd($mix)
.
... lines 1 - 43 | |
public function vote(VinylMix $mix): Response | |
{ | |
dd($mix); | |
} | |
... lines 48 - 49 |
Cool! Copy the name of the route, go back to the template - show.html.twig
- and inside path()
, paste. And because this route has an {id}
wildcard, pass id
set to mix.id
. Also give the form method="POST"
... because anytime that submitting a form will change data on your server, it should submit with POST
.
... lines 1 - 4 | |
{% block body %} | |
... lines 6 - 11 | |
<div class="col-12 col-md-8 ps-5"> | |
... lines 13 - 16 | |
<form action="{{ path('app_mix_vote', {id: mix.id }) }}" method="POST"> | |
... lines 18 - 21 | |
</form> | |
</div> | |
... lines 24 - 25 | |
{% endblock %} |
Heck, we can even enforce this requirement on our route by adding methods: ['POST']
. That's optional, but now, if someone, for some reason, goes directly to this URL, which is a GET request, it won't match the route. Handy!
... lines 1 - 11 | |
class MixController extends AbstractController | |
{ | |
... lines 14 - 42 | |
'/mix/{id}/vote', name: 'app_mix_vote', methods: ['POST']) ( | |
public function vote(VinylMix $mix): Response | |
... lines 45 - 47 | |
} |
Head back over to the form. This form... will be kind of strange. Instead of having fields the user can type into, all we need is a button. Add <button>
with type="submit"
... and then some classes for styling. For the text, use a Font Awesome icon: a <span>
with class="fa fa-thumbs-up"
.
... lines 1 - 4 | |
{% block body %} | |
... lines 6 - 16 | |
<form action="{{ path('app_mix_vote', {id: mix.id }) }}" method="POST"> | |
<button | |
type="submit" | |
class="btn btn-outline-primary" | |
><span class="fa fa-thumbs-up"></span></button> | |
</form> | |
... lines 23 - 25 | |
{% endblock %} |
Perfecto! Let's go check it out. Refresh and... thumbs up! And when we click it... beautiful! It hits the endpoint! Notice that the URL didn't change... that's just because Turbo submitted the form via Ajax... and then our dd()
stopped everything.
Ok, in a minute, we're going to add another button with a thumbs down. So, somehow, in our controller, we're going to need to figure out which button - up or down - was just pushed.
To do that, on the button, add name="direction"
and value="up"
. Now, if we click this button, it will send one piece of POST data called direction
set to the value up
... almost as if the user typed the word up
into a text field.
... lines 1 - 16 | |
<form action="{{ path('app_mix_vote', {id: mix.id }) }}" method="POST"> | |
<button | |
... lines 19 - 20 | |
name="direction" | |
value="up" | |
><span class="fa fa-thumbs-up"></span></button> | |
</form> | |
... lines 25 - 29 |
Ok... but how do we read POST data in Symfony? Whenever you need to read anything from the request - like POST data, query parameters, uploaded files, or headers - you'll need Symfony's Request
object. And there are two ways to get it.
The first is by autowiring a service called RequestStack
. Then you can get the current request by saying $requestStack->getCurrentRequest()
.
This works anywhere that you can autowire a service. But in a controller, there's an easier way. Undo that... and instead, add an argument that is type-hinted with Request
. Get the one from Symfony's HttpFoundation. Let's call it $request
.
... lines 1 - 8 | |
use Symfony\Component\HttpFoundation\Request; | |
... lines 10 - 12 | |
class MixController extends AbstractController | |
{ | |
... lines 15 - 44 | |
public function vote(VinylMix $mix, Request $request): Response | |
{ | |
... line 47 | |
} | |
} |
At first, this looks like autowiring, right? It looks like Request
is a service and we're autowiring that as an argument. But... surprise! Request
is not a service. Nope, this is yet another "thing" that you're allowed to have as an argument to your controller.
Let's review. We now know four different types of arguments that you can have on a controller method. One: you can have route wildcards like $id
. Two: You can autowire services. Three: You can type-hint entities. And four: You can type-hint the Request
class. Yup, the Request
object is so important that Symfony created a special case just for it.
And... it's kind of beautiful. Our whole job as developers is to "read the incoming request" and use it to "create a response". So it's... almost poetic that we can have a method that takes the Request
as an argument and returns a Response
. Input Request
, output Response
.
But I digress. There are a lot of different methods and properties on the Request to fetch whatever you need. To read POST data, say $request->request->get()
and then the name of the field. In this case, direction
.
... lines 1 - 44 | |
public function vote(VinylMix $mix, Request $request): Response | |
{ | |
dd($request->request->get('direction')); | |
} | |
... lines 49 - 50 |
We're not going to talk a lot about the Request
object... because it's... just a simple object that holds data. If you need to read something from it, just look at the docs and it'll tell you how to do it.
All right, back over here, refresh the page... upvote and... got it! Okay, remove the dd()
and set this to a direction variable with $direction =
.
If, for some reason, the direction
POST data is missing (this shouldn't happen unless someone is messing with our site), default it to up
.
... lines 1 - 44 | |
public function vote(VinylMix $mix, Request $request): Response | |
{ | |
$direction = $request->request->get('direction', 'up'); | |
} | |
... lines 49 - 50 |
Now let's add the downvote. Copy the entire button... paste... change the value to down
and update the icon class to fa fa-thumbs-down
.
... lines 1 - 4 | |
{% block body %} | |
... lines 6 - 16 | |
<form action="{{ path('app_mix_vote', {id: mix.id }) }}" method="POST"> | |
... lines 18 - 23 | |
<button | |
type="submit" | |
class="btn btn-outline-primary" | |
name="direction" | |
value="down" | |
><span class="fa fa-thumbs-down"></span></button> | |
</form> | |
... lines 31 - 33 | |
{% endblock %} |
Okay, we know that the value will either be up
or down
. In our controller, let's use this. if ($direction === 'up')
, then $mix->setVotes($mix->getVotes() + 1)
. Else, do the same thing... except it will be - 1
. Below, dd($mix)
.
... lines 1 - 12 | |
class MixController extends AbstractController | |
{ | |
... lines 15 - 44 | |
public function vote(VinylMix $mix, Request $request): Response | |
{ | |
$direction = $request->request->get('direction', 'up'); | |
if ($direction === 'up') { | |
$mix->setVotes($mix->getVotes() + 1); | |
} else { | |
$mix->setVotes($mix->getVotes() - 1); | |
} | |
dd($mix); | |
} | |
} |
On a real site, we'll probably also store which user is voting so that they can't vote over and over again. We'll learn how to do that in a future tutorial. But this will work just fine for now.
All right, head back and refresh. We have 49 votes. If we click the upvote button... 50! If we refresh and click downvote... 48!
Yay! But, we still haven't saved this value to the database. When we refresh, it always goes back to the original "49".
So... next, let's do that! We'll make an UPDATE query to the database and also finish the endpoint by redirecting to another page.
"Houston: no signs of life"
Start the conversation!
// composer.json
{
"require": {
"php": ">=8.1",
"ext-ctype": "*",
"ext-iconv": "*",
"babdev/pagerfanta-bundle": "^3.7", // v3.7.0
"doctrine/doctrine-bundle": "^2.7", // 2.7.0
"doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
"doctrine/orm": "^2.12", // 2.12.3
"knplabs/knp-time-bundle": "^1.18", // v1.19.0
"pagerfanta/doctrine-orm-adapter": "^3.6", // v3.6.1
"pagerfanta/twig": "^3.6", // v3.6.1
"sensio/framework-extra-bundle": "^6.2", // v6.2.6
"stof/doctrine-extensions-bundle": "^1.7", // v1.7.0
"symfony/asset": "6.1.*", // v6.1.0
"symfony/console": "6.1.*", // v6.1.2
"symfony/dotenv": "6.1.*", // v6.1.0
"symfony/flex": "^2", // v2.2.2
"symfony/framework-bundle": "6.1.*", // v6.1.2
"symfony/http-client": "6.1.*", // v6.1.2
"symfony/monolog-bundle": "^3.0", // v3.8.0
"symfony/proxy-manager-bridge": "6.1.*", // v6.1.0
"symfony/runtime": "6.1.*", // v6.1.1
"symfony/twig-bundle": "6.1.*", // v6.1.1
"symfony/ux-turbo": "^2.0", // v2.3.0
"symfony/webpack-encore-bundle": "^1.13", // v1.15.1
"symfony/yaml": "6.1.*", // v6.1.2
"twig/extra-bundle": "^2.12|^3.0", // v3.4.0
"twig/twig": "^2.12|^3.0" // v3.4.1
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
"symfony/debug-bundle": "6.1.*", // v6.1.0
"symfony/maker-bundle": "^1.41", // v1.44.0
"symfony/stopwatch": "6.1.*", // v6.1.0
"symfony/web-profiler-bundle": "6.1.*", // v6.1.2
"zenstruck/foundry": "^1.21" // v1.21.0
}
}