{{ messages[0].message }}

Laravel dynamic relationship properties - a deep dive

Have you ever wondered how laravel magically returns data when a relationship method is accessed like a property ? For example $category->posts returns a collection of all posts belonging to the category even though there's no posts property on the $category object.

Here's the basic setup we'll use to explain this concept. I created a new laravel application and ran the following commands:


php artisan make:model Category -m
php artisan make:model Post -m

This generates a model and migration for a Category and Post respectively. I updated the migrations as such:


// categories table migration

<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateCategoriesTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('categories', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->string('name');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('categories');
    }
}

// posts table migration

<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreatePostsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->string('title');
            $table->integer('category_id');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('posts');
    }
}

I also added a posts relationship to the Category model:


<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Category extends Model
{
    public function posts() {
        return $this->hasMany(Post::class);
    }
}

$category->posts

When the relationship method is accessed as an instance property, it returns an instance of Illuminate\Database\Eloquent\Collection. The items in the collection are all \App\Post instances belonging to $category.

How does Laravel know?

Behind the scenes, all models extend the base abstract Model class Illuminate\Database\Eloquent\Model. This class defines a magical method called __get which looks like this:


/**
* Dynamically retrieve attributes on the model.
*
* @param  string  $key
* @return mixed
*/
public function __get($key)
{
  return $this->getAttribute($key);
}

By default, when trying to access an undefined property on an object, PHP throws an Undefined property error. You can test this out with the following code:


$a = new stdClass();
dd($a->b);

You should receive an error:

Undefined property: stdClass::$b

Okay, PHP throws errors when undefined properties are accessed, but ever wondered why undefined properties on Eloquent models return null instead of an error? That's because of the magical __get method on all model instances. This magical method is automatically called by PHP if an undefined property is called on an object. This method when called receives the undefined property that was being accessed.

When $category->posts is called PHP realises the property does not exist on the $category object and calls __get() with the undefined property,which is in this case posts.

Having a closer look at the abstract model class, you'll realise there's a bunch of traits used, and one of them is Illuminate\Database\Eloquent\Concerns\HasAttribute. Yes, you guessed it, the getAttribute method is defined in this trait.


    /**
     * Get an attribute from the model.
     *
     * @param  string  $key
     * @return mixed
     */
    public function getAttribute($key)
    {
        if (! $key) {
            return;
        }

        // If the attribute exists in the attribute array or has a "get" mutator we will
        // get the attribute's value. Otherwise, we will proceed as if the developers
        // are asking for a relationship's value. This covers both types of values.
        if (array_key_exists($key, $this->attributes) ||
            $this->hasGetMutator($key)) {
            return $this->getAttributeValue($key);
        }

        // Here we will determine if the model base class itself contains this given key
        // since we don't want to treat any of those methods as relationships because
        // they are all intended as helper methods and none of these are relations.
        if (method_exists(self::class, $key)) {
            return;
        }

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

In this method, Laravel checks if the key (in our case posts) passed is one of the attributes on this model (matching the database columns). It also checks if the key is a registered mutator. The last line of this method is what we are concerned about. The getRelationValue method is called, passing in posts as an argument.

    /**
     * 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 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.
        if (method_exists($this, $key)) {
            return $this->getRelationshipFromMethod($key);
        }
    }

This is a very interesting method. The first thing Laravel does here is check if the relationship has already been loaded. This means for every request, no matter how many times we call this specific relationship, the database query to fetch the records would only be executed once. Therefore, writing the following code executes the database query only the first time:


// somewhere in provider
$posts = $category->posts;

// somewhere in middleware 
$posts = $category->posts;

// somewhere in controller
$posts = $category->posts;

// somewhere in view file
$posts = $category->posts;

// yes, just the first one makes a query to the database.

If the relationship has not yet been loaded, laravel calls a method getRelationshipFromMethod which tries to fetch the related data and throws an error if it's wrongly defined:


    /**
     * Get a relationship value from a method.
     *
     * @param  string  $method
     * @return mixed
     *
     * @throws \LogicException
     */
    protected function getRelationshipFromMethod($method)
    {
        $relation = $this->$method();

        if (! $relation instanceof Relation) {
            throw new LogicException(sprintf(
                '%s::%s must return a relationship instance.', static::class, $method
            ));
        }

        return tap($relation->getResults(), function ($results) use ($method) {
            $this->setRelation($method, $results);
        });
    }

In this method, Laravel gets the relationship, which in this case is an instance of Illuminate\Database\Eloquent\Relations\HasMany. The main function used to get the data here is $relation->getResults() which is implemented by all Laravel relationship classes. For the HasMany relationship, here's how it looks:


    /**
     * 
     * Get the results of the relationship.
     *
     * @return mixed
     * 
     */
    public function getResults()
    {
        return ! is_null($this->getParentKey())
                ? $this->query->get()
                : $this->related->newCollection();
    }

Here's where the trail ends. Laravel gets all records using the query builder, or returns a new and empty instance of the database collection.

Alright. That's all I have. I hope you learned some good things about Laravel from this.

Join the weekly newsletter and never miss out on new tips, tutorials, and more.