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 SubscribeNow that each Question
has an owner
- a User
object - it's time to celebrate! On the frontend, we can start rendering real data... instead of always having the same cat picture and question written by the same Tisha. Those are both hard-coded, though we do love Tisha the cat here at SymfonyCasts.
Start on the homepage. Open up templates/question/homepage.html.twig
. And... here's where we loop over the questions. First, for the avatar, we can use the helper method we created earlier: {{ question.owner.avatarUri }}
:
... lines 1 - 2 | |
{% block body %} | |
... lines 4 - 9 | |
<div class="container"> | |
... lines 11 - 15 | |
<div class="row"> | |
{% for question in pager %} | |
<div class="col-12 mb-3"> | |
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);"> | |
<div class="q-container p-4"> | |
<div class="row"> | |
<div class="col-2 text-center"> | |
<img src="{{ question.owner.avatarUri }}" width="100" height="100" alt="{{ question.owner.displayName}} avatar"> | |
... lines 24 - 29 | |
</div> | |
... lines 31 - 38 | |
</div> | |
</div> | |
... lines 41 - 45 | |
</div> | |
</div> | |
{% endfor %} | |
... lines 49 - 50 | |
</div> | |
</div> | |
{% endblock %} | |
... lines 54 - 55 |
Next... down towards the bottom, here's where we print the question owner's name. Let's use question.owner.displayName
:
... lines 1 - 2 | |
{% block body %} | |
... lines 4 - 9 | |
<div class="container"> | |
... lines 11 - 15 | |
<div class="row"> | |
{% for question in pager %} | |
<div class="col-12 mb-3"> | |
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);"> | |
<div class="q-container p-4"> | |
<div class="row"> | |
... lines 22 - 30 | |
<div class="col"> | |
... line 32 | |
<div class="q-display p-3"> | |
... lines 34 - 35 | |
<p class="pt-4"><strong>--{{ question.owner.displayName }}</strong></p> | |
</div> | |
</div> | |
</div> | |
</div> | |
... lines 41 - 45 | |
</div> | |
</div> | |
{% endfor %} | |
... lines 49 - 50 | |
</div> | |
</div> | |
{% endblock %} | |
... lines 54 - 55 |
100 experience points for using two custom methods in a row.
And now... our page is starting to look real! Click into a question. Let's do the same thing for the show page. Open that template: show.html.twig
.
For the avatar, use question.owner.avatarUri
:
... lines 1 - 4 | |
{% block body %} | |
<div class="container"> | |
<div class="row"> | |
<div class="col-12"> | |
<h2 class="my-4">Question:</h2> | |
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);"> | |
<div class="q-container-show p-4"> | |
<div class="row"> | |
<div class="col-2 text-center"> | |
<img src="{{ question.owner.avatarUri }}" width="100" height="100" alt="{{ question.owner.displayName}} avatar"> | |
... lines 15 - 32 | |
</div> | |
... lines 34 - 41 | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
... lines 47 - 59 | |
</div> | |
{% endblock %} |
Then... down here, for the name, {{ question.owner.displayName }}
:
... lines 1 - 4 | |
{% block body %} | |
<div class="container"> | |
<div class="row"> | |
<div class="col-12"> | |
<h2 class="my-4">Question:</h2> | |
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);"> | |
<div class="q-container-show p-4"> | |
<div class="row"> | |
... lines 13 - 33 | |
<div class="col"> | |
<h1 class="q-title-show">{{ question.name }}</h1> | |
<div class="q-display p-3"> | |
... lines 37 - 38 | |
<p class="pt-4"><strong>--{{ question.owner.displayName }}</strong></p> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
... lines 47 - 59 | |
</div> | |
{% endblock %} |
Oh, and I forgot to do one thing. Copy that, head back up to the avatar... so that we can also update the alt
attribute:
... lines 1 - 4 | |
{% block body %} | |
<div class="container"> | |
<div class="row"> | |
<div class="col-12"> | |
<h2 class="my-4">Question:</h2> | |
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);"> | |
<div class="q-container-show p-4"> | |
<div class="row"> | |
<div class="col-2 text-center"> | |
<img src="{{ question.owner.avatarUri }}" width="100" height="100" alt="{{ question.owner.displayName}} avatar"> | |
... lines 15 - 32 | |
</div> | |
... lines 34 - 41 | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
... lines 47 - 59 | |
</div> | |
{% endblock %} |
I also need to do that on the homepage... here it is:
... lines 1 - 2 | |
{% block body %} | |
... lines 4 - 9 | |
<div class="container"> | |
... lines 11 - 15 | |
<div class="row"> | |
{% for question in pager %} | |
<div class="col-12 mb-3"> | |
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);"> | |
<div class="q-container p-4"> | |
<div class="row"> | |
<div class="col-2 text-center"> | |
<img src="{{ question.owner.avatarUri }}" width="100" height="100" alt="{{ question.owner.displayName}} avatar"> | |
... lines 24 - 29 | |
</div> | |
... lines 31 - 38 | |
</div> | |
</div> | |
... lines 41 - 45 | |
</div> | |
</div> | |
{% endfor %} | |
... lines 49 - 50 | |
</div> | |
</div> | |
{% endblock %} | |
... lines 54 - 55 |
Let's try this! Refresh the page and... we are dynamic!
In a real site, we're probably going to need a page where the owner of this question can edit its details. We're not going to build this out all the way - I don't want to dive into the form system - but we are going to get it started. And this is going to lead us to a really interesting security situation.
Over in src/Controller/QuestionController.php
... find the show()
action. Let's cheat by copying this and pasting it. Change the URL to /questions/edit/{slug}
, tweak the route name and update the method name. Inside, just render a template: question/edit.html.twig
:
... lines 1 - 18 | |
class QuestionController extends AbstractController | |
{ | |
... lines 21 - 69 | |
/** | |
* @Route("/questions/edit/{slug}", name="app_question_edit") | |
*/ | |
public function edit(Question $question) | |
{ | |
return $this->render('question/edit.html.twig', [ | |
'question' => $question, | |
]); | |
} | |
... lines 79 - 98 | |
} |
Cool! In templates/question/
, create that: edit.html.twig
.
I'll paste in a basic template:
{% extends 'base.html.twig' %} | |
{% block title %}Edit Question: {{ question.name }}{% endblock %} | |
{% block body %} | |
<div class="container"> | |
<div class="row"> | |
<div class="col-12"> | |
<h1 class="my-4">Edit Question</h1> | |
<blockquote>{{ question.question }}</blockquote> | |
TODO | |
</div> | |
</div> | |
</div> | |
{% endblock %} |
Nothing special here, except that I'm printing the dynamic question text. There's no actually form... since we're focusing on security... but pretend that there is.
Before we try this page, head back into the question show template. Let's add an edit link to help out the owner. Actually, find the h1
. Here we go.
Wrap this in a div with class="d-flex justify-content-between"
... and then close and indent:
... lines 1 - 4 | |
{% block body %} | |
<div class="container"> | |
<div class="row"> | |
<div class="col-12"> | |
<h2 class="my-4">Question:</h2> | |
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);"> | |
<div class="q-container-show p-4"> | |
<div class="row"> | |
... lines 13 - 33 | |
<div class="col"> | |
<div class="d-flex justify-content-between"> | |
<h1 class="q-title-show">{{ question.name }}</h1> | |
... lines 37 - 40 | |
</div> | |
... lines 42 - 46 | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
... lines 53 - 65 | |
</div> | |
{% endblock %} |
Now add a link with href=
path('app_question_edit')
. And, of course, we need to pass this the wildcard: id
set to question.id
. Oh... wait, actually, the wildcard is slug
:
... lines 1 - 18 | |
class QuestionController extends AbstractController | |
{ | |
... lines 21 - 69 | |
/** | |
* @Route("/questions/edit/{slug}", name="app_question_edit") | |
*/ | |
public function edit(Question $question) | |
{ | |
... lines 75 - 77 | |
} | |
... lines 79 - 98 | |
} |
So use slug
set to question.slug
:
... lines 1 - 4 | |
{% block body %} | |
<div class="container"> | |
<div class="row"> | |
<div class="col-12"> | |
<h2 class="my-4">Question:</h2> | |
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);"> | |
<div class="q-container-show p-4"> | |
<div class="row"> | |
... lines 13 - 33 | |
<div class="col"> | |
<div class="d-flex justify-content-between"> | |
<h1 class="q-title-show">{{ question.name }}</h1> | |
<a href="{{ path('app_question_edit', { | |
slug: question.slug | |
}) }}" class="btn btn-secondary btn-sm mb-2">Edit</a> | |
</div> | |
... lines 42 - 46 | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
... lines 53 - 65 | |
</div> | |
{% endblock %} |
Cool. Then say "Edit"... and give this a few classes for prettiness.
Thanks to this... we have an edit button! Oh, but we need some margin! Add mb-2
:
... lines 1 - 4 | |
{% block body %} | |
<div class="container"> | |
<div class="row"> | |
<div class="col-12"> | |
<h2 class="my-4">Question:</h2> | |
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);"> | |
<div class="q-container-show p-4"> | |
<div class="row"> | |
... lines 13 - 33 | |
<div class="col"> | |
<div class="d-flex justify-content-between"> | |
... lines 36 - 37 | |
<a href="{{ path('app_question_edit', { | |
slug: question.slug | |
}) }}" class="btn btn-secondary btn-sm mb-2">Edit</a> | |
</div> | |
... lines 42 - 46 | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
... lines 53 - 65 | |
</div> | |
{% endblock %} |
and... much better. Click that. This is the question edit page... which is not really an edit page... but pretend that it is.
Now let's circle back to the topic of security. Because... we can't just let anyone get to this page: only the owner of this question should be able to edit it.
So inside of QuestionController
, we need a security check. We first need to make sure that the user is logged in. Do that with $this->denyAccessUnlessGranted()
passing IS_AUTHENTICATED_REMEMBERED
:
... lines 1 - 18 | |
class QuestionController extends AbstractController | |
{ | |
... lines 21 - 72 | |
public function edit(Question $question) | |
{ | |
$this->denyAccessUnlessGranted('ROLE_USER'); | |
... lines 76 - 82 | |
} | |
... lines 84 - 103 | |
} |
Thanks to this, we're guaranteed to get a User
object if we say $this->getUser()
. We can use that: if $question->getOwner()
does not equal $this->getUser()
, then someone other than the owner is trying to access this page. Deny access with throw $this->createAccessDeniedException()
. I'll say:
You are not the owner!
But, remember, these error messages are only shown to developers:
... lines 1 - 18 | |
class QuestionController extends AbstractController | |
{ | |
... lines 21 - 72 | |
public function edit(Question $question) | |
{ | |
$this->denyAccessUnlessGranted('ROLE_USER'); | |
if ($question->getOwner() !== $this->getUser()) { | |
throw $this->createAccessDeniedException('You are not the owner!'); | |
} | |
... lines 79 - 82 | |
} | |
... lines 84 - 103 | |
} |
Ok, so right now I'm not logged in at all. So if we refresh, it kicks us back to the login page. So... yay! We just successfully prevented anyone other than the owner from accessing this edit page!
But... bad news friends: I don't like this solution. I don't like putting any manual security logic inside my controller. Why? Because it means that we're going to need to repeat that logic in Twig in order to hide or show the edit button. And what if our logic gets more complex? What if you can edit a question if you're the owner or if you have ROLE_ADMIN
? Now we would need to update and maintain the duplicate logic in two places at least. Nope, we do not want to duplicate our security rules.
So next let's learn about the voter system, which is the key to centralizing all of this authorization logic in a beautiful way.
Hey @gazzatav!
Ah, I know that "EOF" page that you're talking about, and it's super frustrating. When this happens, basically, something inside your app/PHP is exploding in a horrible way. This is... almost the web server's "segfault": something went so wrong that it can't even show it. Often you just need to stop the server and restart it. But it's also very possible that there is some sort of bug/bad behavior in the code... but unfortunately, it's so "bad" (e.g. infinite recursion) that it can't be shown.. which makes it pretty tough to debug.
Looking at the situation above, it seems like the "common thread" is that if you go to ANY question (your question or another question) while not authenticated, you get the EOF. We need to figure out why. I would temporarily, GREATLY reduce the code on the question page - heck even put a "return new Response('foo')" in that controller to see if you can get it to load. Assuming that works, slowly re-add code to your page until you trigger that EOF. Hopefully that'll help you dig down into the problem. If there's a better way to debug the EOF, I'm not aware of it. Also, if you don't have XDebug installed, try that: that can help handle things like infinite recursion better.
Let me know what you find out :).
Cheers!
// composer.json
{
"require": {
"php": ">=8.1",
"ext-ctype": "*",
"ext-iconv": "*",
"babdev/pagerfanta-bundle": "^3.3", // v3.3.0
"composer/package-versions-deprecated": "^1.11", // 1.11.99.4
"doctrine/annotations": "^1.0", // 1.13.2
"doctrine/doctrine-bundle": "^2.1", // 2.6.3
"doctrine/doctrine-migrations-bundle": "^3.0", // 3.1.1
"doctrine/orm": "^2.7", // 2.10.1
"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
"phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
"scheb/2fa-bundle": "^5.12", // v5.12.1
"scheb/2fa-qr-code": "^5.12", // v5.12.1
"scheb/2fa-totp": "^5.12", // v5.12.1
"sensio/framework-extra-bundle": "^6.0", // v6.2.0
"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.8
"symfony/flex": "^1.3.1", // v1.17.5
"symfony/form": "5.3.*", // v5.3.8
"symfony/framework-bundle": "5.3.*", // v5.3.8
"symfony/monolog-bundle": "^3.0", // v3.7.0
"symfony/property-access": "5.3.*", // v5.3.8
"symfony/property-info": "5.3.*", // v5.3.8
"symfony/rate-limiter": "5.3.*", // v5.3.4
"symfony/runtime": "5.3.*", // v5.3.4
"symfony/security-bundle": "5.3.*", // v5.3.8
"symfony/serializer": "5.3.*", // v5.3.8
"symfony/stopwatch": "5.3.*", // v5.3.4
"symfony/twig-bundle": "5.3.*", // v5.3.4
"symfony/ux-chartjs": "^1.3", // v1.3.0
"symfony/validator": "5.3.*", // v5.3.8
"symfony/webpack-encore-bundle": "^1.7", // v1.12.0
"symfony/yaml": "5.3.*", // v5.3.6
"symfonycasts/verify-email-bundle": "^1.5", // v1.5.0
"twig/extra-bundle": "^2.12|^3.0", // v3.3.3
"twig/string-extra": "^3.3", // v3.3.3
"twig/twig": "^2.12|^3.0" // v3.3.3
},
"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.34.0
"symfony/var-dumper": "5.3.*", // v5.3.8
"symfony/web-profiler-bundle": "5.3.*", // v5.3.8
"zenstruck/foundry": "^1.1" // v1.13.3
}
}
Gary Taverner • 6 minutes ago
Hi
At the end of this chapter I found the behaviour when I try to edit a question different from the video. If I'm not authenticated I go to the symfony local server's EOF page (traffic lights, red stripe on blue background # EOF). Searching for 'EOF' in symfony docs gets exactly 0 matches for EOF (which surprised me, and no, 'effective' is not what I was searching for).
Unauthenticated - my question -> # EOF
Authenticated - my question -> app_question_edit
Unauthenticated - not my question -> # EOF
Authenticated - not my question -> 403 Access Denied
The redirection to the login page works if I go to /api/me while unauthenticated.
In the logs I can see that three authenticators are tried and then I'm referred to the entry point which is form_login:
<blockquote>[Application] Aug 6 15:51:17 |DEBUG | SECURI Checking for authenticator support. authenticators=3 firewall_name="main"
[Application] Aug 6 15:51:17 |DEBUG | SECURI Checking support on authenticator. authenticator="App\Security\DummyAuthenticator"
[Application] Aug 6 15:51:17 |DEBUG | SECURI Authenticator does not support the request.
[Application] Aug 6 15:51:17 |DEBUG | SECURI Checking support on authenticator. authenticator="Symfony\Component\Security\Http\Authenticator\FormLoginAuthenticator"
[Application] Aug 6 15:51:17 |DEBUG | SECURI Authenticator does not support the request.
[Application] Aug 6 15:51:17 |DEBUG | SECURI Checking support on authenticator. authenticator="Symfony\Component\Security\Http\Authenticator\RememberMeAuthenticator"
[Application] Aug 6 15:51:17 |DEBUG | SECURI Authenticator does not support the request.
[Application] Aug 6 15:51:17 |DEBUG | DOCTRI SELECT t0.id AS id_1, t0.title AS title_2, t0.slug AS slug_3, t0.question AS question_4, t0.asked_at AS asked_at_5, t0.votes AS votes_6, t0.created_at AS created_at_7, t0.updated_at AS updated_at_8, t0.owner_id AS owner_id_9 FROM question t0 WHERE t0.slug = ? LIMIT 1 0="et-sunt-ut-numquam-sit"
[Application] Aug 6 15:51:17 |DEBUG | SECURI Access denied, the user is not fully authenticated; redirecting to authentication entry point.
[Application] Aug 6 15:51:17 |DEBUG | SECURI Calling Authentication entry point.
[Web Server ] Aug 6 15:51:18 |ERROR | SERVER GET (502) /questions/edit/et-sunt-ut-numquam-sit ip="::1"
</blockquote>
Under form_login in security.yaml I have:
My form_login leads to app_login so I'm mystified as to why this fails. In the address bar I can see the path /questions/edit/et-sunt-ut-numquam-sit. The end of the query looks odd though 't0.slug = ? LIMIT 1 0="et-sunt-ut-numquam-sit"'.
Any ideas?