Laravel 10 CRUD and Image Upload Tutorial with Laravel Breeze and Repo Example

Updated
featured-image.png

💡 I included the link to the example repository at the conclusion of this tutorial. And if you are looking for the Laravel 11 version, you can read it here.

Laravel is a popular open-source PHP framework that makes it easy to build web applications. It provides a powerful set of tools and features for web developers, such as routing, middleware, database integration, and templating.

Laravel 10 is the latest version of Laravel, released in 2023. It comes with several new features and improvements, such as support for PHP 8.1, enhanced security features, and performance optimizations.

In this tutorial, we will create a simple CRUD (Create, Read, Update, Delete) application using Laravel, one of the most popular PHP frameworks. The CRUD application will allow us to manage a list of blog posts, including creating new posts, reading existing posts, updating posts, and deleting posts. In addition to that, we will also learn how to upload and manage images for the blog posts.

By the end of this tutorial, you will have a good understanding of how to build a basic CRUD application in Laravel and how to work with images in the application. Let’s get started!

Step 1: Install Laravel 10

Before we begin, make sure you have the latest version of PHP (at least PHP 8.1) and Composer installed on your system. To install Laravel 10, you can use the following command:

composer create-project --prefer-dist laravel/laravel:^10 blog-laravel10-crud-image

This command will create a new Laravel 10 project in a directory named blog-laravel10-crud-image.

Step 2: Initializing the Database

Next, we need to set up a database. For this tutorial, create a database named blog_laravel10_crud_image. After creating the database, we need to modify the database connection settings in our Laravel app’s .env file to match the new database name, as well as the username and password.

Step 3: Install Laravel Breeze

Laravel Breeze is a lightweight authentication scaffold for Laravel 8 and later versions. It provides a simple way to add authentication functionality to your Laravel application.

To install Laravel Breeze, run the following command in your terminal:

composer require laravel/breeze --dev

After downloading Laravel Breeze via Composer, we can run the breeze:install Artisan command to install Laravel Breeze in our Laravel 10 application:

php artisan breeze:install

A prompt will appear, asking which stack we want to install. Choose “blade”. Another prompt may appear asking if we want to enable dark mode support. For now, we won’t use it, so answer “no”. Finally, a prompt may appear asking if we want to use the Pest testing framework. Currently, we won’t use it, so answer “no”.

Step 4: Create Posts Migration

Now that we have installed Laravel 10 and Laravel Breeze, let’s create a migration for our posts table. This table will store our post data for this CRUD tutorial.

To create a migration, run the following command in your terminal:

php artisan make:migration "create posts table"

This command will create a new migration file in the database/migrations directory.

Open the migration file and add the following code to create the posts table:

public function up()
{
    Schema::create('posts', function (Blueprint $table) {
        $table->id();
        $table->string('title');
        $table->text('content');
        $table->text('featured_image');
        $table->timestamps();
    });
}

public function down()
{
    Schema::dropIfExists('posts');
}

This code will create a posts table with an id, title, content, featured_image, and timestamps columns.

Step 5: Migrate the Migration

Finally, let’s run the migration to create the posts table in our database. To do this, run the following command in your terminal:

php artisan migrate

This command will run all the outstanding migrations in the database/migrations directory.

Step 6: Create Routes

Now that we have our posts table and database set up, let’s create some routes to handle the CRUD operations for our blog posts.

Open the routes/web.php file and update it with the following code:

<?php

use App\Http\Controllers\ProfileController;
use App\Http\Controllers\PostController;
use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return view('welcome');
});

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

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');

    // Add the following route to the existing routes because we want the posts route accessible to authenticated users only.
    // We'll use a resource route because it contains all the exact routes we need for a typical CRUD application.
    Route::resource('posts', PostController::class);
});

require __DIR__.'/auth.php';

This adds the posts resource route to the existing routes and applies the auth middleware to restrict access to authenticated users only. The PostController is set up as a resource controller to handle all the typical CRUD operations.

Read also:

Step 7: Create PostController

Now let’s create a PostController to handle the CRUD operations for our blog posts. To create a new controller with resource methods, run the following command in your terminal:

php artisan make:controller PostController --resource

This command will create a new PostController with all the resource methods in the app/Http/Controllers directory.

Open the PostController and add the following code to the class:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
// Use the Post Model
use App\Models\Post;
// We will use Form Request to validate incoming requests from our store and update method
use App\Http\Requests\Post\StoreRequest;
use App\Http\Requests\Post\UpdateRequest;

class PostController extends Controller
{
    /**
     * Display a listing of the resource.
     */
    public function index(): Response
    {
        return response()->view('posts.index', [
            'posts' => Post::orderBy('updated_at', 'desc')->get(),
        ]);
    }

    /**
     * Show the form for creating a new resource.
     */
    public function create(): Response
    {
        return response()->view('posts.form');
    }

    /**
     * Store a newly created resource in storage.
     */
    public function store(StoreRequest $request): RedirectResponse
    {
        $validated = $request->validated();

        if ($request->hasFile('featured_image')) {
             // put image in the public storage
            $filePath = Storage::disk('public')->put('images/posts/featured-images', request()->file('featured_image'));
            $validated['featured_image'] = $filePath;
        }

        // insert only requests that already validated in the StoreRequest
        $create = Post::create($validated);

        if($create) {
            // add flash for the success notification
            session()->flash('notif.success', 'Post created successfully!');
            return redirect()->route('posts.index');
        }

        return abort(500);
    }

    /**
     * Display the specified resource.
     */
    public function show(string $id): Response
    {
        return response()->view('posts.show', [
            'post' => Post::findOrFail($id),
        ]);
    }

    /**
     * Show the form for editing the specified resource.
     */
    public function edit(string $id): Response
    {
        return response()->view('posts.form', [
            'post' => Post::findOrFail($id),
        ]);
    }

    /**
     * Update the specified resource in storage.
     */
    public function update(UpdateRequest $request, string $id): RedirectResponse
    {
        $post = Post::findOrFail($id);
        $validated = $request->validated();

        if ($request->hasFile('featured_image')) {
            // delete image
            Storage::disk('public')->delete($post->featured_image);

            $filePath = Storage::disk('public')->put('images/posts/featured-images', request()->file('featured_image'), 'public');
            $validated['featured_image'] = $filePath;
        }

        $update = $post->update($validated);

        if($update) {
            session()->flash('notif.success', 'Post updated successfully!');
            return redirect()->route('posts.index');
        }

        return abort(500);
    }

    /**
     * Remove the specified resource from storage.
     */
    public function destroy(string $id): RedirectResponse
    {
        $post = Post::findOrFail($id);

        Storage::disk('public')->delete($post->featured_image);
        
        $delete = $post->delete($id);

        if($delete) {
            session()->flash('notif.success', 'Post deleted successfully!');
            return redirect()->route('posts.index');
        }

        return abort(500);
    }
}

Step 8: Create Form Request for store() method

We can run the following command to create a form request for store():

php artisan make:request Post/StoreRequest

And then add the following code to the class:

    public function authorize(): bool
    {
        // dont' forget to set this as true
        return true;
    }

    public function rules(): array
    {
        // make all of the fields required, set featured image to accept only images
        return [
            'title' => 'required|string|min:3|max:250',
            'content' => 'required|string|min:3|max:6000',
            'featured_image' => 'required|image|max:1024|mimes:jpg,jpeg,png',
        ];
    }

Step 9: Create Form Request for update() method

We can run the following command to create a form request for update():

php artisan make:request Post/UpdateRequest

The code for this class is similar to the code for StoreRequest except that the featured_image rule is set to nullable:

    public function authorize(): bool
    {
        // dont' forget to set this as true
        return true;
    }

    public function rules(): array
    {
        // make all of the fields required, set featured image to accept only images
        return [
            'title' => 'required|string|min:3|max:250',
            'content' => 'required|string|min:3|max:6000',
            'featured_image' => 'nullable|image|max:1024|mimes:jpg,jpeg,png',
        ];
    }

Step 10: Creating Post Model

To facilitate communication with the database, let’s create a model named Post. Run the following command in your terminal:

php artisan make:model Post

This command generates a new model file named Post.php within the app/Models directory.

Step 11: Add $fillable to Post Model

In the app/Models/Post.php file, add the fillable property to specify which fields can be mass-assigned. Here’s an example:

class Post extends Model
{
    protected $fillable = [
        'title', 
        'content',
        'featured_image',
    ];
}

To make files stored in the public disk accessible from the web, a symbolic link needs to be created from the public/storage folder to the storage/app/public folder. By default, the public disk uses the local driver. No changes are required to our public storage configuration. To create the symbolic link, run the following command:

php artisan storage:link

Step 13: TailwindCSS and Vite Configuration

We will be using TailwindCSS for our styling and Vite as our Asset Bundling helper. Therefore, we need to adjust their configurations a bit.

Since we will be writing additional styles to our app, we need to start the Tailwind CLI build process while we code our views. To do this, run the following command:

npx tailwindcss -i ./resources/css/app.css -o ./resources/css/main.css --watch

This command will generate a new file at resources/css/main.css. Now, we need to tell Vite that we will be using main.css instead of app.css.

We will reuse resources/views/layouts/app.blade.php layout so update the Vite code in the head section to the following:

    {{-- some other code --}}
    @vite(['resources/css/main.css', 'resources/js/app.js'])
</head>

Step 14: Setting Up the Layout

We will update the header section inside our layout’s body so that it can handle flash notifications for success messages. Please update it with the following code:

<!-- Page Heading -->
@if (isset($header))
    <header class="bg-white shadow">
        <div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
            {{ $header }}

            {{-- check if there is a notif.success flash session --}}
            @if (Session::has('notif.success'))
            <div class="bg-blue-300 mt-2 p-4">
                {{-- if it's there then print the notification --}}
                <span class="text-white">{{ Session::get('notif.success') }}</span>
            </div>
            @endif
        </div>
    </header>
@endif

So our resources\views\layouts\app.blade.php file will be updated with the below code:

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <meta name="csrf-token" content="{{ csrf_token() }}">

        <title>{{ config('app.name', 'Laravel') }}</title>

        <!-- Fonts -->
        <link rel="preconnect" href="https://fonts.bunny.net">
        <link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />

        <!-- Scripts -->
        @vite(['resources/css/main.css', 'resources/js/app.js'])
    </head>
    <body class="font-sans antialiased">
        <div class="min-h-screen bg-gray-100">
            @include('layouts.navigation')

            <!-- Page Heading -->
            @if (isset($header))
                <header class="bg-white shadow">
                    <div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
                        {{ $header }}

                        @if (Session::has('notif.success'))
                        <div class="bg-blue-300 mt-2 p-4">
                            <span class="text-white">{{ Session::get('notif.success') }}</span>
                        </div>
                        @endif
                    </div>
                </header>
            @endif

            <!-- Page Content -->
            <main>
                {{ $slot }}
            </main>
        </div>
    </body>
</html>

Read also:

Step 15: Create Index View

Create a new file resources/views/posts/index.blade.php with the following contents:

{{-- use AppLayout Component located in app\View\Components\AppLayout.php which use resources\views\layouts\app.blade.php view --}}
<x-app-layout>
    <x-slot name="header">
        <div class="flex justify-between">
            <h2 class="font-semibold text-xl text-gray-800 leading-tight">
                {{ 'Posts' }}
            </h2>
            <a href="{{ route('posts.create') }}" class="bg-blue-500 text-white px-4 py-2 rounded-md">ADD</a>
        </div>
    </x-slot>

    <div class="py-12">
        <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
            <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
                <div class="p-6 text-gray-900">
                    <table class="border-collapse table-auto w-full text-sm">
                        <thead>
                            <tr>
                                <th class="border-b font-medium p-4 pl-8 pt-0 pb-3 text-slate-400 text-left">Title</th>
                                <th class="border-b font-medium p-4 pl-8 pt-0 pb-3 text-slate-400 text-left">Created At</th>
                                <th class="border-b font-medium p-4 pl-8 pt-0 pb-3 text-slate-400 text-left">Updated At</th>
                                <th class="border-b font-medium p-4 pl-8 pt-0 pb-3 text-slate-400 text-left">Action</th>
                            </tr>
                        </thead>
                        <tbody class="bg-white">
                            {{-- populate our post data --}}
                            @foreach ($posts as $post)
                                <tr>
                                    <td class="border-b border-slate-100 dark:border-slate-700 p-4 pl-8 text-slate-500 dark:text-slate-400">{{ $post->title }}</td>
                                    <td class="border-b border-slate-100 dark:border-slate-700 p-4 pl-8 text-slate-500 dark:text-slate-400">{{ $post->created_at }}</td>
                                    <td class="border-b border-slate-100 dark:border-slate-700 p-4 pl-8 text-slate-500 dark:text-slate-400">{{ $post->updated_at }}</td>
                                    <td class="border-b border-slate-100 dark:border-slate-700 p-4 pl-8 text-slate-500 dark:text-slate-400">
                                        <a href="{{ route('posts.show', $post->id) }}" class="border border-blue-500 hover:bg-blue-500 hover:text-white px-4 py-2 rounded-md">SHOW</a>
                                        <a href="{{ route('posts.edit', $post->id) }}" class="border border-yellow-500 hover:bg-yellow-500 hover:text-white px-4 py-2 rounded-md">EDIT</a>
                                        {{-- add delete button using form tag --}}
                                        <form method="post" action="{{ route('posts.destroy', $post->id) }}" class="inline">
                                            @csrf
                                            @method('delete')
                                            <button class="border border-red-500 hover:bg-red-500 hover:text-white px-4 py-2 rounded-md">DELETE</button>
                                        </form>
                                    </td>
                                </tr>
                            @endforeach
                        </tbody>
                    </table>
                </div>
            </div>
        </div>
    </div>
</x-app-layout>

Step 16: Create Show View

Create a new file resources/views/posts/show.blade.php with the following contents:

<x-app-layout>
    <x-slot name="header">
        <h2 class="font-semibold text-xl text-gray-800 leading-tight">
            {{ 'Show' }}
        </h2>
    </x-slot>

    <div class="py-12">
        <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
            <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
                <div class="p-6 text-gray-900">
                    <div class="mb-6">
                        <h2 class="text-lg font-medium text-gray-900">
                            {{ 'Title' }}
                        </h2>
                
                        <p class="mt-1 text-sm text-gray-600">
                            {{ $post->title }}
                        </p>
                    </div>
                    <div class="mb-6">
                        <h2 class="text-lg font-medium text-gray-900">
                            {{ 'Content' }}
                        </h2>
                
                        <p class="mt-1 text-sm text-gray-600">
                            {{ $post->content }}
                        </p>
                    </div>
                    <div class="mb-6">
                        <h2 class="text-lg font-medium text-gray-900">
                            {{ 'Featured Image' }}
                        </h2>
                
                        <p class="mt-1 text-sm text-gray-600">
                            <img class="h-64 w-128" src="{{ Storage::url($post->featured_image) }}" alt="{{ $post->title }}" srcset="">
                        </p>
                    </div>
                    <div class="mb-6">
                        <h2 class="text-lg font-medium text-gray-900">
                            {{ 'Created At' }}
                        </h2>
                
                        <p class="mt-1 text-sm text-gray-600">
                            {{ $post->created_at }}
                        </p>
                    </div>
                    <div class="mb-6">
                        <h2 class="text-lg font-medium text-gray-900">
                            {{ 'Updated At' }}
                        </h2>
                
                        <p class="mt-1 text-sm text-gray-600">
                            {{ $post->updated_at }}
                        </p>
                    </div>
                    <a href="{{ route('posts.index') }}" class="bg-blue-500 text-white px-4 py-2 rounded-md">BACK</a>
                </div>
            </div>
        </div>
    </div>
</x-app-layout>

Step 17: Create Form View

Create a new file resources/views/posts/form.blade.php. Here we will use some already defined components we get from Laravel Breeze. Write the following code:

<x-app-layout>
    <x-slot name="header">
        <h2 class="font-semibold text-xl text-gray-800 leading-tight">
            {{-- Use 'Edit' for edit mode and create for non-edit/create mode --}}
            {{ isset($post) ? 'Edit' : 'Create' }}
        </h2>
    </x-slot>

    <div class="py-12">
        <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
            <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
                <div class="p-6 text-gray-900">
                    {{-- don't forget to add multipart/form-data so we can accept file in our form --}}
                    <form method="post" action="{{ isset($post) ? route('posts.update', $post->id) : route('posts.store') }}" class="mt-6 space-y-6" enctype="multipart/form-data">class="mt-6 space-y-6">
                        @csrf
                        {{-- add @method('put') for edit mode --}}
                        @isset($post)
                            @method('put')
                        @endisset
                
                        <div>
                            <x-input-label for="title" value="Title" />
                            <x-text-input id="title" name="title" type="text" class="mt-1 block w-full" :value="$post->title ?? old('title')" required autofocus />
                            <x-input-error class="mt-2" :messages="$errors->get('title')" />
                        </div>

                        <div>
                            <x-input-label for="content" value="Content" />
                            {{-- use textarea-input component that we will create after this --}}
                            <x-textarea-input id="content" name="content" class="mt-1 block w-full" required autofocus>{{ $post->content ?? old('content') }}</x-textarea-input>
                            <x-input-error class="mt-2" :messages="$errors->get('content')" />
                        </div>

                        <div>
                            <x-input-label for="featured_image" value="Featured Image" />
                            <label class="block mt-2">
                                <span class="sr-only">Choose image</span>
                                <input type="file" id="featured_image" name="featured_image" class="block w-full text-sm text-slate-500
                                    file:mr-4 file:py-2 file:px-4
                                    file:rounded-full file:border-0
                                    file:text-sm file:font-semibold
                                    file:bg-violet-50 file:text-violet-700
                                    hover:file:bg-violet-100
                                "/>
                            </label>
                            <div class="shrink-0 my-2">
                                <img id="featured_image_preview" class="h-64 w-128 object-cover rounded-md" src="{{ isset($post) ? Storage::url($post->featured_image) : '' }}" alt="Featured image preview" />
                            </div>
                            <x-input-error class="mt-2" :messages="$errors->get('featured_image')" />
                        </div>
                
                        <div class="flex items-center gap-4">
                            <x-primary-button>{{ __('Save') }}</x-primary-button>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
    <script>
        // create onchange event listener for featured_image input
        document.getElementById('featured_image').onchange = function(evt) {
            const [file] = this.files
            if (file) {
                // if there is an image, create a preview in featured_image_preview
                document.getElementById('featured_image_preview').src = URL.createObjectURL(file)
            }
        }
    </script>
</x-app-layout>

Step 18: Create textarea-input Component

Create additional component with the following codes:

@props(['disabled' => false])

<textarea {{ $disabled ? 'disabled' : '' }} {!! $attributes->merge(['class' => 'border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm']) !!}>{{$slot}}</textarea>

Step 19: Update Navigation Component

Update the navigation component with the following codes:

<nav x-data="{ open: false }" class="bg-white border-b border-gray-100">
    <!-- Primary Navigation Menu -->
    <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
        <div class="flex justify-between h-16">
            <div class="flex">
                <!-- Logo -->
                <div class="shrink-0 flex items-center">
                    <a href="{{ route('dashboard') }}">
                        <x-application-logo class="block h-9 w-auto fill-current text-gray-800" />
                    </a>
                </div>

                <!-- Navigation Links -->
                <div class="hidden space-x-8 sm:-my-px sm:ml-10 sm:flex">
                    <x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
                        {{ __('Dashboard') }}
                    </x-nav-link>
                    <!-- add this -->
                    <x-nav-link :href="route('posts.index')" :active="request()->routeIs('posts.index')">
                        {{'Posts' }}
                    </x-nav-link>
                </div>
            </div>

            <!-- Settings Dropdown -->
            <div class="hidden sm:flex sm:items-center sm:ml-6">
                <x-dropdown align="right" width="48">
                    <x-slot name="trigger">
                        <button class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none transition ease-in-out duration-150">
                            <div>{{ Auth::user()->name }}</div>

                            <div class="ml-1">
                                <svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
                                    <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
                                </svg>
                            </div>
                        </button>
                    </x-slot>

                    <x-slot name="content">
                        <x-dropdown-link :href="route('profile.edit')">
                            {{ __('Profile') }}
                        </x-dropdown-link>

                        <!-- Authentication -->
                        <form method="POST" action="{{ route('logout') }}">
                            @csrf

                            <x-dropdown-link :href="route('logout')"
                                    onclick="event.preventDefault();
                                                this.closest('form').submit();">
                                {{ __('Log Out') }}
                            </x-dropdown-link>
                        </form>
                    </x-slot>
                </x-dropdown>
            </div>

            <!-- Hamburger -->
            <div class="-mr-2 flex items-center sm:hidden">
                <button @click="open = ! open" class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 focus:text-gray-500 transition duration-150 ease-in-out">
                    <svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
                        <path :class="{'hidden': open, 'inline-flex': ! open }" class="inline-flex" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
                        <path :class="{'hidden': ! open, 'inline-flex': open }" class="hidden" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
                    </svg>
                </button>
            </div>
        </div>
    </div>

    <!-- Responsive Navigation Menu -->
    <div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden">
        <div class="pt-2 pb-3 space-y-1">
            <x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
                {{ __('Dashboard') }}
            </x-responsive-nav-link>
            <!-- add this -->
            <x-responsive-nav-link :href="route('posts.index')" :active="request()->routeIs('posts.index')">
                {{ 'Posts' }}
            </x-responsive-nav-link>
        </div>

        <!-- Responsive Settings Options -->
        <div class="pt-4 pb-1 border-t border-gray-200">
            <div class="px-4">
                <div class="font-medium text-base text-gray-800">{{ Auth::user()->name }}</div>
                <div class="font-medium text-sm text-gray-500">{{ Auth::user()->email }}</div>
            </div>

            <div class="mt-3 space-y-1">
                <x-responsive-nav-link :href="route('profile.edit')">
                    {{ __('Profile') }}
                </x-responsive-nav-link>

                <!-- Authentication -->
                <form method="POST" action="{{ route('logout') }}">
                    @csrf

                    <x-responsive-nav-link :href="route('logout')"
                            onclick="event.preventDefault();
                                        this.closest('form').submit();">
                        {{ __('Log Out') }}
                    </x-responsive-nav-link>
                </form>
            </div>
        </div>
    </div>
</nav>

Here we add our Posts link to the desktop and mobile version navigation.

Step 20: Test Our Laravel App

If you haven’t run npm install since installing Laravel Breeze, make sure to run it first to install all the necessary dependencies. If you have already done that, you can skip this step. Next, run npm run dev to start working with Vite.

Once you have set up your frontend dependencies, you can run your local development server by running the following command:

php artisan serve

Read also:

Welcome Page

You will see the following Laravel 10 Welcome Page. You will also see Log in and Register link at the navbar because we already installed Laravel Breeze.

welcome.png

Register yourself and go to the Dashboard Page.

Dashboard Page

Here is how the Dashboard page looks like. You should also see an additional link at the navbar named Posts:

dashboard.png

Post List Page

Here is how the post list page in /posts should look like. It should have a table with Show, Edit, and Delete buttons in each record:

posts-index.png

Show Post Page

Here is how the Show Post page should look like:

show-post.png

Create and Edit Post Page

Here is how the Create Post page should look like with image preview. This is also similar to the Edit Post page:

create-post.png

Notification Display

A notification should appear after we successfully create, edit, or delete a record:

create-update-notif.png

Conclusion

In this tutorial, we have learned how to create a simple CRUD application using Laravel 8 with Laravel Breeze for authentication. We have covered the basic concepts of Laravel, such as routing, controllers, views, and models, as well as using components from Laravel Breeze to handle user authentication.

We also learned how to use Tailwind CSS and Vite to style our application and optimize asset bundling. Additionally, we covered how to handle flash notifications and file uploading in our application.

Overall, this tutorial provides a solid foundation for building CRUD applications using Laravel, and with the knowledge gained, you can easily extend this application to meet your specific requirements.

💻 The repository for this example can be found at fajarwz/blog-laravel10-crud-image.

Fajarwz's photo Fajar Windhu Zulfikar

I'm a full-stack web developer who loves to share my software engineering journey and build software solutions to help businesses succeed.

Email me
Ads
  • Full-Stack Laravel: Forum Web App (Complete Guide 2024)
  • Flexible and powerful review system for Laravel, let any model review and be reviewed.

Share

Subscribe

Sign up for my email newsletter and never miss a beat in the world of web development. Stay up-to-date on the latest trends, techniques, and tools. Don't miss out on valuable insights. Subscribe now!

Comments

comments powered by Disqus