Using a Model Scope With a Relationship in Laravel

Model scopes help keep your code organized by encapsulating commonly used query constraints and allow for easy reuse. Interestingly we can also add conditions to related Models and take it even a step further.

In this tutorial, we will explore how to use a model scope with a relationship condition to filter and retrieve posts based on their comment count.

Let’s get started!

Step 1: Set Up a Laravel Project

Begin by creating a new Laravel project if you haven’t already. Open your terminal and run:

composer create-project laravel/laravel scopes-parameter
cd scopes-parameter

Step 2: Create Models

Generate a “Post” and a “Comment” model using Artisan:

php artisan make:model Post
php artisan make:model Comment

Step 3: Create Migrations

Let’s create migrations for the “posts” and the “comments” tables. Open your terminal and run the following Artisan commands:

php artisan make:migration create_posts_table
php artisan make:migration create_comments_table

Now let’s edit each of the generated migration files to define the tables’ structure.

The posts migration should contain the usual id and timestamps as well as a title and content column:

database/migrations/2023_11_11_214759_create_posts_table.php
<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->text('content');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('posts');
    }
};

The comments migration should contain the usual id and timestamps as well as the foreign key post_id needed for the relationship with posts and last but not least a comment column to contain the actual comment text.

database/migrations/2023_11_11_214859_create_comments_table.php
<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('comments', function (Blueprint $table) {
            $table->id();
            $table->foreignId('post_id')->constrained()->onDelete('cascade');
            $table->text('comment');
            $table->timestamps();
        });
    }

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

Now execute the migrations to create the “posts” and “comments” tables:

php artisan migrate

Step 4: Define Relationships and Scope in Models

Now that the Models have been created let’s add our custom code to them.

First, open the Post model and add the hasMany relationship and the scope code, that helps with selecting posts with a minimum number of comments:

app/Models/Post.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    // Define hasMany relationship with Comment
    public function comments()
    {
        return $this->hasMany(Comment::class);
    }

    // Define scope that adds a condition of a minimum comment count
    public function scopeWithCommentCount($query, $minCount)
    {
        return $query->has('comments', '>=', $minCount);
    }
}

In the Comment model, add the inverse relationship defining that a comment belongs to a post:

app/Models/Comment.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Comment extends Model
{
    // Define belongsTo relationship with Post
    public function post()
    {
        return $this->belongsTo(Post::class);
    }
}

Step 5: Create a PostController

Now, let’s generate a PostController by running the following Artisan command:

php artisan make:controller PostController

Step 6: Add Scope Logic to PostController

Next, open the file app/Http/Controllers/PostController.php. In this file, we will add two functions:

  • The index function, responsible for listing all posts.
  • The filter function, which utilizes our withCommentCount scope to only list posts meeting a user-specified minimum number of comments.
app/Http/Controllers/PostController.php
<?php

namespace App\Http\Controllers;

use App\Models\Post;
use Illuminate\Http\Request;

class PostController extends Controller
{
    public function index()
    {
        // Retrieve all posts
        $posts = Post::all();

        return view('posts.index', compact('posts'));
    }

    public function filter(Request $request)
    {
        // Validate minimum value in the input
        $request->validate([
            'min_count' => 'required|numeric|min:0',
        ]);

        // Get minimum comment count, that was entered by the user in the form input
        $minCount = $request->input('min_count');

        // Retrieve posts which have at least $minCount comments
        $posts = Post::withCommentCount($minCount)->get();

        return view('posts.index', compact('posts', 'minCount'));
    }
}

Step 7: Seed the Database With Data

In this step, we’ll create a seeder that adds test posts to the database with various amounts of comments we can later filter by.

  1. First, create a new seeder using Artisan:
php artisan make:seeder PostSeeder
  1. Open the generated PostSeeder.php file located in the database/seeders directory
  2. In the run method, add the logic to create and insert test posts with some comments:
database/seeders/PostSeeder.php
<?php

namespace Database\Seeders;

use App\Models\Post;
use Illuminate\Database\Seeder;

class PostSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        // create a post with 1 comment
        $post1 = Post::create([
            'title' => fake()->realText(60),
            'content' => fake()->realText(250)
        ])->comments()->create([
            'comment' => fake()->paragraph
        ]);

        // create a second post with 1 comment
        $post2 = Post::create([
            'title' => fake()->realText(60),
            'content' => fake()->realText(250)
        ])->comments()->create([
            'comment' => fake()->paragraph
        ]);

        // create a third post with 3 comments
        $post3 = Post::create([
            'title' => fake()->realText(60),
            'content' => fake()->realText(250)
        ])->comments()->createMany([[
            'comment' => fake()->paragraph
        ],[
            'comment' => fake()->paragraph
        ],[
            'comment' => fake()->paragraph
        ]]);
    }
}
  1. Save the seeder file.
  2. Run the seeder to populate your database with demo posts:
php artisan db:seed --class=PostSeeder

Now, you have seeded your database with demo posts and comments. You can now use the scope to conveniently filter based on their comment count.

Step 8: Create a View to Display Posts

Create resources/views/posts/index.blade.php and add:

resources/views/posts/index.blade.php
<!-- resources/views/posts/index.blade.php -->
<html>
<head>
    <!-- Include Bootstrap to make the example look better -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"
          integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"
            integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL"
            crossorigin="anonymous"></script>
</head>
<body>
<div class="container">
    <h1>Posts</h1>

    <!-- Filter Form -->
    <form action="{{ route('posts.filter') }}" method="post">
        @csrf
        <div class="row">
            <div class="form-group col-4">
                <label for="minCount">Minimum Comment Count.</label>
                <input type="text" name="min_count" id="minCount" class="form-control" placeholder="Enter minimum amount of comments" value="{{ request("min_count") }}" required>
            </div>
        </div>
        <div class="row mt-3">
            <div class="form-group  col-6">
                <button type="submit" class="btn btn-primary">Apply Filter</button>
                <a href="/posts" class="btn btn-secondary">Reset Filter</a>
            </div>
        </div>
    </form>

    <!-- Post Listing -->
    <div class="row">
        @forelse($posts as $post)
            <div class="col-md-6 mb-3">
                <div class="card">
                    <div class="card-body">
                        <h5 class="card-title">{{ $post->title }}</h5>
                        <p class="card-text">{{ $post->content }}</p>
                        <p class="card-text"><strong>Comment Count:</strong> {{ $post->comments->count() }}</p>
                    </div>
                </div>
            </div>
        @empty
            <p>No posts found.</p>
        @endforelse
    </div>

    <!-- Footer -->
    <footer class="mt-5 text-center">
        <p>Created with ♥ by Laracoding</p>
    </footer>
</div>
</body>
</html>

Step 9: Define Routes

Define routes to access the controller functions in routes/web.php:

routes/web.php
<?php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\PostController;

Route::get('/posts', [PostController::class, 'index'])->name('posts.index');
Route::post('/posts/filter', [PostController::class, 'filter'])->name('posts.filter');

Step 10: Test the Application

To launch the application run the following artisan command:

php artisan serve

You can now test the application by visiting:

http://127.0.0.1:8000/posts

You should now be able to filter by a minimum amount of comments using the form:

Further Examples of Scopes With Conditions on a Relationship

Building on the concepts explored in this tutorial, you can create additional model scopes with relationship conditions to tailor your queries further. These scopes not only enhance convenience but also improve code readability through concise and descriptive statements.

Consider the following powerful examples that demonstrate the application of model scopes with relationship conditions:

// Retrieves categories with at least one featured product.
// Usage: $category->withFeaturedProducts();
public function scopeWithFeaturedProducts($query)
{
    return $query->whereHas('products', function ($subQuery) {
        $subQuery->where('featured', true);
    });
}

// Retrieves tasks assigned to a specific user.
// Usage: $task->assignedToUser($userId);
public function scopeAssignedToUser($query, $userId)
{
    return $query->whereHas('assignee', function ($subQuery) use ($userId) {
        $subQuery->where('id', $userId);
    });
}

// Retrieves projects with at least one task having an upcoming deadline.
// Usage: $project->withUpcomingDeadlines();
public function scopeWithUpcomingDeadlines($query)
{
    return $query->whereHas('tasks', function ($subQuery) {
        $subQuery->where('deadline', '>', now());
    });
}

// Retrieves students enrolled in a specific course.
// Usage: $student->enrolledInCourse($courseId);
public function scopeEnrolledInCourse($query, $courseId)
{
    return $query->whereHas('courses', function ($subQuery) use ($courseId) {
        $subQuery->where('id', $courseId);
    });
}

// Retrieves invoices with at least one paid payment.
// Usage: $invoice->withPaidPayments();
public function scopeWithPaidPayments($query)
{
    return $query->whereHas('payments', function ($subQuery) {
        $subQuery->where('status', 'paid');
    });
}

Conclusion

In this guide, we’ve learned how to use a scope with conditions on any related model using Laravel Eloquent. This lets you reuse your code, making your filtering logic super concise and, most importantly, easy to read and understand.

I’ve also provided some examples where scopes might be applied to related model data to inspire you for more ways to apply this technique. Now go ahead and apply scopes to your models and see how concise and powerful this can be.

Happy coding!

References

This entry is part 3 of 4 in the series Query Scopes in Laravel Eloquent

  1. How to Use Model Scopes With Laravel Eloquent
  2. Using a Model Scope With Parameters in Laravel Eloquent
  3. Using a Model Scope With a Relationship in Laravel
  4. Using Global Scope in Laravel (With Practical Examples)

Johan van den Broek

Johan is the creator of laracoding.com. As a child, he began tinkering with various programming languages, many of which have been long forgotten today. Currently, he works exclusively with PHP and Laravel, and his passion for programming remains to this day.

Leave a Reply

Your email address will not be published. Required fields are marked *

Recent Posts