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 SubscribeAs 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.
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.
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!
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.
Hey t5810,
Good question :) Yes, usually all the query logic is indeed should be placed in the entity repository class... though doing something like this in entities is kinda fine in specific cases. Well, first of all, we showed this for the learning purposes :) It might be a handy shortcut method that you can call right from the entity class. But you should understand its cons and pros. If you have a big collection - it might be overkill to do something like this, and so it's recommended to use Doctrine Criteria that we will show in the next chapter :) And thanks to them, you can move this logic out from entity class to the repository class so that technically you will just have almost no logic in the entity class, just a shortcut. In some cases it might be useful, but you totally can replace it with a custom repository method that you will call in your controller if you can - it would be the most recommended and straightforward way.
Cheers!
Excellent question!
They do not. This is controlled in your config/packages/doctrine.yaml
file: https://github.com/symfony/recipes/blob/master/doctrine/doctrine-bundle/2.4/config/packages/doctrine.yaml#L13-L16
You can see both where the directory and namespace "prefix" is listed. Change those and you can have your entities in whatever directory and namespace you want (or duplicate this entire block and load entities from App\Entity AND also somewhere else).
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.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
}
}
Hi
Great and very helpful course. I have a question related to this lecture:
Shouldn't the method
getApprovedAnswer()
be inQuestionRepository
? I thought that Entity classes should contain only the data related to the table... and helper methods (I considergetApprovedAnswer()
as a helper method, please correct me if I am wrong) should go in Repository classes...Is there any "rule" or best practice?
Cheers.