Filtering to Return only Approved Answers

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

As wonderful as our users are, sometimes we need to mark an answer as spam. Or, maybe in the future, we might add a system that notices too many links in an answer and marks it as "needs approval". So each answer will be one of three statuses: needs approval, spam, or approved. And only answers with the approved status should be visible on the site.

Adding the Answer status Property

Right now, inside of our Answer entity, we don't have any way to track the status. So let's add a new property for it. At your console run:

symfony console make:entity

We're going to update the Answer entity. Add a new field called status and make it a string type. This property will be a, kind of, ENUM field: it'll hold one of three possible short status strings. Set the length to 15, which will be more than enough to hold the status string. Make this required in the database and... done!

Generate the migration immediately:

symfony console make:migration

Let's go double check that just to make sure it doesn't contain any surprises

... lines 1 - 12
final class Version20210902182514 extends AbstractMigration
{
... lines 15 - 19
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE answer ADD status VARCHAR(15) NOT NULL');
... line 24
}
... lines 26 - 32
}

It looks good:

ALTER TABLE answer ADD status.

Close that, spin back to your terminal and execute it:

symfony console doctrine:migrations:migrate

Because we have exactly three possible statuses, I'm going to add a constant for each one. Now, if you're using PHP 8.1, you could use the new enum type to help with this - and you totally should. But either way, you'll ultimately store a string in the database.

Add public const STATUS_NEEDS_APPROVAL = 'needs_approval'. I just made up that needs_approval part - that's what will be stored in the database. Copy that, paste it twice, and create the other two statuses: spam and approved, setting each to a simple string.

... lines 1 - 11
class Answer
{
public const STATUS_NEEDS_APPROVAL = 'needs_approval';
public const STATUS_SPAM = 'spam';
public const STATUS_APPROVED = 'approved';
... lines 17 - 120
}

Awesome. Now default the status property down here to self::STATUS_NEEDS_APPROVAL: comments will "need approval" unless we say otherwise.

... lines 1 - 11
class Answer
{
public const STATUS_NEEDS_APPROVAL = 'needs_approval';
public const STATUS_SPAM = 'spam';
public const STATUS_APPROVED = 'approved';
... lines 17 - 50
private $status = self::STATUS_NEEDS_APPROVAL;
... lines 52 - 120
}

Finally, down on setStatus(), let's add a sanity check: if someone passes a status that is not one of those three, we should throw an exception. So if not in_array($status, [])... and then I'll create an array with the three constants: self::STATUS_NEEDS_APPROVAL, self::STATUS_SPAM and self::STATUS_APPROVED. So if it's not inside that array, then throw a new InvalidArgumentException() with a nice message.

... lines 1 - 11
class Answer
{
public const STATUS_NEEDS_APPROVAL = 'needs_approval';
public const STATUS_SPAM = 'spam';
public const STATUS_APPROVED = 'approved';
... lines 17 - 50
private $status = self::STATUS_NEEDS_APPROVAL;
... lines 52 - 110
public function setStatus(string $status): self
{
if (!in_array($status, [self::STATUS_NEEDS_APPROVAL, self::STATUS_SPAM, self::STATUS_APPROVED])) {
throw new \InvalidArgumentException(sprintf('Invalid status "%s"', $status));
}
$this->status = $status;
return $this;
}
}

A little gatekeeping to make sure that we always have a valid status.

Creating Approved and Non-Approved Answer Fixtures

Now that the new status property is done, open src/Factory/AnswerFactory.php. Down in getDefaults(), set status to Answer::STATUS_APPROVED.

... lines 1 - 28
final class AnswerFactory extends ModelFactory
{
... lines 31 - 37
protected function getDefaults(): array
{
return [
... lines 41 - 45
'status' => Answer::STATUS_APPROVED,
];
}
... lines 49 - 61
}

So when we create answers via the factory, let's make them approved by default so they show up on the site.

But I actually do want a mixture of approved and not approved answers in my fixtures to make sure things are working. To allow that, add a new method: public function, how about, needsApproval(), that will return self. Inside, return $this->addState() and pass this an array with status set to Answer::STATUS_NEEDS_APPROVAL.

... lines 1 - 28
final class AnswerFactory extends ModelFactory
{
... lines 31 - 37
public function needsApproval(): self
{
return $this->addState(['status' => Answer::STATUS_NEEDS_APPROVAL]);
}
... lines 42 - 66
}

Now go open the fixtures class: src/DataFixtures/AppFixtures.php. These 100 answers, thanks to getDefaults(), will all be approved. Let's also save some "needs approval" answers. Do that with AnswerFactory::new() - to get a new instance of AnswerFactory, ->needsApproval(), ->many() to say that we want 20, and finally ->create() to actually do the work.

Thanks to the getDefaults() method, for each Answer, this will create a new, unpublished question to relate to... which is actually not what we want: we want to relate this to one of the questions we've already created. Let's use the same trick we used before. Inside the new() method, we can pass a callable. Use the $questions variable to get it into scope... and then paste.

... lines 1 - 11
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager)
{
... lines 16 - 28
AnswerFactory::new(function() use ($questions) {
return [
'question' => $questions[array_rand($questions)]
];
})->needsApproval()->many(20)->create();
... lines 34 - 35
}
}

So this will create 20 new, "needs approval" answers that are set to a random published Question. Phew! Let's get these loaded. At your terminal, run:

symfony console doctrine:fixtures:load

No errors!

Creating Question::getApprovedAnswers()

Cool. But how do we actually hide the non-approved answers from the frontend?

Go back to the homepage... and find a question with a lot of answers. This one has 10, so there's a pretty good chance that one of these is not approved and should be hidden. But how can we hide those answers?

Inside of show.html.twig, we get the answers by saying question.answers.

... lines 1 - 4
{% block body %}
... lines 6 - 54
<ul class="list-unstyled">
{% for answer in question.answers %}
... lines 57 - 92
{% endfor %}
</ul>
... line 95
{% endblock %}

So this is calling $question->getAnswers(), which, of course, returns all of the related answers.

... lines 1 - 14
class Question
{
... lines 17 - 148
/**
* @return Collection|Answer[]
*/
public function getAnswers(): Collection
{
return $this->answers;
}
... lines 156 - 177
}

We could solve this by going back to QuestionController and, in the show() action, executing a custom query through the AnswerRepository where question equals this question and status = approved... and then passing that array into the template.

But... ugggh, I don't want to do that! I still want to be able to use a nice shortcut method in my template! It makes my life so much easier! So... let's do that!

In the Question class... anywhere, but right after getAnswers() makes sense, create a new function called getApprovedAnswers(). This will return a Collection, just like getAnswers(): Collection is the common interface that ArrayCollection and PersistentCollection both implement.

... lines 1 - 14
class Question
{
... lines 17 - 156
public function getApprovedAnswers(): Collection
{
... lines 159 - 161
}
... lines 163 - 184
}

Inside, we're going to loop over the answers and remove any that are not approved. We could do this with a foreach loop... but there's a helper method on Collection for exactly this.

Return $this->answers->filter() and pass this a callback with an $answer argument. This callback will be executed one time for each Answer object inside the answers collection. If we return true, it will be included in the final collection that's returned. And if we return false, it won't. So we're taking the answers collection and filtering it.

... lines 1 - 14
class Question
{
... lines 17 - 156
public function getApprovedAnswers(): Collection
{
return $this->answers->filter(function(Answer $answer) {
... line 160
});
}
... lines 163 - 184
}

Inside the callback, we need to check if this answer's status is "approved". Instead of doing that here, let's add a helper method inside of Answer.

Down here, add public function isApproved() that will return a boolean. Inside, we need return $this->status === self::STATUS_APPROVED.

... lines 1 - 11
class Answer
{
... lines 14 - 121
public function isApproved(): bool
{
return $this->status === self::STATUS_APPROVED;
}
}

Back over in Question, it's easy: include this answer if $answer->isApproved().

... lines 1 - 14
class Question
{
... lines 17 - 156
public function getApprovedAnswers(): Collection
{
return $this->answers->filter(function(Answer $answer) {
return $answer->isApproved();
});
}
... lines 163 - 184
}

Sweet! We now have a new method inside of Question that will only return approved answers. All we need to do now is use this our template. In show.html.twig, use it in both spots: question.approvedAnswers... and question.approvedAnswers.

... 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.approvedAnswers|length }})</span></h2>
<button class="btn btn-sm btn-secondary">Submit an Answer</button>
</div>
... lines 52 - 54
<ul class="list-unstyled">
{% for answer in question.approvedAnswers %}
... lines 57 - 92
{% endfor %}
</ul>
... line 95
{% endblock %}

There's also a spot on the homepage where we show the count... make sure to use question.approvedAnswers here too.

... lines 1 - 2
{% block body %}
... lines 4 - 9
<div class="container">
... lines 11 - 15
<div class="row">
{% for question in questions %}
<div class="col-12 mb-3">
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);">
... lines 20 - 37
<a class="answer-link" href="{{ path('app_question_show', { slug: question.slug }) }}" style="color: #fff;">
<p class="q-display-response text-center p-3">
<i class="fa fa-magic magic-wand"></i> {{ question.approvedAnswers|length}} answers
</p>
</a>
</div>
</div>
{% endfor %}
</div>
</div>
{% endblock %}
... lines 49 - 50

Ok! Moment of truth. Right now we have 10 answers on this question. When I refresh... oh, it's still 10! Boo. We either have a bug... or that was bad luck and this question has only approved answers. Click back. Find another question that has a lot of answers. Let's see... try this one. We got it! This question originally had 11 answers, but now that we're only showing approved answers, we see 6.

So... this works! But.... there's a performance problem... and you may have spotted it. Open up the profiler to see the queries. We're still querying for all of the answers WHERE question_id = 457. But then... we're only rendering the six approved ones. That's wasteful! What we really want is some way to have this nice getApprovedAnswers() method... but make it query only for the approved answers... instead of querying for all of them and filtering them in PHP.

Is that possible? Yes! Via an amazing "criteria" system.

Leave a comment!

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.1.5
        "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.15.1
        "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/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
    }
}