Home

Building a Store Locator using Laravel, Leaflet and Meilisearch - Part 2

Meilisearch for maps

Overview

In part 1 of this walkthrough we set up a local environment for querying postcodes and meilisearch indexes: Building a Store Locator using Laravel, Leaflet and Meilisearch - Part 1

In the second part we will create a store locator using that environment

For this project i used:

Requirements:

Model

Create a Store Model along with a migration, factory, seeder.

php artisan make:model Store -mfs

Migration

For this example the stores table will have four columns added

Update the "create_stores_table" migration

Schema::create('stores', function (Blueprint $table) {
    $table->id();
    $table->string('client_name');
    $table->string('postcode', 10);
    $table->decimal('lat', 10, 8);
    $table->decimal('lng', 11, 8);
    $table->timestamps();
});

and run the Artisan migrate command to create the stores table in the database.

php artisan migrate

Seeding

Update StoreSeeder.php to create the number of stores as needed, using the Store Factory.

public function run()
{
    Store::factory(300)->create();
}

The Store Factory will make an API call to the postcodes.io service to get accurate location information, and generate a fake company name for the client. The factory checks that the location's latitude & longitude do not contain null values, as can be the case when retrieving from the postcodes.io service, and, for sake of the example, that the postcode does not already exist in the database. If either of those are true, it fetches a new postcode.

use Illuminate\Support\Facades\Http;

class StoreFactory extends Factory
{
    public $location;

    public function __construct()
    {
        parent::__construct();

        $this->getLocation();
    }

    public function definition()
    {
        return [
            'client_name' => $this->faker->company(),
            'postcode' => $this->location['postcode'],
            'lat' => $this->location['latitude'],
            'lng' => $this->location['longitude'],
        ];
    }

    public function getLocation()
    {
        $url = config('postcodes_io.postcodes_io_url') . '/random/postcodes';

        $location = Http::get($url)->json('result');

        if($this->validateLocation($location)) {
            $this->location = $location;
        }
    }

    public function validateLocation($location)
    {
        if(in_array(null, [$location['latitude'], $location['longitude']])) {
            return $this->getLocation();
        }

        if (Store::firstWhere('postcode', $location['postcode'])) {
            return $this->getLocation();
        }

        return true;
    }

}

Searchable Models

A typical query using the Eloquent ORM would use the where method, $model->where($column, $value)->get(), and would expect a column and value to be passed to the query, however when using Scout, this is replaced with $model->search($value) to perform a full text search on the model.

This functionality is provided with the Searchable trait that comes with the Laravel Scout package.

use Laravel\Scout\Searchable;

class Store extends Model
{
    use Searchable;
}

Models with the Searchable trait can now be synced with Meilisearch and future records and updates are automatically synced by default. Once an index has been set up and the trait added to a model, records are also automatically synced when reseeding.

Indexes can be created using the scout:import Artisan command, which will import the data from the database provider being used to Meilisearch. To create an index and sync a model with Meilisearch run the command php artisan scout:import "[MODEL]" where MODEL is the path to the model that is being syncronised. For this example, run the following:

php artisan scout:import "App\Models\Store"

By default a Searchable model will sync all data that is present when you call the toArray() method on a model, which up to now would be:

Once synced, performing a search on the model, such as Store::search($client)->get(); would return all models that match $client in any of the columns.

Using the get() method to return results automatically returns the model from the database. Replacing get() with raw(), returns the data how it is returned from Meilisearch.

The data synced with Meilisearch can be modified by updating the toSearchableArray() method on the model, so if you only need to be able to search on the client_name of the model you could remove unnecessary columns from the returned array.

Visiting the address that Meilisearch is running on and logging in with the MASTER_KEY value will display the stores index and all data that is now stored in Meilisearch. From the GUI you can quickly search through and filter the data thats mirrored in your database.

In order to make this data searchable by distance, the toSearchableArray() method on the model needs to be updated to include an array with a key of '_geo' that contains lat and lng values.

class Store extends Model
{
    use Searchable;

    public function toSearchableArray()
    {
        return $array = [
            'client_name' => $this->client_name,
            'postcode' => $this->postcode,
            '_geo' => [
                'lat' => $this->lat,
                'lng' => $this->lng,
            ]
        ];
    }
}

Run the import command again and see that the Meiliseach data has been updated and now has a _geo key that appears empty in the Meilisearch GUI, but mirrors the toSearchableArray() array.

MEILISEARCH _GEO EXAMPLE

Next up we have to update the filterable and sortible attributes on the index so that the results can be returned by proximity and sorted by distance.

This is quickest and easiest to do with an artisan command.

Create a new command called UpdateMeilisearchAttributesCommand

php artisan make:command UpdateMeilisearchAttributesCommand

The command below asks which index and key should be updated and updates both the filterable and sortable attributes.

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use MeiliSearch\Client as MeiliSearchClient;
use Illuminate\Support\Arr;

class UpdateMeilisearchAttributesCommand extends Command
{
    protected MeiliSearchClient $client;

    protected string $index;

    protected string $key;

    protected $signature = 'meilisearch:update-attributes {index?} {key?}';

    protected $description = 'Update Meilisearch Attributes';

    public function __construct()
    {
        parent::__construct();

        $this->client = new MeiliSearchClient(config('scout.meilisearch.host'), config('scout.meilisearch.key'));
    }

    public function handle()
    {
        $this->index = $this->argument('index') ?? $this->choice('Select Index', $this->getIndexes());

        $this->key = $this->argument('key') ?? $this->ask('Enter Key');

        $this->updateSettings();

        return Command::SUCCESS;
    }

    protected function getIndexes()
    {
        return Arr::pluck($this->client->getAllRawIndexes()['results'], 'uid');
    }

    protected function updateSettings()
    {
        $this->client->index($this->index)->updateSettings([
            'sortableAttributes' => [
                $this->key,
            ],
            'filterableAttributes' => [
                $this->key,
            ],
        ]);
    }

The index in this instance would be stores and the key would be _geo.

The database is now seeded with valid postcode and location information and synced with Meilisearch so we just need to create a few of components to filter and display the data.

Controller & Routing

First create an invokable controller named StoreController

php artisan make:controller StoreController --invokable

and then create a new route in routes\web.php that returns the recently created StoreController.

use \App\Http\Controllers\StoreController;

Route::get('store-locator', StoreController::class)->name('store-locator');

Inside the StoreController, update the __invoke method to return an Inertia response that renders a Vue component named StoreLocator. We will come back to the controller to get store locations once the components have been created.

class StoreLocatorController extends Controller
{
    public function __invoke(Request $request)
    {
        return Inertia::render('StoreLocator');
    }
}

Store Locator Page

The store-locator page will display a map with store locations, a list of results and an input area to enter the postcode and range to apply the filter.

Get started by creating four new files:

StoreLocator.vue

The StoreLocator component will receive stores from the controller as a prop, which will be provided to the child components.

<template>
    <Head :title="title" />
    <div class="w-screen h-screen p-4">
        <div class="flex flex-wrap h-full">
            <main role="main" class="w-full h-full lg:w-3/4">
                <StoreMap />
            </main>
            <aside class="hidden w-full h-full px-2 lg:block lg:w-1/4">
                <StoreSearch @findStores="findStores" />
                <StoreResults class="overflow-y-auto h-4/5" />
            </aside>
        </div>
    </div>
</template>

<script>
import { Head } from '@inertiajs/inertia-vue3';
import StoreMap from '@/Components/StoreMap.vue';
import StoreSearch from '@/Components/StoreSearch.vue';
import StoreResults from '@/Components/StoreResults.vue';
import { Inertia } from '@inertiajs/inertia';
import { computed } from 'vue'

export default {
    data() {
        return {
            title: 'Meili Store Locator',
            search: {
                postcode: this.filters.postcode ?? '',
                distance: this.filters.distance ?? 20
            }
        }
    },
    props: {
        stores: Object,
        filters: Object
    },
    provide() {
        return {
            $stores: computed(() => this.stores),
            $search: computed(() => this.search),
        }
    },
    components: {
        Head,
        StoreMap,
        StoreSearch,
        StoreResults,
    },
    methods: {
        findStores() {
            Inertia.get('/store-locator', {
                postcode: this.search.postcode,
                distance: this.search.distance,
            }, {
                preserveState: true
            });
        }
    },
}
</script>

StoreMap.vue

The store map will render a map using leaflet and plot markers when it receives the data, the map requires a center location, so i've hardcoded the place that has caused me the most misery over the years!

Leaflet is only available once the component has mounted so the function to render the map is called after mounting.

Display Marker on a Map

Markers are displayed on the map by passing a marker object a [lat, lng] array and then adding it to a map or a map layer. I've used layers so that they can easily be cleared and rerendered on update, as can be seen in the renderMarkers() method.

<template>
    <div id="map" class="w-full h-full rounded-xl"></div>
</template>

<script>
    export default {
        inject: [
            '$stores'
        ],
        data() {
            return {
                stores: this.$stores,
                map: Object,
                markersLayer: Object,
            }
        },
        watch: {
            stores(stores) {
                this.clearMarkers();
                this.renderMarkers(stores);
            }
        },
        mounted() {
            this.renderMap();
            this.renderMarkers();
        },
        methods: {
            renderMap() {
                this.map = L.map("map", {
                    center: [53.438889, -2.966389],
                    zoom: 16,
                });

                this.markersLayer = new L.layerGroup();

                this.markersLayer.addTo(this.map)

                L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
                    maxZoom: 19,
                    attribution: "&copy; <a href=\"http://www.openstreetmap.org/copyright\">OpenStreetMap</a>"
                }).addTo(this.map);
            },
            renderMarkers() {
                this.markersLayer.clearLayers()

                this.stores.data?.map((store) => {
                    L.marker(store.location).addTo(this.markersLayer).bindTooltip(store.client);
                })

                if(this.stores.data?.length > 0) {
                    this.map.fitBounds(this.stores.data.map((store) => store.location), {
                        maxZoom: 16
                    });
                }
            },
            clearMarkers() {
                this.markersLayer.clearLayers();
            }
        }
    }
</script>

StoreSearch.vue

The search component is made up of a text input and a slider input so that a postcode and distance can be entered to search. I've initialised the distance at 20km. The component emits an event that tells the parent component to update the map markers, if a postcode is given.

<template>
    <div class="flex flex-wrap items-center gap-2 mb-8">
        <div class="w-full">
            <InputLabel for="postcode" value="Postcode" />
            <TextInput placeholder="Enter Postcode" id="postcode" type="text" class="block w-full" v-model="search.postcode" />
            <InputError class="mt-2" :message="errors.postcode" />
        </div>
        <div class="flex items-center w-full gap-2 justify-evenly">
            <InputLabel for="distance" value="Distance" />
            <input type="range" min="0" max="100" class="flex-grow" v-model="search.distance">
            <InputLabel for="postcode" :value="search.distance" />km
        </div>
        <div class="flex w-full">
            <PrimaryButton @click="findStores" class="ml-auto">Search</PrimaryButton>
        </div>
    </div>
</template>

<script>
    import TextInput from '@/Components/TextInput.vue';
    import PrimaryButton from '@/Components/PrimaryButton.vue';
    import InputError from '@/Components/InputError.vue';
    import InputLabel from '@/Components/InputLabel.vue';

    export default {
        inject: [
            '$search'
        ],
        data() {
            return {
                search: this.$search,
                errors: {
                    postcode: ''
                }
            };
        },
        components: {
            TextInput,
            PrimaryButton,
            InputError,
            InputLabel,
        },
        methods: {
            findStores() {
                this.errors.postcode = null;

                if(!this.search.postcode) {
                    this.errors.postcode = "Postcode Required"
                    return;
                }

                this.$emit('findStores')
            }
        },
    }
</script>

StoreResults.vue

The StoreResults component will display a list of stores in increasing distance order from the postcode provided.

<template>
    <div class="flex flex-col w-full gap-4">
        <div v-for="store in stores.data"
            :key="store"
            class="flex justify-between p-2 text-sm border rounded-lg"
        >
            <div>
                <p>{{ store.client }}</p>
                <p class="text-xs">{{ store.postcode }}</p>
            </div>
            <div>
                <p>Distance</p>
                <p>{{ store.distance }}km</p>
            </div>
        </div>
    </div>
</template>

<script>
    export default {
        inject: [
            '$stores'
        ],
        data() {
            return {
                stores: this.$stores
            }
        }
    }
</script>

To get our markers we can search from our MapSearch component. Clicking the search button checks that a distance and postcode is set before sending a request to the server.

StoreController.php

The StoreController can now be updated to receive the request from the StoreLocator component, check that postcode is valid and perform a search on our Meilisearch index.

In the controller, the postcode is confirmed to be valid with the postcodes.io service and the location information is retrieved, the lat, lng and distance (which is converted from km to m) values are used as parameters in the _geoRadius filter and _geoPoint sort options and applied to the Meilisearch index.

To use filters and sorts within Laravel, pass a callback function to the search() method and then use the search() method on the indexes class with an array of options in the following format as the second variable.

$results = Store::search('', function (Indexes $meiliSearch) use ($location, $distance) {
    return $meiliSearch->search('', [
        'filter' => "_geoRadius($location['latitude'], $location['longitude'],$distance)",
        'sort' => ["_geoPoint($location['latitude'], $location['longitude']):asc"],
    ]);
})->raw();

The hits are then passed in to the StoreResource JsonResource and returned in a format that can then be plotted on to our map.

<?php

namespace App\Http\Controllers;

use Inertia\Inertia;
use App\Models\Store;
use Illuminate\Http\Request;
use MeiliSearch\Endpoints\Indexes;
use Illuminate\Support\Facades\Http;
use App\Http\Resources\StoreResource;

class StoreController extends Controller
{
    public function __invoke(Request $request)
    {
        return Inertia::render('StoreLocator', [
            'stores' => $this->postcodeIsValid($request->postcode) ? $this->getStores($request->postcode, $request->distance) : collect(),
            'filters' => $request->only(['distance', 'postcode'])
        ]);
    }

    protected function postcodeIsValid($postcode)
    {
        return Http::get(config('postcodes_io.postcodes_io_url') . "/postcodes/$postcode/validate")->json('result');
    }

    protected function getStores($postcode, $distance)
    {
        $location =  $this->getLocation($postcode);

        $distance = $distance * 1000;

        $results = Store::search('', function (Indexes $meiliSearch) use ($location, $distance) {
            $geoRadiusData = implode(',', [$location['latitude'], $location['longitude'], $distance]);

            $geoPointData = implode(',', [$location['latitude'], $location['longitude']]);

            return $meiliSearch->search('', [
                'filter' => "_geoRadius($geoRadiusData)",
                'sort' => ["_geoPoint($geoPointData):asc"],
            ]);
        })->raw();

        return StoreResource::collection($results['hits']);
    }

    protected function getLocation($postcode)
    {
        return Http::get(config('postcodes_io.postcodes_io_url') . "/postcodes/$postcode")->json('result');
    }
}

StoreResource.php

class StoreResource extends JsonResource
{
    public function toArray($request)
    {
        return [
            'client' => $this['client_name'],
            'postcode' => $this['postcode'],
            'location' => $this['_geo'],
            'distance' => number_format($this['_geoDistance'] / 1000, 1),
        ];
    }
}

Now, entering a postcode into the search bar and pressing search will query the Meilisearch index and plot the results on the existing map!

Find the accompanying Laravel project on GitHub