In this article, we’ll build the invoicing app using Laravel 9 VILT (Vue, Inertia, Laravel, Tailwind) stack. If you want to develop this invoicing app in more traditional way using Bootstrap and jQuery, then refer to previous couple of articles.
In order to continue here, you need to go through a couple of articles where we defined models, migrations and invoice template. Following are the links to those previous articles which are prerequisite before proceeding ahead in this article:
- Invoicing app in Laravel 9 – migrations and models
- Invoicing app in Laravel 9 – invoice template using dompdf
By following through the above articles, we have got database along with models and invoice template ready to roll. Now, we’ll create Laravel 9 CRUD (Create, Read, Update, Delete) operations for Customer and Item models using VILT stack.
So without further ado, let’s enter following command inside project’s parent folder in order to add Breeze package:
composer require laravel/breeze:* --dev
Then, execute following command in order to install Breeze with VILT based scaffolding:
php artisan breeze:install vue
Now, we’ll create controllers and views for Customer and Item models. So, execute following command in parent folder to create CustomerController.php:
php artisan make:controller CustomerController --model=Customer --resource
Similarly, execute following command to create ItemController.php;
php artisan make:controller ItemController --model=Item --resource
Then, open app/Http/Controllers/CustomerController.php and amend it as follows in order to define basic CRUD (Create, Read, Update, Delete) functions;
<?php
namespace App\Http\Controllers;
use App\Models\Customer;
use Illuminate\Http\Request;
use Inertia\Inertia;
class CustomerController extends Controller
{
public function index()
{
$customers = Customer::all();
return Inertia::render('Customers/Index', ['customers' => $customers]);
}
public function create()
{
return Inertia::render('Customers/Create');
}
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string',
'email' => 'nullable|string',
'phone' => 'nullable|string',
'address' => 'nullable|string',
]);
$item = Customer::create($validated);
return redirect('customers')->with('success', 'Customer created.');
}
public function show(Customer $customer)
{
}
public function edit(Customer $customer)
{
return Inertia::render('Customers/Edit', [
'customer' => [
'id' => $customer->id,
'name' => $customer->name,
'email' => $customer->email,
'phone' => $customer->phone,
'address' => $customer->address,
],
]);
}
public function update(Request $request, Customer $customer)
{
$validated = $request->validate([
'name' => 'required|string',
'email' => 'nullable|string',
'phone' => 'nullable|string',
'address' => 'nullable|string',
]);
$customer->update($validated);
return redirect('customers')->with('success', 'Customer updated.');
}
public function destroy(Customer $customer)
{
$customer->delete();
return back()->with('success', 'Customer deleted.');
}
}
Similarly, open app/Http/Controllers/ItemController.php and amend it as follows;
<?php
namespace App\Http\Controllers;
use App\Models\Item;
use Illuminate\Http\Request;
use Inertia\Inertia;
class ItemController extends Controller
{
public function index()
{
$items = Item::all();
return Inertia::render('Items/Index', ['items' => $items]);
}
public function create()
{
return Inertia::render('Items/Create');
}
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string',
'unit' => 'nullable|string',
'description' => 'nullable|string',
'sale_rate' => 'required|decimal:0,2',
'purchase_rate' => 'required|decimal:0,2',
'quantity' => 'required|numeric',
]);
$item = Item::create($validated);
return redirect('items')->with('success', 'Item created.');
}
public function show(Item $item)
{
}
public function edit(Item $item)
{
return Inertia::render('Items/Edit', [
'item' => [
'id' => $item->id,
'name' => $item->name,
'unit' => $item->unit,
'description' => $item->description,
'sale_rate' => $item->sale_rate,
'purchase_rate' => $item->purchase_rate,
'quantity' => $item->quantity,
],
]);
}
public function update(Request $request, Item $item)
{
$validated = $request->validate([
'name' => 'required|string',
'unit' => 'nullable|string',
'description' => 'nullable|string',
'sale_rate' => 'required|decimal:0,2',
'purchase_rate' => 'required|decimal:0,2',
'quantity' => 'required|numeric',
]);
$item->update($validated);
return redirect('items')->with('success', 'Item updated.');
}
public function destroy(Item $item)
{
$item->delete();
return back()->with('success', 'Item deleted.');
}
}
In order to define routes, open routes/web.php and put the following lines along with other ‘use‘ statements;
use App\Http\Controllers\CustomerController;
use App\Http\Controllers\ItemController;
And then, down somewhere into this routes/web.php, insert following new routes ;
Route::resource('customers', CustomerController::class)->middleware(['auth']);
Route::resource('items', ItemController::class)->middleware(['auth']);
Next, embed ‘flash‘ messaging in Inertia requests by changing share() function inside app/Http/Middleware/HandleInertiaRequests.php as follows (//… represents other code in the file):
//...
public function share(Request $request): array
{
return array_merge(parent::share($request), [
//...
'flash' => function () use ($request) {
return [
'success' => $request->session()->get('success'),
'error' => $request->session()->get('error'),
];
},
//...
We’re done with the controllers and routing. Now let’s create views using Vue templates.
First, create folders Customers and Items inside resources/js/Pages. Being at parent folder level, you may execute following command to make these folders;
mkdir resources/js/Pages/{Customers,Items}
Now create following three files inside each of the above created folders:
- Index.vue
- Create.vue
- Edit.vue
You can execute following command inside parent folder to create these files in one go:
touch resources/js/Pages/{Customers,Items}/{Index,Create,Edit}.vue
Now, go inside resources/js/Pages/Customers and open Index.vue. Then insert following code inside resources/js/Pages/Customers/Index.vue:
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import PrimaryButton from '@/Components/PrimaryButton.vue'
import SecondaryButton from '@/Components/SecondaryButton.vue'
import DangerButton from '@/Components/DangerButton.vue'
import { router, Head } from '@inertiajs/vue3'
const props = defineProps({
customers: Object,
})
const create = () => {
router.visit(route('customers.create'))
}
const edit = (id) => {
router.visit(route('customers.edit', id))
}
const destroy = (id) => {
router.visit(route('customers.destroy', id), {method: 'delete'})
}
</script>
<template>
<AuthenticatedLayout>
<Head title="Customers" />
<h1 class="pl-6 py-2 text-3xl font-bold bg-gray-500 text-white">Customers</h1>
<div v-if="$page.props.flash.success" class="bg-gray-300 font-bold text-blue-900 p-1">
{{ $page.props.flash.success }}
</div>
<div class="m-4">
<PrimaryButton @click="create()">Create customer</PrimaryButton>
</div>
<div class="bg-white rounded-md shadow overflow-x-auto">
<table class="w-full whitespace-nowrap">
<thead>
<tr class="bg-gray-500 text-white font-extrabold">
<th class="px-4 py-1 border">Name</th>
<th class="px-4 py-1 border">Email</th>
<th class="px-4 py-1 border">Phone</th>
<th class="px-4 py-1 border">Address</th>
<th class="px-4 py-1 border">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="customer in customers" :key="customer.id">
<td class="px-4 py-1 border">{{customer.name}}</td>
<td class="px-4 py-1 border">{{customer.email}}</td>
<td class="px-4 py-1 border">{{customer.phone}}</td>
<td class="px-4 py-1 border">{{customer.address}}</td>
<td class="px-4 py-1 border">
<SecondaryButton @click="edit(customer.id)" class="mr-4">
Edit
</SecondaryButton>
<DangerButton @click="destroy(customer.id)">
Delete
</DangerButton>
</td>
</tr>
</tbody>
</table>
</div>
</AuthenticatedLayout>
</template>
Then put following code inside resources/js/Pages/Customers/Create.vue:
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import InputError from '@/Components/InputError.vue'
import InputLabel from '@/Components/InputLabel.vue'
import TextInput from '@/Components/TextInput.vue'
import PrimaryButton from '@/Components/PrimaryButton.vue'
import { Head, useForm } from '@inertiajs/vue3'
const form = useForm({
name: null,
email: null,
phone: null,
address: null,
})
</script>
<template>
<AuthenticatedLayout>
<Head title="Customers" />
<h1 class="pl-6 py-2 text-3xl font-bold bg-gray-500 text-white">Customers</h1>
<div class="min-h-screen flex flex-col sm:justify-start items-center pt-6 sm:pt-0 bg-gray-100">
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white shadow-md overflow-hidden sm:rounded-lg">
<form @submit.prevent="form.post(route('customers.store'))">
<div>
<InputLabel for="name" value="Name" />
<TextInput id="name" v-model="form.name" type="text" class="mt-1 block w-full" autofocus/>
<InputError class="mt-2" :message="form.errors.name" />
</div>
<div>
<InputLabel for="email" value="Email" />
<TextInput id="email" v-model="form.email" type="text" class="mt-1 block w-full"/>
<InputError class="mt-2" :message="form.errors.email" />
</div>
<div>
<InputLabel for="phone" value="Phone" />
<TextInput id="phone" v-model="form.phone" type="text" class="mt-1 block w-full"/>
<InputError class="mt-2" :message="form.errors.phone" />
</div>
<div>
<InputLabel for="address" value="Address" />
<TextInput id="address" v-model="form.address" type="text" class="mt-1 block w-full"/>
<InputError class="mt-2" :message="form.errors.address" />
</div>
<div class="flex items-center justify-end mt-4">
<PrimaryButton type="submit" class="ml-4" :class="{ 'opacity-25': form.processing }" :disabled="form.processing">
Create Customer
</PrimaryButton>
</div>
</form>
</div>
</div>
</AuthenticatedLayout>
</template>
And then insert following code into resources/js/Pages/Customers/Edit.vue:
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import InputError from '@/Components/InputError.vue'
import InputLabel from '@/Components/InputLabel.vue'
import TextInput from '@/Components/TextInput.vue'
import PrimaryButton from '@/Components/PrimaryButton.vue'
import { Head, useForm } from '@inertiajs/vue3'
const props = defineProps({
customer: Object,
})
const form = useForm({
name: props.customer.name,
email: props.customer.email,
phone: props.customer.phone,
address: props.customer.address,
})
</script>
<template>
<AuthenticatedLayout>
<Head title="Customers" />
<h1 class="pl-6 py-2 text-3xl font-bold bg-gray-500 text-white">Customers</h1>
<div class="min-h-screen flex flex-col sm:justify-start items-center pt-6 sm:pt-0 bg-gray-100">
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white shadow-md overflow-hidden sm:rounded-lg">
<form @submit.prevent="form.put(route('customers.update', customer.id))">
<div>
<InputLabel for="name" value="Name" />
<TextInput id="name" v-model="form.name" type="text" class="mt-1 block w-full" autofocus/>
<InputError class="mt-2" :message="form.errors.name" />
</div>
<div>
<InputLabel for="email" value="Email" />
<TextInput id="email" v-model="form.email" type="text" class="mt-1 block w-full"/>
<InputError class="mt-2" :message="form.errors.email" />
</div>
<div>
<InputLabel for="phone" value="Phone" />
<TextInput id="phone" v-model="form.phone" type="text" class="mt-1 block w-full"/>
<InputError class="mt-2" :message="form.errors.phone" />
</div>
<div>
<InputLabel for="address" value="Address" />
<TextInput id="address" v-model="form.address" type="text" class="mt-1 block w-full"/>
<InputError class="mt-2" :message="form.errors.address" />
</div>
<div class="flex items-center justify-end mt-4">
<PrimaryButton type="submit" class="ml-4" :class="{ 'opacity-25': form.processing }" :disabled="form.processing">
Update Customer
</PrimaryButton>
</div>
</form>
</div>
</div>
</AuthenticatedLayout>
</template>
Similarly, go inside resources/js/Pages/Items and open Index.vue. Then, insert following code inside resources/js/Pages/Items/Index.vue:
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import PrimaryButton from '@/Components/PrimaryButton.vue'
import SecondaryButton from '@/Components/SecondaryButton.vue'
import DangerButton from '@/Components/DangerButton.vue'
import { router, Head } from '@inertiajs/vue3'
const props = defineProps({
items: Object,
})
const create = () => {
router.visit(route('items.create'))
}
const edit = (id) => {
router.visit(route('items.edit', id))
}
const destroy = (id) => {
router.visit(route('items.destroy', id), {method: 'delete'})
}
</script>
<template>
<AuthenticatedLayout>
<Head title="Items" />
<h1 class="pl-6 py-2 text-3xl font-bold bg-gray-500 text-white">Items</h1>
<div v-if="$page.props.flash.success" class="bg-gray-300 font-bold text-blue-900 p-1">
{{ $page.props.flash.success }}
</div>
<div class="m-4">
<PrimaryButton @click="create()">Create item</PrimaryButton>
</div>
<div class="bg-white rounded-md shadow overflow-x-auto">
<table class="w-full whitespace-nowrap">
<thead>
<tr class="bg-gray-500 text-white font-extrabold">
<th class="px-4 py-1 border">Name</th>
<th class="px-4 py-1 border">Unit</th>
<th class="px-4 py-1 border">Description</th>
<th class="px-4 py-1 border">Sale rate</th>
<th class="px-4 py-1 border">Purchase rate</th>
<th class="px-4 py-1 border">Quantity</th>
<th class="px-4 py-1 border">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.id">
<td class="px-4 py-1 border">{{item.name}}</td>
<td class="px-4 py-1 border">{{item.unit}}</td>
<td class="px-4 py-1 border">{{item.description}}</td>
<td class="px-4 py-1 border">{{item.sale_rate}}</td>
<td class="px-4 py-1 border">{{item.purchase_rate}}</td>
<td class="px-4 py-1 border">{{item.quantity}}</td>
<td class="px-4 py-1 border">
<SecondaryButton @click="edit(item.id)" class="mr-4">
Edit
</SecondaryButton>
<DangerButton @click="destroy(item.id)">
Delete
</DangerButton>
</td>
</tr>
</tbody>
</table>
</div>
</AuthenticatedLayout>
</template>
Then, put following code inside resources/js/Pages/Items/Create.vue:
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import InputError from '@/Components/InputError.vue'
import InputLabel from '@/Components/InputLabel.vue'
import TextInput from '@/Components/TextInput.vue'
import PrimaryButton from '@/Components/PrimaryButton.vue'
import { Head, useForm } from '@inertiajs/vue3'
const form = useForm({
name: null,
unit: null,
description: null,
sale_rate: null,
purchase_rate: null,
quantity: null,
})
</script>
<template>
<AuthenticatedLayout>
<Head title="Items" />
<h1 class="pl-6 py-2 text-3xl font-bold bg-gray-500 text-white">Items</h1>
<div class="min-h-screen flex flex-col sm:justify-start items-center pt-6 sm:pt-0 bg-gray-100">
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white shadow-md overflow-hidden sm:rounded-lg">
<form @submit.prevent="form.post(route('items.store'))">
<div>
<InputLabel for="name" value="Name" />
<TextInput id="name" v-model="form.name" type="text" class="mt-1 block w-full" autofocus/>
<InputError class="mt-2" :message="form.errors.name" />
</div>
<div>
<InputLabel for="unit" value="Unit" />
<TextInput id="unit" v-model="form.unit" type="text" class="mt-1 block w-full"/>
<InputError class="mt-2" :message="form.errors.unit" />
</div>
<div>
<InputLabel for="description" value="Description" />
<TextInput id="description" v-model="form.description" type="text" class="mt-1 block w-full"/>
<InputError class="mt-2" :message="form.errors.description" />
</div>
<div>
<InputLabel for="sale_rate" value="Sale rate" />
<TextInput id="sale_rate" v-model="form.sale_rate" type="text" class="mt-1 block w-full"/>
<InputError class="mt-2" :message="form.errors.sale_rate" />
</div>
<div>
<InputLabel for="purchase_rate" value="Purchase rate" />
<TextInput id="purchase_rate" v-model="form.purchase_rate" type="text" class="mt-1 block w-full"/>
<InputError class="mt-2" :message="form.errors.purchase_rate" />
</div>
<div>
<InputLabel for="quantity" value="Quantity" />
<TextInput id="quantity" v-model="form.quantity" type="text" class="mt-1 block w-full"/>
<InputError class="mt-2" :message="form.errors.quantity" />
</div>
<div class="flex items-center justify-end mt-4">
<PrimaryButton type="submit" class="ml-4" :class="{ 'opacity-25': form.processing }" :disabled="form.processing">
Create item
</PrimaryButton>
</div>
</form>
</div>
</div>
</AuthenticatedLayout>
</template>
And then, insert following code into resources/js/Pages/Items/Edit.vue:
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import InputError from '@/Components/InputError.vue'
import InputLabel from '@/Components/InputLabel.vue'
import TextInput from '@/Components/TextInput.vue'
import PrimaryButton from '@/Components/PrimaryButton.vue'
import { Head, useForm } from '@inertiajs/vue3'
const props = defineProps({
item: Object,
})
const form = useForm({
name: props.item.name,
unit: props.item.unit,
description: props.item.description,
sale_rate: props.item.sale_rate,
purchase_rate: props.item.purchase_rate,
quantity: props.item.quantity,
})
</script>
<template>
<AuthenticatedLayout>
<Head title="Items" />
<h1 class="pl-6 py-2 text-3xl font-bold bg-gray-500 text-white">Items</h1>
<div class="min-h-screen flex flex-col sm:justify-start items-center pt-6 sm:pt-0 bg-gray-100">
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white shadow-md overflow-hidden sm:rounded-lg">
<form @submit.prevent="form.put(route('items.update', item.id))">
<div>
<InputLabel for="name" value="Name" />
<TextInput id="name" v-model="form.name" type="text" class="mt-1 block w-full" autofocus/>
<InputError class="mt-2" :message="form.errors.name" />
</div>
<div>
<InputLabel for="unit" value="Unit" />
<TextInput id="unit" v-model="form.unit" type="text" class="mt-1 block w-full"/>
<InputError class="mt-2" :message="form.errors.unit" />
</div>
<div>
<InputLabel for="description" value="Description" />
<TextInput id="description" v-model="form.description" type="text" class="mt-1 block w-full" autofocus/>
<InputError class="mt-2" :message="form.errors.description" />
</div>
<div>
<InputLabel for="sale_rate" value="Sale rate" />
<TextInput id="sale_rate" v-model="form.sale_rate" type="text" class="mt-1 block w-full"/>
<InputError class="mt-2" :message="form.errors.sale_rate" />
</div>
<div>
<InputLabel for="purchase_rate" value="Purchase rate" />
<TextInput id="purchase_rate" v-model="form.purchase_rate" type="text" class="mt-1 block w-full"/>
<InputError class="mt-2" :message="form.errors.purchase_rate" />
</div>
<div>
<InputLabel for="quantity" value="Quantity" />
<TextInput id="quantity" v-model="form.quantity" type="text" class="mt-1 block w-full"/>
<InputError class="mt-2" :message="form.errors.quantity" />
</div>
<div class="flex items-center justify-end mt-4">
<PrimaryButton type="submit" class="ml-4" :class="{ 'opacity-25': form.processing }" :disabled="form.processing">
Update item
</PrimaryButton>
</div>
</form>
</div>
</div>
</AuthenticatedLayout>
</template>
Finally, compile client side assets (JS and CSS files) by executing following command;
npm run build
You may run PHP dev server by executing following command in order to check what you have accomplished so far:
php artisan serv
Enter the following URL in your browser to access index of Items (don’t forget to create a user login first):
localhost:8000/items
Thankfully, we’re done with the boring part; we’ll continue in Part 2, where the real fun begins…
1 thought on “Invoicing app in Laravel 9 using VILT stack – part 1”