Setup
In the previous article, we have seen how we can create a searchable trait that adds searching functionality to our Eloquent Models. Today we are going to encapsulate this trait in a package so it can be used in different projects easily.
If you have followed along the previous article, you will know that we had two mandatory files:
app/Concerns/Models/Searchable
trait which contains the logic of the search query scope.
config/searchable.php
configuration file which contains default configuration options, in our case it’s just a request query key.
An easy way to quickly get started with Laravel Package Developing is using Laravel Package Boilerplate it comes with pre-configured continuous integration services out of the box.
Laravel Package Boilerplate
Since we are building specifically a Laravel package, select Laravel and click next button.
Fill in some basic informations about the package, and click next button.
Finally download the Zip file.
Extract the Zip to a folder, and open it in your preferred IDE.
Inside the src
folder is where we put our Searchable trait. But before we do that there are some updates I have to make to the default boilerplate files.
One of these is to remove LaravelSearchableFacade
we don’t need that.
We remove it also from the extra
section of the package’s composer.json
file.
"extra": {
"laravel": {
"providers": [
"Chebaby\\LaravelSearchable\\LaravelSearchableServiceProvider"
],
"aliases": {
"LaravelSearchable": "Chebaby\\LaravelSearchable\\LaravelSearchableFacade"
}
}
}
Remove aliases
key.
"extra": {
"laravel": {
"providers": [
"Chebaby\\LaravelSearchable\\LaravelSearchableServiceProvider"
]
}
}
And then remove it’s reference from register
method of LaravelSearchableServiceProvider
.
One more thing is to rename the LaravelSearchable
class to Searchable
trait.
Development
Inside the config folder there is a config file where we can place custom package configurations. As stated previously, for now, it’s just a request query key.
<?php
return [
// query key
// e.g http://example.com/search?q=searchTerm
'key' => 'q',
];
For the Searchable logic we can copy it from tha last section in the previous article to the src/Searchable.php
file of our package:
<?php
namespace App\Concerns\Models;
use Illuminate\Support\Arr;
use Illuminate\Database\Eloquent\Builder;
trait Searchable
{
/**
* Scope a query to search for a term in the attributes
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @return \Illuminate\Database\Eloquent\Builder
*/
protected function scopeSearch($query)
{
[$searchTerm, $attributes] = $this->parseArguments(func_get_args());
if (!$searchTerm || !$attributes) {
return $query;
}
return $query->where(function (Builder $query) use ($attributes, $searchTerm) {
foreach (Arr::wrap($attributes) as $attribute) {
$query->when(
str_contains($attribute, '.'),
function (Builder $query) use ($attribute, $searchTerm) {
[$relationName, $relationAttribute] = explode('.', $attribute);
$query->orWhereHas($relationName, function (Builder $query) use ($relationAttribute, $searchTerm) {
$query->where($relationAttribute, 'LIKE', "%{$searchTerm}%");
});
},
function (Builder $query) use ($attribute, $searchTerm) {
$query->orWhere($attribute, 'LIKE', "%{$searchTerm}%");
}
);
}
});
}
/**
* Parse search scope arguments
*
* @param array $arguments
* @return array
*/
private function parseArguments(array $arguments)
{
$args_count = count($arguments);
switch ($args_count) {
case 1:
return [request(config('searchable.key')), $this->searchableAttributes()];
break;
case 2:
return is_string($arguments[1])
? [$arguments[1], $this->searchableAttributes()]
: [request(config('searchable.key')), $arguments[1]];
break;
case 3:
return is_string($arguments[1])
? [$arguments[1], $arguments[2]]
: [$arguments[2], $arguments[1]];
break;
default:
return [null, []];
break;
}
}
/**
* Get searchable columns
*
* @return array
*/
public function searchableAttributes()
{
if (method_exists($this, 'searchable')) {
return $this->searchable();
}
return property_exists($this, 'searchable') ? $this->searchable : [];
}
}
Tests
When writing packages, your package will not typically have access to all of Laravel’s testing helpers. If you would like to be able to write your package tests as if the package were installed inside a typical Laravel application, you may use the Orchestral Testbench package. Which is already in the composer.json
of the Laravel Package Boilerplate, so all we have to do is to run composer install
to install package dependencies.
Memory SQLite Connection
To reduce setup configuration, you could use testing database connection (:memory:
with sqlite driver) via setting it up under getEnvironmentSetUp()
or by defining it under PHPUnit Configuration File phpunit.xml.dist
:
<phpunit>
// ...
<php>
<env name="DB_CONNECTION" value="testing"/>
</php>
</phpunit>
Running Testing Migrations
To run migrations that are only used for testing purposes and not part of your package, add the following to your base test class tests/TestCase.php
<?php
namespace Chebaby\LaravelSearchable\Tests;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Orchestra\Testbench\TestCase as BaseTestCase;
class TestCase extends BaseTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->setUpDatabase($this->app);
}
protected function setUpDatabase(Application $app)
{
Schema::create('countries', function (Blueprint $table) {
$table->increments('id');
$table->string('name')->nullable();
});
Schema::create('users', function (Blueprint $table) {
$table->increments('id');
$table->string('name')->nullable();
$table->string('email')->nullable();
$table->string('phone')->nullable();
$table->unsignedBigInteger('country_id')->nullable();
$table->foreign('country_id')->references('id')->on('countries')->onDelete('SET NULL');
});
}
}
We create migration to two table countries
and users
so we can test searching for an attribute in a related model.
Create User/Country Models
Inside the tests
folder we create another folder with the name Models, you guessed it right, it is where our models live.
Rename ExampleTest to SearchableTest to start writing our tests.
In the last article, there is some code snippets showing the trait usage, based on those snippets, I’m whriting tests to cover those use cases.
Snippet 1
User::search('searchTerm', ['name', 'email'])->get();
/**
* SELECT * FROM `users`
* WHERE `name` LIKE '%searchTerm%'
* OR `email` LIKE '%searchTerm%'
*/
Testing snippet 1
/** @test */
public function it_can_search_for_a_term_in_multiple_attributes()
{
User::create([
'name' => 'john doe',
'email' => 'john@doe.com'
]);
$user = User::search('john', ['name', 'email'])->first();
$this->assertEquals('john doe', $user->name);
$this->assertEquals('john@doe.com', $user->email);
}
Testing snippet 1 - result
Snippet 2
User::search(['name', 'email'], 'searchTerm')->get();
/**
* SELECT * FROM `users`
* WHERE `name` LIKE '%searchTerm%'
* OR `email` LIKE '%searchTerm%'
*/
Testing snippet 2
/** @test */
public function it_can_search_in_multiple_attributes_for_a_term()
{
User::create([
'name' => 'jane doe',
'email' => 'jane@doe.com'
]);
$user = User::search(['name', 'email'], 'jane')->first();
$this->assertEquals('jane doe', $user->name);
$this->assertEquals('jane@doe.com', $user->email);
}
Testing snippet 2 - result
Snippet 3
User::search('searchTerm')->get();
/**
* SELECT * FROM `users`
* WHERE `name` LIKE '%searchTerm%'
* OR `email` LIKE '%searchTerm%'
*/
Testing snippet 3
/** @test */
public function it_can_search_for_a_term_without_passing_attributes_using_searchable_property()
{
UserWithSearchableProperty::create([
'name' => 'Johnny doe',
'email' => 'Johnny@doe.com'
]);
$user = UserWithSearchableProperty::search('johnny')->first();
$this->assertEquals('Johnny doe', $user->name);
$this->assertEquals('Johnny@doe.com', $user->email);
}
/** @test */
public function it_can_search_for_a_term_without_passing_attributes_using_searchable_method()
{
UserWithSearchableMethod::create([
'name' => 'Richard Roe',
'email' => 'richard@doe.com'
]);
$user = UserWithSearchableMethod::search('richard')->first();
$this->assertEquals('Richard Roe', $user->name);
$this->assertEquals('richard@doe.com', $user->email);
}
Testing snippet 3 - result
Snippet 4
User::search(['name', 'email', 'phone'])->get();
/**
* SELECT * FROM `users`
* WHERE `name` LIKE '%searchTerm%'
* OR `email` LIKE '%searchTerm%'
* OR `phone` LIKE '%searchTerm%'
*/
Testing snippet 4
/** @test */
public function it_can_search_in_multiple_attributes_without_passing_a_term()
{
User::create([
'name' => 'Janie Roe',
'email' => 'janie@doe.com',
]);
// simulate GET request with query parameter "?q=janie"
request()->query->add(['q' => 'janie']);
$user = User::search(['name', 'email', 'phone'])->first();
$this->assertEquals('Janie Roe', $user->name);
$this->assertEquals('janie@doe.com', $user->email);
request()->query->remove('q');
// update request query key
config(['searchable.key' => 'keyword']);
// simulate GET request with query parameter "?keyword=janie"
request()->query->add(['keyword' => 'janie']);
$user = User::search(['name', 'email', 'phone'])->first();
$this->assertEquals('Janie Roe', $user->name);
$this->assertEquals('janie@doe.com', $user->email);
}