Let's say you have an Eloquent Model with many "children": invoice with items, posts with tags, products with colors and sizes. You need to create a parent-child form (sometimes also called "master-detail") with Laravel and Vue.js. This article will guide you on how to do it.
As a starter, we have installed Laravel with the Laravel Breeze Vue.js preset which also use Inertia.
Now, we will guide you on how to create such forms.
Migrations and Models
Create Migrations for Invoice and InvoiceItem Models.
database/migrations/2023_09_30_141014_create_invoices_table.php
// ... Schema::create('invoices', function (Blueprint $table) { $table->id(); $table->timestamps();});
database/migrations/2023_09_30_141017_create_invoice_items_table.php
// ... Schema::create('invoice_items', function (Blueprint $table) { $table->id(); $table->foreignIdFor(Invoice::class)->constrained(); $table->string('product')->nullable(); $table->string('quantity')->nullable(); $table->string('price')->nullable(); $table->timestamps();});
Create Models.
app/Models/Invoice.php
namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory;use Illuminate\Database\Eloquent\Model;use Illuminate\Database\Eloquent\Relations\HasMany; class Invoice extends Model{ use HasFactory; public function items(): HasMany { return $this->hasMany(InvoiceItem::class); }}
app/Models/InvoiceItem.php
namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory;use Illuminate\Database\Eloquent\Model;use Illuminate\Database\Eloquent\Relations\BelongsTo; class InvoiceItem extends Model{ use HasFactory; protected $fillable = ['product', 'quantity', 'price']; public function invoice(): BelongsTo { return $this->belongsTo(Invoice::class); }}
API
Let's implement API endpoints for storing and updating invoices and their items.
app/Http/Controllers/Api/InvoiceController.php
namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller;use App\Models\Invoice;use Illuminate\Http\Request;use Illuminate\Support\Facades\DB; class InvoiceController extends Controller{ public function store(Request $request) { return Invoice::create()->items()->createMany($request->items); } public function update(Invoice $invoice, Request $request) { return DB::transaction(function () use ($invoice, $request) { $invoice->items()->delete(); return $invoice->items()->createMany($request->items); }); }}
The easiest way to update all children's items without complex comparisons and logic is to delete them and create new entries. We can ensure that we won't lose all records in case createMany()
fails by putting delete()
and createMany()
operations in a transaction.
Now, define API routes.
routes/api.php
use App\Http\Controllers\Api\InvoiceController; Route::post('/invoices', [InvoiceController::class, 'store']);Route::put('/invoices/{invoice}', [InvoiceController::class, 'update']);
Pages and Web Routes
Create an Invoice/Create
page.
resources/js/Pages/Invoice/Create.vue
<script setup>import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';import { Head } from '@inertiajs/vue3';import { ref, reactive, onMounted } from 'vue'; const message = ref('') const invoice = reactive({ items: []}); const add = () => { invoice.items.push({ product: '', quantity: '', price: '' })} onMounted(add) const save = () => { axios.post('/api/invoices', invoice) .then(() => message.value = 'Saved.')}</script> <template> <Head title="Invoice Create" /> <AuthenticatedLayout> <template #header> <h2 class="font-semibold text-xl text-gray-800 leading-tight">Invoice Create</h2> </template> <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 v-if="message" class="p-6 text-gray-900"> <span class="text-green-600">{{ message }}</span> </div> <div v-else class="p-6 text-gray-900"> <div v-if="invoice.items.length > 0" class="flex flex-col gap-3"> <div v-for="(item, index) in invoice.items" :key="index" class="flex flex-row gap-2"> <div class="grow"> <label class="text-sm">Product</label> <input type="text" v-model="item.product" class="rounded border-gray-300 w-full"> </div> <div> <label class="text-sm">Quantity</label> <input type="text" v-model="item.quantity" class="rounded border-gray-300 w-full"> </div> <div class=""> <label class="text-sm">Price</label> <input type="text" v-model="item.price" class="rounded border-gray-300 w-full"> </div> <button @click="invoice.items.splice(index, 1)" type="button" class="bg-gray-100 text-red-600 rounded w-11 h-11 self-end flex items-center justify-center" > <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6"> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> </svg> </button> </div> </div> <button @click="add" type="button" class="text-blue-600 border border-blue-600 px-3 py-2 rounded mt-4 font-semibold"> Add new item </button> <button @click="save" type="button" class="bg-blue-600 text-white px-3 py-2 rounded mt-4 font-semibold ml-2"> Save </button> </div> </div> </div> </div> </AuthenticatedLayout></template>
The invoice
variable will hold all entries in an items
array. The add
function will push a new item into the collection. We also do this once when the page is loaded by defining onMounted(add)
and having one empty entry ready to be filled in.
Create an Invoice/Edit
page.
resources/js/Pages/Invoice/Edit.vue
<script setup>import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';import { Head, usePage } from '@inertiajs/vue3';import { ref } from 'vue'; const message = ref('')const page = usePage()const invoice = page.props.invoice defineProps({ invoice: { type: Object }}) const add = () => { invoice.items.push({ product: '', quantity: '', price: '' })} const update = () => { axios.put(`/api/invoices/${ invoice.id }`, invoice) .then(() => message.value = 'Updated.')}</script> <template> <Head :title="'Update Invoice #'+invoice.id" /> <AuthenticatedLayout> <template #header> <h2 class="font-semibold text-xl text-gray-800 leading-tight"> Update Invoice #{{ invoice.id }} </h2> </template> <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 v-if="message" class="p-6 text-gray-900"> <span class="text-green-600">{{ message }}</span> </div> <div v-else class="p-6 text-gray-900"> <div v-if="invoice.items.length > 0" class="flex flex-col gap-3"> <div v-for="(item, index) in invoice.items" :key="index" class="flex flex-row gap-2"> <div class="grow"> <label class="text-sm">Product</label> <input type="text" v-model="item.product" class="rounded border-gray-300 w-full"> </div> <div> <label class="text-sm">Quantity</label> <input type="text" v-model="item.quantity" class="rounded border-gray-300 w-full"> </div> <div class=""> <label class="text-sm">Price</label> <input type="text" v-model="item.price" class="rounded border-gray-300 w-full"> </div> <button @click="invoice.items.splice(index, 1)" type="button" class="bg-gray-100 text-red-600 rounded w-11 h-11 self-end flex items-center justify-center" > <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6"> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> </svg> </button> </div> </div> <button @click="add" type="button" class="text-blue-600 border border-blue-600 px-3 py-2 rounded mt-4 font-semibold"> Add new item </button> <button @click="update" type="button" class="bg-blue-600 text-white px-3 py-2 rounded mt-4 font-semibold ml-2"> Update </button> </div> </div> </div> </div> </AuthenticatedLayout></template>
The edit page is slightly different. Instead of defining the invoice
variable, we have the invoice
property. We will pass the actual invoice with the data from the backend.
Define routes for these pages.
routes/web.php
use App\Models\Invoice; Route::get('/invoices/create', function () { return Inertia::render('Invoice/Create');})->middleware(['auth', 'verified'])->name('invoice.create'); Route::get('/invoices/{invoice}/edit', function (Invoice $invoice) { // Here, we load all items of the invoice $invoice->load('items'); return Inertia::render('Invoice/Edit', // And pass it to the page ['invoice' => $invoice] );})->middleware(['auth', 'verified'])->name('invoice.update');
And build the project by running the NPM command.
npm run build
Now, you can easily edit hasMany
relationships and start implementing more features.
No comments or questions yet...