Dynamic Laravel Eloquent model relationships
Update: Laravel has introduced this feature in version 7.x on June 3rd 2020 via the
resolveRelationUsing()
Eloquent method.
In a modular application architecture, separation of concerns is paramount. In Laravel, there are a few ways to extend the behavior of models when you don't control the model directly. You can use the IoC container to swap an instance of a model with another instance. You can use traits to "mix in" functionality or you can macro classes that work alongside the model instance. Macros
A macro in Laravel provides a way to hook into a class at runtime using PHP Reflection to execute a callback/closure as if it had been declared directly on the class. Here's an example:
use Illuminate\Database\Eloquent\Builder; Builder::macro('orders', function (Builder $builder) {return $builder->getModel()->hasMany(Order::class, 'user_id', 'user_id');});
In the example above, you can create a HasMany
relationship to a Order
model via the user_id
column. You would define the macro in a ServiceProvider
class, in the register method. Then, somewhere in your application, you would call it like so:
User::with('orders')->get()
That should actually get you up and running as simply as that. However, the macro would be registered globally for all of your Eloquent Builder
queries. You probably only want to register the macro with one or two models. Models that actually have the required columns where it makes sense to define the relationship.
Global Scopes
Global scopes allow you to add constraints to all queries for a given model.
<?php use Illuminate\Database\Eloquent\Builder;use Illuminate\Database\Eloquent\Model;use Illuminate\Database\Eloquent\Scope; class CustomScope implements Scope{ /** * Apply the scope. * * @param Builder $builder * @param Model $model * * @return void */ public function apply(Builder $builder, Model $model) { // Apply the scope }}
Again, in a modular architecture, you would register the Scope class within the register()
method of a ServiceProvider
.
use App\User; User::addGlobalScope(new CustomScope);
Using this technique of applying a global scope to a model, you can ensure that only the models you actually want to extend with a macro have the ability to call the macro at runtime. Pulling it all together
Let's imagine we have two modules, Core
and Catalog
. Our Core
module has a User
Eloquent model where you want to add a hasMany
relationship to our User
model so we can easily load Order
models in the Catalog
module that are associated with our User
. Make sense? Let's also imagine both your User
and Order
models are already defined.
All you have to do is create a scope class that will add the macro.
<?php namespace Ignite\Catalog\Scopes; use Ignite\Catalog\Entities\Order;use Illuminate\Database\Eloquent\Builder;use Illuminate\Database\Eloquent\Model;use Illuminate\Database\Eloquent\Scope; class UserOrdersScope implements Scope{ /** * Apply the scope. * * @param Builder $builder * @param Model $model * * @return void */ public function apply(Builder $builder, Model $model) { $builder->macro('orders', function (Builder $builder) { return $builder->getModel()->hasMany(Order::class, 'user_id', 'user_id'); }); }}
In your service provider, you would assign the scope as a global scope on the User
model:
<?php use Illuminate\Support\ServiceProvider;use Ignite\Catalog\Scopes\UserScope;use Ignite\Core\Entities\User; class CatalogServiceProvider extends ServiceProvider{ public function register() { User::addGlobalScope(app(UserOrdersScope::class)); }}
Now you should be able to access the relationship as if you had defined orders()
on the User
model:
return User::with('orders')->whereKey($id)->first();