Laravel Scout: Create A Single API Endpoint for Searching Purpose Only

Nasrul Hazim Bin Mohamad - Sep 21 '22 - - Dev Community

Today, I had a request, to improve how searching should work in the application which my team currently working on.

The searching mechanism that implemented at the moment, is kind of slow - I can say performance issue.

Then I have a thought, can we make an API, a single API that can do all the searching in your database - I mean any targeted table like users, posts, orders, etc.

So I look in to Laravel Scout and install as usual.

The only thing I configure is the driver - I use database driver, and the searchable keys.

TLDR;

composer require laravel/scout
Enter fullscreen mode Exit fullscreen mode

Then update .env

SCOUT_DRIVER=database
Enter fullscreen mode Exit fullscreen mode

Setup the \App\Models\User:


use Illuminate\Contracts\Auth\MustVerifyEmail;
use Laravel\Jetstream\HasProfilePhoto;
use Laravel\Sanctum\HasApiTokens;
use Laravel\Scout\Searchable;

class User extends Authenticatable implements MustVerifyEmail
{
    use HasApiTokens;
    use HasProfilePhoto;
    use Searchable;

    /**
     * Get the indexable data array for the model.
     *
     * @return array
     */
    public function toSearchableArray()
    {
        return [
            'name' => $this->name,
            'email' => $this->email,
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

So, above are how I configured the Laravel Scout.

The important part is the API endpoint. The API endpoint should be usable to any kind of searching - in case I want to search for notifications, for posts, for orders, etc. I want everything to be at one single API endpoint - /search.

So here how I did at first:


<?php

use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::middleware('auth:sanctum')
    ->get('/search', function (Request $request) {
        return User::search($request->search)->first();
    });
Enter fullscreen mode Exit fullscreen mode

But this only allow to search for one model and first record found only.

Imagine I have a \App\Models\Post model, configured similar to \App\Models\User. How can I make the API endpoint more dynamic?

<?php

use App\Models\Post;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::middleware('auth:sanctum')
    ->get('/search', function (Request $request) {
        return match($request->type) {
            'user' => User::search($request->search)->first(),
            'post' => Post::search($request->search)->first(),
        }
    });
Enter fullscreen mode Exit fullscreen mode

But it's kind of, hard to maintain in future, too many hardcoded in this route.

So let's refactor a bit, we going to pull out the match part, to somewhere else - a helper.


<?php

use Laravel\Scout\Searchable;

if (! function_exists('search')) {
    function search(string $type, string $keyword, bool $paginate = false)
    {
        abort_if(
            ! class_exists($type), 
            "Class $type not exists."
        );

        throw_if(
            ! in_array(
                Searchable::class, 
                class_uses_recursive($type)
            )
        );

        $class = $type;

        $query = $class::search($keyword);

        return $paginate
            ? $query->paginate()
            : $query->first();
    }
}
Enter fullscreen mode Exit fullscreen mode

The abort_if() just to ensured the class we going to use exists.

The throw_if(), we are strictly accept ONLY Laravel Scout Searchable trait.

So your new route will be:


use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::middleware('auth:sanctum')->get('/search', function (Request $request) {
    return search(
        $request->type, 
        $request->search, 
        $request->query('paginate', false)
    );
});
Enter fullscreen mode Exit fullscreen mode

BUT! We missing one more part. How do we know if that type, is belongs to which model? There's multiple way doing this, a simple mapping will do between type and model.

How I, I make use of Laravel Enum by Spatie, and my route will be like the following:

<?php

use App\Enums\SearchType;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::middleware('auth:sanctum')->get('/search', function (Request $request) {
    abort_if(
        empty($request->type) || empty(SearchType::tryFrom($request->type)),
        404,
        'Unknown search type'
    );

    abort_if(
        empty($request->search),
        404,
        'Please provide search keyword'
    );

    return search(SearchType::tryFrom($request->type), $request->search, $request->query('paginate', false));
});
Enter fullscreen mode Exit fullscreen mode

Where the SearchType:

<?php

namespace App\Enums;

use Spatie\Enum\Laravel\Enum;

/**
 * @method static self user()
 * @method static self profile()
 */
class SearchType extends Enum
{
    public static function values(): array
    {
        return [
            'user' => \App\Models\User::class,
            'profile' => \Profile\Models\Profile::class,
        ];
    }

    protected static function labels(): array
    {
        return [
            'user' => __('User'),
            'profile' => __('Profile'),
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

And I need to update my search() helper, to rely on this new enum as well:

<?php

use App\Enums\SearchType;
use Laravel\Scout\Searchable;

if (! function_exists('search')) {
    function search(SearchType $type, string $keyword, bool $paginate = false)
    {
        throw_if(
            ! in_array(Searchable::class, class_uses_recursive($type->value))
        );

        $class = $type->value;

        $query = $class::search($keyword);

        return $paginate
            ? $query->paginate()
            : $query->first();
    }
}
Enter fullscreen mode Exit fullscreen mode

So now, everything in place, I can simply later add Laravel\Scout\Searchable trait to any model and update my App\Enums\SearchType to accept new type of searching.

So the usage:

curl --request GET \
  --url 'http://127.0.0.1:8000/api/search?type=profile&search=00000000' \
  --header 'Accept: application/json' \
  --header 'Authorization: Bearer WD7wV13lCSp1XqesClDsND4IP92OPiMwC9soWuS6'
Enter fullscreen mode Exit fullscreen mode

And the response will be like:

{
    "uuid": "82dea65f-38bd-4ef6-a4cd-c3bffa3b3b2f",
    "profile_no": "00000000",
    "name": "Superadmin",
    "created_at": "2022-09-15T05:01:20.000000Z",
    "updated_at": "2022-09-15T05:01:20.000000Z"
}
Enter fullscreen mode Exit fullscreen mode

I hope this give some insight, how you can write better code for your application.

By the end of the day, I have a better solution for my team for provide and maintain the Search API, and for the performance issue - let the team switch to this approach and give it a try.

Don't forget to make use of the API Resource.
You can change to Agnolia if you want to, but let's keep it for database.
Database driver only support MySQL and PostgreSQL at the moment.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player