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 mission: let's add a search box to this answers page. Head over to popularAnswers.html.twig
. We don't actually need a row here... so I'm going to simplify my markup: move this <ul>
to the bottom. Cool. Now we can give this div
on top a d-flex
class and also justify-content-between
. This will let us have this <h1>
on the left and a search form on the right.
... lines 1 - 5 | |
<div class="container my-md-4"> | |
<div class="d-flex justify-content-between"> | |
<h1>Most Popular Answers</h1> | |
... lines 9 - 18 | |
</div> | |
<ul class="list-unstyled"> | |
{% for answer in answers %} | |
{{ include('answer/_answer.html.twig', { | |
showQuestion: true | |
}) }} | |
{% endfor %} | |
</ul> | |
</div> | |
... lines 29 - 30 |
Add the form
tag. This will submit right to this AnswerController
route. So set the action to {{ path('app_popular_answers') }}
. I'm going to not add a method=""
attribute, because that defaults to GET
, which is what you want for a search form.
... lines 1 - 5 | |
<div class="container my-md-4"> | |
<div class="d-flex justify-content-between"> | |
<h1>Most Popular Answers</h1> | |
<form action="{{ path('app_popular_answers') }}"> | |
... lines 11 - 17 | |
</form> | |
</div> | |
... lines 20 - 27 | |
</div> | |
... lines 29 - 30 |
Inside, add the search field: <input type="search">
. I'll break this on multiple lines. Add name="q"
- that q
could be anything, but we'll read that from our controller - a class
, a placeholder
and an aria-label=""
for accessibility since we don't have a real label for this field.
... lines 1 - 5 | |
<div class="container my-md-4"> | |
<div class="d-flex justify-content-between"> | |
<h1>Most Popular Answers</h1> | |
<form action="{{ path('app_popular_answers') }}"> | |
<input | |
type="search" | |
name="q" | |
class="form-control" | |
placeholder="Search..." | |
aria-label="Search" | |
> | |
</form> | |
</div> | |
... lines 20 - 27 | |
</div> | |
... lines 29 - 30 |
By the way, I'm not using the Symfony's form component because we haven't talked about it yet... but also because this form is so simple that it's overkill anyways.
Refresh now. Looks awesome! And if we fill in the box and hit enter... we come right back to this page, but now with ?q=bananas
on the URL. The results don't change because we're not reading that query parameter in our code yet. So let's do that.
Head into AnswerController
. Here's the plan: we're going to read that ?q=
from the URL, pass that string into findMostPopular()
as an argument, and then use that inside of the query to add a where answer.content LIKE
that search term. So, a fuzzy search.
But inside of the controller, how can we read the ?q=
from the URL in Symfony? Whenever you need to read anything from the request - like query parameters, post data, headers or cookies - you need Symfony's Request
object: it holds all of these goodies.
And if you're in a controller, it's easy to get! Add a new argument type-hinted with Request
- the one from HttpFoundation
. You can call the argument anything, but I'll use $request
to avoid being crazy.
... lines 1 - 12 | |
class AnswerController extends AbstractController | |
{ | |
... lines 15 - 17 | |
public function popularAnswers(AnswerRepository $answerRepository, Request $request) | |
{ | |
... lines 20 - 26 | |
} | |
... lines 28 - 50 | |
} |
Here's how this works, it's pretty simple: if you have an argument to your controller that's type-hinted with Symfony's Request
class, Symfony will pass you the Request
object. This class has a bunch of methods on it to get anything you need from the request. To fetch a query parameter, use $request->query->get()
and then the name: q
. If that query parameter isn't there, this will return null.
... lines 1 - 12 | |
class AnswerController extends AbstractController | |
{ | |
... lines 15 - 17 | |
public function popularAnswers(AnswerRepository $answerRepository, Request $request) | |
{ | |
$answers = $answerRepository->findMostPopular( | |
$request->query->get('q') | |
); | |
... lines 23 - 26 | |
} | |
... lines 28 - 50 | |
} |
Over in the repository, add a new string $search
argument... I'll let it be optional, in part, so that it accepts a null
value.
... lines 1 - 15 | |
class AnswerRepository extends ServiceEntityRepository | |
{ | |
... lines 18 - 43 | |
public function findMostPopular(string $search = null): array | |
{ | |
... lines 46 - 60 | |
} | |
} |
For the query, let's do it in pieces. Add $queryBuilder =
the first part... and stop after the addSelect()
.
... lines 1 - 15 | |
class AnswerRepository extends ServiceEntityRepository | |
{ | |
... lines 18 - 43 | |
public function findMostPopular(string $search = null): array | |
{ | |
$queryBuilder = $this->createQueryBuilder('answer') | |
->addCriteria(self::createApprovedCriteria()) | |
->orderBy('answer.votes', 'DESC') | |
->innerJoin('answer.question', 'question') | |
->addSelect('question'); | |
... lines 51 - 60 | |
} | |
} |
At the bottom return $queryBuilder
and then the rest. I'll... fix my typo.
... lines 1 - 15 | |
class AnswerRepository extends ServiceEntityRepository | |
{ | |
... lines 18 - 43 | |
public function findMostPopular(string $search = null): array | |
{ | |
$queryBuilder = $this->createQueryBuilder('answer') | |
->addCriteria(self::createApprovedCriteria()) | |
->orderBy('answer.votes', 'DESC') | |
->innerJoin('answer.question', 'question') | |
->addSelect('question'); | |
... lines 51 - 60 | |
} | |
} |
The reason we're splitting this into two pieces is that we only want to apply the search logic if a search term was actually passed. Splitting it lets us say if $search
, then, $queryBuilder->andWhere()
with answer.content
- that's the field we're going to search inside of - LIKE :searchTerm
. That searchTerm
could be anything: it's just a placeholder that we fill in by saying ->setParameter('searchTerm', $search)
. Except... to be a fuzzy search, we need to put %
on each side. I know, it looks funny, but that's exactly what we want.
... lines 1 - 15 | |
class AnswerRepository extends ServiceEntityRepository | |
{ | |
... lines 18 - 43 | |
public function findMostPopular(string $search = null): array | |
{ | |
$queryBuilder = $this->createQueryBuilder('answer') | |
->addCriteria(self::createApprovedCriteria()) | |
->orderBy('answer.votes', 'DESC') | |
->innerJoin('answer.question', 'question') | |
->addSelect('question'); | |
if ($search) { | |
$queryBuilder->andWhere('answer.content LIKE :searchTerm') | |
->setParameter('searchTerm', '%'.$search.'%'); | |
} | |
return $queryBuilder | |
->setMaxResults(10) | |
->getQuery() | |
->getResult(); | |
} | |
} |
Let's try it! Clear the ?q=
from the URL first. Cool: we have our normal, non-filtered results. Copy a word from an answer to search for. And... got it! The top item became the second result... but this third result is definitely new. But let's search a different word to make it even more obvious. Yup! That's working.
Though... it's not very obvious that we're filtering because we're not rendering the search term in the search box. Open up popularAnswers.html.twig
and add a value=""
. To render the current search term, we could read the query parameter in the controller and pass it into our template as a variable. But in this case, we can cheat because the request object is available in every template via app.request
. So we can say app.request.query.get('q')
.
... lines 1 - 5 | |
<div class="container my-md-4"> | |
<div class="d-flex justify-content-between"> | |
... lines 8 - 9 | |
<form action="{{ path('app_popular_answers') }}"> | |
<input | |
... lines 12 - 13 | |
value="{{ app.request.query.get('q') }}" | |
... lines 15 - 17 | |
> | |
</form> | |
</div> | |
... lines 21 - 28 | |
</div> | |
... lines 30 - 31 |
Now... much better.
But, our search could be smarter! Well, if we wanted to make our search really smart, we should probably use something like Elasticsearch. But to make our search a little bit cooler, let's also return results that match the question's text.
For example, clear out the search term... and let's search for something that's in the first question. Hit enter. That result disappears because we're not searching the question text yet.
Over in AnswerRepository
, let's think. We want to query where answer.content LIKE :searchTerm
or the question's text is LIKE :searchTerm
.
The QueryBuilder
does have an orWhere()
method. Big win, right!
Actually... no! I never use that method. The reason is that it gets tricky to get the parentheses correct in a query when using orWhere()
. I'll show you what I mean when we see the final query. The point is that if you need an OR
in a WHERE statement, you should still use andWhere()
. Yup, we can say: answer.content LIKE :searchTerm OR
and then pass another expression. We want to search on the $question
property of the Question
entity. And since we joined over to the Question
entity and aliased it to question
, we can say question.question LIKE
and use that same :searchTerm
placeholder.
... lines 1 - 15 | |
class AnswerRepository extends ServiceEntityRepository | |
{ | |
... lines 18 - 43 | |
public function findMostPopular(string $search = null): array | |
{ | |
$queryBuilder = $this->createQueryBuilder('answer') | |
->addCriteria(self::createApprovedCriteria()) | |
->orderBy('answer.votes', 'DESC') | |
->innerJoin('answer.question', 'question') | |
->addSelect('question'); | |
if ($search) { | |
$queryBuilder->andWhere('answer.content LIKE :searchTerm OR question.question LIKE :searchTerm') | |
->setParameter('searchTerm', '%'.$search.'%'); | |
} | |
... lines 56 - 60 | |
} | |
} |
That's it! When we refresh now... yes! That first result showed back up! And check out the query for this page, it's pretty sweet.... and easier to see in the formatted version. Check out the WHERE clause. I totally forgot that we were already filtering WHERE status = approved
. But because we put the OR
statement inside of the andWhere()
, Doctrine surrounded the entire fuzzy search part with parentheses. If we had used orWhere()
, that wouldn't have happened... and our query logic would have been wrong: it would have allowed non-approved answers to be returned as long the search term matched the question text.
Ok! We've mastered the ManyToOne
relationship, which is actually the same as the OneToMany
relationship. We got two for one! That means that there are only two more relationships to learn about: OneToOne
and ManyToMany
. Except... that's not true: we really only have one more relationship to learn about. Next: we'll discover that there are really only two types of relationships, not four.
"Houston: no signs of life"
Start the conversation!
// 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
}
}