Laravel (4.2): Single table inheritance for Eloquent

Originally I wanted to publish this article when Laravel 4.2 was only in development but it is out for a while now so I’m here to present my trait for this particular issue. Whilst the title may not be straight forward, here is the thing with more than 3 words: Multiple models sharing one table, auto-filtering queries based on column value. If you are familiar with WordPress you probably know that there is only one table with all the pages, posts, attachments and “custom post types“. This article is not about explaining all the little details, it’s for more advanced developers who can grab my code snippet. I assume you already know Laravel, Eloquent and PHP5.4.

The problem

I wanted to create a single table for different post types, easy. After that I decided to create a (main) Model for the table, that’s not hard either. But what if I want to access different post types using their own models? (Eg.: base model is Post and I want Article and Page model to return only posts where type column is article or page.) There was a “overwriting” method earlier in Laravel 4.1 which was not that sexy (at least it worked) and while it’s still works in Laravel 4.2 you don’t want to go down that way because it will mess up all your sub-queries. Okay, so now what? The solution will be similar to the new SoftDeletingTrait in Laravel 4.2. (If you haven’t met it yet click here.)

Back to the drawing boards

We will need two different file, one for the trait and one for the scope which will actually do the hard stuff. While your needs might be different than mines I think if you decided to go with child models you won’t need “column filtering removal method” so be aware (if somehow you need to be able to remove auto-filtered columns you can do that based on SofDeletingTrait and SoftDeletingScope). You can put these files wherever you want but I’ll put mine into models/traits.

This will be the: app/models/traits/AutoFilterTrait.php

<?php
namespace Illuminate\Database\Eloquent;
use DB;

trait AutoFilterTrait {

	/**
	* Add AutoFilterScope to the global scope
	* @return void
	*/
	public static function bootAutoFilterTrait() {

		static::addGlobalScope(new AutoFilterScope);

	}

}
?>

Okay, the main trait is more less than, let’s move on to the scope file itself. This will be the: app/models/traits/AutoFilterScope.php

<?php
namespace Illuminate\Database\Eloquent;

use DB;

class <span class="hiddenSpellError" pre="class " data-mce-bogus="1"-->AutoFilterScope implements ScopeInterface {

	/**
	* Apply the scope to a given Eloquent query builder.
	* @param  \Illuminate\Database\Eloquent\Builder  $builder
	* @return void
	**/
	public function apply( Builder $builder ) {

		$model = $builder->getModel();
		$autofilters = $model->getAutoFilterData();

		# If there is no autofilter variable set we can stop interrupting the Builder
		if ( !count( $autofilters ) ) return;

		foreach ( $autofilters AS $id => $data ) {
			$builder->where( $data['column'], $data['relation'], $data['value'] );
		}

	}

	/**
	* Remove the scope from the given Eloquent query builder.
	* @param  \Illuminate\Database\Eloquent\Builder  $builder
	* @return void
	*/
	public function remove( Builder $builder ) {

		$model = $builder->getModel();
		$autofilters = $model->getAutoFilterData();

		# If there is no autofilter variable set we can stop interrupting the Builder
		if ( ! count( $autofilters ) ) return;

		$af_assoc = $this->autoFilterAssoc( $autofilters );
		$query = $builder->getQuery();
		$bindings = $builder->getBindings();

		# We will ignore non-basic where items because only basic has bindings
		$bindkey = 0;
		# Exploring wheres of the current Builder
		foreach ( (array)$query->wheres AS $key => $value) {
			# If the where type is basic we can check if there is anything to remove.
			if ( strtolower($value['type']) == 'basic' ) $bindkey++;

			# If we used the column
			if ( isset($af_assoc[ $value['column'] ]) ) {

				# If the autofilter and the binded value is the same we can remove that
				if ( $bindings[$bindkey - 1] == $value['value'] ) {
					unset( $bindings[$key] );
				}

				# Remove where entry
				unset( $query->wheres[$key] );
			}
		}

		# Re-indexing keys
		$query->wheres = array_values($query->wheres);
		array_values($bindings);
		$builder->setBindings( $bindings );

	}

	/**
	* Converts autofilter variable to associative variable that we can use for searching
	* @param array $items_raw Original autofilter set
	* @return array
	**/
	public function autoFilterAssoc ( array $items_raw ) {
		$items = array();
		foreach ( $items_raw AS $id => $data ) {
			$items[ $data['column'] ] = array(
				'relation' => $data['relation'],
				'value'    => $data['value']
			);
		}

		return $items;
	}

}
?>

By default Laravel will attach our scope to sub queries too, that’s why we need the remove function (even though we don’t want to add our filtering to sub-queries). First I thought that this will be easy, I’ll just remove the where and I’ll be done, but the problem was more complicated, if you remove only the “where” entry the bindings will stay in its place therefore every single bindings will be in wrong place. So the affected binding has to be removed as well.

If you created the two file and dumped the autoloader you can use the trait in you child models like this:

<?php

use Illuminate\Database\Eloquent\<span class="hiddenSpellError" pre="use " data-mce-bogus="1"-->AutoFilterTrait;

class Article extends Post {
	use AutoFilterTrait;
	protected $autoFilter = array(
		array(
			"column" => "type",
			"relation" => "=",
			"value" => "article"
		)
	);
?>

Also, you can add a saving listener to you boot method of your models like this (only code fragment):

<?php
class BaseModel extends Eloquent {
	/**
	* Column and value pairs
	**/
	protected $autoFillColumn = array();

	/**
	* Fill columns on save if the columns are empty (or fill process is in force mode)
	* @param bool $force (optional) force column value
	* @return bool
	**/
	public function autoFillColumns ( $force = false ) {
		if ( count( $this->autoFillColumn ) ) {

			foreach ( $this->autoFillColumn AS $column => $data ) {
				$this->$column = $data;
			}

			return true;
		} else {
			return false;
		}
	}

	/**
	* Fires when the model is booting.
	* @return void
	**/
	protected static function boot() {
		parent::boot();

		static::saving( function( $model ) {

			if ( count( $model->autoFillColumn ) ) {
				$model->autoFillColumns();
			}

		});
	}

}
?>

I hope this helps and I wish you the best. If you have any question or comments I’m all ears.

2 comments - Post a comment

Was totally stuck until I read this, now back up and ruignnn.

Hi Jozsef,

I love the idea of using traits, but in Laravel 5 I’m getting into infinite loop. Any idea?

Participate

Your email address will not be published. Required fields are marked *