How To Filter Models By Another Model

Click to share! ⬇️

How To Filter Models By Another Model

In this installment of our test driven forum building exercise, we will add the ability for a user to browse threads based on a given channel. We’ll start by adding a test for browsing distinct threads. Along the way we are going to run into many errors which will guide us on what steps to take next. During the process of creating our first test, we’ll see the need to create a more specific unit test in order to get the feature test to pass. We’ll change our route model binding setup to make use of a slug rather than an id, and we’ll also build out a new method on the Channel model. Ready, set, go!


Adding a Test For Browsing Distinct Threads

Now that we have added support for channels into our forum, it would make sense that we can actually browse according to channel. If we are browsing a given URI which belongs to a given channel, we should see only threads associated with that channel. Let’s start cooking up a test for that. A first stab at this may look like so:

<?php

namespace TestsFeature;

use TestsTestCase;
use IlluminateFoundationTestingRefreshDatabase;
use IlluminateFoundationTestingDatabaseMigrations;

class ReadThreadsTest extends TestCase
{
    use DatabaseMigrations;

    public function setUp()
    {
        parent::setUp();

        $this->thread = create('AppThread');
    }

    public function test_a_user_can_browse_threads()
    {
        $response = $this->get('/threads');
        $response->assertSee($this->thread->title);
        $response->assertStatus(200);
    }

    public function test_a_user_can_read_a_single_thread()
    {
        $response = $this->get('/threads/' . $this->thread->channel . '/' . $this->thread->id);
        $response->assertSee($this->thread->title);
        $response->assertStatus(200);
    }

    public function test_a_user_can_see_replies_that_are_associated_with_a_thread()
    {
        $reply = factory('AppReply')->create(['thread_id' => $this->thread->id]);
        $response = $this->get('/threads/' . $this->thread->id . '/' . $this->thread->id);
        $response->assertSee($reply->body);
        $response->assertStatus(200);
    }

    public function test_a_user_can_filter_threads_according_to_channel()
    {
        $channel = create('AppChannel');
        $threadInChannel = create('AppChannel', ['channel_id' => $channel->id]);
        $threadNotInChannel = create('AppChannel');

        $this->get('/threads/' . $channel->slug)
            ->assertSee($threadInChannel->title)
            ->assertDontSee($threadNotInChannel->title);
    }
}

Ok let’s test it out.

vagrant@homestead:~/Code/forumio$ phpunit --filter test_a_user_can_filter_threads_according_to_channel
PHPUnit 6.5.5 by Sebastian Bergmann and contributors.

E                                                                   1 / 1 (100%)

Time: 733 ms, Memory: 8.00MB

There was 1 error:

1) TestsFeatureReadThreadsTest::test_a_user_can_filter_threads_according_to_channel
IlluminateDatabaseQueryException: SQLSTATE[HY000]: General error: 1 table channels has no column named channel_id (SQL: insert into "channels" ("name", "slug", "channel_id", "updated_at", "created_at") values (cupiditate, cupiditate, 2, 2018-01-04 19:06:33, 2018-01-04 19:06:33))

Hmm, wait. That doesn’t seem right. Oh ok, it looks like we messed up the test code. First we need to create a channel and then two threads. Our first stab had a mistake where we created 3 channels. Let’s fix that up.

    public function test_a_user_can_filter_threads_according_to_channel()
    {
        $channel = create('AppChannel');
        $threadInChannel = create('AppThread', ['channel_id' => $channel->id]);
        $threadNotInChannel = create('AppThread');

        $this->get('/threads/' . $channel->slug)
            ->assertSee($threadInChannel->title)
            ->assertDontSee($threadNotInChannel->title);
    }

Are we good now? Let’s see.

vagrant@homestead:~/Code/forumio$ phpunit --filter test_a_user_can_filter_threads_according_to_channel
PHPUnit 6.5.5 by Sebastian Bergmann and contributors.

F                                                                   1 / 1 (100%)

Time: 778 ms, Memory: 8.00MB

There was 1 failure:

1) TestsFeatureReadThreadsTest::test_a_user_can_filter_threads_according_to_channel
Failed asserting that 'the html' contains "Quibusdam et sapiente impedit rerum unde eligendi.".

Well, we get an error, but not really the error we were expecting. To be clear, we are expecting a route not found error. Maybe this is one of those cases where we need to toggle exception handling. Let’s try that.

    public function test_a_user_can_filter_threads_according_to_channel()
    {
        $channel = create('AppChannel');
        $threadInChannel = create('AppThread', ['channel_id' => $channel->id]);
        $threadNotInChannel = create('AppThread');

        $this->withoutExceptionHandling()->get('/threads/' . $channel->slug)
            ->assertSee($threadInChannel->title)
            ->assertDontSee($threadNotInChannel->title);
    }

This is the error we were looking for.

vagrant@homestead:~/Code/forumio$ phpunit --filter test_a_user_can_filter_threads_according_to_channel
PHPUnit 6.5.5 by Sebastian Bergmann and contributors.

E                                                                   1 / 1 (100%)

Time: 720 ms, Memory: 8.00MB

There was 1 error:

1) TestsFeatureReadThreadsTest::test_a_user_can_filter_threads_according_to_channel
SymfonyComponentHttpKernelExceptionNotFoundHttpException: GET http://localhost/threads/sed

With that in place, how do we fix this type of error? We add the needed route to the routes file of course. Here we go:


<?php

Route::get('/', function () {
    return view('welcome');
});

Auth::routes();

Route::get('/home', 'HomeController@index')->name('home');

Route::get('/threads', 'ThreadsController@index');
Route::get('/threads/{channel}', 'ThreadsController@index');
Route::get('/threads/create', 'ThreadsController@create');
Route::get('/threads/{channel}/{thread}', 'ThreadsController@show');
Route::post('/threads', 'ThreadsController@store');

Route::post('/threads/{channel}/{thread}/replies', 'RepliesController@store');

We know before we even run the test that we are going to need to update the index() method on the ThreadsController class in order to handle this new route so let’s focus on that now.


Two For One

What we are setting up here is the ability for the index() method on the ThreadsController class to handle two scenarios for us. On the one hand, we know that typically an index() method is used to give us all of a particular resource. We can also use this method with an argument so that when it is provided, only a specific type of resource is returned. So what we want to have happen is, if no channel slug is provided, then we get all threads. If a channel slug is provided, than we only want the threads associated with that channel slug. Here is how we can make that work.


    public function index($channelSlug = null)
    {
        if ($channelSlug) {
            $channelId = Channel::where('slug', $channelSlug)->first()->id;
            $threads = Thread::where('channel_id', $channelId)->latest()->get();
        } else {
            $threads = Thread::latest()->get();
        }
        return view('threads.index', compact('threads'));
    }

Now we can run our test and see that it is working, great!

vagrant@homestead:~/Code/forumio$ phpunit --filter test_a_user_can_filter_threads_according_to_channel
PHPUnit 6.5.5 by Sebastian Bergmann and contributors.

.                                                                   1 / 1 (100%)

Time: 1.32 seconds, Memory: 10.00MB

OK (1 test, 2 assertions)

Refactor To Use Route Model Binding

Let’s refactor how we want the code to read, even if the associated methods are not present yet. For example, if we refactor our index method like so:


    public function index(Channel $channel)
    {
        if ($channel->exists) {
            $threads = $channel->threads()->latest()->get();
        } else {
            $threads = Thread::latest()->get();
        }
        return view('threads.index', compact('threads'));
    }

We know that the threads() method does not yet exist, but we like how the code reads. So we will leave this in place, and build out what we need to in order to make it work. Let’s start by running the test and see where we are at.

vagrant@homestead:~/Code/forumio$ phpunit --filter test_a_user_can_filter_threads_according_to_channel
PHPUnit 6.5.5 by Sebastian Bergmann and contributors.

E                                                                   1 / 1 (100%)

Time: 971 ms, Memory: 8.00MB

There was 1 error:

1) TestsFeatureReadThreadsTest::test_a_user_can_filter_threads_according_to_channel
IlluminateDatabaseEloquentModelNotFoundException: No query results for model [AppChannel].

Looks like we have a model not found exception. Interesting. Why is this? Ok what is happening here is that Route Model Binding in Laravel is always going to fetch the model by it’s id in a default configuration. We are passing a slug to find the model in this case, so now what? Well, you can specify in the Model class to use a slug instead of an id like this:


<?php

namespace App;

use IlluminateDatabaseEloquentModel;

class Channel extends Model
{
    public function getRouteKeyName()
    {
        return 'slug';
    }
}

Cool! Let’s run the test again.

vagrant@homestead:~/Code/forumio$ phpunit --filter test_a_user_can_filter_threads_according_to_channel
PHPUnit 6.5.5 by Sebastian Bergmann and contributors.

E                                                                   1 / 1 (100%)

Time: 904 ms, Memory: 8.00MB

There was 1 error:

1) TestsFeatureReadThreadsTest::test_a_user_can_filter_threads_according_to_channel
BadMethodCallException: Call to undefined method IlluminateDatabaseQueryBuilder::threads()

Ok, not bad. We fixed the other error but now we have an undefined method error. We kind of expected this however since we know that the threads() method is not defined on the model yet. We can build it out now, and include another test! This will be a Unit Test for the Channel.


<?php

namespace TestsFeature;

use IlluminateFoundationTestingDatabaseMigrations;
use TestsTestCase;
use IlluminateFoundationTestingWithFaker;
use IlluminateFoundationTestingRefreshDatabase;

class ChannelTest extends TestCase
{
    use DatabaseMigrations;

    public function test_a_channel_consists_of_threads()
    {
        $channel = create('AppChannel');
        $thread = create('AppThread', ['channel_id' => $channel->id]);

        $this->assertTrue($channel->threads->contains($thread));
    }
}

Here we create a channel, and then a thread in that channel. We then assert that the collection of threads in that channel has the thread. Run the test and we get an expected failure since we need a method.

vagrant@homestead:~/Code/forumio$ phpunit --filter test_a_channel_consists_of_threads
PHPUnit 6.5.5 by Sebastian Bergmann and contributors.

E                                                                   1 / 1 (100%)

Time: 840 ms, Memory: 8.00MB

There was 1 error:

1) TestsFeatureChannelTest::test_a_channel_consists_of_threads
Error: Call to a member function contains() on null

/home/vagrant/Code/forumio/tests/Unit/ChannelTest.php:19

ERRORS!
Tests: 1, Assertions: 0, Errors: 1.

We can now go ahead and build out that method on our model like so:

<?php

namespace App;

use IlluminateDatabaseEloquentModel;

class Channel extends Model
{
    public function getRouteKeyName()
    {
        return 'slug';
    }

    public function threads()
    {
        return $this->hasMany(Thread::class);
    }
}

We can run the unit test again and it should now pass!

vagrant@homestead:~/Code/forumio$ phpunit --filter test_a_channel_consists_of_threads
PHPUnit 6.5.5 by Sebastian Bergmann and contributors.

.                                                                   1 / 1 (100%)

Time: 687 ms, Memory: 8.00MB

OK (1 test, 1 assertion)

Fantastic. We should now be able to go back and run our feature test and we should be good there as well!

vagrant@homestead:~/Code/forumio$ phpunit --filter test_a_user_can_filter_threads_according_to_channel
PHPUnit 6.5.5 by Sebastian Bergmann and contributors.

.                                                                   1 / 1 (100%)

Time: 1.05 seconds, Memory: 10.00MB

OK (1 test, 2 assertions)

Running All Tests

We’re at a pretty good spot now. We added a feature, added some tests to support it, made some refactors, and those tests are passing nicely. So we are all done and good to go… Or are we? It’s a good idea to run the full suite of tests any time you add some new features to the codebase. We need to make sure that we didn’t affect anything elsewhere. Ok, let’s go ahead and do that.

vagrant@homestead:~/Code/forumio$ phpunit
PHPUnit 6.5.5 by Sebastian Bergmann and contributors.

F...................                                              20 / 20 (100%)

Time: 2.47 seconds, Memory: 12.00MB

There was 1 failure:

1) TestsFeatureCreateThreadsTest::test_guest_can_not_create_threads
Response status code [404] is not a redirect status code.
Failed asserting that false is true.

/home/vagrant/Code/forumio/vendor/laravel/framework/src/Illuminate/Foundation/Testing/TestResponse.php:93
/home/vagrant/Code/forumio/tests/Feature/CreateThreadsTest.php:16

FAILURES!
Tests: 20, Assertions: 32, Failures: 1.

Uh oh. We have a problem. One of our tests is now failing. We are going to need to fix this before moving forward. It looks like the test of test_guest_can_not_create_threads() is failing, so let’s inspect that test.


    function test_guest_can_not_create_threads()
    {
        $this->get('/threads/create')->assertRedirect('/login');

        $this->post('/threads')->assertRedirect('login');
    }

We move the route we had created further down in the routes file and this should clear things up.

<?php

Route::get('/', function () {
    return view('welcome');
});

Auth::routes();

Route::get('/home', 'HomeController@index')->name('home');

Route::get('/threads', 'ThreadsController@index');
Route::get('/threads/create', 'ThreadsController@create');
Route::get('/threads/{channel}/{thread}', 'ThreadsController@show');
Route::post('/threads', 'ThreadsController@store');
Route::get('/threads/{channel}', 'ThreadsController@index');
Route::post('/threads/{channel}/{thread}/replies', 'RepliesController@store');

Running the full suite of tests shows that now, everything is passing. Great!

vagrant@homestead:~/Code/forumio$ phpunit
PHPUnit 6.5.5 by Sebastian Bergmann and contributors.

....................                                              20 / 20 (100%)

Time: 2.34 seconds, Memory: 12.00MB

OK (20 tests, 35 assertions)

Displaying All Threads In A Dropdown Menu

We can make a few edits to our app.blade.php layout file so that we have a link to all threads, as well as a dropdown for all channels that exist. Then, the user can click on each individual channel in the dropdown menu and only threads associated with that channel will display. Check out the highlighted markup below that will set this up for you.

<!DOCTYPE html>
<html lang="{{ app()->getLocale() }}">
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <!-- CSRF Token -->
    <meta name="csrf-token" content="{{ csrf_token() }}">

    <title>{{ config('app.name', 'Laravel') }}</title>

    <!-- Styles -->
    <link href="{{ asset('css/app.css') }}" rel="stylesheet">
</head>
<body style="padding-bottom: 100px">
<div id="app">
    <nav class="navbar navbar-default navbar-static-top">
        <div class="container">
            <div class="navbar-header">

                <!-- Collapsed Hamburger -->
                <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#app-navbar-collapse" aria-expanded="false">
                    <span class="sr-only">Toggle Navigation</span>
                    <span class="icon-bar"></span>
                    <span class="icon-bar"></span>
                    <span class="icon-bar"></span>
                </button>

                <!-- Branding Image -->
                <a class="navbar-brand" href="{{ url('/threads') }}">
                    {{ config('app.name', 'Laravel') }}
                </a>
            </div>

            <div class="collapse navbar-collapse" id="app-navbar-collapse">
                <!-- Left Side Of Navbar -->
                <ul class="nav navbar-nav">
                    <li><a href="/threads">All Threads</a></li>
                     <li class="dropdown">
                        <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Channels
                            <span class="caret"></span></a>
                        <ul class="dropdown-menu">
                            @foreach(AppChannel::all() as $channel)
                                <li><a href="/threads/{{$channel->slug}}">{{$channel->name}}</a></li>
                            @endforeach
                        </ul>
                    </li>
                </ul>

                <!-- Right Side Of Navbar -->
                <ul class="nav navbar-nav navbar-right">
                    <!-- Authentication Links -->
                    @guest
                        <li><a href="{{ route('login') }}">Login</a></li>
                        <li><a href="{{ route('register') }}">Register</a></li>
                    @else
                        <li class="dropdown">
                            <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-expanded="false" aria-haspopup="true">
                                {{ Auth::user()->name }} <span class="caret"></span>
                            </a>

                            <ul class="dropdown-menu">
                                <li>
                                    <a href="{{ route('logout') }}"
                                       onclick="event.preventDefault();
                                                     document.getElementById('logout-form').submit();">
                                        Logout
                                    </a>

                                    <form id="logout-form" action="{{ route('logout') }}" method="POST" style="display: none;">
                                        {{ csrf_field() }}
                                    </form>
                                </li>
                            </ul>
                        </li>
                    @endguest
                </ul>
            </div>
        </div>
    </nav>

    @yield('content')
</div>

<!-- Scripts -->
<script src="{{ asset('js/app.js') }}"></script>
</body>
</html>

Sure enough, we now have a nice dropdown with all of the test channels that have been set up in our project so far. Nice!
populate dropdown menu with php


How To Filter Models By Another Model

In this tutorial we successfully set up the ability to filter threads by channels. The nice thing is that it is all backed up by tests as well. If you know how to do this with one type of model, you can easily apply it to other scenarios. Here we we used channels to filter threads. The same approach would work when you filter blog posts by categories, or threads by tags for example. Nice work!

Click to share! ⬇️