automatic-relation-loading-eager-loading

A few days ago, Litvinchuk added the method withRelationshipAutoloading in #53655. As explained in the PR description, it checks the relationships you're calling and applies Auto Eager Loading to them. Of course, this method is only available in v12.8 and above, so I decided to add it to earlier versions in case I work on a project that uses a version before that. 🔥

Let's go step by step so I can show you exactly how I did it. 👣

First, we will overwrite a few Laravel classes, including Model, Eloquent Collection, and Eloquent Builder, along with some traits.

We'll create a new folder called Overwrites inside the app directory. Inside that, we'll have two subfolders: one called Classes and the other called Traits.

So, let’s begin with the traits. We’ll create a new one called HasAttributes. This trait already exists in Laravel, but we’re going to make our version:

namespace App\Overwrites\Traits;

trait HasAttributes
{
    /**
     * Get a relationship.
     *
     * @param  string  $key
     * @return mixed
     */
    public function getRelationValue($key)
    {
        // If the key already exists in the relationships array, it just means the
        // relationship has already been loaded, so we'll just return it out of
        // here because there is no need to query within the relations twice.
        if ($this->relationLoaded($key)) {
            return $this->relations[$key];
        }

        if (! $this->isRelation($key)) {
            return;
        }

        if ($this->attemptToAutoloadRelation($key)) {
            return $this->relations[$key];
        }

        if ($this->preventsLazyLoading) {
            $this->handleLazyLoadingViolation($key);
        }

        // If the "attribute" exists as a method on the model, we will just assume
        // it is a relationship and will load and return results from the query
        // and hydrate the relationship's value on the "relationships" array.
        return $this->getRelationshipFromMethod($key);
    }
}

We're also going to create the HasRelationships trait:

namespace App\Overwrites\Traits;

use Closure;
use Illuminate\Database\Eloquent\Model;

trait HasRelationships
{
    /**
     * The relationship autoloader callback.
     *
     * @var \Closure|null
     */
    protected $relationAutoloadCallback = null;

    /**
     * Determine if a relationship autoloader callback has been defined.
     *
     * @return bool
     */
    public function hasRelationAutoloadCallback()
    {
        return ! is_null($this->relationAutoloadCallback);
    }

    /**
     * Define an automatic relationship autoloader callback for this model and its relations.
     *
     * @param  \Closure  $callback
     * @param  mixed  $context
     * @return $this
     */
    public function autoloadRelationsUsing(Closure $callback, $context = null)
    {
        $this->relationAutoloadCallback = $callback;

        foreach ($this->relations as $key => $value) {
            $this->propagateRelationAutoloadCallbackToRelation($key, $value, $context);
        }

        return $this;
    }

    /**
     * Enable relationship autoloading for this model.
     *
     * @return $this
     */
    public function withRelationshipAutoloading()
    {
        $this->newCollection([$this])->withRelationshipAutoloading();

        return $this;
    }

    /**
     * Attempt to autoload the given relationship using the autoload callback.
     *
     * @param  string  $key
     * @return bool
     */
    protected function attemptToAutoloadRelation($key)
    {
        if (! $this->hasRelationAutoloadCallback()) {
            return false;
        }

        $this->invokeRelationAutoloadCallbackFor($key, []);

        return $this->relationLoaded($key);
    }

    /**
     * Invoke the relationship autoloader callback for the given relationships.
     *
     * @param  string  $key
     * @param  array  $tuples
     * @return void
     */
    protected function invokeRelationAutoloadCallbackFor($key, $tuples)
    {
        $tuples = array_merge([[$key, get_class($this)]], $tuples);

        call_user_func($this->relationAutoloadCallback, $tuples);
    }

    /**
     * Propagate the relationship autoloader callback to the given related models.
     *
     * @param  string  $key
     * @param  mixed  $values
     * @param  mixed  $context
     * @return void
     */
    protected function propagateRelationAutoloadCallbackToRelation($key, $models, $context = null)
    {
        if (! $this->hasRelationAutoloadCallback() || ! $models) {
            return;
        }

        if ($models instanceof Model) {
            $models = [$models];
        }

        if (! is_iterable($models)) {
            return;
        }

        $callback = fn(array $tuples) => $this->invokeRelationAutoloadCallbackFor($key, $tuples);

        foreach ($models as $model) {
            // Check if relation autoload contexts are different to avoid circular relation autoload...
            if (is_null($context) || $context !== $model) {
                $model->autoloadRelationsUsing($callback, $context);
            }
        }
    }
}

Next, we'll begin working on the classes, starting with the Builder class, where we'll implement our custom version:

namespace App\Overwrites\Classes;

use Illuminate\Database\Eloquent\Builder as BaseBuilder;

class Builder extends BaseBuilder
{
    /**
     * Execute the query as a "select" statement.
     *
     * @param  array|string  $columns
     * @return \Illuminate\Database\Eloquent\Collection
     */
    public function get($columns = ['*'])
    {
        $builder = $this->applyScopes();

        // If we actually found models we will also eager load any relationships that
        // have been specified as needing to be eager loaded, which will solve the
        // n+1 query issue for the developers to avoid running a lot of queries.
        if (count($models = $builder->getModels($columns)) > 0) {
            $models = $builder->eagerLoadRelations($models);
        }

        $collection = $builder->getModel()->newCollection($models);

        if (Model::isAutomaticallyEagerLoadingRelationships()) {
            $collection->withRelationshipAutoloading();
        }

        return $this->applyAfterQueryCallbacks($collection);
    }
}

The method overrides Laravel's core get method, not an addition to the framework. Ensure that it doesn't affect Laravel's original functionality.

Also, for the Collection class, we’ll build a custom version:

namespace App\Overwrites\Classes;

use Illuminate\Database\Eloquent\Collection as BaseCollection;

class Collection extends BaseCollection
{
    /**
     * Load a relationship path for models of the given type if it is not already eager loaded.
     *
     * @param  array>  $tuples
     * @return void
     */
    public function loadMissingRelationshipChain(array $tuples)
    {
        [$relation, $class] = array_shift($tuples);

        $this->filter(function ($model) use ($relation, $class) {
            return ! is_null($model) &&
                ! $model->relationLoaded($relation) &&
                $model::class === $class;
        })->load($relation);

        if (empty($tuples)) {
            return;
        }

        $models = $this->pluck($relation)->whereNotNull();

        if ($models->first() instanceof BaseCollection) {
            $models = $models->collapse();
        }

        (new static($models))->loadMissingRelationshipChain($tuples);
    }

    /**
     * Enable relationship autoloading for all models in this collection.
     *
     * @return $this
     */
    public function withRelationshipAutoloading()
    {
        $callback = fn($tuples) => $this->loadMissingRelationshipChain($tuples);

        foreach ($this as $model) {
            if (! $model->hasRelationAutoloadCallback()) {
                $model->autoloadRelationsUsing($callback);
            }
        }

        return $this;
    }
}

Finally, we’ll create our special Model class. This class will extend Laravel’s Model, allowing us to retain all the original functionalities while ensuring that Laravel utilizes our custom upper-layer classes:

namespace App\Overwrites\Classes;

use App\Overwrites\Traits\{HasAttributes, HasRelationships};
use Illuminate\Database\Eloquent\Model as BaseModel;
use App\Overwrites\Classes\Collection as CustomCollection;
use App\Overwrites\Classes\Builder as CustomBuilder;

abstract class Model extends BaseModel
{
    use HasAttributes, HasRelationships;

    /**
     * Indicates whether relations should be automatically loaded on all models when they are accessed.
     *
     * @var bool
     */
    protected static $modelsShouldAutomaticallyEagerLoadRelationships = false;

    /**
     * Determine if model relationships should be automatically eager loaded when accessed.
     *
     * @param  bool  $value
     * @return void
     */
    public static function automaticallyEagerLoadRelationships($value = true)
    {
        static::$modelsShouldAutomaticallyEagerLoadRelationships = $value;
    }

    /**
     * Determine if relationships are being automatically eager loaded when accessed.
     *
     * @return bool
     */
    public static function isAutomaticallyEagerLoadingRelationships()
    {
        return static::$modelsShouldAutomaticallyEagerLoadRelationships;
    }

    /**
     * Create a new Eloquent query builder for the model.
     *
     * @param \Illuminate\Database\Query\Builder $query
     * @return \App\Overwrites\Builder
     */
    public function newEloquentBuilder($query)
    {
        return new CustomBuilder($query);
    }

    /**
     * Create a new Eloquent Collection instance.
     *
     * @param  array  $models
     * @return TCollection
     */
    public function newCollection(array $models = [])
    {
        return new CustomCollection($models);
    }
}

Now, ensure all your models extend the Model class we created earlier.

The automatic relationship calling method can save time, but it comes with potential drawbacks. Based on my personal experience with the V9 project, it may lead to unforeseen issues. Therefore, it's important to carefully consider its use before implementing it.

Here’s the Arabic version of this article. Enjoy!