In the final article of this tutorial, we are going to create the frontend part of the app. First of all, let's make a plan.
For this blog application, we'll have a homepage displaying a list of all recent posts, a category page displaying a list of posts under a specific category, a tag page displaying a list of posts with a specific tag, a post page displaying the content of a specific post, and lastly, a search page displaying a list of posts based on a search query.
All of these pages will have a sidebar with a search box, a list of categories and tags. The post page will also have a related posts section at the bottom.
We've dealt with the database and the models in the previous article, so here we'll start with the routes.
Create the routes
routes/web.php
. . .
// Homepage
Route::get('/', [PostController::class, 'home'])->name('home');
// A list of posts under this category
Route::get('/category/{category}', [CategoryController::class, 'category'])->name('category');
// A list of posts with this tag
Route::get('/tag/{tag}', [TagController::class, 'tag'])->name('tag');
// Display a single post
Route::get('/post/{post}', [PostController::class, 'post'])->name('post');
// A list of posts based on search query
Route::post('/search', [PostController::class, 'search'])->name('search');
. . .
The homepage
Each of these routes has a corresponding controller method. We'll start with the home()
controller:
app/Http/Controllers/PostController.php
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use App\Models\Post;
use App\Models\Tag;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
use Illuminate\Database\Eloquent\Builder;
class PostController extends Controller
{
/**
* Display the home page
*/
public function home(): View
{
$posts = Post::where('is_published', true)->paginate(env('PAGINATE_NUM'));
$categories = Category::all();
$tags = Tag::all();
return view('home', [
'posts' => $posts,
'categories' => $categories,
'tags' => $tags
]);
}
. . .
}
Line 22, there are two things you need to note here.
First, where('is_published', true)
makes sure that only published articles are retrieved.
And second, paginate()
method is one of Laravel's built-in method, allowing you to easily create pagination in your app. The paginate()
takes an integer as input. For example, paginate(10)
means 10 items will be displayed on each page. Since this input variable will be used on many pages, you can create an environmental variable in the .env
file, and then you can retrieve it anywhere using env()
method.
.env
. . .
PAGINATE_NUM=12
Next, let's create the corresponding homepage view. Here is the template structure I've created, and this is the view structure:
resources/views
├── category.blade.php
├── home.blade.php
├── layout.blade.php
├── post.blade.php
├── search.blade.php
├── tag.blade.php
├── vendor
│ ├── list.blade.php
│ └── sidebar.blade.php
└── welcome.blade.php
We'll start with the layout.blade.php
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@vite(['resources/css/app.css', 'resources/js/app.js'])
@yield('title')
</head>
<body class="container mx-auto font-serif">
<nav class="flex flex-row justify-between h-16 items-center border-b-2">
<div class="px-5 text-2xl">
<a href="/"> My Blog </a>
</div>
<div class="hidden lg:flex content-between space-x-10 px-10 text-lg">
<a href="https://github.com/ericnanhu" class="hover:underline hover:underline-offset-1">GitHub</a>
<a href="{{ route('dashboard') }}" class="hover:underline hover:underline-offset-1">Dashboard</a>
<a href="#" class="hover:underline hover:underline-offset-1">Link</a>
</div>
</nav>
@yield('content')
<footer class="bg-gray-700 text-white">
<div class="flex justify-center items-center sm:justify-between flex-wrap lg:max-w-screen-2xl mx-auto px-4 sm:px-8 py-10">
<p class="font-serif text-center mb-3 sm:mb-0">
Copyright ©
<a href="https://www.ericsdevblog.com/" class="hover:underline">Eric Hu</a>
</p>
<div class="flex justify-center space-x-4">
. . .
</div>
</div>
</footer>
</body>
</html>
Line 8, by default, Laravel uses Vite for asset bundling. In the previous article, we have installed Laravel Breeze, which uses TailwindCSS. This line of code will automatically import the corresponding app.css
and app.js
for you.
You can use a different framework of course, but you'll have to consult the respective documentations for details on how to use them with Laravel or Vite.
Next, for the homepage:
resources/views/home.blade.php
@extends('layout')
@section('title')
<title>Home</title>
@endsection
@section('content')
<div class="grid grid-cols-4 gap-4 py-10">
<div class="col-span-3 grid grid-cols-1">
@include('vendor.list')
</div>
@include('vendor.sidebar')
</div>
@endsection
And the post list:
resources/views/vendor/list.blade.php
<!-- List of posts -->
<div class="grid grid-cols-3 gap-4">
@foreach ($posts as $post)
<!-- post -->
<div class="mb-4 ring-1 ring-slate-200 rounded-md h-fit hover:shadow-md">
<a href="{{ route('post', ['post' => $post->id]) }}"><img class="rounded-t-md object-cover h-60 w-full" src="{{ Storage::url($post->cover) }}" alt="..." /></a>
<div class="m-4 grid gap-2">
<div class="text-sm text-gray-500">
{{ \Carbon\Carbon::parse($post->created_at)->format('M d, Y') }}
</div>
<h2 class="text-lg font-bold">{{ $post->title }}</h2>
<p class="text-base">
{{ Str::limit(strip_tags($post->content), 150, '...') }}
</p>
<a class="bg-blue-500 hover:bg-blue-700 rounded-md p-2 text-white uppercase text-sm font-semibold font-sans w-fit focus:ring" href="{{ route('post', ['post' => $post->id]) }}">Read more →</a>
</div>
</div>
@endforeach
</div>
{{ $posts->links() }}
Line 6, Storage::url($post->cover)
generates the URL that points to the cover image.
Line 9, the way Laravel stores timestamps is not user friendly, so here we are using Carbon to reformat the timestamps.
Line 13, here we are using strip_tags()
to remove the HTML tags, and then limit()
sets the maximum length of the string, the excessive part will be replaced with ...
.
Line 21, remember be used paginate()
method to create paginator in the controller? this is how we can display the paginator in the view.
As well as the sidebar:
resources/views/vendor/sidebar.blade.php
<div class="col-span-1">
<div class="border rounded-md mb-4">
<div class="bg-slate-200 p-4">Search</div>
<div class="p-4">
<form action="{{ route('search') }}" method="POST" class="grid grid-cols-4 gap-2">
{{ csrf_field() }}
<input type="text" name="q" id="search" class="border rounded-md w-full focus:ring p-2 col-span-3" placeholder="Search something..." />
<button type="submit" class="bg-blue-500 hover:bg-blue-700 rounded-md p-2 text-white uppercase font-semibold font-sans w-full focus:ring col-span-1">
Search
</button>
</form>
</div>
</div>
<div class="border rounded-md mb-4">
<div class="bg-slate-200 p-4">Categories</div>
<div class="p-4">
<ul class="list-none list-inside">
@foreach ($categories as $category)
<li>
<a href="{{ route('category', ['category' => $category->id]) }}" class="text-blue-500 hover:underline">{{ $category->name }}</a>
</li>
@endforeach
</ul>
</div>
</div>
<div class="border rounded-md mb-4">
<div class="bg-slate-200 p-4">Tags</div>
<div class="p-4">
@foreach ($tags as $tag)
<span class="mr-2"><a href="{{ route('tag', ['tag' => $tag->id]) }}" class="text-blue-500 hover:underline">{{ $tag->name }}</a></span>
@endforeach
</div>
</div>
<div class="border rounded-md mb-4">
<div class="bg-slate-200 p-4">More Card</div>
<div class="p-4">
<p>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Placeat,
voluptatem ab tempore recusandae sequi libero sapiente autem! Sit hic
reprehenderit pariatur autem totam, voluptates non officia accusantium
rerum unde provident!
</p>
</div>
</div>
<div class="border rounded-md mb-4">
<div class="bg-slate-200 p-4">...</div>
<div class="p-4">
<p>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Placeat,
voluptatem ab tempore recusandae sequi libero sapiente autem! Sit hic
reprehenderit pariatur autem totam, voluptates non officia accusantium
rerum unde provident!
</p>
</div>
</div>
</div>
The category page
app/Http/Controllers/CategoryController.php
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use App\Models\Tag;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class CategoryController extends Controller
{
/**
* Display a list of posts belong to the category
*/
public function category(string $id): View
{
$category = Category::find($id);
$posts = $category->posts()->where('is_published', true)->paginate(env('PAGINATE_NUM'));
$categories = Category::all();
$tags = Tag::all();
return view('category', [
'category' => $category,
'posts' => $posts,
'categories' => $categories,
'tags' => $tags
]);
}
. . .
}
resources/views/category.blade.php
@extends('layout')
@section('title')
<title>Category - {{ $category->name }}</title>
@endsection
@section('content')
<div class="grid grid-cols-4 gap-4 py-10">
<div class="col-span-3 grid grid-cols-1">
@include('vendor.list')
</div>
@include('vendor.sidebar')
</div>
@endsection
The tag page
app/Http/Controllers/TagController.php
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use App\Models\Tag;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class TagController extends Controller
{
/**
* Display a list of posts belong to the category
*/
public function tag(string $id): View
{
$tag = Tag::find($id);
$posts = $tag->posts()->where('is_published', true)->paginate(env('PAGINATE_NUM'));
$categories = Category::all();
$tags = Tag::all();
return view('tag', [
'tag' => $tag,
'posts' => $posts,
'categories' => $categories,
'tags' => $tags
]);
}
. . .
}
resources/views/tag.blade.php
@extends('layout')
@section('title')
<title>Tag - {{ $tag->name }}</title>
@endsection
@section('content')
<div class="grid grid-cols-4 gap-4 py-10">
<div class="col-span-3 grid grid-cols-1">
@include('vendor.list')
</div>
@include('vendor.sidebar')
</div>
@endsection
The post page
app/Http/Controllers/PostController.php
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use App\Models\Post;
use App\Models\Tag;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
use Illuminate\Database\Eloquent\Builder;
class PostController extends Controller
{
. . .
/**
* Display requested post
*/
public function post(string $id): View
{
$post = Post::find($id);
$categories = Category::all();
$tags = Tag::all();
$related_posts = Post::where('is_published', true)->whereHas('tags', function (Builder $query) use ($post) {
return $query->whereIn('name', $post->tags->pluck('name'));
})->where('id', '!=', $post->id)->take(3)->get();
return view('post', [
'post' => $post,
'categories' => $categories,
'tags' => $tags,
'related_posts' => $related_posts
]);
}
. . .
}
Line 27-29, this is how we are able to retrieve related posts. The idea is to get the posts with the same tags. This chain of methods looks kind of scary, but don't worry, let's analyze them one by one.
The first method is easy, where('is_published', true)
returns all the posts that are published.
The whereHas()
method is where things get complicated. To understand whereHas()
we need to first talk about has()
. has()
is a Laravel Eloquent method that allows us to check the existence of a relationship. For example:
$posts = Post::has('comments', '>', 3)->get();
This code will retrieve all the posts that have more than 3 comments. Notice that you cannot use where()
to do this because comments
is not a column in the posts
table, it is another table that has a relation with posts
.
whereHas()
works just like has()
, only it offers a little more power. Its second parameter is a function that allows us to inspect the content that "another table", which in our case, is the tags
table. We can access the tags
table through the variable $q
.
Line 28, the whereIn()
method takes two parameters, the first one is the specified column, the second is an array of acceptable values. The method will return the records with only the acceptable values and exclude the rest.
The rest should be very easy to understand. where('id', '!=', $post->id)
exclude the current post, and take(3)
takes the first three records.
resources/views/post.blade.php
@extends('layout')
@section('title')
<title>Page Title</title>
@endsection
@section('content')
<div class="grid grid-cols-4 gap-4 py-10">
<div class="col-span-3">
<img class="rounded-md object-cover h-96 w-full" src="{{ Storage::url($post->cover) }}" alt="..." />
<h1 class="mt-5 mb-2 text-center text-2xl font-bold">{{ $post->title }}</h1>
<p class="mb-5 text-center text-sm text-slate-500 italic">By {{ $post->user->name }} | {{ \Carbon\Carbon::parse($post->created_at)->format('M d, Y') }}</p>
<div>{!! $post->content !!}</div>
<div class="my-5">
@foreach ($post->tags as $tag)
<a href="{{ route('tag', ['tag' => $tag->id]) }}" class="text-blue-500 hover:underline" mr-3">#{{ $tag->name }}</a>
@endforeach
</div>
<hr>
<!-- Related posts -->
<div class="grid grid-cols-3 gap-4 my-5">
@foreach ($related_posts as $post)
<!-- post -->
<div class="mb-4 ring-1 ring-slate-200 rounded-md h-fit hover:shadow-md">
<a href="{{ route('post', ['post' => $post->id]) }}"><img class="rounded-t-md object-cover h-60 w-full" src="{{ Storage::url($post->cover) }}" alt="..." /></a>
<div class="m-4 grid gap-2">
<div class="text-sm text-gray-500">
{{ \Carbon\Carbon::parse($post->created_at)->format('M d, Y') }}
</div>
<h2 class="text-lg font-bold">{{ $post->title }}</h2>
<p class="text-base">
{{ Str::limit(strip_tags($post->content), 150, '...') }}
</p>
<a class="bg-blue-500 hover:bg-blue-700 rounded-md p-2 text-white uppercase text-sm font-semibold font-sans w-fit focus:ring" href="{{ route('post', ['post' => $post->id]) }}">Read more →</a>
</div>
</div>
@endforeach
</div>
</div>
@include('vendor.sidebar')
</div>
@endsection
Line 15, {!! $post->content !!}
is Laravel's safe mode. It tells Laravel to render HTML tags instead of displaying them as plain text.
The search page
app/Http/Controllers/PostController.php
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use App\Models\Post;
use App\Models\Tag;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
use Illuminate\Database\Eloquent\Builder;
class PostController extends Controller
{
. . .
/**
* Display search result
*/
public function search(Request $request): View
{
$key = $request->input('q');
$posts = Post::where('title', 'like', "%{$key}%")->orderBy('id', 'desc')->paginate(env('PAGINATE_NUM'));
$categories = Category::all();
$tags = Tag::all();
return view('search', [
'key' => $key,
'posts' => $posts,
'categories' => $categories,
'tags' => $tags,
]);
}
. . .
}
resources/views/search.blade.php
@extends('layout')
@section('title')
<title>Search - {{ $key }}</title>
@endsection
@section('content')
<div class="grid grid-cols-4 gap-4 py-10">
<div class="col-span-3 grid grid-cols-1">
@include('vendor.list')
</div>
@include('vendor.sidebar')
</div>
@endsection
If you liked this article, please also check out my other tutorials: