Published 1 week ago.

Author: Peter Steenbergen

In Part 1 we installed the Elasticsearch PHP package to a blank Laravel project and created a custom client so we can override the connection easy in production.

In this part we continue with creating and designing a model which we will use in Elasticsearch.

Outlining the application

Our application will index blog posts to Elasticsearch. Our blog posts will have the following fields:

  • id
  • is_active
  • publish_date
  • title
  • content
  • category
  • author

Thinking about the index mappings

Elasticsearch can easilly be used for storing and searching (un)structured data into an index. In a RDBMS like MySQL you have tables with fields. In Elasticsearch you can see an index as a table. The index has a mapping for all the fields (properties).

Let's state some rules for the blog post.

  • A blog post can be active;
  • If a blog gets inactive the blog post will be deleted from the index;
  • When the publish_date is set that will be the date the blog will be visible;
  • A blog post can be written by multiple authors;
  • A blog post can be placed in multiple categories.

Creating the Model and Migration

Let's start by creating a model with a migration file through Laravel Artisan helper.

php artisan make:model Blog -m

The -m flag will create a migration file direcly after creating the model.

Defining the Model

<?php

namespace App\Models;

use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

/**
 * Class Blog
 * @property integer $id
 * @property boolean $is_active
 * @property Carbon $publish_date
 * @property string $title
 * @property string $content
 * @property array $category
 * @property array $author
 */
class Blog extends Model
{
    use HasFactory;

    protected $fillable = [
        'is_active',
        'publish_date',
        'title',
        'content',
        'category',
        'author',
    ];
  
    protected $hidden = [
  	    'is_active',
	];

    protected $casts = [
        'is_active' => 'boolean',
        'category' => 'array',
        'author' => 'array',
    ];

    protected $dates = [
        'publish_date',
    ];
}

In the migration file created change the up methode with the following contents:

Schema::create('blogs', function (Blueprint $table) {
  $table->id();
  $table->boolean('is_active')->default(0);
  $table->dateTime('publish_date')->nullable();
  $table->string('title');
  $table->text('content');
  $table->json('category')->nullable();
  $table->json('author');
  $table->timestamps();
});

Making a job for indexing to Elasticsearch

php artisan make:job IndexBlogElasticsearchJob

Put the contents of the file:

<?php

namespace App\Jobs;

use App\Models\Blog;
use Elasticsearch\Client;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class IndexBlogElasticsearchJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    protected $blog;

    /**
     * IndexBlogElasticsearchJob constructor.
     * @param Blog $blog
     */
    public function __construct(Blog $blog)
    {
        $this->blog = $blog;
    }

    /**
     * Execute the job.
     * @param Client $client
     */
    public function handle(Client $client)
    {
        $params = [
            'index' => 'blogs',
            'id' => $this->blog->id,
            'body' => $this->blog->toArray(),
        ];

        $client->index($params);
    }
}

Making a job for deleting from Elasticsearch

php artisan make:job RemoveBlogElasticsearchJob

Put the contents of this file:

<?php

namespace App\Jobs;

use Elasticsearch\Client;
use Elasticsearch\Common\Exceptions\Missing404Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class RemoveBlogElasticsearchJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    protected $blogId;

    public function __construct($blogId)
    {
        $this->blogId = $blogId;
    }

    public function handle(Client $client)
    {
        $params = [
            'index' => 'blogs-v1',
            'id' => $this->blogId,
        ];

        try {
            $client->delete($params);
        } catch (Missing404Exception $exception) {
            // Already deleted..
        }
    }
}

Model Observers

Instead of publishing a change to Elasticsearch manually we are going to implement a model observer. So when a Blog post is being created, updated or deleted Elasticsearch will be updated based on the event of the Blog model.

Since we do not use SoftDeletes we will send the ID of the blog on the queue for deletion. If you use another queue driver than sync the model will be already gone in the database and you get a ModelNotFoundException in Laravel and the blog post will remain in Elasticsearch unless deleted manually.

php artisan make:observer BlogObserver --model=Blog

Put the contents of the file:

<?php

namespace App\Observers;

use App\Jobs\IndexBlogElasticsearchJob;
use App\Jobs\RemoveBlogElasticsearchJob;
use App\Models\Blog;

class BlogObserver
{
    /**
     * Handle the Blog "created" event.
     *
     * @param  \App\Models\Blog  $blog
     * @return void
     */
    public function created(Blog $blog)
    {
        if ($blog->is_active) {
            dispatch(new IndexBlogElasticsearchJob($blog));
        }
    }

    /**
     * Handle the Blog "updated" event.
     *
     * @param  \App\Models\Blog  $blog
     * @return void
     */
    public function updated(Blog $blog)
    {
        if ($blog->is_active) {
            dispatch(new IndexBlogElasticsearchJob($blog));
        } else {
            dispatch(new RemoveBlogElasticsearchJob($blog->id));
        }
    }

    /**
     * Handle the Blog "deleted" event.
     *
     * @param  \App\Models\Blog  $blog
     * @return void
     */
    public function deleted(Blog $blog)
    {
        dispatch(new RemoveBlogElasticsearchJob($blog->id));
    }

    /**
     * Handle the Blog "restored" event.
     *
     * @param  \App\Models\Blog  $blog
     * @return void
     */
    public function restored(Blog $blog)
    {
        if ($blog->is_active) {
            dispatch(new IndexBlogElasticsearchJob($blog));
        }
    }

    /**
     * Handle the Blog "force deleted" event.
     *
     * @param  \App\Models\Blog  $blog
     * @return void
     */
    public function forceDeleted(Blog $blog)
    {
        dispatch(new RemoveBlogElasticsearchJob($blog->id));
    }
}

Activate the Observer in the AppServiceProvider's boot section:

/**
 * Bootstrap any application services.
 *
 * @return void
 */
public function boot()
{
    Blog::observe(BlogObserver::class);
}

Closing

In this part of the series we setup a blog Post wich will be saved to Elasticsearch if its active or gets deleted when the blog post is removed rom the table using an observer.

In the next series we will ingest some fake blog posts and create a basic search with Laravel Livewire.