Creating Your First Blog With TALL - Part Seven
Tags: laravel, livewire, tailwindcss, alpinejs
Published: 2021-04-30 08:19:00

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 learnt how to create a responsive navigation bar for the blog using Tailwind CSS and Alpinejs. You also learnt 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 in 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 actions 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 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/zpeeybcnu99ew5i0w55o.png)

## 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 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](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself) 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4q84zy01elxaq00wg9v0.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/zwjd6qq2pv3vg1nx1r6q.png)

## 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 it 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.