We’ve come a long way since the days of hand-writing SQL queries in our web apps. Tools like Laravel’s Eloquent ORM allow us to interact with databases at a higher level, freeing us from lower level details like query syntax and security.
When starting out with Eloquent, it’s natural to reach for familiar operations like where
and join
. For more advanced users, features like scopes, accessors, and mutators offer more expressive alternatives to the query-building patterns of old.
Let’s explore another alternative that can be used as a stand-in for repetitive where
statements and local scopes. This technique involves creating new Eloquent models that extend other models. By extending another model, you inherit the full functionality of the parent model, while retaining the ability to add custom methods, scopes, event listeners, etc. This is commonly referred to as “Single Table Inheritance,” but I prefer to just call it “Model Inheritance”.
Most web apps have the concept of an "administrator." Administrators are typically users with elevated permissions and access to restricted areas of the application. To make the distinction between a normal user and an admin user, statements like the one below emerge as a common pattern:
$admins = User::where('is_admin', true)->get();
When a specific where
statement becomes a pattern throughout your app, it is often beneficial to replace it with a local scope. By implementing an isAdmin
scope on the User
model, we can write a more expressive and reusable Eloquent statement:
$admins = User::isAdmin()->get(); // Implementation:class User extends Model{ public function scopeIsAdmin($query) { $query->where('is_admin', true); }}
Let's take this abstraction one step further using model inheritance. By extending the User
model and adding a global scope, we achieve the exact same result as before, but now with an entirely new entity in our application. This entity (Admin
) can now be home to custom methods, scopes, and other meaningful functionality.
$admins = Admin::all(); // Implementation:class Admin extends User{ protected $table = 'users'; public static function boot() { parent::boot(); static::addGlobalScope(function ($query) { $query->where('is_admin', true); }); }}
Note:
protected $table = ‘users’
is necessary for queries to work properly. Eloquent uses a model’s class name to determine the name of the database table. Therefore, it assumes a table name of “admins” instead of “users,” resulting in aBase table or view not found
error.
Once you have an Admin
model, it is easier and cleaner to separate admin-specific functionality from your User
class. For example:
Simple operations like sending notifications to all administrators become simpler with the new Admin
model.
Notification::send(Admin::all(), NewSignUp($user));
Any time an operation on the User
model is restricted to admins only, a guard clause is typically needed to ensure authorization.
// Guard clauseif ($admin = User::find($id)->is_admin !== true) { throw new Exception;} $admin->impersonate($user);
Because of Admin
’s global scope, the guard clause becomes unnecessary when the impersonate
method is called on the Admin
class.
Admin::findOrFail($id)->impersonate($user);
In a testing context, you may need to create User
models with admin privileges using model factories like the example below.
$admin = factory(User::class)->create(['is_admin' => true]); // User factory implementation$factory->define(User::class, function () { return [ ... 'is_admin' => false, ];});
We can improve this statement by introducing a model factory state to encapsulate what defines a user as an admin.
$admin = factory(User::class)->states('admin')->create(); // Admin state implementation$factory->state(User::class, 'admin', function () { return ['is_admin' => true];});
This is surely an improvement, however, the factory still returns an instance of the User
model. By defining an entirely new factory for Admin
, we get the same permissions while returning an instance of Admin
.
$admin = factory(Admin::class)->create(); // Admin factory implementation$factory->define(Admin::class, function () { return ['is_admin' => true] + factory(User::class)->raw();});
Similar to how Eloquent evaluates table names, a model’s class name is used to determine foreign keys and pivot tables. Therefore, accessing relationships from the Admin
model is problematic.
Admin::first()->posts;// Throws: Unknown column 'posts.admin_id' // Failing implementation:class Admin extends User { //} class User extends Model { public function posts() { return $this->hasMany(Post::class); }}
Eloquent can’t process this relationship because it assumes each Post
has an admin_id
field instead of a user_id
field. We can fix this by explicitly passing the user_id
foreign key on the User
model:
// Working implementation:class Admin extends User { //} class User extends Model { public function posts() { return $this->hasMany(Post::class, 'user_id'); }}
The same problem exists with many-to-many relationships. Eloquent assumes the pivot table name matches the current model’s class name:
Admin::first()->tags;// Throws: Table 'admin_tag' doesn't exist // Failing implementation:class Admin extends User { //} class User extends Model { public function tags() { return $this->belongsToMany(Tag::class); }...
Again, we can solve this problem by explicitly passing the pivot table name and foreign key:
// Working implementation:class Admin extends User { //} class User extends Model { public function tags() { return $this->belongsToMany(Tag::class, 'user_tag', 'user_id'); }...
Although explicitly defining foreign keys and pivot table names will allow our Admin
model to access relationships defined on the User
model, this is less than ideal. The existence of these seemingly unnecessary definitions is not apparent anywhere in the codebase.
However, you can create a HasParentModel
trait that automatically handles this issue. From Eloquent’s perspective, it substitutes the current model’s class name with the class name of the parent model. Check out an example on GitHub.
There's a lot more that you can do to make Laravel handle single-table inheritance well. We've created a package that makes it easy to add to your Laravel apps and are preparing to release it any day now. Make sure to watch our Twitter for the announcement!
Let’s take a look at our new Admin
model that utilizes this trait:
use App\Abilities\HasParentModel; class Admin extends User{ use HasParentModel; // Notice we no longer need: protected $table = 'users' public static function boot() { parent::boot(); static::addGlobalScope(function ($query) { $query->where('is_admin', true); }); }}
Now our User
model’s relationships can go back to relying on Eloquent’s sensible defaults.
// Working implementation:class User extends Model{ public function posts() { return $this->hasMany(Post::class); } public function tags() { return $this->belongsToMany(Tag::class); }}
The HasParentModel
trait cleans up our models and lets developers know there is something special going on under the hood.
We’ve identified common Eloquent patterns and cleaned them up using model inheritance. This technique helps us create better-named and encapsulated entities within our application. Remember, model inheritance can be applied to any Eloquent model, not just Users
and Admins
. The possibilities are endless!
Go be creative, have fun, and share what you’ve learned along the way. Let us know how you're using this technique for fun or in the wild! (Tweet @calebporzio and @tightenco)
Enjoy!
We appreciate your interest.
We will get right back to you.