Eoghan O'Brien
husband · father · developer
Home

Dynamic Laravel Eloquent model relationships

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();