Laravel and Vue.js: Parent-Child (Master-Detail) Form Example

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.

Create Form

Edit Form

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...

Like our articles?

Become a Premium Member for $129/year or $29/month
What else you will get:
  • 59 courses (1057 lessons, total 42 h 44 min)
  • 78 long-form tutorials (one new every week)
  • access to project repositories
  • access to private Discord

Recent Premium Tutorials