Laravel Catch All Relationship

Laravel Catch All Relationship
Photo by Declan Lopez / Unsplash

Sometimes you want a "catch all" relationship in your Laravel model. The most common case is the "admin" User able to access all entities of a given other model, with no explicit belongsToMany or hasMany relationships described in the database, while other Users can access only assigned entities.

Of course it is possible to provide a classic relationship function, and then another function which returns the result of that explicit relationship or a Entity::all() result set on the basis of your specific condition (e.g. an assigned role). But this break the useful chaining of Eloquent queries, and you are no longer able to perform a

$user->entities()->where('name', 'foobar')->orderBy('created_at', 'desc')->get();

kind of query I (and probably you) like to run.

So, I've managed to create a custom kind of Illuminate\Database\Eloquent\Relations\Relation able to retrieve all entities for a given Model, to be used in my relationship function.

The code looks like:

<?php

namespace App\Helpers;

use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;

class RelateToAll extends Relation
{
  private $catchAll;
  private $ownerKey;

  public function __construct(Builder $query, Model $parent, $ownerKey, $catchAll = null)
  {
    /*
      It is possible to provide own callback to query all models.
      By default, it assumes that ID is numeric and is > 0
    */
    if (is_null($catchAll)) {
      $catchAll = function($query) use ($ownerKey) {
        $query->where($ownerKey, '>', 0);
      };
    }

    $this->related = $parent;
    $this->ownerKey = $ownerKey;
    $this->catchAll = $catchAll;

    parent::__construct($query, $parent);
  }

  public function addConstraints()
  {
    if (static::$constraints) {
      $query = $this->getRelationQuery();
      ($this->catchAll)($query);
    }
  }

  public function addEagerConstraints(array $models)
  {
    /*
      Warning! It is not possible to eager load this relation!
    */
    throw new \Exception("No eager loading for catch all relation!", 1);
  }

  public function initRelation(array $models, $relation)
  {
    foreach ($models as $model) {
      $model->setRelation($relation, $this->related->newCollection());
    }

    return $models;
  }

  public function match(array $models, Collection $results, $relation)
  {
    return $this->matchMany($models, $results, $relation);
  }

  public function getResults()
  {
    return $this->query->get();
  }
}

and I've also provided a Trait to extend my "catch all" models

<?php

namespace App\Models\Concerns;

trait CustomRelationships
{
  public function relatoToAll($related, $foreignKey = 'id')
  {
    $instance = $this->newRelatedInstance($related);
    return new \App\Helpers\RelateToAll($instance->newQuery(), $this, $foreignKey);
  }
}

so that now my App\Models\User class looks like

<?php

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;

use App\Models\Concerns\CustomRelationships;

class User extends Authenticatable
{
  use CustomRelationships;

  public function entities()
  {
    if ($this->role == 'admin') {
      return $this->relatoToAll(Entity::class);
    }
    else {
      return $this->belongsToMany(Entity::class);
    }
  }
}

The described RelateToAll class permits to define a custom query matching all given models (the provided one assumes there is a numeric id always major than 0: valid in most situations).

To be noticed that this implementation cannot be leveraged with eager loading, as the kind of relationship (and the generated query) change for each particular User (due the if coded in the relationship function): trying to execute something like

User::where('id', '>', 0)->with('entities')->get();

the belongsToMany relationship is used to map each User to the assigned Entities, which looks into explicit connections within the database.