Laravel shop tutorial 3 – Implementing Smart Search

This tutorial is one more addition to my “Building a shop with Laravel” tutorial series.

In this tutorial I will share what I have learned while building a “Smart Search” feature into a big project. What is smart search? Take a look at the GIF below to see what I mean:

The real, working demo of this type of search is also available at:
http://demos.maxoffsky.com/shop-search/

And the source for the demo is at https://github.com/msurguy/laravel-smart-search

Smart Search is a type of autocomplete search that can search through multiple types of content that your website is serving. Let’s say you have an online shop that has products and categories. The most basic example of use of smart search is to let the user search through categories and products without specifying what type of content they are looking for. The application should be able to give the user a few possible results and placing them in proper types of content that the application has.

This type of search could be incredibly useful in certain cases and increase discoverability of your content by the users. Instead of going to a separate page that shows the results the users could just start typing a part of the word they are looking for and see results immediately.

How would you go about implementing that kind of search in a Laravel application?

Choosing an autocomplete library

I don’t like reinventing the wheel so I use off the shelf tools to build my projects quickly. In order to implement a smart search you need an autocomplete plugin. There are lots of existing autocomplete libraries available out there :

While there are so many options, I chose Selectize.js for all of my cool search/tagging/autocomplete projects. Selectize transforms any “select” element into a text input that allows tagging, selecting multiple items, smart searching, remote data loading and more. I am using it on many websites already (mainly for tagging/autocomplete):

I could write a book on using Selectize.js for many different solutions improving user interface, but for the purpose of this tutorial I will only show you how to use its ability to separate autocomplete results by different types of content using “optgroup” tags.

EDIT: I did in fact write two chapters about Selectize in my e-book, you can get it here: Integrating Front end components with web applications.

Getting and installing Selectize.js

So the first thing to proceed with this tutorial is to download Selectize.js, specifically you need the whole “dist” directory that you can then place somewhere in the “public” folder of your Laravel application. I placed the downloaded “dist” folder into “public/vendor/selectize” folder.

Now if I want to use the plugin in my application, I can place the following HTML and JS in my view file:

// add Selectize stylesheet to the document head:
<head>
  ...
  <link href="{{ url('vendor/selectize/css/selectize.bootstrap3.css') }}" rel="stylesheet">

  <script type="text/javascript" src='//code.jquery.com/jquery-1.10.2.min.js'></script>
  <script type="text/javascript" src='{{ url("vendor/selectize/js/standalone/selectize.min.js") }}'></script>

</head>

// Then add the HTML for the select element that will be "Selectized"

<select id="searchbox" name="q" placeholder="Search products or categories..." class="form-control"></select>

// Activate Selectize
<script>
	$(document).ready(function(){
	    $('#searchbox').selectize();
	});
</script>

Great! We now can use the magical powers of Selectize to beautifully present our search results using this great jQuery plugin. What’s left for us is to create a data source that Selectize would use to populate search result options that that user would choose from.

Planning the search results API

Let’s take a look at how the process of  the search looks like, from start to finish, client side and server side:

The Selectize plugin needs to retrieve data that has the same format for all items received from the API in order for us to be able to display it properly in the dropdown. Let’s come up with the common data format so that we can then use it in Selectize to show the results in categories and in products. Here is an example JSON data that we wold like to receive from the application’s API upon searching:

{
  "data":[
    {
      "slug":"5-fifth-product",
      "name":"Fifth product",
      "icon":"http://laravel-smart-search.gopagoda.com/img/icons/product-5.jpg",
      "url":"http://laravel-smart-search.gopagoda.com/products/5-fifth-product",
      "class":"product"
    },
    {
      "slug":"4-fourth-product",
      "name":"Fourth product",
      "icon":"http://laravel-smart-search.gopagoda.com/img/icons/product-4.jpg",
      "url":"http://laravel-smart-search.gopagoda.com/products/4-fourth-product",
      "class":"product"
    },
    {
      "slug":"2-second-product",
      "name":"Second product",
      "icon":"http://laravel-smart-search.gopagoda.com/img/icons/product-2.jpg",
      "url":"http://laravel-smart-search.gopagoda.com/products/2-second-product",
      "class":"product"
    },
    {
      "slug":"baby-shower-products",
      "name":"Baby Shower Products",
      "icon":"http://laravel-smart-search.gopagoda.com/img/icons/category-icon.png",
      "url":"http://laravel-smart-search.gopagoda.com/categories/baby-shower-products",
      "class":"category"
    }
  ]
}

As you  can see, the  value of “class” will be what separates the content by type (“category” or “product”). A bit later we will build out the API that will give us result like the JSON above. For now, let’s make Selectize work with this kind of data. I have played with Selectize.js enough to come up with a script that will parse the above JSON and provide a nice dropdown for the user to use:

Search results dropdown - Laravel

Parsing the JSON to display results with Selectize

Selectize can easily work with “optgroup” type of “select” HTML element, and by using the available options you can load the data dynamically from the server and group it by a specific value (in our case “class”).

I won’t get too much into all Selectize’s available options, but the way to make it work with a Laravel application is as follows.

Place these 3 lines in the Blade template to pass the URL of the root of your application to javascript:

<script type="text/javascript">
    var root = '{{url("/")}}';
</script>

Place this script where you want to initialize the Selectize plugin, (I have it in “public/js/main.js” which I then load into the layout.blade.php):

$(document).ready(function(){
    $('#searchbox').selectize({
        valueField: 'url',
        labelField: 'name',
        searchField: ['name'],
        maxOptions: 10,
        options: [],
        create: false,
        render: {
            option: function(item, escape) {
                return '<div><img src="'+ item.icon +'">' +escape(item.name)+'</div>';
            }
        },
        optgroups: [
            {value: 'product', label: 'Products'},
            {value: 'category', label: 'Categories'}
        ],
        optgroupField: 'class',
        optgroupOrder: ['product','category'],
        load: function(query, callback) {
            if (!query.length) return callback();
            $.ajax({
                url: root+'/api/search',
                type: 'GET',
                dataType: 'json',
                data: {
                    q: query
                },
                error: function() {
                    callback();
                },
                success: function(res) {
                    callback(res.data);
                }
            });
        },
        onChange: function(){
            window.location = this.items[0];
        }
    });
});

You can use this snippet of code to load the data from the API and display it nicely in the drop down. Whew, the last part is left – building the actual API that will provide us with consistently formatted JSON data feeding the Selectize options.

Building the API

Thankfully, Laravel makes it super easy to build APIs and Eloquent makes it a breeze searching for data that matches some parameter. Let’s get our hands dirty in some beautiful PHP code (sounds contradicting, doesn’t it?). First, let’s create an empty SearchController class, place it in “app/controllers/Api/SearchController.php“:

<?php

/**
 * Api/SearchController is used for the "smart" search throughout the site.
 * it returns and array of items (with type and icon specified) so that the selectize.js plugin 
 * can render the search results properly
 **/

class ApiSearchController extends BaseController {

}

Now, let’s create a route that will use an action (for example “index”) of that controller when it receives a “GET” request to the “api/search” route, in routes.php file :

Route::get('api/search', 'ApiSearchController@index');

The foundation has been laid, now we only need to populate the “index” action of the controller. Let’s think about that for a minute. The index action should do the following things:

  • Receive a string of text (search query) that the data needs to be matched against.
  • Go through products and categories, matching their names (or any other data) against the search query and converting the result of matched entries to an array using Eloquent’s “toArray()” method.
  • Append extra parameters to each array element of the results – such as icon and type of the result item.
  • Merge all arrays together to have one list of data that has the same format.
  • Return the single array of data as JSON so that Selectize can process it and show it to the user.
Let’s create a skeleton of the index action that we can then fill up to implement the functionality listed above:
public function index()
{
	// Retrieve the user's input and escape it
	$query = e(Input::get('q',''));

	// If the input is empty, return an error response
	if(!$query && $query == '') return Response::json(array(), 400);

	$products = ...

	$categories = ...

	// Normalize data
	// Add type of data to each item of each set of results
	// Merge all data into one array
	$data = array_merge($products, $categories);

	return Response::json(array(
		'data'=>$data
	));
}
To select the products that match the search query, we will use the following Eloquent query:
$products = Product::where('published', true)
	->where('name','like','%'.$query.'%')
	->orderBy('name','asc')
	->take(5)
	->get(array('slug','name','icon'))->toArray();
In the similar way, we will select the categories that match the search query:
$categories = Category::where('name','like','%'.$query.'%')
	->has('products')
	->take(5)
	->get(array('slug', 'name'))
	->toArray();
The last part (and the one that I had to come up with) is to create a shorthand function that we could use to append an arbitrary value to each element of the returned array, such as an icon, because categories don’t have icons, whereas the products do. This function is then modified a bit to append a URL that will be linking to the item of the search result (in my example, the url is based on the “slug” of each element), the two functions are below:
public function appendValue($data, $type, $element)
{
	// operate on the item passed by reference, adding the element and type
	foreach ($data as $key => & $item) {
		$item[$element] = $type;
	}
	return $data;		
}

public function appendURL($data, $prefix)
{
	// operate on the item passed by reference, adding the url based on slug
	foreach ($data as $key => & $item) {
		$item['url'] = url($prefix.'/'.$item['slug']);
	}
	return $data;		
}
These functions are used in the index action as follows:
// Data normalization
$categories = $this->appendValue($categories, url('img/icons/category-icon.png'),'icon');

$products   = $this->appendURL($products, 'products');
$categories = $this->appendURL($categories, 'categories');

// Add type of data to each item of each set of results
$products   = $this->appendValue($products, 'product', 'class');
$categories = $this->appendValue($categories, 'category', 'class');
The whole code for the index action of the SearchController that provides the JSON data to the Selectize plugin:
<?php

/**
 * ApiSearchController is used for the "smart" search throughout the site.
 * it returns and array of items (with type and icon specified) so that the selectize.js plugin can render the search results properly
 **/

class ApiSearchController extends BaseController {

	public function appendValue($data, $type, $element)
	{
		// operate on the item passed by reference, adding the element and type
		foreach ($data as $key => & $item) {
			$item[$element] = $type;
		}
		return $data;		
	}

	public function appendURL($data, $prefix)
	{
		// operate on the item passed by reference, adding the url based on slug
		foreach ($data as $key => & $item) {
			$item['url'] = url($prefix.'/'.$item['slug']);
		}
		return $data;		
	}

	public function index()
	{
		$query = e(Input::get('q',''));

		if(!$query && $query == '') return Response::json(array(), 400);

		$products = Product::where('published', true)
			->where('name','like','%'.$query.'%')
			->orderBy('name','asc')
			->take(5)
			->get(array('slug','name','icon'))->toArray();

		$categories = Category::where('name','like','%'.$query.'%')
			->has('products')
			->take(5)
			->get(array('slug', 'name'))
			->toArray();

		// Data normalization
		$categories = $this->appendValue($categories, url('img/icons/category-icon.png'),'icon');

		$products 	= $this->appendURL($products, 'products');
		$categories  = $this->appendURL($categories, 'categories');

		// Add type of data to each item of each set of results
		$products = $this->appendValue($products, 'product', 'class');
		$categories = $this->appendValue($categories, 'category', 'class');

		// Merge all data into one array
		$data = array_merge($products, $categories);

		return Response::json(array(
			'data'=>$data
		));
	}
}

That’s it! The smart search should now work displaying the product and category names and taking the user to the selected result. If you wanted to add any more data types to the result, you could easily do it using Eloquent and my normalization functions.

This wraps up the tutorial, I hope you enjoyed it. Let me know if you have any questions/suggestions about this implementation, I’d be happy to hear your thoughts.

Get complete source for this tutorial at this Github Repository : https://github.com/msurguy/laravel-smart-search

You may also like

  • Pingback: In-place Pagination using Backbone.js and Laravel (shop tutorial #4) - Maks Surguy's blog on PHP and Laravel()

  • Oli

    Hi Maksim,

    Thank you for this excellent tutorial. I’ve been trying to adapt this to my own app, but I need to concatenate two columns (firstname and lastname).

    I have tried using an accessor in the model, but it breaks when I use get(array…) in the controller.

    Other than using a raw SQL concat, can you think of any neat way around it?

  • I think SQL concat is an OK way to go in this instance. In fact in the application this is what I used and it worked great so maybe give that a try.

  • Thank you for this post, very useful.

  • maxsurguy

    Glad to know it’s useful 🙂 Enjoy!

  • RicardoRamirezR

    Great! Your post is really good, I can take from here a missing part on my project! thanks a lot!

  • maxsurguy

    Enjoy!

  • Finsok Yagman

    can anyone please share the database for this tutorial

  • maxsurguy
  • Finsok Yagman

    Thanks

  • Thank you for this tutorial!

  • Nik

    Thanks a lot for the tutorial, this was really to implement on my project, and it works like a charm 🙂

  • Mike Edinger

    I get an ajax error code 500 (internal server error). I searched online and saw I needed an csrf token is that correct?

  • Priti

    I am trying to integrate in Laravel 4 – Options doesn’t render on browser once the json response arrives.

  • maxsurguy

    Do you see the correct response in the Network tab of the developer tools?

  • Sid Heart

    i am trying to install in laravel 5.2 i fix this for laravel 5 almost but when i see thet response in the Network tab of the developer tools i got error Call to undefined method IlluminateDatabaseQueryBuilder::products()
    Here is my Controller
    & $item) {
    $item[$element] = $type;
    }
    return $data;

    }

    public function appendURL($data, $prefix)
    {
    // operate on the item passed by reference, adding the url based on slug
    foreach ($data as $key => & $item) {
    $item[‘url’] = url($prefix.’/’.$item[‘slug’]);
    }
    return $data;
    }

    public function index()
    {
    $query = e(Input::get(‘q’,”));

    if(!$query && $query == ”) return Response::json(array(), 400);

    $products = Product::where(‘published’, true)
    ->where(‘name’,’like’,’%’.$query.’%’)
    ->orderBy(‘name’,’asc’)
    ->take(5)
    ->get(array(‘slug’,’name’,’icon’))->toArray();

    $categories = Category::where(‘name’,’like’,’%’.$query.’%’)
    ->has(‘products’)
    ->take(5)
    ->get(array(‘slug’, ‘name’))
    ->toArray();

    // Data normalization
    $categories = $this->appendValue($categories, url(‘img/icons/category-icon.png’),’icon’);

    $products = $this->appendURL($products, ‘products’);
    $categories = $this->appendURL($categories, ‘categories’);

    // Add type of data to each item of each set of results
    $products = $this->appendValue($products, ‘product’, ‘class’);
    $categories = $this->appendValue($categories, ‘category’, ‘class’);

    // Merge all data into one array
    $data = array_merge($products, $categories);

    return Response::json(array(
    ‘data’=>$data
    ));
    }
    }

    my Product and Category Model is Empty i migrate your sql file to my database first i learn than implement original App

More in Code Blog
Screen Shot 2013-12-16 at 3.28.13 PM
Releasing Admin panel for SEO polymorphic relationships using Laravel

A few days ago I posted a detailed tutorial on how to use Laravel's polymorphic relationships to simplify management of...

Close