Di bawah ini adalah panduan lengkap untuk membuat CRUD Products menggunakan Laravel 11, Inertia.js, dan Vue 3. Dalam CRUD Products ini, nantinya akan ada category yang telah kita buat sebelumnya untuk kita tampilkan.
Step 1: Membuat Model, Migration, dan Factory Product
Di Part 3, kita sudah buat migration untuk products. Jika sudah berhasil, skip step ini. Tutorial ini saya ulang agar lebih nyambung nantinya.
Buat model Product dengan migration:
php artisan make:model Product -m
Buka file migrasi di database/migrations/xxxx_xx_xx_create_products_table.php dan tambahkan kolom:
public function up(): void
{
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->foreign('category_id')->references('id')->on('categories')->onDelete('set null');
$table->string('name');
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->decimal('price', 10, 2);
$table->integer('stock')->default(0);
$table->timestamps();
});
}
Kemudian jalankan migrasi:
php artisan migrate
Buka app/Models/Product.php:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
protected $fillable = [
'name',
'slug',
'price',
'stock',
'description',
'category_id'
];
public function category()
{
return $this->belongsTo(Category::class);
}
public function images()
{
return $this->hasMany(ProductImage::class);
}
}
Step 2: Membuat Controller dan Routes CRUD Products
Buat controller ProductController:
php artisan make:controller ProductController
Buka app/Http/Controllers/ProductController.php dan isi:
<?php
namespace App\Http\Controllers;
use App\Models\Product;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Illuminate\Support\Facades\Storage;
use App\Models\Category;
class ProductController extends Controller
{
public function __construct()
{
// Pastikan user terautentikasi untuk kelola product
//$this->middleware('auth');
}
public function index()
{
$products = Product::with('category')->with('images')->latest()->paginate(10);
return Inertia::render('Products/Index', [
'products' => $products,
]);
}
public function create()
{
return Inertia::render('Products/Create', [
'categories' => Category::all(['id', 'name'])
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'slug' => 'required|string|unique:products,slug',
'price' => 'required|numeric|min:0',
'stock' => 'required|integer|min:0',
'description' => 'nullable|string',
'category_id' => 'nullable|exists:categories,id',
'images.*' => 'image|max:2048', // Validasi setiap file gambar
]);
$product = Product::create([
'name' => $validated['name'],
'slug' => $validated['slug'],
'price' => $validated['price'],
'stock' => $validated['stock'],
'description' => $validated['description'] ?? null,
'category_id' => $validated['category_id'] ?? null,
]);
// Upload multiple images jika ada
if ($request->hasFile('images')) {
foreach ($request->file('images') as $file) {
$path = $file->store('products', 'public');
$product->images()->create(['image' => $path]);
}
}
return redirect()->route('products.index')->with('success', 'Product created successfully with multiple images.');
}
public function edit(Product $product)
{
return Inertia::render('Products/Edit', [
'product' => [
'id' => $product->id,
'name' => $product->name,
'slug' => $product->slug,
'price' => $product->price,
'stock' => $product->stock,
'description' => $product->description, // Pastikan field ini dikirim
'image' => $product->image,
'image_url' => $product->image ? asset('storage/' . $product->image) : null,
'category_id' => $product->category_id
],
'categories' => Category::all(['id', 'name'])
]);
}
public function update(Request $request, Product $product)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'slug' => 'required|string|unique:products,slug,' . $product->id,
'price' => 'required|numeric|min:0',
'stock' => 'required|integer|min:0',
'description' => 'nullable|string',
'category_id' => 'nullable|exists:categories,id',
'images.*' => 'image|max:2048',
]);
$product->update($validated);
// Jika ada file baru diupload, simpan
if ($request->hasFile('images')) {
foreach ($request->file('images') as $file) {
$path = $file->store('products', 'public');
$product->images()->create(['image' => $path]);
}
}
return redirect()->route('products.index')->with('success', 'Product updated successfully with multiple images.');
}
public function destroy(Product $product)
{
if ($product->image && Storage::disk('public')->exists($product->image)) {
Storage::disk('public')->delete($product->image);
}
$product->delete();
return redirect()->route('products.index')->with('success', 'Product deleted successfully.');
}
}
Daftarkan routes di routes/web.php:
use App\Http\Controllers\ProductController;
Route::middleware(['auth', 'verified'])->group(function () {
Route::resource('products', ProductController::class);
});
Step 3: Membuat Halaman Vue untuk CRUD Products
Secara default, Inertia + Breeze menggunakan folder resources/js/Pages untuk menaruh file Vue.
Buat folder resources/js/Pages/Products dan di dalamnya buat file Index.vue, Create.vue, dan Edit.vue.
Index.vue
<template>
<div class="flex min-h-screen bg-gray-100">
<!-- Sidebar -->
<aside class="w-64 bg-blue-600 text-white flex-shrink-0">
<div class="p-6">
<h2 class="text-2xl font-bold">Admin Panel</h2>
</div>
<nav class="mt-6">
<ul>
<li>
<Link href="/dashboard"
class="block px-4 py-2 hover:bg-blue-700"
>
Dashboard
</Link>
</li>
<li>
<Link href="/categories"
class="block px-4 py-2 hover:bg-blue-700"
>
Categories
</Link>
</li>
<li>
<Link href="/products"
class="block px-4 py-2 hover:bg-blue-700"
>
Products
</Link>
</li>
<li>
<Link href="/orders"
class="block px-4 py-2 hover:bg-blue-700"
>
Orders
</Link>
</li>
</ul>
</nav>
</aside>
<!-- Main Content -->
<main class="flex-1 p-6">
<h1 class="text-2xl font-bold text-gray-800 mb-6">Product Management</h1>
<!-- Button Tambah Produk -->
<div class="mb-4 flex justify-end">
<Link href="/products/create" class="px-4 py-2 bg-blue-600 text-white rounded">Create Product</Link>
</div>
<!-- Tabel Produk -->
<div class="bg-white rounded-lg shadow">
<table class="min-w-full bg-white border">
<thead class="bg-gray-100">
<tr>
<th class="px-4 py-2 text-left">Name</th>
<th class="px-4 py-2 text-left">Price</th>
<th class="px-4 py-2 text-left">Stock</th>
<th class="px-4 py-2 text-left">Categories</th>
<th class="px-4 py-2 text-left">Image</th>
<th class="px-4 py-2 text-center">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="product in products.data" :key="product.id" class="border-t">
<td class="px-4 py-2">{{ product.name }}</td>
<td class="px-4 py-2">{{ product.price }}</td>
<td class="px-4 py-2">{{ product.stock }}</td>
<td class="px-4 py-2">{{ product.category ? product.category.name : '-' }}</td>
<td class="px-4 py-2">
<div>
<p>{{ product.name }}</p>
<div class="flex space-x-2">
<div v-for="img in product.images" :key="img.id">
<img :src="`/storage/${img.image}`" class="w-16 h-16 object-cover" />
</div>
</div>
</div>
</td>
<td class="px-4 py-2 text-center">
<Link
:href="`/products/${product.id}/edit`"
class="px-2 py-1 bg-green-600 text-white rounded hover:bg-green-700"
>
Edit
</Link>
<button
@click="destroy(product.id)"
class="ml-2 px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700"
>
Delete
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Simple pagination -->
<div class="mt-4">
<div class="flex space-x-2">
<div v-for="link in products.links" :key="link.url">
<Link
:class="[
'px-3 py-1 border rounded',
link.active ? 'bg-blue-600 text-white' : 'bg-white text-black'
]"
:href="link.url"
v-html="link.label"
></Link>
</div>
</div>
</div>
</main>
</div>
</template>
<script>
import { Inertia } from '@inertiajs/inertia';
import { Link } from '@inertiajs/inertia-vue3';
export default {
props: {
products: Object,
},
components: {
Link
},
methods: {
destroy(id) {
if (confirm('Are you sure?')) {
Inertia.delete(`/products/${id}`);
}
},
},
};
</script>
Create.vue
<template>
<div class="flex min-h-screen bg-gray-100">
<!-- Sidebar -->
<aside class="w-64 bg-blue-600 text-white flex-shrink-0">
<div class="p-6">
<h2 class="text-2xl font-bold">Admin Panel</h2>
</div>
<nav class="mt-6">
<ul>
<li>
<Link href="/dashboard"
class="block px-4 py-2 hover:bg-blue-700"
>
Dashboard
</Link>
</li>
<li>
<Link href="/categories"
class="block px-4 py-2 hover:bg-blue-700"
>
Categories
</Link>
</li>
<li>
<Link href="/products"
class="block px-4 py-2 hover:bg-blue-700"
>
Products
</Link>
</li>
<li>
<Link href="/orders"
class="block px-4 py-2 hover:bg-blue-700"
>
Orders
</Link>
</li>
</ul>
</nav>
</aside>
<!-- Main Content -->
<main class="flex-1 p-6">
<h1 class="text-2xl font-bold text-gray-800 mb-6">Add New Product</h1>
<div class="bg-white p-6 rounded-lg shadow">
<form @submit.prevent="submit" class="space-y-4">
<div>
<label class="block font-medium">Name</label>
<input v-model="form.name" type="text" class="w-full border px-3 py-2 rounded" />
<div v-if="errors.name" class="text-red-500 text-sm">{{ errors.name }}</div>
</div>
<div>
<label class="block font-medium">Slug</label>
<input v-model="form.slug" type="text" class="w-full border px-3 py-2 rounded" />
<div v-if="errors.slug" class="text-red-500 text-sm">{{ errors.slug }}</div>
</div>
<div>
<label class="block font-medium">Category</label>
<select v-model="form.category_id" class="w-full border px-3 py-2 rounded">
<option value="">-- Select Category --</option>
<option v-for="cat in categories" :key="cat.id" :value="cat.id">{{ cat.name }}</option>
</select>
</div>
<div>
<label class="block font-medium">Price</label>
<input v-model="form.price" type="number" class="w-full border px-3 py-2 rounded" />
<div v-if="errors.price" class="text-red-500 text-sm">{{ errors.price }}</div>
</div>
<div>
<label class="block font-medium">Stock</label>
<input v-model="form.stock" type="number" class="w-full border px-3 py-2 rounded" />
<div v-if="errors.stock" class="text-red-500 text-sm">{{ errors.stock }}</div>
</div>
<div>
<label class="block font-medium">Description</label>
<textarea v-model="form.description" class="w-full border px-3 py-2 rounded" rows="4"></textarea>
<div v-if="errors.description" class="text-red-500 text-sm">{{ errors.description }}</div>
</div>
<!-- Input untuk upload multiple images -->
<div>
<label class="block font-medium">Images</label>
<input type="file" multiple @change="handleFiles" class="w-full border px-3 py-2 rounded" />
<div v-if="errors['images']" class="text-red-500 text-sm">{{ errors['images'] }}</div>
<div v-if="errors['images.*']" class="text-red-500 text-sm">{{ errors['images.*'] }}</div>
</div>
<!-- Preview gambar baru -->
<div v-if="newImagesPreview.length > 0">
<h2 class="font-medium mb-2">Images Preview:</h2>
<div class="flex space-x-2">
<div v-for="(imgSrc, index) in newImagesPreview" :key="index">
<img :src="imgSrc" class="w-16 h-16 object-cover rounded border" />
</div>
</div>
</div>
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Save</button>
</form>
</div>
</main>
</div>
</template>
<script>
import { ref } from 'vue';
import { useForm, Link } from '@inertiajs/inertia-vue3';
export default {
props: {
categories: {
type: Array,
default: () => []
}
},
components: {
Link
},
setup(props) {
const form = useForm({
name: '',
slug: '',
price: '',
stock: '',
description: '',
category_id: '',
images: []
});
const errors = form.errors;
const newImagesPreview = ref([]);
const handleFiles = (e) => {
const files = Array.from(e.target.files);
if (files && files.length > 0) {
form.images = files;
newImagesPreview.value = files.map(file => URL.createObjectURL(file));
} else {
form.images = [];
newImagesPreview.value = [];
}
};
const submit = () => {
form.post('/products', {
onSuccess: () => {
alert('Product created successfully with multiple images!');
}
});
};
return { form, handleFiles, submit, errors, newImagesPreview };
},
};
</script>
Edit.vue
<template>
<div class="flex min-h-screen bg-gray-100">
<!-- Sidebar -->
<aside class="w-64 bg-blue-600 text-white flex-shrink-0">
<div class="p-6">
<h2 class="text-2xl font-bold">Admin Panel</h2>
</div>
<nav class="mt-6">
<ul>
<li>
<Link href="/dashboard"
class="block px-4 py-2 hover:bg-blue-700"
>
Dashboard
</Link>
</li>
<li>
<Link href="/categories"
class="block px-4 py-2 hover:bg-blue-700"
>
Categories
</Link>
</li>
<li>
<Link href="/products"
class="block px-4 py-2 hover:bg-blue-700"
>
Products
</Link>
</li>
<li>
<Link href="/orders"
class="block px-4 py-2 hover:bg-blue-700"
>
Orders
</Link>
</li>
</ul>
</nav>
</aside>
<!-- Main Content -->
<main class="flex-1 p-6">
<h1 class="text-2xl font-bold text-gray-800 mb-6">Edit Product</h1>
<div class="bg-white p-6 rounded-lg shadow">
<form @submit.prevent="submit" class="space-y-4">
<div>
<label class="block font-medium">Name</label>
<input v-model="form.name" type="text" class="w-full border px-3 py-2 rounded" />
<div v-if="errors.name" class="text-red-500 text-sm">{{ errors.name }}</div>
</div>
<div>
<label class="block font-medium">Slug</label>
<input v-model="form.slug" type="text" class="w-full border px-3 py-2 rounded" />
<div v-if="errors.slug" class="text-red-500 text-sm">{{ errors.slug }}</div>
</div>
<div>
<label class="block font-medium">Category</label>
<select v-model="form.category_id" class="w-full border px-3 py-2 rounded">
<option value="">-- Select Category --</option>
<option v-for="cat in categories" :key="cat.id" :value="cat.id">{{ cat.name }}</option>
</select>
</div>
<div>
<label class="block font-medium">Price</label>
<input v-model="form.price" type="number" class="w-full border px-3 py-2 rounded" />
<div v-if="errors.price" class="text-red-500 text-sm">{{ errors.price }}</div>
</div>
<div>
<label class="block font-medium">Stock</label>
<input v-model="form.stock" type="number" class="w-full border px-3 py-2 rounded" />
<div v-if="errors.stock" class="text-red-500 text-sm">{{ errors.stock }}</div>
</div>
<div>
<label class="block font-medium">Description</label>
<textarea v-model="form.description" class="w-full border px-3 py-2 rounded" rows="4"></textarea>
<div v-if="errors.description" class="text-red-500 text-sm">{{ errors.description }}</div>
</div>
<!-- Menampilkan gambar lama -->
<div v-if="product.images && product.images.length > 0">
<h2 class="font-medium mb-2">Current Images:</h2>
<div class="flex space-x-2">
<div v-for="img in product.images" :key="img.id" class="relative">
<img :src="`/storage/${img.image}`" class="w-16 h-16 object-cover rounded border" />
<!-- Tambahkan tombol delete jika ingin menghapus gambar lama -->
</div>
</div>
</div>
<!-- Input untuk gambar baru -->
<div>
<label class="block font-medium">Add More Images</label>
<input type="file" multiple @change="handleFiles" class="w-full border px-3 py-2 rounded" />
<div v-if="errors['images']" class="text-red-500 text-sm">{{ errors['images'] }}</div>
<div v-if="errors['images.*']" class="text-red-500 text-sm">{{ errors['images.*'] }}</div>
</div>
<!-- Preview gambar baru yang akan diupload -->
<div v-if="newImagesPreview.length > 0">
<h2 class="font-medium mb-2">New Images Preview:</h2>
<div class="flex space-x-2">
<div v-for="(imgSrc, index) in newImagesPreview" :key="index">
<img :src="imgSrc" class="w-16 h-16 object-cover rounded border" />
</div>
</div>
</div>
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Update</button>
</form>
</div>
</main>
</div>
</template>
<script>
import { ref } from 'vue';
import { useForm,Link } from '@inertiajs/inertia-vue3';
export default {
props: {
product: {
type: Object,
default: () => ({
images: [] // Pastikan jika images tidak ada, default array
})
},
categories: {
type: Array,
default: () => []
}
},
components: {
Link
},
setup(props) {
const form = useForm({
name: props.product.name || '',
slug: props.product.slug || '',
price: props.product.price || '',
stock: props.product.stock || '',
description: props.product.description || '',
category_id: props.product.category_id || '',
images: [] // untuk file baru yang diupload
});
const errors = form.errors;
const newImagesPreview = ref([]);
const handleFiles = (e) => {
const files = Array.from(e.target.files);
if (files && files.length > 0) {
form.images = files;
// Buat preview untuk masing-masing file
newImagesPreview.value = files.map(file => URL.createObjectURL(file));
} else {
form.images = [];
newImagesPreview.value = [];
}
};
const submit = () => {
form.transform((data) => {
data._method = 'PUT'; // Gunakan PUT untuk update
return data;
});
form.post(`/products/${props.product.id}`, {
onSuccess: () => {
alert('Product updated successfully with multiple images!');
}
});
};
return { form, handleFiles, submit, errors, newImagesPreview };
},
};
</script>
Step 5: Buat tabel Product_images
Kita akan menyimpan banyak gambar dalam tabel product_images, yang berisi product_id (relasi ke products), serta kolom image.
Buat migrasi:
php artisan make:migration create_product_images_table
Edit file migrasi database/migrations/xxxx_xx_xx_create_product_images_table.php:
public function up()
{
Schema::create('product_images', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('product_id');
$table->string('image');
$table->timestamps();
$table->foreign('product_id')->references('id')->on('products')->onDelete('cascade');
});
}
Kemudian lakukan migrasi:
php artisan migrate
Step 6: Test Aplikasi
Jalankan perintah:
php artisan serve
npm run dev
Lalu akses URL
http://localhost:8000/products/create
Setelah berhasil insert product baru. akan seperti berikut: