Finally, it is time for the most important part of this tutorial. Let's create a fully featured blog application, with posts, categories, as well as tags. Previously, we discussed the CRUD operations for posts, and in this article, we are going to repeat that for categories and tags, and we are also going to discuss how to deal with the relations between them as well.
Initialize a new Laravel project
Once again, we'll start with a fresh project. Create a working directory and change into it. Make sure Docker is up and running, then execute the following command:
curl -s https://laravel.build/<app_name> | bash
Change into the app directory and start the server.
cd <app_name>
./vendor/bin/sail up
To make things easier, let's create an alias for sail
. Run the following command:
alias sail='[ -f sail ] && sh sail || sh vendor/bin/sail'
From now on, you can run sail directly without specifying the entire path.
sail up
User authentication
Step two, as we've mentioned before, Laravel comes with a large ecosystem, and Laravel Breeze is a part of this ecosystem. It provides a quick way to set up user authentication and registration in a Laravel application.
Breeze includes pre-built authentication views and controllers, as well as a set of backend APIs for handling user authentication and registration. The package is designed to be easy to install and configure, with minimal setup required.
Use the following commands to install Laravel Breeze:
sail composer require laravel/breeze --dev
sail artisan breeze:install
sail artisan migrate
sail npm install
sail npm run dev
This process will automatically generate required controllers, middleware and views that are necessary for creating a basic user authentication system. You may access the registration page by visiting http://127.0.0.1/register
.
Register a new account and you will be redirected to the dashboard.
In this article, we are not going to discuss exactly how this user authentication system works, as it is related to some rather advanced concepts. But it is highly recommended that you take a look at the generated files, they offer you a deeper insight into how things work in Laravel.
Set up database
Next, we need to have a big picture on how our blog app looks. First, we need to have a database that can store posts, categories, and tags. Each database table would have the following structure:
Posts
key | type |
---|---|
id | bigInteger |
created_at | |
updated_at | |
title | string |
cover | string |
content | text |
is_published | boolean |
Categories
key | type |
---|---|
id | bigInteger |
created_at | |
updated_at | |
name | string |
Tags
key | type |
---|---|
id | bigInteger |
created_at | |
updated_at | |
name | string |
And of course, there should also be a users table, but it has already been generated for us by Laravel Breeze, so we'll skip it this time.
These tables also have relations with each other, as shown in the list below:
- Each user has multiple posts
- Each category has many posts
- Each tag has many posts
- Each post belongs to one user
- Each post belongs to one category
- Each post has many tags
To create these relations, we must modify the posts table:
Posts with relations
key | type |
---|---|
id | bigInteger |
created_at | |
updated_at | |
title | string |
cover | string |
content | text |
is_published | boolean |
user_id | bigInteger |
category_id | bigInteger |
And we also need a separate table for post/tag relation:
Post/tag
key | type |
---|---|
post_id | bigInteger |
tag_id | bigInteger |
Implement database structure
To implement this design, generate models and migration files using the following commands:
sail artisan make:model Post --migration
sail artisan make:model Category --migration
sail artisan make:model Tag --migration
And a separate migration file for the post_tag
table:
sail artisan make:migration create_post_tag_table
database/migrations/create_posts_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->timestamps();
$table->string('title');
$table->string('cover');
$table->text('content');
$table->boolean('is_published');
$table->bigInteger('user_id');
$table->bigInteger('category_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('posts');
}
};
database/migrations/create_categories_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->timestamps();
$table->string('name');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('categories');
}
};
database/migrations/create_tags_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('tags', function (Blueprint $table) {
$table->id();
$table->timestamps();
$table->string('name');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('tags');
}
};
database/migrations/create_post_tag_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('post_tag', function (Blueprint $table) {
$table->id();
$table->timestamps();
$table->bigInteger('post_id');
$table->bigInteger('tag_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('post_tag');
}
};
Apply these changes with the following command:
sail artisan migrate
And then for the corresponding models, we need to enable mass assignment for selected fields so that we may use create
or update
methods on them, as we've discussed in previous tutorials. And we also need to define relations between database tables.
app/Models/Post.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Post extends Model
{
use HasFactory;
protected $fillable = [
"title",
'content',
'cover',
'is_published'
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class);
}
}
app/Models/Category.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Category extends Model
{
use HasFactory;
protected $fillable = [
'name',
];
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
}
app/Models/Tag.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Tag extends Model
{
use HasFactory;
protected $fillable = [
'name',
];
public function posts(): BelongsToMany
{
return $this->belongsToMany(Post::class);
}
}
app/Models/User.php
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'name',
'email',
'password',
];
/**
* The attributes that should be hidden for serialization.
*
* @var array<int, string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'email_verified_at' => 'datetime',
];
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
}
Controllers and routes
As for the controllers, we need one resource controller for each resources (post, category and tag).
php artisan make:controller PostController --resource
php artisan make:controller CategoryController --resource
php artisan make:controller TagController --resource
Then create routes for each of these controllers:
routes/web.php
<?php
use App\Http\Controllers\CategoryController;
use App\Http\Controllers\PostController;
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\TagController;
use Illuminate\Support\Facades\Route;
// Dashboard routes
Route::prefix('dashboard')->group(function () {
// Dashboard homepage
Route::get('/', function () {
return view('dashboard');
})->name('dashboard');
// Dashboard category resource
Route::resource('categories', CategoryController::class);
// Dashboard tag resource
Route::resource('tags', TagController::class);
// Dashboard post resource
Route::resource('posts', PostController::class);
})->middleware(['auth', 'verified']);
Route::middleware('auth')->group(function () {
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
});
require __DIR__ . '/auth.php';
Notice that all the routes are grouped with a /dashboard
prefix, and the group has a middleware auth
, meaning that the user must be logged in to access the dashboard.
Category/tag controllers
The CategoryController
and the TagController
are fairly straightforward. You can set them up the same way we created the PostController
in the previous article.
app/Http/Controllers/CategoryController.php
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class CategoryController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index(): View
{
$categories = Category::all();
return view('categories.index', [
'categories' => $categories
]);
}
/**
* Show the form for creating a new resource.
*/
public function create(): View
{
return view('categories.create');
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request): RedirectResponse
{
// Get the data from the request
$name = $request->input('name');
// Create a new Post instance and put the requested data to the corresponding column
$category = new Category();
$category->name = $name;
// Save the data
$category->save();
return redirect()->route('categories.index');
}
/**
* Display the specified resource.
*/
public function show(string $id): View
{
$category = Category::all()->find($id);
$posts = $category->posts();
return view('categories.show', [
'category' => $category,
'posts' => $posts
]);
}
/**
* Show the form for editing the specified resource.
*/
public function edit(string $id): View
{
$category = Category::all()->find($id);
return view('categories.edit', [
'category' => $category
]);
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, string $id): RedirectResponse
{
// Get the data from the request
$name = $request->input('name');
// Find the requested category and put the requested data to the corresponding column
$category = Category::all()->find($id);
$category->name = $name;
// Save the data
$category->save();
return redirect()->route('categories.index');
}
/**
* Remove the specified resource from storage.
*/
public function destroy(string $id): RedirectResponse
{
$category = Category::all()->find($id);
$category->delete();
return redirect()->route('categories.index');
}
}
app/Http/Controllers/TagController.php
<?php
namespace App\Http\Controllers;
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 listing of the resource.
*/
public function index(): View
{
$tags = Tag::all();
return view('tags.index', [
'tags' => $tags
]);
}
/**
* Show the form for creating a new resource.
*/
public function create(): View
{
return view('tags.create');
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request): RedirectResponse
{
// Get the data from the request
$name = $request->input('name');
// Create a new Post instance and put the requested data to the corresponding column
$tag = new Tag();
$tag->name = $name;
// Save the data
$tag->save();
return redirect()->route('tags.index');
}
/**
* Display the specified resource.
*/
public function show(string $id): View
{
$tag = Tag::all()->find($id);
$posts = $tag->posts();
return view('tags.show', [
'tag' => $tag,
'posts' => $posts
]);
}
/**
* Show the form for editing the specified resource.
*/
public function edit(string $id): View
{
$tag = Tag::all()->find($id);
return view('tags.edit', [
'tag' => $tag
]);
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, string $id): RedirectResponse
{
// Get the data from the request
$name = $request->input('name');
// Find the requested category and put the requested data to the corresponding column
$tag = Tag::all()->find($id);
$tag->name = $name;
// Save the data
$tag->save();
return redirect()->route('tags.index');
}
/**
* Remove the specified resource from storage.
*/
public function destroy(string $id): RedirectResponse
{
$tag = Tag::all()->find($id);
$tag->delete();
return redirect()->route('tags.index');
}
}
Remember that you can check the name of the routes using the following command:
sail artisan route:list
Post controller
The PostController, on the other hand, is a bit more complicated, since you have to deal with image uploads and relations in the store()
method. Let's take a closer look:
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
{
. . .
/**
* Store a newly created resource in storage.
*/
public function store(Request $request): RedirectResponse
{
// Get the data from the request
$title = $request->input('title');
$content = $request->input('content');
if ($request->input('is_published') == 'on') {
$is_published = true;
} else {
$is_published = false;
}
// Create a new Post instance and put the requested data to the corresponding column
$post = new Post();
$post->title = $title;
$post->content = $content;
$post->is_published = $is_published;
// Save the cover image
$path = $request->file('cover')->store('cover', 'public');
$post->cover = $path;
// Set user
$user = Auth::user();
$post->user()->associate($user);
// Set category
$category = Category::find($request->input('category'));
$post->category()->associate($category);
// Save post
$post->save();
//Set tags
$tags = $request->input('tags');
foreach ($tags as $tag) {
$post->tags()->attach($tag);
}
return redirect()->route('posts.index');
}
. . .
}
A few things to be noted in this store()
method. First, line 28 to 32, we are going to use an HTML checkbox to represent the is_published
field, and its values are either 'on'
or null
. But in the database, its values are saved as true
or false
, so we must use an if
statement to solve this issue.
Line 41 to 42, to retrieve files, we must use the file()
method instead of input()
, and the file is saved in the public
disk under directory cover
.
Line 45 to 46, get the current user using Auth::user()
, and associate the post with the user using associate()
method. And line 49 to 50 does the same thing for category. Remember you can only do this from $post
and not $user
or $category
, since the user_id
and category_id
columns are in the posts
table.
Lastly, for the tags, as demonstrated from line 56 to 60, you must save the current post to the database, and then retrieve a list of tags, and attach each of them to the post one by one, using the attach()
method.
For the update()
method, things work similarly, except that you must remove all existing tags before you can attach the new ones.
$post->tags()->detach();
Views
When building a view system, always remember to be organized. This is the structure I'm going with:
resources/views
├── auth
├── categories
│ ├── create.blade.php
│ ├── edit.blade.php
│ ├── index.blade.php
│ └── show.blade.php
├── components
├── layouts
├── posts
│ ├── create.blade.php
│ ├── edit.blade.php
│ ├── index.blade.php
│ └── show.blade.php
├── profile
├── tags
│ ├── create.blade.php
│ ├── edit.blade.php
│ ├── index.blade.php
│ └── show.blade.php
├── dashboard.blade.php
└── welcome.blade.php
I've created three directories, posts
, categories
and tags
, and each of them has four templates, create
, edit
, index
and show
(except for posts
since it is unnecessary to have a show
page for posts in the dashboard).
Including all of these views in one article would make this tutorial unnecessarily long, so instead, i'm only going to demonstrate the create, edit and index pages for posts. However, the source code for this tutorial is available for free here, if you need some reference.
Post create view
resources/views/posts/create.blade.php
<x-app-layout>
<x-slot name="header">
<div class="flex justify-between">
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
{{ __('Posts') }}
</h2>
<a href="{{ route('posts.create') }}">
<x-primary-button>{{ __('New') }}</x-primary-button>
</a>
</div>
<script src="https://cdn.tiny.cloud/. . ./tinymce.min.js" referrerpolicy="origin"></script>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="p-4 sm:p-8 bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="">
<form action="{{ route('posts.store') }}" method="POST" class="mt-6 space-y-3" enctype="multipart/form-data">
{{ csrf_field() }}
<input type="checkbox" name="is_published" id="is_published">
<x-input-label for="is_published">Make this post public</x-input-label>
<br>
<x-input-label for="title">{{ __('Title') }}</x-input-label>
<x-text-input id="title" name="title" type="text" class="mt-1 block w-full" required autofocus autocomplete="name" />
<br>
<x-input-label for="content">{{ __('Content') }}</x-input-label>
<textarea name="content" id="content" cols="30" rows="30"></textarea>
<br>
<x-input-label for="cover">{{ __('Cover Image') }}</x-input-label>
<x-text-input id="cover" name="cover" type="file" class="mt-1 block w-full" required autofocus autocomplete="cover" />
<br>
<x-input-label for="category">{{ __('Category') }}</x-input-label>
<select id="category" name="category">
@foreach($categories as $category)
<option value="{{ $category->id }}">{{ $category->name }}</option>
@endforeach
</select>
<br>
<x-input-label for="tags">{{ __('Tags') }}</x-input-label>
<select id="tags" name="tags[]" multiple>
@foreach($tags as $tag)
<option value="{{ $tag->id }}">{{ $tag->name }}</option>
@endforeach
</select>
<br>
<x-primary-button>{{ __('Save') }}</x-primary-button>
</form>
<script>
tinymce.init({. . .});
</script>
</div>
</div>
</div>
</div>
</x-app-layout>
I'm using TinyMCE as the rich text editor, you can replace it with something else, or simply use a <textarea></textarea>
if you wish.
Line 19, this form must have enctype="multipart/form-data"
since we are not just transferring texts, there are files as well.
Line 31, remember to use type="file"
here since we are uploading an image.
Line 34 to 38, the value of the option will be transferred to the backend.
Line 41 to 45, there are two things you must pay attention to here. First, notice name="tags[]"
, the []
tells Laravel to transfer an iterable array instead of texts. And second, multiple
creates a multi-select form instead of single select like the one for categories.
Post edit view
resources/views/posts/edit.blade.php
<x-app-layout>
<x-slot name="header">
<div class="flex justify-between">
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
{{ __('Posts') }}
</h2>
<a href="{{ route('posts.create') }}">
<x-primary-button>{{ __('New') }}</x-primary-button>
</a>
</div>
<script src="https://cdn.tiny.cloud/. . ./tinymce.min.js" referrerpolicy="origin"></script>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="p-4 sm:p-8 bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="">
<form action="{{ route('posts.update', ['post' => $post->id]) }}" method="POST" class="mt-6 space-y-3" enctype="multipart/form-data">
{{ csrf_field() }}
{{ method_field('PUT') }}
<input type="checkbox" name="is_published" id="is_published" @checked($post->is_published)/>
<x-input-label for="is_published">Make this post public</x-input-label>
<br>
<x-input-label for="title">{{ __('Title') }}</x-input-label>
<x-text-input id="title" name="title" type="text" class="mt-1 block w-full" required autofocus autocomplete="name" value="{{ $post->title }}" />
<br>
<x-input-label for="content">{{ __('Content') }}</x-input-label>
<textarea name="content" id="content" cols="30" rows="30">{{ $post->content }}</textarea>
<br>
<x-input-label for="cover">{{ __('Update Cover Image') }}</x-input-label>
<img src="{{ Illuminate\Support\Facades\Storage::url($post->cover) }}" alt="cover image" width="200">
<x-text-input id="cover" name="cover" type="file" class="mt-1 block w-full" autofocus autocomplete="cover" />
<br>
<x-input-label for="category">{{ __('Category') }}</x-input-label>
<select id="category" name="category">
@foreach($categories as $category)
<option value="{{ $category->id }}" @selected($post->category->id == $category->id)>{{ $category->name }}</option>
@endforeach
</select>
<br>
<x-input-label for="tags">{{ __('Tags') }}</x-input-label>
<select id="tags" name="tags[]" multiple>
@foreach($tags as $tag)
<option value="{{ $tag->id }}" @selected($post->tags->contains($tag))>{{ $tag->name }}</option>
@endforeach
</select>
<br>
<x-primary-button>{{ __('Save') }}</x-primary-button>
</form>
<script>
tinymce.init({. . .});
</script>
</div>
</div>
</div>
</div>
</x-app-layout>
Line 19 to 21, by default, HTML doesn't support PUT method, so what we can do is use method="POST"
, and then tell Laravel to use PUT method with {{ method_field('PUT') }}
.
Post index view
resources/views/posts/index.blade.php
<x-app-layout>
<x-slot name="header">
<div class="flex justify-between">
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
{{ __('Posts') }}
</h2>
<a href="{{ route('posts.create') }}">
<x-primary-button>{{ __('New') }}</x-primary-button>
</a>
</div>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
@foreach($posts as $post)
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg mb-4 px-4 h-20 flex justify-between items-center">
<div class="text-gray-900 dark:text-gray-100">
<p>{{ $post->title }}</p>
</div>
<div class="space-x-2">
<a href="{{ route('posts.edit', ['post' => $post->id]) }}"> <x-primary-button>{{ __('Edit') }}</x-primary-button></a>
<form method="post" action="{{ route('posts.destroy', ['post' => $post->id]) }}" class="inline">
{{ csrf_field() }}
{{ method_field('DELETE') }}
<x-danger-button>
{{ __('Delete') }}
</x-danger-button>
</form>
</div>
</div>
@endforeach
</div>
</div>
</x-app-layout>
Notice the delete button, instead of a regular button, it must be a form with DELETE method, since a regular link has only GET method.
With this demonstration, you should be able to build the rest of the view system with ease.
Screenshots
Last but not least, here are some screenshots for the dashboard I've created.
In the next article, we are going to move on from the dashboard, and create the frontend part of the application the user can see.
If you liked this article, please also check out my other tutorials:
Top comments (0)