A few days ago Spatie, the company where I work, launched a new website: vrijwilligerswerk.be. Translated to english it roughly comes down to workforvolunteers.be. On the website various organizations can post their jobs that can be filled by a volunteer. Volunteers have a very user-friendly way to search for job. They can also opt in to weekly mails with new jobs that match their search queries. In this post I'll give some background on how the application was built and highlight some of the technologies used.

When starting out with the project several months ago we decided performance and ease of use were essential in delevering a good result. You don't want a potential volunteer to wade through complex forms and waiting for results. Our design team came up with a beautiful and easy to use search and results page. Here's what it looks like:

Screen Shot 2016-02-27 at 20.41.03

As soon as you start typing a query or alter one of the criteria search results will be displayed immediately. You won't see a spinner or something similar.

Screen Shot 2016-02-27 at 20.43.53 Take it for a spin yourself here.

Let's get technical. The site runs on a Digital Ocean droplet. Nginx, PHP 7 and http2 are used. The site itself is built with Laravel 5.2. Like in almost all our projects we used our Blender template as a starting point.

When a new job gets submitted (either by an admin in the admin section or an organization when it is logged in), the job is sent to Algolia. For those who are not in the know: Algolia is a hosted search engine with a beautiful API. If you want to know more about them just visit their site or watch the video's Jeffrey Way made on Laracasts about the service.

To send a job to Algolia our homebrew searchindex package is used. The package provides a Spatie\SearchIndex\Searchable</pre>-interface. It expects you to implement one method: getSearchableBody that'll determine which data will get sent to Algolia.

//in the Job model

public function getSearchableBody() : array
{
    return [
        'id' => $this->id,

        'name' => $this->name,
        'profile' => $this->profile,
        'description' => $this->description,

        'address' => $this->address,
        'city_id' => $this->city->id,
        'postal' => $this->city->postal,
        'city' => $this->city->name,

        'lat' => $this->getPoint()->lat,
        'lng' => $this->getPoint()->lng,

        'listHtml' => (string)view('front.search._partials.job', ['job' => $this]),

        ...
    ];
}

See that listHtml-key? That contains the html that'll be rendered on the search result page. More on that later.

Our Laravel app works event based. This is the code that will be performed when a job was updated (regardless of wheter an admin or an organization updated the job).

public function whenJobWasUpdated(JobWasUpdated $event)
{
    $job = $event->job;

    $job->online
        ? $this->index->upsertToIndex($job)
        : $this->index->removeFromIndex('job', $job->id);
}

Now that you know how jobs are being stored in the search index let's talk about how we can search them. The search page is built with React. There are two big components on the page: the search form and the results section. Explaining how React works is out of scope for this article, if you want to learn more about that watch this presentation by Frank De Jonge at last year's Laracon.

Whenever you click an option in the form or type a query the state of the form is changed. Whenever the state of the form changes, we'll convert the state to an AlgoliaQuery object.

let algoliaQuery = new JobQuery()
   .searchFor(queryAttributes.search_text)
   .nearCity(queryAttributes.city_id)
   .aroundRadius(queryAttributes.around_radius)
   .forTargetAudience(queryAttributes.target_audience_ids)
   .inSector(queryAttributes.sector_ids)
   .withTasks(queryAttributes.task_ids)
   .withRequiredLanguages(queryAttributes.required_language_ids)
   .inOrganization(queryAttributes.organization_id)
   .withFrequency(queryAttributes.frequency)
   .filterOnJobsForThePhysicallyChallenged(queryAttributes.accessibility)
   .startingAfter(queryAttributes.start_date)
   .endingBefore(queryAttributes.end_date)
   .getAlgoliaQuery();

   this.searchIndex.executeQuery(algoliaQuery)

The query is then passed to the searchIndex. The searchIndex is the code snippet above is just a simple wrapper around the official Algolia JavaScript client:

import algoliasearch from 'algoliasearch';

class SearchIndex {

    constructor(config)
    {
        let client = algoliasearch(config.applicationId, config.apiKey);

        this.index = client.initIndex(config.indexName);
    }

    executeQuery(query)
    {
        return this.index.search(query.getSearchText(), query.getParameters());
    }
}
export default SearchIndex;

When an answer rolls in from Algolia (which often takes no longer than just a few milliseconds) we'll update the SearchResults store. This will update the page: the search results are now displayed.

Summarized this means that when a volunteer performs a query all magic happens clientside, there is not a single query being executed on the server. Remember that listHtml-key that we sent to Algolia? We simply display that piece of html on the searchresults page.

When a volunteer saves a search query we will, once a week, send that volunteer the new jobs that match the criteria of the query. The jobs in that mail are also determined by the Algolia index.

To wrap up I'll highlight of the packages that were used:

  • algolia/algoliasearch-client-php: the official Algolia PHP client.
  • laracasts/PHP-Vars-To-Js-Transformer: this package provides an easy way to pass data from the server to JavaScript.
  • spatie/laravel-fractal: various pages on the website need data stored in the database. This package transforms the models to the format that is needed in JavaScript
  • watson/sitemap: on the site there isn't a list with all the jobs. You can only find jobs when using the searchform. So a searchcrawler can't find all pages. For those crawlers we build up a sitemap.
  • spatie/laravel-url-signer: In the new jobs mail digest that we send out weekly a volunteer can click unsubscribe links.
  • propaganistas/laravel-phone: an organization must put in a telephone number. This package makes it easy to validate that value

You know what? Here's the entire require section of the composer.json file. I hope you'll find something that can be useful in your project too.

"require": {
  "algolia/algoliasearch-client-php": "^1.6",
  "barryvdh/laravel-debugbar": "^2.1",
  "barryvdh/laravel-ide-helper": "~2.0",
  "bugsnag/bugsnag-laravel": "~1.4",
  "davejamesmiller/laravel-breadcrumbs": "~3.0",
  "dimsav/laravel-translatable": "~5.0",
  "doctrine/dbal": "^2.5",
  "filp/whoops": "^2.0",
  "fzaninotto/faker": "~1.4",
  "guzzlehttp/guzzle": "~6.0",
  "illuminate/html": "~5.0",
  "jenssegers/date": "^3.0",
  "laracasts/flash": "~1.3",
  "laracasts/presenter": "0.2.*",
  "laracasts/testdummy": "~2.3",
  "laracasts/utilities": "^2.1",
  "laravel/framework": "5.2.*",
  "league/flysystem-aws-s3-v2": "~1.0",
  "maatwebsite/excel": "^2.0.0",
  "maknz/slack": "~1.5",
  "myclabs/php-enum": "^1.4",
  "pda/pheanstalk": "~3.0",
  "phpseclib/phpseclib": "0.3.*",
  "propaganistas/laravel-phone": "^2.6",
  "spatie/activitylog": "~2.0",
  "spatie/array-functions": "^1.1",
  "spatie/eloquent-sortable": "~1.0",
  "spatie/geocoder": "^2.1",
  "spatie/integration": "^2.1.0",
  "spatie/laravel-analytics": "~1.0",
  "spatie/laravel-backup": "~2.4",
  "spatie/laravel-googletagmanager": "^2.0",
  "spatie/laravel-medialibrary": "^3.1.1",
  "spatie/laravel-or-abort": "^1.0",
  "spatie/laravel-paginateroute": "^1.0",
  "spatie/laravel-tail": "1.*",
  "spatie/laravel-url-signer": "^1.0",
  "spatie/searchindex": "^3.1.1",
  "spatie/seeders": "^3.0",
  "spatie/string": "^2.0",
  "spatie/laravel-authorize": "^1.0",
  "spatie/laravel-failed-job-monitor": "^1.0"
  "spatie/laravel-fractal": "^1.7",
  "spatie/laravel-newsletter": "^2.2.0",
  "spatie/laravel-partialcache": "^1.1",
  "spatie/laravel-permission": "^1.3",
  "spatie/laravel-robots-middleware": "^1.0",
  "spatie/laravel-sluggable": "^1.0",
  "vespakoen/menu": "~3.0",
  "watson/sitemap": "^2.0",
},

Our entire team learned quite a lot while working on this project. Though we released some other sites which leverage React in the meantime, we used it first on this project. We're quite happy with the framework and will probably stick with it for a while. It was also the first time we used Algolia and that was a good experience as well. We still think Elasticsearch is awesome, but whenever we'll need a client side API we'll probably favor Algolia.

If you enjoyed reading this, here's another technical post on how we built a Laravel webshop.

Small disclaimer: I'm not affiliated in any way with Algolia.