Leveraging the Question Owner

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

Now 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!

Creating the Question Edit Page

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.

Linking to the Edit Page

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.

Leave a comment!

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.4
        "doctrine/annotations": "^1.0", // 1.13.2
        "doctrine/doctrine-bundle": "^2.1", // 2.4.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
    }
}