How to make WordPress-like custom fields for Laravel models
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.