1
\$\begingroup\$

I have the following Laravel controller that searches through two models - Manufacturer and SearchTerm (which contains models of manufacturers, synonyms of models as well as common misspellings) using four different conditions: the first two for exact matches and the last two for partial matches.

I don't have a lot of experience with programming logic like this, and while it does thankfully work, it also seems to run very slow on my local server (2-3 seconds returning results from tables that are currently less than a hundred rows).

Controller

class SearchController extends Controller
{
    public function index(Manufacturer $manufacturer) {
        if (request("search")) {

        $exact_terms = SearchTerm::with("manufacturer")->where('term', request("search"))->get();
        if($exact_terms->isNotEmpty()) {
            echo "Exact search term match!";
            $results = $exact_terms;

            foreach ($results as $result) {
                $slug = $result->manufacturer->slug;
            }

            return redirect()->action([ManufacturerController::class, 'index'], ["manufacturer" => $slug]); 
        }

        $exact_make = Manufacturer::where('slug', request("search"))->get();
        if($exact_make->isNotEmpty()) {
            echo "Exact manufacturer match!";
            $results = $exact_make;

            foreach ($results as $result) {
                $slug = $result->slug;
            }
            
            return redirect()->action([ManufacturerController::class, 'index'], ["manufacturer" => $slug]); 
        }

        $search_terms = SearchTerm::with("manufacturer")->where('term', "like", '%' . request("search") . '%')->get()->unique("manufacturers");
        if($search_terms->isNotEmpty()) {
            echo "Search term match!";
            foreach ($search_terms as $search_term) {
                $search_term = $search_term->manufacturer;
            }
            $results = collect()->add($search_term); // Needed to always return a collection to the view, even if made up of one object
        } 

        $search_make = Manufacturer::where('slug', 'like', '%' . request("search") . '%')->get();
        if($search_make->isNotEmpty()) {
            echo "Manufacturer match!";
            $results = $search_make;
        }

    $results = isset($results) ? $results : collect();
    }
    
    return view("search", [
    "manufacturer" => $manufacturer,
    "results" => $results,
]);
}
}

View

@if(!$results->isEmpty())

<h1 class="my-5"> {{ $results->count() }} {{ $results->count() == 1 ? "result" : "results" }} found for <i>{{ request("search") }}</i></h1>

@foreach ($results as $result)
<img src="{{ asset("/images/badges/".$result->slug.".svg")}}" class="search-result-badge" alt="...">
<h2>{{$result->name}}</h2>

@endforeach    
@else
<p><h1>Sorry! We couldn't find any results for <i>"{{ request("search") }}"</i>.</h1></p>
<br>
@endif
\$\endgroup\$
5
  • \$\begingroup\$ Maybe you can also inspect these two packages and see what they did there. \$\endgroup\$
    – Tpojka
    Commented Feb 2, 2022 at 20:54
  • \$\begingroup\$ This looks questionable: foreach ($results as $result) { $slug = $result->manufacturer->slug; } ...is the intention to keep overwriting the $slug variable in a loop and only preserve the final value? This technique is repeated two more times in your code. Please explain. And $results keeps getting overwritten. Lots of red flags on this question to be honest. I'm leaning toward "Not Working As Intended". \$\endgroup\$ Commented Feb 2, 2022 at 21:24
  • 1
    \$\begingroup\$ @mickmackusa The code is for searching and it does that, so I think the question is on-topic, based on meta which says in order to be off topic code has to be "very obviously broken (won't run at all, or fails on a simple test case)". \$\endgroup\$
    – Laurel
    Commented Feb 2, 2022 at 22:39
  • \$\begingroup\$ @Laurel I don't use laravel, so don't know if it is broken. I was just seeing several red flags. I don't know if those queries might return multiple results -- if so, it is not working as intended. If you say it is not broken, okay. \$\endgroup\$ Commented Feb 2, 2022 at 22:44
  • \$\begingroup\$ @mickmackusa The solution definitely works but as I said I don't have much experience with programming logically so I don't know how well it works for edgecases. No, that wasn't the intention, it was just the best way I could figure out at the time of getting the relevant slug into a variable while also making sure what was being returned to the view was still a Collection. \$\endgroup\$ Commented Feb 3, 2022 at 18:23

1 Answer 1

3
\$\begingroup\$

Controller

For loops with discarded $results

This is weird:

foreach ($results as $result) {
    $slug = $result->slug; // or similar
}

You go through all that effort to get potentially a lot of matches, and then only use the last result. Why? If you only want one result, then you shouldn't get() a bunch of results. Only get the first(), perhaps after sorting appropriately. For example:

// First one:
$exact_terms = SearchTerm::with("manufacturer")->where('term', request("search"))->first();

// Highest ID:
$exact_terms = SearchTerm::with("manufacturer")->where('term', request("search"))->orderBy('id', 'DESC')->first();

I expect this is where the performance problem is.

Echos

What is the point of echo here? The only time that you would see it was while testing, so it should be removed after you've finished debugging those sections.

Sanitizing user input

The user can mess around with your like searches by entering a lot of %s in request("search") which may affect performance a little. I would strip these out, in addition to backslash and underscore.

(To clarify, you will need to keep the percents you are concatenating around request("search") as this is the only way to perform the query you want.)

Invert if

Your code has a bug, and this shows it. Where does $results come from when request("search") is missing? (Remember, frontend validation can be bypassed.)

if (!request("search")) {
    return view("search", ["manufacturer" => $manufacturer, "results" => $results, ]);
}
// Otherwise...

You'll have to have a return at the bottom but that's a small price to pay for not having so much indenting

Make the collection in a more readable way

collect()->add($search_term); becomes collect([$search_term]);, which is the same but more readable.

Null coalesce

$results = isset($results) ? $results : collect(); becomes $results ??= collect();. If you're not using PHP 7.4 to be able to do this, then update!

Return earlier with $search_terms

You throw away the results from a match with $search_terms when you have a match for $search_make. Not doing that would also allow you to streamline that part of the code.

View

Use whitespace for readability

Indent properly! The same goes for the controller.

Use spaces inside braces: {{ $content }}, not {{$content}}. Also use spaces around concatenation: $str . $str, not $str.$str.

Alt text

Don't use "..." as an alt text because that doesn't help a screen reader to know what's there. It seems appropriate here to leave it blank (to tell the screen reader to skip the image) as the image doesn't add anything to a screen reader beyond what they already can understand with $result->name on the next line.

Pluralization

In Laravel 9 you can use the helper str()->plural() (with the new, shorter syntax that makes it worth using in Blade). If you plan to localize, then you'll need to use trans_choice instead.

Invalid HTML

It's not valid to have a h1 inside a p.

Invert if

Specifically, !$results->isEmpty() can become $results->isNotEmpty() which is probably more readable.

My rewrite of your view

@if($results->isNotEmpty())
    <h1 class="my-5"> {{ $results->count() . ' ' . str('result')->plural($results->count()) }} found for <i>{{ request("search") }}</i></h1>
    @foreach ($results as $result)
        <img src="{{ asset("/images/badges/" . $result->slug . ".svg")}}" class="search-result-badge" alt="">
        <h2>{{ $result->name }}</h2>
    @endforeach
@else
    <h1>Sorry! We couldn't find any results for <i>"{{ request("search") }}"</i>.</h1>
    <br>
@endif

PS I'm not sure if asset("/images/badges/" . $result->slug . ".svg" is vulnerable to directory traversal. I hope your slug is not user-generated, or is otherwise heavily sanitized.

\$\endgroup\$
8
  • \$\begingroup\$ Some great tips here and much less radical than I thought it would be - I assumed that my general approach of using if loops would be bad. RE: the for loops - the first two are to isolate the slug into a variable for the purpose of the return view(), and the third one was to make every searchterm return a manufacturer, which would allow me to just do $result->slug on the view for all of the search results. Essentially to prevent as much conditional checking in the view as possible, in hindsight maybe laziness/optimising the wrong side. \$\endgroup\$ Commented Feb 3, 2022 at 18:30
  • \$\begingroup\$ Similarly with collect()->add($search_term) - it's to force the search result (which is often an object of one, but not always) into a Collection. By doing this the search conditions always return Collections that can then easily be output in Blade without additional logic. Maybe I went about this the wrong way and it's worth making the Blade file a little less clean for the sake of returning only the results I need. Although it's worth nothing that only two of the conditions always return just one result so for the other two a get() will always be needed. \$\endgroup\$ Commented Feb 3, 2022 at 18:37
  • \$\begingroup\$ Interestingly I was under the impression that is required when using LIKE in SQL. If not, what does it add and why is it so commonly used? Also regarding the loading of images, all the slugs are generated by me, but is this a typical way to load images/assets? It seems there should be a more idiomatic Laravel way of doing this that doesn't hardcode paths, but maybe this is the more common way to do it. \$\endgroup\$ Commented Feb 3, 2022 at 18:57
  • \$\begingroup\$ I now see what you're saying about collect() and have implemented accordingly. However, my SQL queries simply don't work without wrapping them in %. \$\endgroup\$ Commented Feb 3, 2022 at 19:10
  • 1
    \$\begingroup\$ @HashimAziz You can use str_replace in PHP to remove characters from a string. The MATCH solution looks fine (as long as you make sure to format it like that, in a way that prevents SQL injection), but I really wonder if implementing the first section of my answer would solve most of your performance problems without it. I have a search function in my website where a user can search "john bob" and it matches with LIKE across first and last name columns (returning "Johnathan Bob" and "Roboberty O'Johnson") and it doesn't have any performance issues w/ 500+ people in the db. \$\endgroup\$
    – Laurel
    Commented Feb 3, 2022 at 19:40

Not the answer you're looking for? Browse other questions tagged or ask your own question.