TALL Stack

Creating Your First Blog With TALL – Part Seven

Table of Content

Hey! Just so you know if today is your first time being here, this is a tutorial series and this one is the seventh episode of the tutorial series

In the previous episode, you learned how to create a responsive navigation bar for the blog using Tailwind CSS and Alpinejs. You also learned how to layout a post detail page using utility classes from Tailwind CSS. As is always the case, this episode builds on that by teaching you how to create posts using Laravel Livewire.

At the end of this episode, you should be able to:

  • delete, publish and unpublish posts using Livewire actions.
  • add new posts to the database using Eloquent.
  • style forms with Tailwind CSS.
  • use Laravel Livewire model binding to bind properties from Livewire components to forms in the view files.
  • validate forms using Livewire.
  • upload files using Laravel Livewire.

Note: I've noticed that the slug wasn't added to the Post model and the create posts migration. If you didn't also notice and correct it, kindly add slug to the $fillable array in app/Models/Post.php and modify the create posts migration accordingly(make the featured_image column too nullable). Then run php artisan migrate:refresh to rollback and rerun the migrations.

Note: The above truncates all your data, including users' data. We're taking this approach because updating only the slug column means we have to provide a slug for all posts. We also want to tidy up our posts table of the auto-generated data.

However, you'd need to recreate your account to continue with the tutorial.

First things first! Remember the view for the dashboard page was resources/views/dashboard.blade.php, and that was what you returned in the route for the dashboard page. Remember also that the default Jetstream welcome.blade.php component view was inserted into this view, making the default Jetstream dashboard display the welcome page.

Locate and delete this snippet from the resources/views/dashboard.blade.php to remove the welcome component:

<x-jet-welcome />

You're going to display posts you've already added in the dashboard, that's why you have to delete the welcome page.

Now you could just display the posts like how you did on the homepage but this is an admin area, so you wouldn't want to clutter the dashboard page. What will be more appropriate is to display minimal details of the posts in a table. This way, you can even add, edit and delete buttons to take action on each post. This is the approach we're going to take.

Updating the web.php Routes File

For us to be able to preview the pages we're going to create in the next sections, the routes/web.php file needs to be updated. Please make sure its contents match the route definitions below:

use App\Http\Livewire\CategoryPosts;
// use App\Http\Livewire\Dashboard\FeaturedImageUpload;
// use App\Http\Livewire\Dashboard\NewPost;
use App\Http\Livewire\Detail;
use App\Http\Livewire\ShowPosts;
use Illuminate\Support\Facades\Route;

/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/

Route::get('/', ShowPosts::class)->name('home');

Route::get('categories/{category}', CategoryPosts::class)->name('category');

Route::group(['prefix' => 'dashboard', 'middleware' => 'auth:sanctum'], function () {
    Route::get('/', function () {
        return view('dashboard');
    })->name('dashboard');

    // Route::get('post/add', NewPost::class)->name('new-post');

    // Route::get('post/upload/{id}', 
        // FeaturedImageUpload::class
    // )->name('upload-featured-image');

    Route::get('post/edit/{id}', function ($id) {
        return view('dashboard');
    })->name('edit-post');
});

Route::get('{slug}', Detail::class)->name('post-detail');

You've commented out the new post and featured image upload routes for now since these components are not yet available. After completely working on a component, you'll have to uncomment it here to make the route work.

Displaying Posts in the Dashboard

Create a new Posts Livewire component to display posts in the dashboard page:

php artisan make:livewire Posts

Open the file and make sure it is equal to this:

use App\Models\Post;
use Livewire\Component;
use Livewire\WithPagination;

class Posts extends Component
{
    use WithPagination;

    public function render()
    {
        $posts = Post::paginate(10);
        return view('livewire.dashboard.posts', 
                ['posts' => $posts]
            );
    }
}

You're using the WithPagination Livewire trait in this component. This trait makes paginating data using Livewire a breeze. You didn't make $posts a public variable because Livewire doesn't support using types other than the Stringable, Collection, DateTime, Model, EloquentCollection PHP types and the JavaScript data types this way(the paginate method returns a LengthAwarePaginator, not a collection of models).

Change the contents of the corresponding view to the following:

<div class="m-4">
    <div class="flex flex-row justify-between">
        <h2 class="text-xl font-bold text-gray-600">Posts</h2>
        <a href="{{ route('new-post') }}" class="px-2 py-1 text-white bg-green-700 rounded">New</a>
    </div>
    <div class="p-3">
        <!-- Session checks -->
        @if (session()->has('message'))
            <!-- Show message -->
            <div class="p-2 text-green-900 bg-green-600 bg-opacity-25 rounded-md">{{ session('message') }}</div>
        @endif
        <table class="table w-full">
            <thead class="text-sm text-gray-600 border-b border-gray-200">
                <th>Category</th>
                <th>Title</th>
                <th>Status</th>
                <th>Actions</th>
            </thead>
            <tbody class="text-sm text-gray-600">
                @foreach ($posts as $post)
                <tr class="table-row px-2 border-b border-gray-200 hover:bg-gray-100">
                    <td class="py-4">{{ $post->category }}</td>
                    <td>
                        {{ ucwords($post->title) }}
                    </td>
                    <td class="text-center">
                        {{ $post->is_published ? "Published" : "Pending" }}
                    </td>
                    <td class="text-center">
                        <button class="p-1 text-xs text-gray-100 bg-red-600 rounded-sm"
                        wire:click="delete({{ $post->id }})">
                            Delete
                        </button>
                        <button class="p-1 text-xs text-gray-100 bg-yellow-700 rounded-sm" wire:click="publish({{ $post->id }})">
                            {{ !$post->is_published ? "Publish" : "Unpublish" }}
                        </button>
                        <a href="{{ route('edit-post', $post->id) }}"
                            class="bg-blue-600 px-2 py-1.5 text-xs rounded text-white">Edit</a>
                    </td>
                </tr>
                @endforeach
            </tbody>
        </table>
        <div class="my-3">
            {{ $posts->links() }}
        </div>
    </div>
</div>

The view of a Livewire component must be laid out in a single top-level element, that's why we put everything in the div element. We've added a heading and a button linking to the page we'd use for creating new posts.

Remember Laravel helpers are also available in the views, so you're using the Session::has helper here to check for and display a message (which is from a session) when a post is deleted or published. Also, note the use of the @if @endif conditional block. This ensures the message is only displayed if there's a session that has the message variable.

Ignoring HTML, the next block iterates through the $posts and displays each post in a table row (tr). We have decided to display only a few columns from the posts table to make the table less cluttered.

Lastly from the loop, the Action column has three buttons: Delete for deleting this post, Publish/Unpublish for publishing/unpublishing this post, and Edit for editing the post.

While Edit is ordinarily a link to the edit post page, Delete and Publish/Unpublish bind the delete and publish methods in the Posts component to their wire:click event directives. The wire:click directive attaches the given method to the click event of the button element.

The last snippet is the $posts->links(). This adds the pagination links and is the same way a traditional Laravel app will display pagination. In other words, the difference between the syntax of a Livewire pagination and a traditional Laravel pagination is the use of the WithPagination trait. However, while pagination with Livewire doesn't refresh the whole page, Laravel's does and this is one advantage of using Livewire pagination.

Add the delete and publish methods to complete the Posts component:

public function delete(int $id)
{
    $post = Post::find($id);
    unlink(storage_path("app/public/posts/".$post->featured_image));
    $post->delete();
    session()->flash("message", "Post has been deleted");
}

public function publish(int $id)
{
    $post = Post::find($id);
    $status = $post->is_published ? "unpublished": "published";
    $post->is_published = !$post->is_published;
    $post->published_date = now();
    $post->save();
    session()->flash("message", "Post $status successfully");
}

The delete method first finds the post from the database using the post ID passed to it from the view. It then deletes the post's featured image from the storage folder using the storage_path helper to locate the file and the PHP unlink function for actually deleting the file. Lastly, we delete the post and flash a success method to the session.

Similarly, in the publish method you retrieve the post using its ID, set the status variable that specifies which word to use between 'published' and 'unpublished' in the success message, toggle the is_published column of the post, set the published_date column to the current date and then save the post back to the database. Finally, you flash the success message to the session for display in the view.

Save, start your servers and open your browser. Logging in to the dashboard should now show you posts available:
Posts in Admin Dashboard

Adding New Posts

Your next task is to allow the administrator to create new posts when they're logged in. The new post route was added to make this possible. You even added a button that'll send you to the page where you can add a new post at the top of the posts table in the dashboard page. Though, as at now clicking this button sends you to the correct page, the page doesn't have this functionality. It currently returns the dashboard page.

Create a NewPost Livewire component to handle adding a new post:

php artisan make:livewire Dashboard.NewPost

Now, make sure the NewPost component contains the following:

<?php

namespace App\Http\Livewire\Dashboard;

use App\Models\Post;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;

class NewPost extends Component
{
    public $post;

    protected $rules = [
        'post.title' => 'required|string',
        'post.category' => 'required',
        'post.body' => 'required|string|min:500',
        'post.excerpt' => 'required|min:100:max:250',
        'post.is_published' => 'boolean'
    ];

    protected $messages = [
        'required' => 'This field is required',
        'min' => 'Value must be more than :min chars',
        'max' => 'Maximum value is 250 chars'
    ];

    public function render()
    {
        return view('livewire.dashboard.new-post');
    }

    public function save()
    {
        $this->validate();

        $post = Post::create([
            'title' => $this->post['title'],
            'excerpt' => $this->post['excerpt'],
            'category' => $this->post['category'],
            'body' => $this->post['body'],
            'published_date' => now(),
            'user_id' => Auth()->user()->id,
            'is_published' => $this->post['is_published']
        ]);

        $id = $post->save();
        return redirect()->to(route('upload-featured-image', ['id' => $id]));
    }
}

Pretty many things are going on here. We have the $post member variable which holds all fields of a blog post. $rules define what validation rules should be applied to each field. You see that each rule has the post.{field} format as its key with the constraints that should be applied to it being the value. Occasionally, you'd only have the field as the key when you're dealing with a small and/or disjointed number of fields.

$messages show the provided messages for each constraint instead of the default Laravel validation error messages.

And finally, we have the save method that validates the form data against the $rules we specified earlier. When validation passes, you create a new post using the create method, save the post, and redirect to the featured image upload page with the post id.

You'll now have to create the form in the resources/views/livewire/dashboard/new-post.blade.php view. Enter this code in that view to do so:

<div class="p-4 mx-auto mt-3 bg-gray-100 md:p-8 md:w-4/5 md:mt-0">
    <h1 class="mb-3 text-xl font-semibold text-gray-600">New post</h1>
    <form wire:submit.prevent="save">
        <div class="overflow-hidden bg-white rounded-md shadow">
            <div class="px-4 py-3 space-y-8 sm:p-6">
                <div class="grid grid-cols-6 gap-6">
                    <div class="col-span-6 sm:col-span-3">
                        <x-jet-label for="title">
                            {{ __("Post title") }}
                        </x-jet-label>
                        <x-jet-input class="w-full" type="text" 
                           wire:model="post.title" placeholder="Post title" />
                        <x-jet-input-error for="post.title" />
                    </div>

                    <div class="col-span-6 sm:col-span-3">
                        <x-jet-label>
                            {{ __("Excerpt") }}
                        </x-jet-label>
                        <x-jet-input type="text" class="w-full" wire:model="post.excerpt" 
                           placeholder="Excerpt" />
                        <x-jet-input-error for="post.excerpt" />
                    </div>
                </div>
                <div class="grid grid-cols-6 gap-6">
                    <div class="col-span-6 sm:col-span-3">
                        <x-jet-label for="category" 
                            class="block text-sm font-medium text-gray-700">
                            {{ __("Category") }}
                        </x-jet-label>
                        <x-jet-input id="category" wire:model="post.category" type="text"
                            placeholder="Category" class="w-full" />
                        <x-jet-input-error for="post.category" />
                    </div>
                    <div class="col-span-6 mt-4 sm:col-span-1">
                        <x-jet-label class="text-sm font-medium text-gray-700">
                            <x-jet-input wire:model="post.is_published" type="checkbox" 
                              class="form-checkbox" />
                            {{ __("Publish") }}
                        </x-jet-label>
                        <x-jet-input-error for="post.is_published" />
                    </div>
                </div>
                <div class="flex flex-col">
                    <x-jet-label for="body">
                        {{ __("Body") }}
                    </x-jet-label>
                    <textarea id="body" rows="4" wire:model="post.body" 
                       class="border-gray-300 rounded-sm form-textarea">
                    </textarea>
                    <x-jet-input-error for="post.body" />
                </div>
            </div>
            <div class="px-4 py-3 text-right bg-gray-50 sm:px-6">
                <x-jet-button class="inline-flex justify-center">
                    {{ __("Next") }}
                </x-jet-button>
            </div>
        </div>
    </form>
</div>

From this, wire:submit.prevent="save" ensures that when the form is submitted, the save method is called. Of course, this is the method that saves the post into the database. The other thing worth mentioning is that the .prevent event modifier prevents refreshing the browser(which is the default behavior when you submit a form). Thus, it's equivalent to the event.preventDefault() JavaScript modifier.

You're making use of the Jetstream components for the form's elements here. The <x-jet-label ...> elements represent the Jetstream label component which we make use of here. The <x-jet-input ...> is an input component while the nearby <x-jet-input-error ...> displays errors associated with that input field. We also have the <x-jet-button ...> component which submits the form. All these components can be found in the resources/views/vendor/jetstream/components folder at the root of the project(we talked about this in the earlier episodes).

We decided to use the ready-made Jetstream components because apart from that being easier, it reduces the time we'd have spent crafting and designing the form ourselves. This also helps us to follow the DRY principles.

Save all files and try to click the New post button to see the form in action. Try to submit without providing the required fields, or by providing invalid data and you should see errors displayed next to each invalid field.

This is how the form looks like:
New post form

The last thing left for us to work on is the featured image upload page. This is necessary because when you submit the form with valid data, it sends you to the next page for the upload. This will be covered in the next section.

Uploading Files

Just like many other things, Livewire supports file uploads out of the box. It handles file uploads like you would with other form input fields. The only different thing you need to add to take advantage of this feature is to add the WithFileUploads trait to your Livewire component.

Create a new FeaturedImageUpload component in the Dashboard namespace:

php artisan make:livewire Dashboard.FeaturedImageUpload

Open the component class and modify it to equal the following:

<?php

namespace App\Http\Livewire\Dashboard;

use App\Models\Post;
use Livewire\Component;
use Livewire\WithFileUploads;
use Illuminate\Support\Str;

class FeaturedImageUpload extends Component
{
    use WithFileUploads;
    public $photo;
    public $post;

    protected $rules = [
        'photo' => 'required|image|max:2048'
    ];

    public function mount($id)
    {
        $this->post = Post::find($id);
    }

    public function render()
    {
        return view('livewire.dashboard.featured-image-upload');
    }

    public function upload()
    {
        $this->validate();

        $image = $this->photo->storeAs('posts/', Str::random(30));
        $this->post->featured_image = $image;
        $this->post->save();
        session()->flash("message", "Featured image successfully uploaded");
    }
}

With the WithFileUploads trait, you can take advantage of the fluent file upload feature Livewire provides. $photo is the featured image we're dealing with while $post is the blog post we're creating. You see we're using mount to fetch this post.

The upload method is where we're actually doing the upload. First, we validate the form to ensure there's an upload and the uploaded file is an image not more than 2MB. Secondly, we store the image in the storage/app/posts directory with a randomly generated string as its name. After saving the image, the storeAs method returns the path to the uploaded image, which we assigned to the post. Finally, we save the post with the updated featured image and flash a success message to the session.

Open the view file and enter this code into it:

<div class="p-4 mx-auto mt-3 h-4/5 md:p-8 md:w-3/5">
    <h2 class="my-2 text-lg font-semibold text-gray-700">Upload featured image</h2>
    <form wire:submit.prevent="upload" enctype="multipart/form-data">
        <div class="bg-white rounded shadow">
            @if (session()->has('message'))
                <div class="p-2 m-2 text-green-900 bg-green-600 bg-opacity-25 rounded-md">
                    {{ session('message') }} <a href="{{ route('new-post') }}">Add another one</a>
                </div>
            @endif
            <div class="p-8">
                <x-jet-label for="photo" class="flex items-center justify-center text-3xl border-2 border-dashed rounded-sm w-3/3 h-60 bg-gray-50">
                {{ __('Choose image') }}
                <x-jet-input type="file" id="photo" accept="jpg,png,gif"
                wire:model="photo" class="w-0 h-0"/>
                </x-jet-label>
                <x-jet-input-error for="photo" />
                </div>
            <div class="px-4 py-3 text-right bg-gray-50 sm:px-6">
                <x-jet-button class="inline-flex justify-center">
                    {{ __("Upload") }}
                </x-jet-button>
            </div>
        </div>
    </form>
</div>

There's nothing new here, except that we specified the enctype attribute to multipart/form-data. This is necessary to make file uploads successful.

Save the files. Create and submit a new post to see the featured image upload page:
Featured image upload

Polishing Things Up

Previously, because we're automatically generating the posts for display, we used a static image in the public directory for all posts. Now that we're using the form to create posts the links to the images will be broken. This is because Livewire doesn't upload files to the public directory: it uploads them in the storage directory.

From the FeaturedImageUpload component, we said that images are uploaded to the storage/app/public/posts folder. However, you already know that for files to be accessible to the public, they need to be in the public folder. This is achieved by creating symlinks since file storage is done in the storage folder.

To create the symlink, add this to the $link array in config/filesystems.php:

public_path('posts') => storage_path('app/posts'),

Now, save the file and run the storage:link Artisan command to create the link:

php artisan storage:link

Our last work is to change the image source in both resources/views/livewire/post-item.blade.php and resources/views/livewire/detail.blade.php from:

<img src="{{ asset("storage/posts/$post->featured_image") }}" ...>

to:

<img src="{{ asset($post->featured_image) }}" ...>

You should now be able to see the images if you navigate to the home, category, or detail page.

Congratulations! This brings us to the end of the seventh episode. In our next episode, we'll create and set up the remaining pages in the admin area.

chevron_left
chevron_right

Leave a comment

Your email address will not be published. Required fields are marked *

Comment
Name
Email
Website