Блог на Laravel 7, часть 6. Публичная часть — все посты, посты категории, посты автора

17.12.2020

Теги: LaravelMySQLPHPWeb-разработкаБазаДанныхБлогПрактикаФреймворкШаблонСайта

Теперь займемся публичной частью блога — список всех постов, список постов категории, список постов автора, список постов с тегом, страница просмотра поста. Сначала добавим маршруты, потом создадим контроллер BlogController, потом — шаблоны для показа всех этих страниц. И добавим связи между моделями, чтобы по автору и тегу получать список постов.

Добавляем маршртуты в файл route/web.php:

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

/*
 * Блог: все посты, посты категории, посты тега, страница поста
 */
Route::group([
    'as' => 'blog.', // имя маршрута, например blog.index
    'prefix' => 'blog', // префикс маршрута, например blog/index
], function () {
    // главная страница (все посты)
    Route::get('index', [BlogController::class, 'index'])
        ->name('index');
    // категория блога (посты категории)
    Route::get('category/{category:slug}', [BlogController::class, 'category'])
        ->name('category');
    // тег блога (посты с этим тегом)
    Route::get('tag/{tag:slug}', [BlogController::class, 'tag'])
        ->name('tag');
    // автор блога (посты этого автора)
    Route::get('author/{user}', [BlogController::class, 'author'])
        ->name('author');
    // страница просмотра поста блога
    Route::get('post/{post:slug}', [BlogController::class, 'post'])
        ->name('post');
});

Создаем контроллер BlogController:

namespace App\Http\Controllers;

use App\Category;
use App\Post;
use App\Tag;
use App\User;
use Illuminate\Http\Request;

class BlogController extends Controller {
    /**
     * Главная страница блога (список всех постов)
     */
    public function index() {
        $posts = Post::orderBy('created_at', 'desc')->paginate(5);
        return view('blog.index', compact('posts'));
    }

    /**
     * Страница просмотра отдельного поста блога
     */
    public function post(Post $post) {
        return view('blog.post', compact('post'));
    }

    /**
     * Список постов блога выбранной категории
     */
    public function category(Category $category) {
        $posts = $category->posts()
            ->orderBy('created_at', 'desc')
            ->paginate(5);
        return view('blog.category', compact('category', 'posts'));
    }

    /**
     * Список постов блога выбранного автора
     */
    public function author(User $user) {
        $posts = $user->posts()
            ->orderBy('created_at', 'desc')
            ->paginate(5);
        return view('blog.author', compact('user', 'posts'));
    }

    /**
     * Список постов блога с выбранным тегом
     */
    public function tag(Tag $tag) {
        $posts = $tag->posts()
            ->orderBy('created_at', 'desc')
            ->paginate(5);
        return view('blog.tag', compact('tag', 'posts'));
    }
}

Создаем связи между моделями:

class User extends Authenticatable {

    /**
     * Связь модели User с моделью Post, позволяет получить все
     * посты пользователя
     */
    public function posts() {
        return $this->hasMany(Post::class);
    }
    /* ... */
}
namespace App;

use Illuminate\Database\Eloquent\Model;

class Tag extends Model {
    /**
     * Связь модели Tag с моделью Post, позволяет получить посты,
     * связанные с тегом через сводную таблицу post_tag
     */
    public function posts() {
        return $this->belongsToMany(Post::class)->withTimestamps();
    }
    /* ... */
}
namespace App;

use Illuminate\Database\Eloquent\Model;

class Category extends Model {
    /**
     * Связь модели Category с моделью Post, позволяет получить все
     * посты, размещенные в текущей категори
     */
    public function posts() {
        return $this->hasMany(Post::class);
    }

    /**
     * Связь модели Category с моделью Category, позволяет получить
     * родителя текущей категории
     */
    public function parent() {
        return $this->belongsTo(Category::class, 'parent_id');
    }

    /**
     * Связь модели Category с моделью Category, позволяет получить все
     * дочерние категории текущей категории
     */
    public function children() {
        return $this->hasMany(Category::class, 'parent_id');
    }
}
Из класса модели были удалены методы roots(), descendants(), hierarchy() — которые были добавлены в предыдущей части. Поскольку построить дерево категорий блога для меню в левой колонке получилось и без этих методов.
namespace App;

use Illuminate\Database\Eloquent\Model;

class Post extends Model {
    /**
     * Связь модели Post с моделью Tag, позволяет получить
     * все теги поста
     */
    public function tags() {
        return $this->belongsToMany(Tag::class)->withTimestamps();
    }

    /**
     * Связь модели Post с моделью Category, позволяет получить
     * родительскую категорию поста
     */
    public function category() {
        return $this->belongsTo(Category::class);
    }

    /**
     * Связь модели Post с моделью User, позволяет получить
     * автора поста
     */
    public function user() {
        return $this->belongsTo(User::class);
    }
}

Шаблон resources/views/blog/index.blade.php для показа списка всех постов блога:

@extends('layout.site', ['title' => 'Все посты блога'])

@section('content')
    <h1 class="mb-3">Все посты блога</h1>
    @foreach ($posts as $post)
        @include('blog.part.post', ['post' => $post])
    @endforeach
    {{ $posts->links() }}
@endsection

Шаблон resources/views/blog/index.blade.php для показа списка постов категории:

@extends('layout.site', ['title' => $category->name])

@section('content')
    <h1 class="mb-3">{{ $category->name }}</h1>
    @foreach ($posts as $post)
        @include('blog.part.post', ['post' => $post])
    @endforeach
    {{ $posts->links() }}
@endsection

Шаблон resources/views/blog/author.blade.php для показа списка постов одного автора:

@extends('layout.site', ['title' => 'Посты автора: ' . $user->name])

@section('content')
    <h1 class="mb-3">Посты автора: {{ $user->name }}</h1>
    @foreach ($posts as $post)
        @include('blog.part.post', ['post' => $post])
    @endforeach
    {{ $posts->links() }}
@endsection

Шаблон resources/views/blog/author.blade.php для показа списка всех постов с тегом:

@extends('layout.site', ['title' => 'Посты с тегом: ' . $tag->name])

@section('content')
    <h1 class="mb-3">Посты с тегом: {{ $tag->name }}</h1>
    @foreach ($posts as $post)
        @include('blog.part.post', ['post' => $post])
    @endforeach
    {{ $posts->links() }}
@endsection

Все эти шаблоны подключают еще один шаблон resources/views/blog/part/post.blade.php:

<div class="card mb-4">
    <div class="card-header">
        <h2>{{ $post->name }}</h2>
    </div>
    <div class="card-body">
        <img src="https://via.placeholder.com/1000x300" alt="" class="img-fluid">
        <p class="mt-3 mb-0">{{ $post->excerpt }}</p>
    </div>
    <div class="card-footer">
        <div class="clearfix">
            <span class="float-left">
                Автор:
                <a href="{{ route('blog.author', ['user' => $post->user->id]) }}">
                    {{ $post->user->name }}
                </a>
                <br>
                Дата: {{ $post->created_at }}
            </span>
            <span class="float-right">
                <a href="{{ route('blog.post', ['post' => $post->slug]) }}"
                   class="btn btn-dark">Читать дальше</a>
            </span>
        </div>
    </div>
    @if ($post->tags->count())
        <div class="card-footer">
            Теги:
            @foreach($post->tags as $tag)
                @php $comma = $loop->last ? '' : ' • ' @endphp
                <a href="{{ route('blog.tag', ['tag' => $tag->slug]) }}">{{ $tag->name }}</a>
                {{ $comma }}
            @endforeach
        </div>
    @endif
</div>

Шаблон resources/views/blog/post.blade.php для показа отдельного поста блога:

@extends('layout.site', ['title' => $post->name])

@section('content')
    <div class="card mb-4">
        <div class="card-header">
            <h1>{{ $post->name }}</h1>
        </div>
        <div class="card-body">
            <img src="http://via.placeholder.com/1000x300" alt="" class="img-fluid">
            <div class="mt-4">{!! $post->content !!}</div>
        </div>
        <div class="card-footer">
                Автор:
                <a href="{{ route('blog.author', ['user' => $post->user->id]) }}">
                    {{ $post->user->name }}
                </a>
                <br>
                Дата: {{ $post->created_at }}
        </div>
        @if ($post->tags->count())
            <div class="card-footer">
                Теги:
                @foreach($post->tags as $tag)
                    @php $comma = $loop->last ? '' : ' • ' @endphp
                    <a href="{{ route('blog.tag', ['tag' => $tag->slug]) }}">
                        {{ $tag->name }}</a>
                    {{ $comma }}
                @endforeach
            </div>
        @endif
    </div>
@endsection

С показом списка постов категории получилось неудачно — хотя мы добавили связь, позволяющую получить все посты категории, это не совсем то, что нам нужно. Эта связь позволяет получить только посты, размещенные в этой категории, но не позволяет получить посты, размещенные в дочерних категориях. Так что добавим еще один метод в модель Category и исправим метод category() контроллера.

class Category extends Model {
    /**
     * Возвращает массив идентификаторов всех потомков категории
     */
    public static function descendants($id) {
        // получаем прямых потомков категории с идентификатором $id
        $children = self::where('parent_id', $id)->get();
        $ids = [];
        foreach ($children as $child) {
            $ids[] = $child->id;
            // для каждого прямого потомка получаем его прямых потомков
            if ($child->children->count()) {
                $ids = array_merge($ids, self::descendants($child->id));
            }
        }
        return $ids;
    }
}
class BlogController extends Controller {
    /**
     * Список постов блога выбранной категории
     */
    public function category(Category $category) {
        $descendants = array_merge(Category::descendants($category->id), [$category->id]);
        $posts = Post::whereIn('category_id', $descendants)
            ->orderBy('created_at', 'desc')
            ->paginate(5);
        return view('blog.category', compact('category', 'posts'));
    }
}

Оптимизация кол-ва запросов

Посмотрел, сколько SQL-запросов было выполнено при формировании страницы списка постов категории:

# laravel получает данные о категории по slug (привязка модели к маршруту)
select * from `categories` where `slug` = 'selifanu-sei-ze-cas-299' limit 1
# первый вызов рекурсивной функции Category::descendants()
select * from `categories` where `parent_id` = 1
# запрос при обращении к виртуальному свойству children, при выполнении кода $child->children->count()
select * from `categories` where `categories`.`parent_id` = 5 and `categories`.`parent_id` is not null
# второй вызов рекурсивной функции Category::descendants()
select * from `categories` where `parent_id` = 5
# запрос при обращении к виртуальному свойству children, при выполнении кода $child->children->count()
select * from `categories` where `categories`.`parent_id` = 6 and `categories`.`parent_id` is not null
# служебный запрос для построения постраничной навгигации
select count(*) as `aggregate` from `posts` where `category_id` in (5, 6, 1)
# получаем посты, расположенные в этой категории и во всех потомках
select * from `posts` where `category_id` in (5, 6, 1) order by `created_at` desc limit 5 offset 0
# получаем данные об авторе первого поста в списке
select * from `users` where `users`.`id` = 4 limit 1
# получаем все теги для первого поста в списке
select
    `tags`.*, `post_tag`.`post_id` as `pivot_post_id`, `post_tag`.`tag_id` as `pivot_tag_id`,
    `post_tag`.`created_at` as `pivot_created_at`, `post_tag`.`updated_at` as `pivot_updated_at`
from
    `tags` inner join `post_tag` on `tags`.`id` = `post_tag`.`tag_id`
where
    `post_tag`.`post_id` = 30
# получаем данные об авторе второго поста в списке
select * from `users` where `users`.`id` = 10 limit 1
# получаем все теги для второго поста в списке
select
    `tags`.*, `post_tag`.`post_id` as `pivot_post_id`, `post_tag`.`tag_id` as `pivot_tag_id`,
    `post_tag`.`created_at` as `pivot_created_at`, `post_tag`.`updated_at` as `pivot_updated_at`
from
    `tags` inner join `post_tag` on `tags`.`id` = `post_tag`.`tag_id`
where
    `post_tag`.`post_id` = 25
# получаем данные об авторе третьего поста в списке
select * from `users` where `users`.`id` = 8 limit 1
# получаем все теги для третьего поста в списке
select
    `tags`.*, `post_tag`.`post_id` as `pivot_post_id`, `post_tag`.`tag_id` as `pivot_tag_id`,
    `post_tag`.`created_at` as `pivot_created_at`, `post_tag`.`updated_at` as `pivot_updated_at`
from
    `tags` inner join `post_tag` on `tags`.`id` = `post_tag`.`tag_id`
where
    `post_tag`.`post_id` = 32
# получаем данные об авторе четвертого поста в списке
select * from `users` where `users`.`id` = 5 limit 1
# получаем все теги для четвертого поста в списке
select
    `tags`.*, `post_tag`.`post_id` as `pivot_post_id`, `post_tag`.`tag_id` as `pivot_tag_id`,
    `post_tag`.`created_at` as `pivot_created_at`,  `post_tag`.`updated_at` as `pivot_updated_at`
from
    `tags` inner join `post_tag` on `tags`.`id` = `post_tag`.`tag_id`
where
    `post_tag`.`post_id` = 12
# получаем данные об авторе пятого поста в списке
select * from `users` where `users`.`id` = 10 limit 1
# получаем все теги для пятого поста в списке
select
    `tags`.*, `post_tag`.`post_id` as `pivot_post_id`, `post_tag`.`tag_id` as `pivot_tag_id`,
    `post_tag`.`created_at` as `pivot_created_at`, `post_tag`.`updated_at` as `pivot_updated_at`
from
    `tags` inner join `post_tag` on `tags`.`id` = `post_tag`.`tag_id`
where
    `post_tag`.`post_id` = 41
# все категории для построения меню в левой колонке
select * from `categories`
# популяртные теги для меню в левой колонке
select
    `tags`.*,
    (select
        count(*) from `posts` inner join `post_tag` on `posts`.`id` = `post_tag`.`post_id`
    where
        `tags`.`id` = `post_tag`.`tag_id`) as `posts_count`
from
    `tags`
order by
    `posts_count` desc
limit
    10

Давайте уменьшим кол-во запросов — для этого используем жадную загрузку в методах контроллера:

class BlogController extends Controller {
    /**
     * Главная страница блога (список всех постов)
     */
    public function index() {
        $posts = Post::with('user')->with('tags')
            ->orderBy('created_at', 'desc')
            ->paginate(5);
        return view('blog.index', compact('posts'));
    }

    /**
     * Страница просмотра отдельного поста блога
     */
    public function post(Post $post) {
        return view('blog.post', compact('post'));
    }

    /**
     * Список постов блога выбранной категории
     */
    public function category(Category $category) {
        $descendants = array_merge(Category::descendants($category->id), [$category->id]);
        $posts = Post::whereIn('category_id', $descendants)
            ->with('user')->with('tags')
            ->orderBy('created_at', 'desc')
            ->paginate(5);
        return view('blog.category', compact('category', 'posts'));
    }

    /**
     * Список постов блога выбранного автора
     */
    public function author(User $user) {
        $posts = $user->posts()
            ->with('user')->with('tags')
            ->orderBy('created_at', 'desc')
            ->paginate(5);
        return view('blog.author', compact('user', 'posts'));
    }

    /**
     * Список постов блога с выбранным тегом
     */
    public function tag(Tag $tag) {
        $posts = $tag->posts()
            ->with('user')->with('tags')
            ->orderBy('created_at', 'desc')
            ->paginate(5);
        return view('blog.tag', compact('tag', 'posts'));
    }
}

Кроме того, перепишем метод Category::descendants(), чтобы он выполнял только один SQL-запрос:

class Category extends Model {
    /**
     * Возвращает массив идентификаторов всех потомков категории
     */
    public static function descendants($parent) {
        static $items = null;
        if (is_null($items)) {
            $items = self::all();
        }
        $ids = [];
        foreach ($items->where('parent_id', $parent) as $item) {
            $ids[] = $item->id;
            $ids = array_merge($ids, self::descendants($item->id));
        }
        return $ids;
    }
}

В итоге, получим только восемь запросов вместо девятнадцати:

# laravel получает данные о категории по slug (привязка модели к маршруту)
select * from `categories` where `slug` = 'selifanu-sei-ze-cas-299' limit 1
# первый вызов рекурсивной функции Category::descendants()
select * from `categories`
# служебный запрос для построения постраничной навгигации
select count(*) as `aggregate` from `posts` where `category_id` in (5, 6, 1)
# получаем посты, расположенные в этой категории и во всех потомках
select * from `posts` where `category_id` in (5, 6, 1) order by `created_at` desc limit 5 offset 0
# получаем данные об авторах всех постов в списке
select * from `users` where `users`.`id` in (4, 5, 8, 10)
# получаем все теги для всех постов в списке
select
    `tags`.*, `post_tag`.`post_id` as `pivot_post_id`, `post_tag`.`tag_id` as `pivot_tag_id`,
    `post_tag`.`created_at` as `pivot_created_at`, `post_tag`.`updated_at` as `pivot_updated_at`
from
    `tags` inner join `post_tag` on `tags`.`id` = `post_tag`.`tag_id`
where
    `post_tag`.`post_id` in (12, 25, 30, 32, 41)
# все категории для построения меню в левой колонке
select * from `categories`
# популяртные теги для меню в левой колонке
select
    `tags`.*,
    (select
        count(*) from `posts` inner join `post_tag` on `posts`.`id` = `post_tag`.`post_id`
    where
        `tags`.`id` = `post_tag`.`tag_id`) as `posts_count`
from
    `tags`
order by
    `posts_count` desc
limit
    10

Комментрии к посту

Совсем забыл про комментрии к посту, давайте внесем изменения в контроллер и создадим шаблон для показа комментариев. Кроме того, нам надо показывать не все посты и комментарии, а только опубликованные — в этом нам помогут методы scopePublished() моделей Post и Comment.

class Post extends Model {
    /**
     * Количество постов на странице при пагинации
     */
    protected $perPage = 5;
    
    /**
     * Выбирать из БД только опубликовынные посты
     */
    public function scopePublished($builder) {
        return $builder->whereNotNull('published_by');
    }
    /* ... */
}
class Comment extends Model {
    /**
     * Количество комментриев на странице при пагинации
     */
    protected $perPage = 5;
    
    /**
     * Выбирать из БД только опубликованные комментарии
     */
    public function scopePublished($builder) {
        return $builder->whereNotNull('published_by');
    }
    /* ... */
}
namespace App\Http\Controllers;

use App\Category;
use App\Comment;
use App\Post;
use App\Tag;
use App\User;
use Illuminate\Http\Request;

class BlogController extends Controller {

    /**
     * Главная страница блога (список всех постов)
     */
    public function index() {
        $posts = Post::published()
            ->with('user')->with('tags')
            ->orderByDesc('created_at')
            ->paginate();
        return view('blog.index', compact('posts'));
    }

    /**
     * Страница просмотра отдельного поста блога
     */
    public function post(Post $post) {
        $comments = $post->comments()
            ->published()
            ->orderBy('created_at')
            ->paginate();
        return view('blog.post', compact('post', 'comments'));
    }

    /**
     * Список постов блога выбранной категории
     */
    public function category(Category $category) {
        $descendants = array_merge(Category::descendants($category->id), [$category->id]);
        $posts = Post::whereIn('category_id', $descendants)
            ->published()
            ->with('user')->with('tags')
            ->orderByDesc('created_at')
            ->paginate();
        return view('blog.category', compact('category', 'posts'));
    }

    /**
     * Список постов блога выбранного автора
     */
    public function author(User $user) {
        $posts = $user->posts()
            ->published()
            ->with('user')->with('tags')
            ->orderByDesc('created_at')
            ->paginate();
        return view('blog.author', compact('user', 'posts'));
    }

    /**
     * Список постов блога с выбранным тегом
     */
    public function tag(Tag $tag) {
        $posts = $tag->posts()
            ->published()
            ->with('user')->with('tags')
            ->orderByDesc('created_at')
            ->paginate();
        return view('blog.tag', compact('tag', 'posts'));
    }
}

Шаблон resources/views/blog/part/comments.blade.php для просмотра коментариев:

<h3 id="comment-list">Все комментарии</h3>
@if ($comments->count())
    @foreach ($comments as $comment)
        <div class="card mb-3" id="comment-{{ $comment->id }}">
            <div class="card-header p-2">
                {{ $comment->user->name }}
            </div>
            <div class="card-body p-2">
                {{ $comment->content }}
            </div>
            <div class="card-footer p-2">
                {{ $comment->created_at }}
            </div>
        </div>
    @endforeach
    {{ $comments->fragment('comment-list')->links() }}
@else
    <p>К этому посту еще нет комментариев</p>
@endif

И подключим его в шаблоне resources/views/blog/post.blade.php для просмотра поста:

@extends('layout.site', ['title' => $post->name])

@section('content')
    <div class="card mb-4">
        <!-- просмотр поста -->
    </div>
    @include('blog.part.comments', ['comments' => $comments])
@endsection

Поиск: Laravel • MySQL • PHP • Web-разработка • База данных • Блог • Практика • Фреймворк • Шаблон сайта

Каталог оборудования
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Производители
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Функциональные группы
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.