Laravel for Beginners #4 - Create a Dashboard

Laravel for Beginners #4 - Create a Dashboard

·

15 min read

Download source code here. ⬅️

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.

Laravel Breeze 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

keytype
idbigInteger
created_at
updated_at
titlestring
coverstring
contenttext
is_publishedboolean

Categories

keytype
idbigInteger
created_at
updated_at
namestring

Tags

keytype
idbigInteger
created_at
updated_at
namestring

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

keytype
idbigInteger
created_at
updated_at
titlestring
coverstring
contenttext
is_publishedboolean
user_idbigInteger
category_idbigInteger

And we also need a separate table for post/tag relation:

Post/tag

keytype
post_idbigInteger
tag_idbigInteger

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.

Dashboard home

Categories list

Create category

Update post

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: