Build a Laravel 10 CRUD App with Image Upload using Breeze - Step-by-Step Tutorial with Repo Example
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!
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 pmid-laravel10-breeze
This command will create a new Laravel 10 project in a directory named pmid-laravel10-breeze.
Initializing the Database
Next, we need to set up a database. For this tutorial, create a database named pmid_laravel10_breeze
. 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.
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".
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.
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.
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:
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
$file = Storage::disk('public')->put('images/posts/featured-images', request()->file('featured_image'), 'public');
// get the image path in the url
$path = Storage::url($file);
$validated['featured_image'] = $path;
}
// 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')) {
// get current image path and replace the storage path with public path
$currentImage = str_replace('/storage', '/public', $post->featured_image);
// delete current image
Storage::delete($currentImage);
$file = Storage::disk('public')->put('images/posts/featured-images', request()->file('featured_image'), 'public');
$path = Storage::url($file);
$validated['featured_image'] = $path;
}
$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);
$currentImage = str_replace('/storage', '/public', $post->featured_image);
Storage::delete($currentImage);
$delete = $post->delete($id);
if($delete) {
session()->flash('notif.success', 'Post deleted successfully!');
return redirect()->route('posts.index');
}
return abort(500);
}
}
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',
];
}
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',
];
}
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',
];
}
Create a Symbolic Link for Our Public Storage
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
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>
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:
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>
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="{{ asset($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>
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) ? asset($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>
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>
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.
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.
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:
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:
Show Post Page
Here is how the Show Post page should look like:
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:
Notification Display
A notification should appear after we successfully create, edit, or delete a record:
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.
Top comments (0)