Awwwards
How To: Let Your Laravel Eloquent Models Find Themselves's Hero Image

How To: Let Your Laravel Eloquent Models Find Themselves

Ever wanted to imbue some of your Eloquent objects with a little more self-awareness? I'm not talking about App\Post being able to pass a Turing test or learn your habits but more like knowing what the "You Are Here" dot means.

Laravel already comes with really simple ways to access your objects from the database. Trying to retrieve a single App\Post by ID from the database, for instance, is child's play.

$post = App\Post::find(1);

Or you might know the slug and use that to retrieve the Post, provided you have ensured uniqueness for that field.

$post = App\Post::where('slug', ‘this-post-right-here’)->first();

If you're following along, you might do something like this:

if(is_numeric($id)) {
    $post = App\Post::find($id);
} else {
    $post = App\Post::where('slug', $id)->first();
}

Imagine copying and pasting this into every model that needs this kind of retrieval. You would probably change the $post variable to match the model you're after (like $comment), and you have to change the class name (like App\Comment). And if you start adding logic you'll have to go back to older models and make sure they have all the newest procedures! And you're testing this at every turn, right?

This tiny, simple, repeatable procedure is starting to look like a maintainability monstrosity. Can we make this better? Of course we can!

So, let's go ahead and make it a Trait so we can reuse it with any model that is similar in structure.

<?php

namespace App\Traits;

trait CanResolveSelf
{
    //
}

What structure, you ask? Good question.

Model Structure

First let's define what structure your models should have. Ideally, you have an incrementing (and thus unique) ID so you can take advantage of the find() method. You would also have a text field (like slug or email) that requires uniqueness. And you could also be using the SoftDeletes trait! You're already adding the withTrashed() query scope in your head, aren't you!

This new trait will collapse all of the cumbersome finding/filtering/checking functions into a single line that you can reuse across models.

Let's take a TDD approach.

Set It up

Let's setup the test suite. I'm starting with a fresh Laravel installation, so let's review that and get started.

We're going to use the sqlite database driver in :memory:. Let's add the following to the bottom of our phpunit.xml file to define the testing environment.

<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>

Now, create the model in question. Let's roll with the App\Post example. We'll create the model and tag it to also create the migration for it.

php artisan make:model Post -m

Now the migration:

Schema::create('posts', function (Blueprint $table) {
    $table->increments('id');
    $table->string('slug')->unique();
    $table->string('title');
    $table->text('body');
    $table->softDeletes();
    $table->timestamps();
});

And the Post model:

class Post extends Model
{
    use Illuminate\Database\Eloquent\SoftDeletes;
    use \App\Traits\CanResolveSelf;
}

This should be sufficient for now.

TDD It

Remember, the goal of this Trait is to simplify the process of retrieving a model from the database.

First, if we're given the model itself, we should return that model.

/** @test */
public function if_given_itself_should_return_itself()
{
    $posts = factory(App\Post::class, 2)->create();
    $post = $posts->first();
    $result = App\Post::resolveSelf($post);
    $this->assertEquals($post, $result);
}

Let's pass the model to the function, and then return that model. Great.

public static function resolveSelf($model)
{
    return $model;
}

Second, if we pass a null value to the function, it should return null.

/** @test */
public function if_given_null_should_return_null()
{
    $posts = factory(App\Post::class, 2)->create();
    $result = App\Post::resolveSelf(null);
    $this->assertNull($result);
}

Notice that this doesn't actually require any code changes to pass. But it will eventually. We'll get back to this one later.

Next, if we pass a numeric value to the function, we should use the findOrFail() function to retrieve it (we'll also handle Exceptions later).

/** @test */
public function if_given_id_should_return_appropriate_model()
{
    factory(App\Post::class, 2)->create();
    $post = App\Post::findOrFail(1);
    $result = App\Post::resolveSelf(1);
    $this->assertEquals($post, $result);
}

Now we're back to green.

public static function resolveSelf($model)
{
    $className = get_called_class();
    if(is_numeric($model)) {
         return $className::findOrFail($model);
    }
    return $model;
}

Ok, back to it. We receive an identifier that isn't numeric. Let's assume it's a string corresponding to our unique text field.

/** @test */
public function if_given_text_should_return_appropriate_model()
{
    factory(App\Post::class, 2)->create();
    $post = App\Post::findOrFail(1);
    $result = App\Post::resolveSelf($post->slug);
    $this->assertEquals($post, $result);
}

And to pass this test:

public static function resolveSelf($model)
{
    $className = get_called_class();
    if(is_numeric($model)) {
         return $className::findOrFail($model);
    } else {
        return $className::where('slug', $model)->firstOrFail();
    }
    return $model;
}

But now the first two tests aren't passing. We're checking if $model is numeric which in the first test it's not. This means we're trying to find the Post where slug is equal to the entire Post object coming in as a parameter. The first test is actually throwing an uncaught Exception, but we'll get to that later. Let's add a check to see if the incoming model is already a Post object.

public static function resolveSelf($model)
{
    $className = get_called_class();
    if(!$model instanceof $class_name) {
        if(is_numeric($model)) {
             $model = $className::findOrFail($model);
        } else {
            $model = $className::where('slug', $model)->firstOrFail();
        }
    }
    return $model;
}

Note: We're using get_called_class() because we're in the static context so that this Trait can be used across multiple models.

So the first test passes, but the second (null) test doesn't. Let's fix that.

public static function resolveSelf($model)
{
    $className = get_called_class();

    if(is_null($model)) {
        return null;
    }

    if(!$model instanceof $class_name) {
        if(is_numeric($model)) {
            $model = $className::findOrFail($model);
        } else {
            $model = $className::where('slug', $model)->firstOrFail();
        }
    }
    return $model;
}

Awesome! Now let's do something about those OrFail methods. We know that it will throw a ModelNotFoundException so let's catch that, and throw a regular Exception with a custom message. That we know if it was an id or slug mismatch. Sweet.

The two tests:

/** 
 * @test
 * @expectedException \Exception
 * @expectedExceptionMessage App\Post not found with the given ID.
 */
public function should_throw_exception_if_id_not_found()
{
    factory(App\Post::class, 2)->create();
    App\Post::resolveSelf(3);
}

/**
 * @test
 * @expectedException \Exception
 * @expectedExceptionMessage App\Post not found with the given slug.
 */
public function should_throw_exception_if_slug_not_found()
{
    factory(App\Post::class, 2)->create();
    App\Post::resolveSelf('foo');
}

And to pass the two tests:

public static function resolveSelf($model)
{
    $className = get_called_class();

    if(is_null($model)) {
        return null;
    }

    if(!$model instanceof $class_name) {
        if(is_numeric($model)) {
            try {
                $model = $className::findOrFail($model);
            } catch {ModelNotFoundException $e) {
                throw new \Exception($className . ‘ not found with the given ID.’);
            }
        } else {
            try {
                $model = $className::where('slug', $model)->firstOrFail();
            } catch (ModelNotFoundException $e) {
                throw new \Exception($className . ‘ not found with the given slug.’);
            }
        }
    }
    return $model;
}

Now what about SoftDeletes? Typically, we know when we're looking for a possibly trashed item. So, we can flag the function to include trashed() items.

Last couple of tests to handle soft deletes:

/** @test */
public function returns_appropriate_model_from_id_even_if_it_was_soft_deleted()
{
    factory(App\Post::class, 2)->create();
    $post = App\Post::findOrFail(1);
    $post->delete();
    $result = App\Post::resolveSelf(1, $withTrashed = true);
    $this->assertEquals(App\Post::withTrashed()->findOrFail(1), $result);
    $this->assertTrue($result->trashed());
}

/** @test */
public function returns_appropriate_model_from_slug_even_if_it_was_soft_deleted()
{
    factory(App\Post::class, 2)->create();
    $post = App\Post::findOrFail(1);
    $post->delete();
    $result = App\Post::resolveSelf($post->slug, $withTrashed = true);
    $this->assertEquals(App\Post::withTrashed()->findOrFail(1), $result);
    $this->assertTrue($result->trashed());
}

And the full Trait:

<?php

namespace App\Traits;

use Illuminate\Database\Eloquent\ModelNotFoundException;

trait CanResolveSelf
{
    /**
     * Resolves model regardless of given identifier.
     *
     * @param $model
     * @param bool $withTrashed
     * @return mixed
     * @throws \Exception
     */
    public static function resolveSelf($model, $withTrashed = false)
    {
        $className = get_called_class();

        if(is_null($model)) {
            return null;
        }
        
        if(!$model instanceof $className) {
            if(is_numeric($model)) {
                try {
                    $model = $className::when($withTrashed, function ($query) {
                        return $query->withTrashed();
                    })->findOrFail($model);
                } catch (ModelNotFoundException $e) {
                    throw new \Exception($className . ' not found with the given ID.');
                }
            } else {
                try {
                    $model = $className::when($withTrashed, function ($query) {
                        return $query->withTrashed();
                    })->where('slug', $model)->firstOrFail();
                } catch (ModelNotFoundException $e) {
                    throw new \Exception($className . ' not found with the given slug.');
                }
            }
        }
        return $model;
    }
}

Looks pretty good. We also took advantage of Eloquent's when() method to avoid using a clumsy if-statement there.

You can also see some room to expand the function. Maybe you have different unique fields per model that you want to define when you invoke the method so you add a $field = 'slug' parameter. Or you can reduce the bigger if-statement down by using when() to determine if you should use findOrFail() or where(/** foo */)->firstOrFail(). Either way, you have a test suite to make sure it all works!

With this Trait we can now collapse all of this to a single method that always returns the method (or throws an informative Exception) which means we can chain methods off of it like we normally would! You just collapsed ~30 lines into 1 line and it works like you expect because you tested it. No more copying/pasting and potentially introducing a stupid bug. And if you find yourself needing more logic in the retrieval procedure, just create a test, add it to the Trait and you're off!

It also allows a really clean API where you can pass IDs or slugs or emails or any unique identifier and retrieve the model you're looking for. That way, handling blog/posts/1 and blog/posts/this-post won't require any extra lines or code changes. It'll just work.

See the full source code for the Trait and tests on Github.


Patrick Guevara's Profile Picture

Patrick Guevara


Chief Software Engineer

Patrick cofounded Metric Loop on the dream of building really great software with a clean, transparent approach. He lives in Austin, Texas with his wife Jess and their dog named Moose.