Создание форума в Laravel

Для работы с данным материалом, в качестве основы, я использовал исходники с сайта laracasts.com, которые можно бесплатно скачать с GitHub. Серия уроков называется Let’s Build A Forum with Laravel and TDD. Там также используется Vue.js фреймворк, но здесь я его не использую. Данный материал посвящен исключительно Laravel. Тестирование здесь также не рассматривается. 

Сначала опишу как форум будет выглядеть, а потом можно будет перейти к технической части. 

Главная страница форума отображает список опубликованных тем.Каждая тема состоит из раздела заголовка и раздела описания. В разделе заголовка вы можете видеть название темы и количество пользовательских сообщений в этой теме. 

Название темы представляет из себя ссылку, которая ведет на страницу подробного описания темы, где пользователи могут оставлять свои сообщения. О странице подробного описания смотрите далее. 

В навигационном меню слева расположены пункты: «Темы форума» и «Рубрики». В пункте справа – указано имя зарегистрированного пользователя. Все три пункта являются заголовками выпадающих меню. При нажатии на пункт пользователя можно перейти:

  • к профилю пользователя со списком созданных им тем и сообщений;
  • выйти из учетной записи пользователя.

В меню «Рубрики» можно создать новую рубрику или, выбрав одну из существующих, перейти к странице с темами этой рубрики.В меню «Темы форума» можно создать новую тему или отобразить темы разными способами.Страницы с формами создания новой рубрики и новой темы выглядят следующим образом:Алиас или псевдоним у рубрики – это слово или словосочетание, которое будет входить в адрес страницы темы из определенной рубрики. 

После регистрации или входа под своим именем на сайт пользователь автоматически перенаправляется на страницу своего профиля, на которой отображается список созданных пользователем тем и сообщений. 

Процесс регистрации/авторизации пользователя реализован стандартными средствами фреймворка. Для того чтобы добавить данный функционал в ваше приложение используйте команду: php artisan make:auth

Здесь пользователь может перейти к подробному просмотру темы, перейдя по ссылке – названию темы. Подробная страница отдельной темы выглядит следующим образом:На этой странице пользователи могут удалить свою тему или свои сообщения; могут добавлять новые сообщения в своих темах или в темах других пользователей. Также для каждого сообщения предусмотрена возможность проголосовать за сообщение (на рисунке цифра со звездочкой). 

На этом описание интерфейса форума законченно. Далее рассматривается сам код приложения.

Установка и настройка

Устанавливаем фреймворк, используя composer:

composer create-project --prefer-dist laravel/laravel forum

Или с помощью их собственного установщика:

laravel new forum

В файле .env прописываем параметры доступа к БД. Далее в файле config/app.php прописываем:

'name' => 'Форум'

В дальнейшем это название будет указано в верхней навигационной панели и внутри тега title. 

Далее в файле прописываем:

'locale' => 'ru'

В дальнейшем это значение будет определять атрибут lang тега html на странице. 

Теперь в папке resources/views создадим папку layouts. В ней создадим два вида:

  • общий, для всего сайта – app.blade.php;
  • вид главной навигационной панели – nav.blade.php.

В файле app.blade.php будет следующее содержимое:

<!DOCTYPE html>
<html lang="{{ config('app.locale') }}">
<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 rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" >
    <link href="{{ asset('css/app.css') }}" rel="stylesheet">
    <!-- Scripts -->
    <script>
        window.Laravel = {!! json_encode([
            'csrfToken' => csrf_token(),
        ]) !!};
    </script>
    <style>
        body { padding-bottom: 100px; }
        .level { display: flex; align-items: center; }
        .flex { flex: 1; }
    </style>
</head>
<body>
<div id="app">
    @include ('layouts.nav')
    @yield('content')
</div>
<!-- Scripts -->
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js"></script>
<script src="{{ asset('js/app.js') }}"></script>
</body>
</html>

На страницу я добавил загрузку jquery и bootstrap. Дефолтные стили из файла css/app.css и js-код из файла js/app.js не используются, все их содержимое можно удалить. 

Внутри тега div с id=»app» мы включаем навигационную панель и веб контент, выводимый программно, для каждой страницы отдельно:

<div id="app">
    @include ('layouts.nav')
    @yield('content')
</div>

В файле навигационной панели nav.blade.php содержимое должно быть следующее:

<nav class="navbar navbar-inverse 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">
                <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('/') }}">
                {{ 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 class="dropdown">
                    <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true"
                       aria-expanded="false">Темы форума <span class="caret"></span></a>
                    <ul class="dropdown-menu">
                        <li>
                            <a href="/threads/create">Новая тема</a>
                        </li>
                        <li role="separator" class="divider"></li>
                        <li><a href="/">Все темы</a></li>
                        @if (auth()->check())
                            <li><a href="/?by={{ auth()->user()->name }}">Мои темы</a></li>
                        @endif
                        <li><a href="/?popular=1">Популярные темы</a></li>
                        <li><a href="/?answered=1">Темы с сообщениями</a></li>
                    </ul>
                </li>
                <li class="dropdown">
                    <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true"
                       aria-expanded="false">Рубрики <span class="caret"></span></a>
                    <ul class="dropdown-menu" aria-labelledby="dropdownMenuDivider">
                    <li>
                        <a href="/channels/create">Новая рубрика</a>
                    </li>
                    <li role="separator" class="divider"></li>
                        @foreach ($channels 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 -->
                @if (Auth::guest())
                    <li><a href="{{ route('login') }}">Войти</a></li>
                    <li><a href="{{ route('register') }}">Зарегистрироваться</a></li>
                @else
                    <li class="dropdown">
                        <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button"
                           aria-expanded="false">
                            {{ Auth::user()->name }} <span class="caret"></span>
                        </a>
                        <ul class="dropdown-menu" role="menu">
                            <li>
                                <a href="{{ route('profile', Auth::user()) }}">Мой профиль</a>
                            </li>
                            <li>
                                <a href="{{ route('logout') }}"
                                   onclick="event.preventDefault();
                                                     document.getElementById('logout-form').submit();">
                                    Выйти
                                </a>
                                <form id="logout-form" action="{{ route('logout') }}" method="POST"
                                      style="display: none;">
                                    {{ csrf_field() }}
                                </form>
                            </li>
                        </ul>
                    </li>
                @endif
            </ul>
        </div>
    </div>
</nav>

Несколько слов об участке кода:

@if (auth()->check())
    <li><a href="/?by={{ auth()->user()->name }}">Мои темы</a></li>
@endif
<li><a href="/?popular=1">Популярные темы</a></li>
<li><a href="/?answered=1">Темы с сообщениями</a></li>

Здесь вы видите, что ссылки указывают на корень сайта (/), за обработку этого маршрута будет отвечать контроллер, который «вытащит» из запроса эти GET-параметры, используя метод $request->intersect() и вернет нужный набор тем из БД. 

С основной разметкой и настройками мы закончили. Далее нас ждет работа с БД и маршрутами.

БД и маршруты

В Laravel 5.4 используется кодировка utf8mb4, поэтому нужно использовать БД с поддержкой этой кодировки. Иначе будет ошибка:

[Illuminate\Database\QueryException]
SQLSTATE[42000]: Syntax error or access violation: 1071 Specified key was too long; max key length is 767 bytes (SQL: alter table users add unique users_email_unique(email))
[PDOException]
SQLSTATE[42000]: Syntax error or access violation: 1071 Specified key was too long; max key length is 767 bytes

Если нет возможности использовать БД с кодировкой utf8mb4, то стандартную длину строки можно подкорректировать в файле app/Providers/AppServiceProvider.php. Для этого в начале файла нужно указать:

use Illuminate\Support\Facades\Schema;

В метод boot() добавить:

Schema::defaultStringLength(191);

Для форума нам потребуются следующие таблицы: threads, replies, channels, favorites. Таблица threads будет содержать темы форума; replies будет содержать сообщения пользователей внутри тем; channels – это рубрики (каждая тема относится к определенной рубрике); favorites – используется для создания полиморфной связи (отношения) между голосами за сообщение (favorites) и самим сообщением (reply). 

Для создания таблиц воспользуемся Laravel-миграциями. По умолчанию, в папке database/migrations уже есть два файла: create_users_table.php и create_password_resets_table.php. Как видно из названий они нужны для работы системы авторизации/регистрации. Для работы приложения нам потребуются модели, чтобы одновременно создать файлы моделей и файлы миграций воспользуемся консолью:

php artisan make:model Channel -m
php artisan make:model Favorite -m
php artisan make:model Reply -m
php artisan make:model Thread -m

К моделям вернемся позже, а пока разберемся с файлами миграций. 

Начнем с файла миграции «threads». Здесь содержимое метода up() должно выглядеть следующим образом:

Schema::create('threads', function (Blueprint $table) {
    $table->increments('id');
    $table->unsignedInteger('user_id');
    $table->unsignedInteger('channel_id');
    $table->string('title');
    $table->text('body');
    $table->timestamps();
});

Метод up() файла миграции «replies» должен выглядеть следующим образом:

Schema::create('replies', function (Blueprint $table) {
    $table->increments('id');
    $table->integer('thread_id');
    $table->integer('user_id');
    $table->text('body');
    $table->timestamps();
});

Метод up() файла миграции «channels» должен выглядеть следующим образом:

Schema::create('channels', function (Blueprint $table) {
    $table->increments('id');
    $table->string('name', 50);
    $table->string('slug', 50);
    $table->timestamps();
});

Метод up() файла миграции «favorites» должен выглядеть следующим образом:

Schema::create('favorites', function (Blueprint $table) {
    $table->increments('id');
    $table->unsignedInteger('user_id');
    $table->unsignedInteger('favorited_id');
    $table->string('favorited_type', 50);
    $table->timestamps();
    $table->unique(['user_id', 'favorited_id', 'favorited_type']);
});

Как уже говорилось выше таблица «favorites» используется для создания полиморфной связи (отношения) между голосами за сообщение (favorites) и самим сообщением (reply). Главные поля, на которые нужно обратить внимание: favorited_id и favorited_type. Первое содержит id сообщения, а второе — имя класса-модели (App\Reply). В последней строчке мы задаем уникальный составной ключ, который предотвращает повторное голосование за одно и то же сообщение. 

Теперь для того чтобы создать таблицы в БД воспользуемся командой:

php artisan migrate

На этом предварительная работа с БД закончена. Далее перейдем к маршрутам. 

Все маршруты (routes) для нашего приложения должны быть расположены в файле routes/web.php. Содержимое этого файла должно быть следующим:

Auth::routes();
Route::get('/home', 'HomeController@index');
Route::get('/', 'ThreadsController@index');
Route::get('threads/create', 'ThreadsController@create');
Route::get('threads/{channel}/{thread}', 'ThreadsController@show');
Route::delete('threads/{channel}/{thread}', 'ThreadsController@destroy');
Route::post('threads', 'ThreadsController@store');
Route::get('threads/{channel}', 'ThreadsController@index');
Route::post('/threads/{channel}/{thread}/replies', 'RepliesController@store');
Route::delete('/replies/{reply}', 'RepliesController@destroy');
Route::get('channels/create', 'ChannelsController@create');
Route::post('channels', 'ChannelsController@store');
Route::post('/replies/{reply}/favorites', 'FavoritesController@store');
Route::get('/profiles/{user}', 'ProfilesController@show')->name('profile');

В следующих разделах мы перейдем к рассмотрению работы составных частей приложения (контроллеры, модели, виды).

Рубрики (Channels)

Рубрики являются родительскими объектами блога, к ним относятся все темы, а к темам, в свою очередь, относятся все сообщения. Поэтому в этом разделе поговорим о рубриках, а в следующих разделах перейдем к темам и сообщениям. 

Для рубрик мы указали следующие два маршрута:

Route::get('channels/create', 'ChannelsController@create');
Route::post('channels', 'ChannelsController@store');

Первый маршрут нужен для перехода на страницу с формой создания рубрики, а второй отвечает за сохранение полученных из формы данных в базе данных. В обоих случаях используется ChannelsController. 

Создадим файл ChannelsController.php в папке Http/Controllers. Его содержимое будет следующим:

namespace App\Http\Controllers;
use App\Channel;
use Illuminate\Http\Request;
class ChannelsController extends Controller
{
    public function __construct()
    {
        $this->middleware('auth');
    }
    public function create()
    {
        return view('channels.create');
    }
    public function store(Request $request)
    {
        $this->validate($request, [
            'name' => 'required|max:50',
            'slug' => 'required|max:50',
        ]);
        $channel = Channel::create([
            'name' => request('name'),
            'slug' => request('slug')
        ]);
        return back();
    }
}

Как видно из кода контроллера вид для рубрик у нас только один – ‘channels.create’. После сохранения новой рубрики в БД будем возвращаться на эту же страницу. 

Список рубрик у нас помещен в верхнем навигационном меню (nav.balde.php) в следующем участке кода:

<li role="separator" class="divider"></li>
    @foreach ($channels as $channel)
        <li><a href="/threads/{{ $channel->slug }}">{{ $channel->name }}</a></li>
    @endforeach
</ul>

В цикле используется переменная $channels, она должна быть доступной глобально, поэтому воспользуемся услугами класса AppServiceProvider. В методе boot() нужно добавить следующий код:

public function boot()
{
    ...
    $channels = Channel::all();
    \View::share('channels', $channels);
}

Чтобы переменная была доступна во всех видах, мы использовали метод: View::share('key', 'value')

Мы еще не добавили вид создания рубрики – ‘channels.create’. Для этого нам нужно создать папку channels (внутри resources/views) и в ней создать файл create.blade.php. Содержимое файла должно быть следующим:

@extends('layouts.app')
@section('content')
    <div class="container">
        <div class="row">
            <div class="col-md-8 col-md-offset-2">
                <div class="panel panel-default">
                    <div class="panel-heading">Создать новую Рубрику</div>
                    <div class="panel-body">
                        <form method="POST" action="/channels">
                            {{ csrf_field() }}
                            <div class="form-group">
                                <label for="title">Название:</label>
                                <input type="text" class="form-control" id="name" name="name"
                                       value="{{ old('name') }}" required>
                            </div>
                            <div class="form-group">
                                <label for="title">Алиас (псевдоним):</label>
                                <input type="text" class="form-control" id="slug" name="slug"
                                       value="{{ old('slug') }}" required>
                            </div>
                            <div class="form-group">
                                <button type="submit" class="btn btn-primary">Сохранить</button>
                            </div>
                            @if (count($errors))
                                <ul class="alert alert-danger">
                                    @foreach ($errors->all() as $error)
                                        <li>{{ $error }}</li>
                                    @endforeach
                                </ul>
                            @endif
                        </form>
                    </div>
                </div>
            </div>
        </div>
    </div>
@endsection

Теперь осталось добавить нужный код в модель Channel. Готовый вариант файла Channel.php должен выглядеть следующим образом:

namespace App;
use Illuminate\Database\Eloquent\Model;
class Channel extends Model
{
    protected $guarded = [];
    public function getRouteKeyName()
    {
        return 'slug';
    }
    public function threads()
    {
        return $this->hasMany(Thread::class);
    }
}

Немного о коде. 
Массив $guarded оставляем пустым, чтобы пользователь мог беспрепятственно создавать новые рубрики. 

В методе getRouteKeyName() возвращаем название поля (slug), по которому Laravel будет автоматически возвращать нужную модель (Channel). Например, когда нам нужно будет отобразить список тем определенной рубрики, по URI-сегменту threads/{channel} Laravel выберет нужную рубрику, используя поле slug, указанное в ссылке на странице. Пример ссылки был указан выше, в коде из навигационной панели. 

В методе threads() указываем, что рубрика включается в себя разные темы.

Темы (Threads)

Следующие маршруты из файла web.php относятся к темам:

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

Как видим по коду, во всех маршрутах используются разные методы ThreadsController. Два маршрута используют один и тот же метод – index:

Route::get('/', 'ThreadsController@index');
Route::get('threads/{channel}', 'ThreadsController@index');

Первый маршрут отвечает за отображение главной страницей сайта со списком всех тем форума, второй маршрут используется для вывода списка тем, относящихся к определенной рубрике (channel). Код этого метода следующий:

public function index(Channel $channel, ThreadFilters $filters)
{
    $threads = $this->getThreads($channel, $filters);
    if (request()->wantsJson()) {
        return $threads;
    }
    return view('threads.index', compact('threads'));
}

Здесь переменная $threads использует метод getThreads($channel, $filters) для получения списка тем. Код этого метода следующий:

protected function getThreads(Channel $channel, ThreadFilters $filters)
{
    $threads = Thread::latest()->filter($filters);
    if ($channel->exists) {
        $threads->where('channel_id', $channel->id);
    }
    return $threads->get();
}

Вначале, в переменной $threads мы определяем параметры сортировки тем по дате (Thread::latest()); фильтрацию по пользователю, сортировку по популярности, фильтрацию и сортировку по содержанию ответов (filter($filters)). Методы фильтров содержатся в классе ThreadFilters, о котором будет сказано позже. Далее, атрибут $channel->exists указывает, существует ли модель данной рубрики. Если да, то выбираем темы, относящиеся к данной рубрике (с предыдущими параметрами фильтрации/сортировки), если нет, то выбираем все темы (с предыдущими параметрами фильтрации/сортировки). 

Фильтрация по запросу (классы: Filters и ThreadFilters) 
Выше, для получения нужного списка пользователей мы фильтровали темы по пользователю, сортировали по популярности, фильтровали и сортировали по содержанию ответов. Для этого мы использовали метод filter($filters).

$threads = Thread::latest()->filter($filters);

Это так называемая заготовка запроса или Local Scopes. Данный метод на самом деле находится в файле модели Thread.php и выглядит он следующим образом:

public function scopeFilter($query, ThreadFilters $filters)
{
    return $filters->apply($query);
}

Здесь используется метод apply($query) класса ThreadFilters. Содержимое которого следующее:

class ThreadFilters extends Filters
{
    protected $filters = ['by', 'popular', 'answered'];
    /*фильтрация по имени пользователя */
    protected function by($username)
    {
        $user = User::where('name', $username)->firstOrFail();
        return $this->builder->where('user_id', $user->id);
    }
    /*сортировка по популярности */
    protected function popular()
    {
        $this->builder->getQuery()->orders = [];
        return $this->builder->orderBy('replies_count', 'desc');
    }
    /*фильтрация (и сортировка) по количеству ответов */
    protected function answered()
    {
        $this->builder->getQuery()->orders = [];
        return $this->builder
            ->whereHas('replies')
            ->orderBy('replies_count', 'desc');
    }
}

Здесь вы видите все три метода, нужные для фильтрации и/или сортировки. Метода apply($query) здесь нет, потому что он находится в родительском классе Filters (файл Filters.php). Его полный код следующий:

class Filters
{
    protected $request;
    protected $builder;
    protected $filters = [];
    public function __construct(Request $request)
    {
        $this->request = $request;
    }
    public function apply($builder)
    {
        $this->builder = $builder;
        foreach ($this->getFilters() as $filter => $value) {
            if (method_exists($this, $filter)) {
                $this->$filter($value);
            }
        }
        return $this->builder;
    }
    public function getFilters()
    {
        return $this->request->intersect($this->filters);
    }
}

Возвращаемся к контроллеру ThreadsController… 
Его полный код будет следующим:

class ThreadsController extends Controller
{
    public function __construct()
    {
        $this->middleware('auth')->except(['index', 'show']);
    }
    public function index(Channel $channel, ThreadFilters $filters)
    {
        $threads = $this->getThreads($channel, $filters);
        if (request()->wantsJson()) {
            return $threads;
        }
        return view('threads.index', compact('threads'));
    }
    public function create()
    {
        return view('threads.create');
    }
    public function store(Request $request)
    {
        $this->validate($request, [
            'title' => 'required',
            'body' => 'required',
            'channel_id' => 'required|exists:channels,id'
        ]);
        $thread = Thread::create([
            'user_id' => auth()->id(),
            'channel_id' => request('channel_id'),
            'title' => request('title'),
            'body' => request('body')
        ]);
        return redirect($thread->path());
    }
    public function show($channel, Thread $thread)
    {
        return view('threads.show', [
            'thread' => $thread,
            'replies' => $thread->replies()->paginate(20)
        ]);
    }
    public function destroy($channel, Thread $thread)
    {
        $this->authorize('update', $thread);
        $thread->delete();
        if (request()->wantsJson()) {
            return response([], 204);
        }
        return redirect('/threads');
    }
    protected function getThreads(Channel $channel, ThreadFilters $filters)
    {
        $threads = Thread::latest()->filter($filters);
        if ($channel->exists) {
            $threads->where('channel_id', $channel->id);
        }
        return $threads->get();
    }
}

Рассмотрим подробнее метод destroy($channel, Thread $thread). Приведу его код еще раз отдельно.

public function destroy($channel, Thread $thread)
{
    $this->authorize('update', $thread);
    $thread->delete();
    if (request()->wantsJson()) {
        return response([], 204);
    }
    return redirect('/threads');
}

Метод authorize(‘update’, $thread) – это метод авторизации, использующий политику (Policy). Параметр ‘update’ будет содержаться в будущем классе ThreadPolicy как одноименный метод. Этот метод будет проверять, является ли данный пользователь создателем темы, указанной в параметре $thread. Если да, то тему можно удалить, если нет, то браузеру вернется ответ с кодом 403 Forbidden («запрещено»). 

Для создания политики ThreadPolicy воспользуемся командой:

php artisan make:policy ThreadPolicy

И в созданном классе добавим метод update(). Полный код класса будет следующим:

class ThreadPolicy
{
    use HandlesAuthorization;
    public function update(User $user, Thread $thread)
    {
        return $thread->user_id === $user->id;
    }
}

После создания политики зарегистрируем ее в классе AuthServiceProvider (app/Providers/ AuthServiceProvider.php). Таким образом, массив $policies в этом классе будет выглядеть следующим образом:

protected $policies = [
    Thread::class => ThreadPolicy::class,
];

Модель Thread 
Файл модели, как вы помните, уже был создан ранее. Содержимое класса модели должно быть следующее:

class Thread extends Model
{
    protected $guarded = [];
    /* Активная загрузка (eager loading)  */
    protected $with = ['creator', 'channel'];
    protected static function boot()
    {
        parent::boot();
        static::addGlobalScope('replyCount', function ($builder) {
            $builder->withCount('replies');
        });
        static::deleting(function ($thread) {
            $thread->replies()->delete();
        });
    }
    public function path()
    {
        return "/threads/{$this->channel->slug}/{$this->id}";
    }
    public function creator()
    {
        return $this->belongsTo(User::class, 'user_id');
    }
    public function channel()
    {
        return $this->belongsTo(Channel::class);
    }
    public function replies()
    {
        return $this->hasMany(Reply::class);
    }
    public function addReply($reply)
    {
        $this->replies()->create($reply);
    }
    public function scopeFilter($query, ThreadFilters $filters)
    {
        return $filters->apply($query);
    }
}

Мы видим здесь рассмотренный метод scopeFilter(). Также нам здесь интересен метод boot(). Приведу его код еще раз отдельно.

protected static function boot()
{
    parent::boot();
    static::addGlobalScope('replyCount', function ($builder) {
        $builder->withCount('replies');
    });
    static::deleting(function ($thread) {
        $thread->replies()->delete();
    });
}

В данном методе мы определяем глобальную заготовку или global scope. Благодаря этой заготовке мы получаем доступ к полю replies_count модели с количеством связанных сообщений. Данное поле мы использовали ранее, когда сортировали темы по популярности и фильтровали темы с сообщениями (см. ThreadFilters). 

Метод deleting() – это событие. Когда удаляется тема, удаляются все связанные сообщения.

Темы (Threads). Виды

Всего у тем 3 вида: create.blade.php, index.blade.php, show.blade.php. Привожу здесь их полное содержимое. 

create.blade.php

@extends('layouts.app')
@section('content')
    <div class="container">
        <div class="row">
            <div class="col-md-8 col-md-offset-2">
                <div class="panel panel-default">
                    <div class="panel-heading">Создать новую Тему</div>
                    <div class="panel-body">
                        <form method="POST" action="/threads">
                            {{ csrf_field() }}
                            <div class="form-group">
                                <label for="channel_id">Рубрики:</label>
                                <select name="channel_id" id="channel_id" class="form-control" required>
                                    <option value="">Выберите одну из следующих...</option>
                                    @foreach ($channels as $channel)
                                        <option value="{{ $channel->id }}" {{ old('channel_id') == $channel->id ? 'selected' : '' }}>
                                            {{ $channel->name }}
                                        </option>
                                    @endforeach
                                </select>
                            </div>
                            <div class="form-group">
                                <label for="title">Заголовок:</label>
                                <input type="text" class="form-control" id="title" name="title"
                                       value="{{ old('title') }}" required>
                            </div>
                            <div class="form-group">
                                <label for="body">Описание:</label>
                                <textarea name="body" id="body" class="form-control"
                                          rows="8" required>{{ old('body') }}</textarea>
                            </div>
                            <div class="form-group">
                                <button type="submit" class="btn btn-primary">Опубликовать</button>
                            </div>
                            @if (count($errors))
                                <ul class="alert alert-danger">
                                    @foreach ($errors->all() as $error)
                                        <li>{{ $error }}</li>
                                    @endforeach
                                </ul>
                            @endif
                        </form>
                    </div>
                </div>
            </div>
        </div>
    </div>
@endsection

index.blade.php

@extends('layouts.app')
@section('content')
    <div class="container">
        <div class="row">
            <div class="col-md-8 col-md-offset-2">
                @forelse ($threads as $thread)
                    <div class="panel panel-default">
                        <div class="panel-heading">
                            <div class="level">
                                <h4 class="flex">
                                    <a href="{{ $thread->path() }}">
                                        {{ $thread->title }}
                                    </a>
                                </h4>
                                <a href="{{ $thread->path() }}">
                                    Сообщений: {{ $thread->replies_count }}
                                </a>
                            </div>
                        </div>
                        <div class="panel-body">
                            <div class="body">{{ $thread->body }}</div>
                        </div>
                    </div>
                @empty
                    <p>Пока что здесь ничего нет.</p>
                @endforelse
            </div>
        </div>
    </div>
@endsection

show.blade.php

@extends('layouts.app')
@section('content')
    <div class="container">
        <div class="row">
            <div class="col-md-8">
                <div class="panel panel-default">
                    <div class="panel-heading">
                        <div class="level">
                            <span class="flex">
                                <a href="{{ route('profile', $thread->creator) }}">{{ $thread->creator->name }}</a> опубликовал:
                                {{ $thread->title }}
                            </span>
                            @can ('update', $thread)
                                <form action="{{ $thread->path() }}" method="POST">
                                    {{ csrf_field() }}
                                    {{ method_field('DELETE') }}
                                    <button type="submit" class="btn btn-link">Удалить тему</button>
                                </form>
                            @endcan
                        </div>
                    </div>
                    <div class="panel-body">
                        {{ $thread->body }}
                    </div>
                </div>
                @foreach ($replies as $reply)
                    @include ('threads.reply')
                @endforeach
                {{ $replies->links() }}
                @if (auth()->check())
                    <form method="POST" action="{{ $thread->path() . '/replies' }}">
                        {{ csrf_field() }}
                        <div class="form-group">
                            <textarea name="body" id="body" class="form-control" placeholder="Есть что сообщить?"
                                      rows="5"></textarea>
                        </div>
                        <button type="submit" class="btn btn-default">Добавить</button>
                    </form>
                @else
                    <p class="text-center">Пожалуйста <a href="{{ route('login') }}">войдите</a> для того чтобы учавствовать в обсуждении.</p>
                @endif
            </div>
            <div class="col-md-4">
                <div class="panel panel-default">
                    <div class="panel-body">
                        <p>
                            Тема была опубликована {{ $thread->created_at->format('d-m-Y') }} в {{ $thread->created_at->format('H:i:s') }}
                            Опубликовал: <a href="#">{{ $thread->creator->name }}</a>.
                            Сообщений в теме: {{ $thread->replies_count }}.
                        </p>
                    </div>
                </div>
            </div>
        </div>
    </div>
@endsection

Сообщения (Replies)

Следующие маршруты из файла web.php относятся к сообщениям:
Route::post('/threads/{channel}/{thread}/replies', 'RepliesController@store');
Route::delete('/replies/{reply}', 'RepliesController@destroy');
Контроллер RepliesController
class RepliesController extends Controller
{
    public function __construct()
    {
        $this->middleware('auth');
    }
    public function store($channelId, Thread $thread)
    {
        $this->validate(request(), ['body' => 'required']);
        $thread->addReply([
            'body' => request('body'),
            'user_id' => auth()->id()
        ]);
        return back();
    }
    public function destroy(Reply $reply)
    {
        $this->authorize('update', $reply);
        $reply->delete();
        return back();
    }
}
Здесь ничего интересного. Два метода: store() и destroy() используются для создания и удаления записей. В методе destroy() мы пользуемся методом authorize(), который вызывает метод update() из класса политики ReplyPolicy. Содержимое файла ReplyPolicy.php для этого класса аналогичное файлу ThreadPolicy.php, который мы создавали ранее. 
Вид из файла reply.blade.php 
Единственный вид для сообщений. В нем выводится список сообщений из БД, с возможностью удаления и голосования за сообщение. Код, отвечающий за создание нового сообщения, располагается в файле show.blade.php. Вид из этого файла отвечает за вывод подробного описания отдельной темы. Данный вид мы рассматривали в предыдущем разделе.
<div class="panel panel-default">
    <div class="panel-heading">
        <div class="level">
            <h5 class="flex">
                <a href="{{ route('profile', $reply->owner) }}">
                    {{ $reply->owner->name }}
                </a> написал {{ $reply->created_at->format('d-m-Y') }} в {{ $reply->created_at->format('H:i:s') }}
            </h5>
            <div>
                <form method="POST" action="/replies/{{ $reply->id }}/favorites">
                    {{ csrf_field() }}
                    <button type="submit" class="btn btn-default" {{ $reply->isFavorited() ? 'disabled' : '' }}>
                        {{ $reply->favorites_count }}
                        <span class="glyphicon glyphicon glyphicon-star" style="color: #c9302c"></span>
                    </button>
                </form>
            </div>
        </div>
    </div>
    <div class="panel-body">
        {{ $reply->body }}
    </div>
    @can ('update', $reply)
        <div class="panel-footer">
            <form method="POST" action="/replies/{{ $reply->id }}">
                {{ csrf_field() }}
                {{ method_field('DELETE') }}
                <button type="submit" class="btn btn-danger btn-xs">Удалить</button>
            </form>
        </div>
    @endcan
</div>
Модель Reply

class Reply extends Model
{
    use Favoritable;
    protected $guarded = [];
    /* Активная загрузка (eager loading)  */
    protected $with = ['owner', 'favorites'];
    public function owner()
    {
        return $this->belongsTo(User::class, 'user_id');
    }
}
Часть функционала вынесем в отдельный файл – Favoritable.php. Содержимое трейта из этого файла будет следующим:
trait Favoritable
{
    /* Полиморфное отношение – получаем все голоса за сообщения */
    public function favorites()
    {
        return $this->morphMany(Favorite::class, 'favorited');
    }
    /* Сохраняем новый голос за сообщение */
    public function favorite()
    {
        $attributes = ['user_id' => auth()->id()];
        if (!$this->favorites()->where($attributes)->exists()) {
            return $this->favorites()->create($attributes);
        }
    }
    /* Проверяем голосовал ли уже этот пользователь за это сообщение
     * (используется в reply.blade.php)
     */
    public function isFavorited()
    {
        return !! $this->favorites->where('user_id', auth()->id())->count();
    }
    /* Получаем количество голосований за сообщение
     * (используется в reply.blade.php)
     */
    public function getFavoritesCountAttribute()
    {
        return $this->favorites->count();
    }
}
Для работы функционала голосования мы используем следующий маршрут из файла web.php:
Route::post('/replies/{reply}/favorites', 'FavoritesController@store');
Для работы маршрута нам нужен FavoritesController, поэтому создадим нужный для него файл. Содержимое класса FavoritesController должно быть следующим:
class FavoritesController extends Controller
{
    public function __construct()
    {
        $this->middleware('auth');
    }
    public function store(Reply $reply)
    {
        $reply->favorite();
        return back();
    }
}
Здесь, в методе store() мы используем метод favorite() из модели Reply (трейт Favoritable), который был приведен выше. 
В разделе «БД и маршруты» мы создали модель Favorite. Содержимое класса Favorite должно быть следующим:
class Favorite extends Model
{
    protected $guarded = [];
}
Массив $guarded оставляем пустым, чтобы новые голоса могли беспрепятственно сохраняться в БД.

Личная страница пользователя

В файле web.php у нас есть маршрут, который направляет на личную страницу зарегистрированного пользователя:
Route::get('/profiles/{user}', 'ProfilesController@show')->name('profile');
Создадим контроллер ProfilesController, который будет содержать всего лишь один метод отображения профиля пользователя со списком размещенных пользователем тем.
class ProfilesController extends Controller
{
    public function show(User $user)
    {
        return view('profiles.show', [
            'profileUser' => $user,
            'threads' => $user->threads()->paginate(30)
        ]);
    }
}
Для отображения страницы создадим файл вида resources/views/profiles/show.blade.php. Разместим следующую разметку в этом файле:
@extends('layouts.app')
@section('content')
    <div class="container">
        <div class="row">
            <div class="col-md-8 col-md-offset-2">
                <div class="page-header">
                    <h1>
                        {{ $profileUser->name }}
                        <small>Профиль был создан {{ $profileUser->created_at->format('d-m-Y') }} в {{ $profileUser->created_at->format('H:i:s') }}</small>
                    </h1>
                </div>
                @foreach ($threads as $thread)
                    <div class="panel panel-default">
                        <div class="panel-heading">
                            <div class="level">
                               <span class="flex">
                                    <a href="{{ route('profile', $thread->creator) }}">{{ $thread->creator->name }}</a> опубликовал:
                                    <a href="{{ $thread->path() }}">{{ $thread->title }}</a>
                               </span>
                                <span>{{ $thread->created_at->format('d-m-Y | H:i:s') }}</span>
                            </div>
                        </div>
                        <div class="panel-body">
                            {{ $thread->body }}
                        </div>
                    </div>
                @endforeach
                {{ $threads->links() }}
            </div>
        </div>
    </div>
@endsection
При регистрации или аутентификации пользователя будем направлять пользователя на его личную страницу. За регистрацию и аутентификацию отвечают два контроллера из папки app/Http/Controllers/Auth: RegisterController и LoginController. В каждом из них нужно разместить следующую функцию:
protected function redirectTo()
{
    return route('profile', auth()->user()->name);
}

Новые темы (непрочитанные)

Как мы знаем, на форуме может быть много пользователей и каждый из них может создавать новые темы. В результате главная страница будет содержать бесчисленный набор разных тем, в котором трудно определить, какие темы были уже прочитаны, а какие нет. Чтобы облегчить пользователям жизнь можно выделять новые (непрочитанные) темы жирным шрифтом.Как это сделать вы узнаете в данном разделе. 
Первым делом исправим файл threads/index.blade.php. Этот файл отвечает за список всех тем форума, которые выводятся на главной странице. Найдем строчку {{ $thread->title }} и вместо нее вставим следующий код:
@if (auth()->check())
    @if ($thread->hasUpdatesFor(auth()->user()))
        <strong>
            {{ $thread->title }}
        </strong>
    @else
        {{ $thread->title }}
    @endif
@else
    {{ $thread->title }}
@endif
Здесь мы проверяем, является ли пользователь зарегистрированным, далее проверяем, существует ли запись в кэше, о том, что данный пользователь уже просматривал эту тему (метод hasUpdatesFor()), если нет, то выделяем название темы жирным шрифтом. 
Добавим новый метод hasUpdatesFor() в модель Thread.
public function hasUpdatesFor($user)
{
    $key = $user->visitedThreadCacheKey($this);
    return $this->updated_at > cache($key);
}
Переменная $key содержит ключ записи из кэша. Сама запись содержит дату и время просмотра темы пользователем. Если тема еще не была просмотрена или была обновлена после просмотра пользователем функция hasUpdatesFor() вернет true. 
Добавим метод visitedThreadCacheKey() в модель User.
public function visitedThreadCacheKey($thread)
{
    return sprintf("users.%s.visits.%s", $this->id, $thread->id);
}
В эту же модель также нужно добавить метод read(), который будет вызываться из контроллера ThreadsController (метод show()) при открытии пользователем каждой темы.
public function read($thread)
{
    cache()->forever(
        $this->visitedThreadCacheKey($thread),
        Carbon::now()
    );
}
Данный метод добавит в кэш время просмотра темы этим пользователем. 
Для работы Carbon не забудьте в начале файла добавить:
use Carbon\Carbon;
Далее откроем файл контроллера ThreadsController и в метод show() добавим следующий код:
if (auth()->check()) {
    auth()->user()->read($thread);
}
Здесь мы проверяем, если пользователь зарегистрирован, то следует добавить запись в кэш с текущей датой просмотра темы. 
Теперь все должно работать, новые темы должны помечаться жирным шрифтом. Но что если пользователь прочитал тему, закрыл, а потом в этой теме были добавлены или удалены сообщения. Вдруг пользователь захочет просмотреть эти изменения. Чтобы оповестить об этом пользователя, будем снова выделять жирным шрифтом название темы. Для того чтобы это происходило нужно всего лишь обновлять значение темы updated_at при каждом изменении модели Reply. 
Как обновить значение темы updated_at? Для этого нам нужно открыть модель Reply. Указать, что модель Reply принадлежит модели Thread.
public function thread()
{
    return $this->belongsTo('App\Thread');
}
После чего, в этом же файле (Reply.php), в теле класса добавить следующий код:
protected $touches = ['thread'];
Теперь при каждом обновлении модели Reply, у владеющей модели Thread будет обновляться поле updated_at и название темы будет выделяться жирным шрифтом.

Действия пользователя. Часть 1

Заглянем в профиль пользователя. Сейчас у нас там выводится список тем, которые были размещены пользователем на сайте и больше там ничего нет.Давайте расширим функционал пользовательского профиля и включим в него вывод сообщений, которые пользователь разместил на сайте. 
В этой части мы добавим новую модель Activity, которая будет связана полиморфным отношением с моделями: Reply и Thread. Также создадим трейт RecordsActivity, который будут использовать обе модели: Reply и Thread. В трейте в момент создания сообщения/темы будет создаваться новый экземпляр модели Activity, содержащий тип модели (сообщение или тема), id сообщения или темы и id пользователя. 
После чего в следующей части мы добавим связь между моделями: Activity и User, благодаря чему, через модель Activity мы сможем выводить созданные данным пользователем сообщения и темы на страницу его профиля. 
Схематически связанные между собой модели образуют следующую цепочку:Итак, как говорится, приступим. 
Для начала создадим файл модели Activity и файл миграции. Используем уже известную команду:
php artisan make:model Activity –m
Откроем файл миграции и в методе up() добавим следующий код:
Schema::create('activities', function (Blueprint $table) {
    $table->increments('id');
    $table->unsignedInteger('user_id')->index();
    $table->unsignedInteger('subject_id')->index();
    $table->string('subject_type', 50);
    $table->string('type', 50);
    $table->timestamps();
});
Здесь мы создаем таблицу activities, с полями:
  • id пользователя (user_id);
  • id темы/сообщения (subject_id);
  • тип (subject_type): тема или сообщение (в таблице будет указано App\Thread или App\Reply);
  • и еще одно поле type, которое будет хранить тип события и тип активности (в таблице будет указано created_thread или created_reply). Данное поле пригодится потом при выводе записей на страницу.
Далее, в файл Activity.php пропишем следующий код:
namespace App;
use Illuminate\Database\Eloquent\Model;
class Activity extends Model
{
    protected $guarded = [];
    public function subject()
    {
        return $this->morphTo();
    }
    public static function feed($user, $take = 50)
    {
        return static::where('user_id', $user->id)
            ->latest()
            ->with('subject')
            ->take($take)
            ->get()
            ->groupBy(function ($activity) {
                return $activity->created_at->format('d-m-Y');
            });
    }
}
Здесь в методе feed() мы выбираем последние 50 записей из таблицы activities с id данного пользователя, сгруппированные по дате создания темы/сообщения. В результате мы получим объект Collection следующего вида:
Collection {
  #items: array:2 [
    "07-09-2017" => Collection {#279
      #items: array:1 [
        0 => Activity {#295
          #relations: array:1 [
            "subject" => Thread {#334}
          ]
        }
      ]
    }
    "06-09-2017" => Collection {#344}
  ]
}
Далее создадим трейт RecordsActivity (папка app).
namespace App;
trait RecordsActivity
{
    protected static function bootRecordsActivity()
    {
        if (auth()->guest()) return;
        foreach (static::getActivitiesToRecord() as $event) {
            static::$event(function ($model) use ($event) {
                $model->recordActivity($event);
            });
        }
    }
    protected static function getActivitiesToRecord()
    {
        return ['created'];
    }
    protected function recordActivity($event)
    {
        $this->activity()->create([
            'user_id' => auth()->id(),
            'type' => $this->getActivityType($event)
        ]);
    }
    public function activity()
    {
        return $this->morphMany('App\Activity', 'subject');
    }
    protected function getActivityType($event)
    {
        $type = strtolower((new \ReflectionClass($this))->getShortName());
        return "{$event}_{$type}";
    }
}
Несколько слов о коде выше. Так как данный трейт мы добавим в модели Reply и Thread, то первый метод bootRecordsActivity() будет запускаться во время события «created» обеих моделей. Данный метод будет запускать метод recordActivity(), который в свою очередь будет создавать новый экземпляр модели Activity, связанный с созданным сообщением или темой. 
Давайте теперь добавим созданный трейт в классы моделей Reply и Thread. 
Например, для Thread это будет выглядеть так:
class Thread extends Model
{
    use RecordsActivity;
    …
}
C этой частью мы закончили, продолжим работу в следующей части.

Действия пользователя. Часть 2

В прошлой части мы подготовили модели для добавления нового функционала в профиль пользователя. В данной части завершим начатое и выведем созданные пользователем сообщения/темы на страницу его профиля. 
В прошлой части в модель Activity мы добавили метод feed() для выборки последних 50 записей из таблицы activities, сгруппированные по дате создания темы/сообщения. Давайте теперь вызовем этот метод из контроллера ProfilesController. Для этого нужно изменить метод show() контроллера следующим образом:
public function show(User $user)
{
    return view('profiles.show', [
        'profileUser' => $user,
        'activities' => Activity::feed($user)
    ]);
}
Вторым элементом массива мы передаем переменную $activities (с выборкой тем/сообщений из таблицы activities) в вид profiles.show. 
Далее в модель User добавим связь с моделью Activity:
public function activity()
{
    return $this->hasMany(Activity::class);
}
Теперь, когда пользователь связан с его активностью, и контроллер передает данные об активности пользователя из БД в вид profiles.show, можно переходить к работе с отображением страницы профиля, то есть к видам. 
В первую очередь откроем вид profiles.show (папка resources/views/profiles). Здесь нам нужно удалить все строки начиная от @foreach ($threads as $thread) и заканчивая {{ $threads->links() }}. Затем нужно добавить новые строки:
@foreach ($activities as $date => $activity)
    <h3 class="page-header">{{ $date }}</h3>
    @foreach ($activity as $record)
        @include ("profiles.activities.{$record->type}", ['activity' => $record])
    @endforeach
@endforeach
Для наглядности приведу полное содержимое файла show.blade.php. Добавленные строчки здесь выделены жирным шрифтом.
@extends('layouts.app')
@section('content')
    <div class="container">
        <div class="row">
            <div class="col-md-8 col-md-offset-2">
                <div class="page-header">
                    <h1>
                        {{ $profileUser->name }}
                        <small>Профиль был создан {{ $profileUser->created_at->format('d-m-Y') }} в {{ $profileUser->created_at->format('H:i:s') }}</small>
                    </h1>
                </div>
                @foreach ($activities as $date => $activity)
                    <h3 class="page-header">{{ $date }}</h3>
                    @foreach ($activity as $record)
                        @include ("profiles.activities.{$record->type}", ['activity' => $record])
                    @endforeach
                @endforeach
            </div>
        </div>
    </div>
@endsection
В конструкции @foreach мы выводим полученные из контроллера ProfilesController строки таблицы activities, с группированные по дате создания. В зависимости от содержимого поля type таблицы activities (created_thread или created_reply) выбираем свой вид для темы или сообщения. Это будут виды: profiles.activities.created_reply и profiles.activities.created_thread. 
Создадим новую папку activities (внутри папки resources/views/profiles). В ней создадим нужные виды. 
Создадим файл created_reply.blade.php и добавим в него следующее содержимое:
@component('profiles.activities.activity')
    @slot('heading')
        <span class="flex">
	        {{ $profileUser->name }} ответил в
	        <a href="{{ $activity->subject->thread->path() }}">"{{ $activity->subject->thread->title }}"</a>
        </span>
        <span>{{ $activity->subject->created_at->format('d-m-Y | H:i:s') }}</span>
    @endslot
    @slot('body')
        {{ $activity->subject->body }}
    @endslot
@endcomponent
Создадим файл created_thread.blade.php и добавим в него следующее содержимое:
@component('profiles.activities.activity')
    @slot('heading')
    	<span class="flex">
	        {{ $profileUser->name }} опубликовал
	        <a href="{{ $activity->subject->path() }}">{{ $activity->subject->title }}</a>
        </span>
        <span>{{ $activity->subject->created_at->format('d-m-Y | H:i:s') }}</span>
    @endslot
    @slot('body')
        {{ $activity->subject->body }}
    @endslot
@endcomponent
В этих двух видах мы используем Blade-директиву @component. То есть содержимое этих видов будет включаться в отдельный компонент, в нашем случае profiles.activities.activity. Создадим новый файл для этого компонента – activity.blade.php (в папке resources/views/profiles/activities). Добавим в файл следующее содержимое:
<div class="panel panel-default">
    <div class="panel-heading">
        <div class="level">
        	{{ $heading }}
        </div>
    </div>
    <div class="panel-body">
        {{ $body }}
    </div>
</div>
На этом работа закончена. Страница профиля пользователя теперь будет иметь следующий вид:

Действия пользователя. Часть 3

Продолжим работу с профилем пользователя. На данный момент у нас там выводятся записи о сообщениях пользователя и об опубликованных им новых темах. Добавим к этому также записи о сообщениях, за которые пользователь проголосовал. 
Первым делом нам нужно включить трейт RecordsActivity в модель Favorite. Добавляемая строчка выделена жирным шрифтом.
class Favorite extends Model
{
    use RecordsActivity;
    …
}
После чего все модели проекта будут составлять следующую структуру:Теперь мы сможем отслеживать момент добавления голоса за сообщение и сохранять запись об этом событии в таблице activities. 
Также в этот же файл добавим следующий код:
/* Полиморфное отношение – получаем сообщение, за которое был отдан голос */
public function favorited()
{
    return $this->morphTo();
}
Далее отредактируем файл модели Reply. Добавим в него следующее содержимое:
public function path()
{
    return $this->thread->path() . "#reply-{$this->id}";
}
Данный путь в виде ссылки будет вести со страницы профиля пользователя на страницу темы форума, при этом браузер будет автоматически переходить к нужному сообщению благодаря использованию якоря. 
Модифицируем html-код в файле reply.blade.php (папка resources/views/threads/). Заменим строку:
<div class="panel panel-default">
на следующую:
<div id="reply-{{ $reply->id }}" class="panel panel-default">
Далее создадим вид для вывода списка сообщений, за которые проголосовал пользователь. Файл будет называться created_favorite.blade.php (папка resources/views/profiles/activities/). Его содержимое должно быть следующим:
@component('profiles.activities.activity')
    @slot('heading')
        <span class="flex">
	        {{ $profileUser->name }} проголосовал за
	        <a href="{{ $activity->subject->favorited->path() }}"> сообщение</a>
        </span>
        <span>{{ $activity->subject->created_at->format('d-m-Y | H:i:s') }}</span>
    @endslot
    @slot('body')
        {{ $activity->subject->favorited->body }}
    @endslot
@endcomponent
Здесь мы используем Blade-директиву @component. Поэтому содержимое этого вида включается в отдельный компонент, в нашем случае profiles.activities.activity, который мы создали в предыдущем разделе этого материала (Действия пользователя. Часть 2). 
Последнее, что мы сделаем, это немного модифицируем файл для вида профиля пользователя show.blade.php (папка resources/views/profiles/). Найдите строчку
@include ("profiles.activities.{$record->type}", ['activity' => $record])
и замените ее следующим кодом:
@if (view()->exists("profiles.activities.{$record->type}"))
    @include ("profiles.activities.{$record->type}", ['activity' => $record])
@endif
На этом все, теперь страница профиля пользователя должна выглядеть следующим образом:

Запрет отправки сообщений чаще раза в минуту

На данный момент пользователи могут создавать и отправлять сообщения без ограничений по времени, хоть каждую секунду. Естественно это не очень хорошо. Вернее совсем не хорошо. Поэтому в этом разделе разберем, как добавить ограничение создания и отправки сообщений чаще раза в минуту. 
Первым делом в класс политики ReplyPolicy добавим следующий метод:
public function create(User $user)
{
    if (! $lastReply = $user->fresh()->lastReply) {
        return true;
    }
    return ! $lastReply->wasJustPublished();
}
В данном методе, в условии мы проверяем, отправлял ли пользователь сообщение вообще, и если отправлял, то, как давно это было сделано. Если сообщение было отправлено меньше минуты назад, то новое сообщение уже не будет принято. Пользователю нужно будет подождать пока не пройдет минута со времени отправки последнего сообщения. Для работы данного функционала нам нужны два новых метода: lastReply() и wasJustPublished(). Добавим их далее. 
Метод lastReply() добавим в модели пользователя User. Данный метод возвращает последнее сообщение из БД.
public function lastReply()
{
    return $this->hasOne(Reply::class)->latest();
}
Метод wasJustPublished() добавим в модель Reply. Если сообщение было создано меньше минуты назад, то метод вернет true и тогда пользователь не сможет сохранить его в БД.
public function wasJustPublished()
{
    return $this->created_at->gt(Carbon::now()->subMinute());
}
В контроллере RepliesController (метод store()) используем фасад Gate для проверки возможности добавления нового сообщения. Добавим в начало метода store() следующий код:
if (Gate::denies('create', new Reply)) {
    return back()->with('warning', 'Вы добавляете сообщения слишком часто. Сделайте перерыв. :)');
}
В методе denies() мы указываем название метода (create) из класса политики ReplyPolicy, который мы добавили в самом начале. 
Теперь в файле вида show.blade.php добавим код для вывода сообщения, отправляемого в вид из контроллера RepliesController. Найдите следующий код в файле:
@foreach ($replies as $reply)
    @include ('threads.reply')
@endforeach
И выше него добавьте новый код:
@if (session('warning'))
    <div class="alert alert-warning alert-dismissable">
        <button type="button" class="close" data-dismiss="alert">&times;</button>
        {{ session('warning') }}
    </div>
@endif
На этом работа закончена. Теперь на странице темы, на которой было отправлено два сообщения с разницей меньше минуты будет выводиться сообщение: «Вы добавляете сообщения слишком часто. Сделайте перерыв. :)».