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
Then update .env
SCOUT_DRIVER=database
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,
];
}
}
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();
});
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(),
}
});
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();
}
}
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)
);
});
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));
});
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'),
];
}
}
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();
}
}
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'
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"
}
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.
Top comments (0)