How to make WordPress-like custom fields for Laravel models

by Jeffrey van Rossum

Have you ever run into a situation where you want to be able to dynamically store data along with your Laravel Model, without having to add an (excessive) amount of columns to your database table? I sure have.

I think there a couple of ways to prevent your table becoming massive in terms of columns. For example, WordPress uses something called Custom Fields. With custom fields, you can dynamically store data with along with your WP_Post objects. Those custom fields are stored in a separate database table.

Let's see how we could achieve something like that for Laravel. At this moment, I can think of two ways:

  • Adding one additional column to our model's table and store our fields there as a json object
  • Adding a separate database table for our custom fields (like WordPress)

We will take a look at both approaches. In both cases we are using a Trait. That will keep our Model clean and makes it easily reusable. Like this:

class Article extends Model
{
    use HasMeta;
}

Add one additional column

For this example, the additional column will be called meta . You'll have to add this column to the table that you would want to support custom fields. Make sure to make it of the type JSON or TEXT.

Let's make it a Trait. In the below example I've created the Trait within the App\Traits namespace.

<?php

namespace App\Traits;

use App\Meta;

trait HasMeta
{
    public function getMetaAttribute($value)
    {
        return json_decode($value, true);
    }

    public function setMetaAttribute($value)
    {
        $this->attributes['meta'] = json_encode($value);
    }

    public function getMeta($key)
    {
        return $this->meta[$key] ?? null;
    }

    public function updateMeta($key, $value)
    {
        $meta = $this->meta;

        $meta[$key] = $value;

        return $this->update([
            'meta' => $meta
        ]);
    }

    public function deleteMeta($key)
    {
        $meta = $this->meta;

        unset($meta[$key]);

        return $this->update([
            'meta' => $meta
        ]);
    }
}

Now it is fairly easy to interact with your meta fields:

$article->updateMeta('field_1', 'A fantastic value');
$article->getMeta('field_1');
$article->deleteMeta('field_1');

Using a separate table

Let's first create a basic model called Meta.

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Meta extends Model
{
    protected $guarded = [];
    public $timestamps = false;
}

Next, we need to create the table through a migration. You can run the following command:

php artisan make:migration create_metas_table --create=metas

Once you have your migration file created, make sure to add the following fields:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateMetasTable extends Migration
{
    public function up()
    {
        Schema::create('metas', function (Blueprint $table) {
            $table->id();
            $table->string('key');
            $table->text('value');
            $table->string('model_type');
            $table->integer('model_id')->unsigned();
        });
    }

    public function down()
    {
        Schema::dropIfExists('metas');
    }
}

I've added the model_type column mostly because at one point, you might want to fetch custom fields based on that - but you won't really need it with what we are doing in this article.

Our Trait would look something like this:

<?php

namespace App\Traits;

use App\Meta;

trait HasMeta
{
    public function metas()
    {
        return $this->hasMany(Meta::class, 'model_id');
    }
		
    public function getMeta($key)
    {
        $meta = Meta::where(['key' => $key, 'model_id' => $this->id])
            ->first();

        if (empty($meta->value)) {
            return null;
        }

        return $this->maybeDecodeMetaValue($meta->value);
    }

    protected function maybeDecodeMetaValue($value)
    {
        $object = json_decode($value, true);

        if (json_last_error() === JSON_ERROR_NONE) {
            return $object;
        }

        return $value;
    }

    protected function maybeEncodeMetaValue($value)
    {
        if (is_object($value) || is_array($value)) {
            return json_encode($value, true);
        }
        return $value;
    }

    public function updateMeta($key, $value)
    {
        $meta = Meta::where(['key' => $key, 'model_id' => $this->id]);

        if ($meta->exists()) {
            return $meta->first()->update(['value' => $value]);
        }

        return Meta::create([
            'key' => $key,
            'value' => $this->maybeEncodeMetaValue($value),
            'model_type' => get_class($this),
            'model_id' => $this->id
        ]);
    }

    public function deleteMeta($key)
    {
        return Meta::where(['key' => $key, 'model_id' => $this->id])->delete();
    }
}

Interacting with it would be the same as with our earlier approach:

$article->updateMeta('field_1', 'A fantastic value');
$article->getMeta('field_1');
$article->deleteMeta('field_1');

Conclusion

As you can see, the second approach needs a little more code to get working. Partly due to making sure we are encoding and decoding values when needed (for arrays and objects). But the advantage is, that you can now start using it on any model you want without having to add a new column to your database table.

Another advantage of the second approach is, that it will be easier to make specific database queries. For example, you might want to retrieve articles with a meta key with a specific value. You could do that using the whereHas method:

App\Article::whereHas('metas', function ($query) {
    $query->where(['key' => 'your_meta_field', 'value' => 'the value of your meta field']);
})->get();

That being said, with the first approach you could also make database queries if you used the JSON data type on your meta column. Your database would have to support that to work (MySQL 5.7 or above). I'm not sure about the performance difference between those two approaches.

App\Article::where('meta->your_meta_field', 'the value of your field')->get()

That leaves us with the question: which of the two approaches should we use? I feel the first approach is more clean. It needs less code and keeps your data more grouped together. I would favor that if my database supports JSON queries.

Did you like this post?

If you sign up for my newsletter, I can keep you up to date on more posts like this when they are published.