Dynamic Add & Remove Row in Laravel: A Complete CRUD Guide

Here's a step-by-step guide to help you complete your Laravel project where you dynamically add and remove ingredients for a recipe, with proper models, tables, and CRUD operations.
✅ Step 1: Create Migration Files
Run these Artisan commands to generate your migrations:
php artisan make:model Recipe -m
php artisan make:model Ingredient -m
In create_recipes_table.php
:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('recipes', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('recipes');
}
};
In create_ingredients_table.php
:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('ingredients', function (Blueprint $table) {
$table->id();
$table->foreignId('recipe_id')->constrained('recipes')->onDelete('cascade');
$table->string('name');
$table->decimal('quantity', 8, 2);
$table->string('unit');
$table->string('image');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('ingredients');
}
};
Run the migrations:
php artisan migrate
✅ Step 2: Create Models (Already Done)
Your Recipe
and Ingredient
models are correct ✅
Recipe
model:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Recipe extends Model
{
use HasFactory;
protected $fillable = [
'name',
];
public function ingredients()
{
return $this->hasMany(Ingredient::class);
}
}
ingredient model:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Ingredient extends Model
{
use HasFactory;
protected $fillable = [
'recipe_id',
'name',
'quantity',
'unit',
'image'
];
public function recipe()
{
return $this->belongsTo(Recipe::class);
}
}
✅ Step 3: Create Controller
php artisan make:controller RecipeController -r
✅ Step 4: Setup Routes
In routes/web.php
:
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\RecipeController;
/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider and all of them will
| be assigned to the "web" middleware group. Make something great!
|
*/
Route::get('recipe/fetch', [RecipeController::class, 'fetch'])->name('recipe.fetch');
Route::resource('recipe', RecipeController::class);
✅ Step 5: Create Blade Views
resources/views/layouts/app.blade.php
<!DOCTYPE html>
<html>
<head>
<title>@yield('title', 'Recipe App')</title>
<meta name="csrf-token" content="{{ csrf_token() }}">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" />
<link href="https://cdn.datatables.net/1.13.5/css/jquery.dataTables.min.css" rel="stylesheet" />
<link href="https://cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/toastr.min.css" rel="stylesheet" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
@stack('styles')
</head>
<body>
<div class="container mt-5">
@yield('content')
</div>
<script src="https://code.jquery.com/jquery-3.7.0.min.js"></script>
<script src="http://ajax.aspnetcdn.com/ajax/jquery.validate/1.11.1/jquery.validate.min.js"></script>
<script src="https://cdn.datatables.net/1.13.5/js/jquery.dataTables.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/toastr.min.js"></script>
<script>
@if (session('success'))
toastr.success("{{ session('success') }}");
@endif
@if (session('error'))
toastr.error("{{ session('error') }}");
@endif
</script>
@yield('scripts')
</body>
</html>
resources/views/recipes/index.blade.php
@extends('layouts.app')
@section('title', 'Recipe List')
@section('content')
<h2>Recipe</h2>
<a href="{{ route('recipe.create') }}" class="btn btn-primary mb-3">Create Recipe</a>
<table id="recipeTable" class="table table-bordered">
<thead>
<tr>
<th>Name</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
@endsection
@section('scripts')
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>
$(document).ready(function() {
$.ajaxSetup({
headers: {
'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
}
});
var table = $('#recipeTable').DataTable({
ajax: "{{ route('recipe.fetch') }}",
columns: [{
data: 'name'
},
{
data: 'id',
orderable: false,
searchable: false,
render: function(id) {
return `
<a href="/recipe/${id}/edit" class="btn btn-sm btn-info">Edit</a>
<button class="btn btn-sm btn-danger delete-btn" data-id="${id}">Delete</button>
`;
}
}
]
});
$('#recipeTable tbody').on('click', '.delete-btn', function() {
let id = $(this).data('id');
Swal.fire({
title: 'Are you sure?',
text: "You won't be able to revert this!",
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#3085d6',
cancelButtonColor: '#d33',
confirmButtonText: 'Yes, delete it!',
cancelButtonText: 'Cancel'
}).then((result) => {
if (result.isConfirmed) {
$.ajax({
url: `/recipe/${id}`,
method: 'DELETE',
success: function() {
toastr.success('Deleted successfully');
table.ajax.reload(null, false);
},
error: function() {
toastr.error('Delete failed');
}
});
}
});
});
});
</script>
@endsection
resources/views/recipes/upsert.blade.php
@extends('layouts.app')
@section('style')
<style>
.SimpleimageBox {
width: 150px;
height: 150px;
border-radius: 20px;
}
</style>
@endsection
@section('content')
<div class="container-fluid ">
<section class="content-header">
<div class="container-fluid">
<div class="row mb-2">
<div class="col-sm-6">
<h1 class="page-header-title">
@if (@$recipe)
Update
@else
Create
@endif Recipe
</h1>
</div>
</div>
</div>
</section>
<form
action="@if (@$recipe->id) {{ route('recipe.update', ['recipe' => @$recipe->id]) }} @else {{ route('recipe.store') }} @endif "
method="post" enctype="multipart/form-data" id="recipeForm" name="recipeForm">
@csrf
@if (@$recipe->id)
@method('PUT')
@endif
<div class="card col-md-12">
<div class="card-body">
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="name">Name <span class="validation">*</span></label>
<input type="text" name="name" class="form-control" id="name" maxlength="35"
placeholder="Title" value="{{ @$recipe->name }}">
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-md-12">
<div class="table-responsive">
<table class="table table-bordered text-nowrap border-bottom" id="imageTable">
<thead>
<tr>
<th class="wd-20p border-bottom-0">Name <span class="validation">*</span></th>
<th class="wd-20p border-bottom-0">QTY <span class="validation">*</span></th>
<th class="wd-20p border-bottom-0">Unit <span class="validation">*</span></th>
<th class="wd-20p border-bottom-0"></th>
<th class="wd-20p border-bottom-0">Image <span class="validation">*</span></th>
<th class="wd-20p border-bottom-0">Action</th>
</tr>
</thead>
<tbody>
@if (count(@$recipe_ingredient) > 0)
@foreach (@$recipe_ingredient as $r_key => $ri)
<tr class="addImageData">
<input type="hidden" name="recipe_id[]" class="recipe_id"
id="recipe_id_{{ $r_key }}" value="{{ $ri['id'] }}">
<td>
<input type="text" name="i_name[{{ $r_key }}]"
class="form-control i_name" placeholder="Enter name"
value="{{ $ri['name'] }}" id="i_name_{{ $r_key }}"
>
</td>
<td>
<input type="number" name="quantity[{{ $r_key }}]"
class="form-control quantity" placeholder="Enter QTY"
value="{{ $ri['quantity'] }}" id="quantity_{{ $r_key }}"
min="1">
</td>
<td>
<input type="number" name="unit[{{ $r_key }}]"
class="form-control unit" placeholder="Enter unit"
value="{{ $ri['unit'] }}" id="unit_{{ $r_key }}"
min="1">
</td>
<td>
<img src='{{ asset('storage/recipe/' . $ri->recipe_id . '/' . $ri->image) }}'
class='mb-1 image_append SimpleimageBox mt-3'
id="photo_{{ $r_key }}" width="100" height="100"/>
</td>
<td>
<input type="file" name="image[{{ $r_key }}]"
class="form-control imgInp" accept="image/*"
data-msg-accept="Please upload file in these format only (jpg, jpeg, png)."
data-icon_image_id="photo_{{ $r_key }}"
value="{{ $ri['path'] }}" />
</td>
<td>
@if ($r_key == 0)
<a id="add_image"><i
class="fa fa-plus-square fa-1x btn btn-success"
aria-hidden="true"></i></a>
@else
<a id="add_image"><i
class="fa fa-minus-square fa-1x btn btn-danger remove-image"
aria-hidden="true"></i></a>
@endif
</td>
</tr>
@endforeach
@else
<tr class="addImageData">
<input type="hidden" name="recipe_id[]" class="recipe_id" id="recipe_id">
<td>
<input type="text" name="i_name[0]" class="form-control i_name"
placeholder="Enter name" id="i_name_0">
</td>
<td>
<input type="number" name="quantity[0]" class="form-control quantity"
placeholder="Enter QTY" id="quantity_0">
</td>
<td>
<input type="number" name="unit[0]" class="form-control unit"
placeholder="Enter unit" id="unit_0">
</td>
<td>
<img src='{{ asset('storage/default/img.jpg') }}'
class='mb-1 image_append SimpleimageBox mt-2' id="photo_0"
width="100" height="100" />
</td>
<td>
<input type="file" name="image[0]" class="form-control imgInp"
accept="image/*"
data-msg-accept="Please upload file in these format only (jpg, jpeg, png)."
data-icon_image_id="photo_0" />
</td>
<td>
<a id="add_image">
<i class="fa fa-plus-square fa-1x btn btn-success"
aria-hidden="true">
</i>
</a>
</td>
</tr>
@endif
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-12">
<button type="submit" class="btn btn-primary float-right">
Submit
</button>
<a href="{{ URL::previous() }}" class="btn btn-warning float-right mr-2">Back</a>
</div>
</div>
</div>
</form>
</div>
@endsection
@section('scripts')
<script>
/**
* Rearranges the name and id attributes of form inputs in the table.
* This ensures that the form data is submitted as correctly indexed arrays.
*/
function rearrangeAttributes() {
$(".addImageData").each(function(index, value) {
// Update names for array submission
$(this).find('.i_name').attr('name', 'i_name[' + index + ']');
$(this).find('.quantity').attr('name', 'quantity[' + index + ']');
$(this).find('.unit').attr('name', 'unit[' + index + ']');
$(this).find('.imgInp').attr('name', 'image[' + index + ']');
$(this).find('.recipe_id').attr('name', 'recipe_id[' + index + ']');
// Update IDs for labels and JavaScript targeting
$(this).find('.i_name').attr('id', 'i_name_' + index);
$(this).find('.quantity').attr('id', 'quantity_' + index);
$(this).find('.unit').attr('id', 'unit_' + index);
$(this).find('.image_append').attr('id', 'photo_' + index);
$(this).find('.recipe_id').attr('id', 'recipe_id_' + index);
$(this).find('.imgInp').attr('data-icon_image_id', 'photo_' + index);
});
}
/**
* Adds and manages validation rules for all dynamic fields in the table.
* This function is called on page load and every time a new row is added.
*/
function setupDynamicValidators() {
// Custom validation method for file size (e.g., max 2MB)
$.validator.addMethod('filesizesimple', function(value, element, param) {
return this.optional(element) || (element.files[0].size <= param * 1000000);
}, 'File size must be less than {0} MB');
// Apply validation rules to each ingredient name input
$('.i_name').each(function() {
$(this).rules('add', {
required: true,
messages: {
required: "Please enter the ingredient name."
}
});
});
// Apply validation rules to each quantity input
$('.quantity').each(function() {
$(this).rules('add', {
required: true,
number: true,
min: 1,
messages: {
required: "Please enter a quantity.",
number: "Please enter a valid number.",
min: "Quantity must be at least 1."
}
});
});
// Apply validation rules to each unit input
$('.unit').each(function() {
$(this).rules('add', {
required: true,
number: true,
min: 1,
messages: {
required: "Please enter a unit.",
number: "Please enter a valid number.",
min: "Unit must be at least 1."
}
});
});
// Apply validation rules for each image input field
$('.imgInp').each(function(key) {
const recipeId = $('#recipe_id_' + key).val();
$(this).rules('add', {
required: function() {
// Image is required only if it's a new ingredient (no existing ID)
return !recipeId;
},
filesizesimple: 2, // 2 MB
messages: {
'required': 'Please upload an image.'
}
});
});
}
// Custom validator to ensure a field is not just whitespace
jQuery.validator.addMethod("noSpace", function(value, element) {
return value.trim().length > 0;
},
"This field is required and cannot be empty.");
$(document).ready(function() {
// Initialize form validation
$("#recipeForm").validate({
ignore: [], // Validate hidden fields if necessary
rules: {
name: {
required: true,
noSpace: true,
},
},
messages: {
name: {
required: "Please enter the recipe name.",
noSpace: "Recipe name cannot be empty."
},
},
errorElement: 'span',
// *** THIS IS THE CORRECTED PART ***
errorPlacement: function(error, element) {
error.addClass('invalid-feedback');
if (element.closest('td').length) {
// Place the error message inside the table cell
element.closest('td').append(error);
} else if (element.closest('.form-group').length) {
// Default placement for elements in a .form-group
element.closest('.form-group').append(error);
} else {
// Fallback
error.insertAfter(element);
}
},
highlight: function(element, errorClass, validClass) {
$(element).addClass('is-invalid');
},
unhighlight: function(element, errorClass, validClass) {
$(element).removeClass('is-invalid');
}
});
// Add a new ingredient row
$('#add_image').click(function() {
const newRowHtml = `
<tr class="addImageData">
<input type="hidden" name="recipe_id[]" class="recipe_id">
<td>
<input type="text" name="i_name[]" class="form-control i_name" placeholder="Enter name">
</td>
<td>
<input type="number" name="quantity[]" class="form-control quantity" placeholder="Enter QTY" min="1">
</td>
<td>
<input type="number" name="unit[]" class="form-control unit" placeholder="Enter unit" min="1">
</td>
<td>
<img src='{{ asset('storage/default/img.jpg') }}'
class='mb-1 image_append SimpleimageBox mt-2'
width="100" height="100" />
</td>
<td>
<input type="file" name="image[]" class="form-control imgInp"
accept="image/*"
data-msg-accept="Please upload file in these format only (jpg, jpeg, png).">
</td>
<td>
<a href="javascript:void(0);" class="remove-image">
<i class="fa fa-minus-square fa-1x btn btn-danger" aria-hidden="true"></i>
</a>
</td>
</tr>`;
$('#imageTable tbody').append(newRowHtml);
rearrangeAttributes();
setupDynamicValidators();
});
// Remove an ingredient row
$(document).on('click', '.remove-image', function() {
$(this).closest('tr').remove();
rearrangeAttributes();
});
// Preview image on file selection
$(document).on('change', '.imgInp', function(e) {
const data_id = $(this).data('icon_image_id');
if (e.target.files && e.target.files[0]) {
const reader = new FileReader();
reader.onload = function(e) {
$('#' + data_id).attr('src', e.target.result);
};
reader.readAsDataURL(e.target.files[0]);
}
// Trigger validation for the selected file input
$(this).valid();
});
// Initial setup of attributes and validators for existing rows
rearrangeAttributes();
setupDynamicValidators();
});
</script>
@endsection
✅ Step 6: Handle Store Logic in Controller
<?php
namespace App\Http\Controllers;
use App\Models\Recipe;
use App\Models\Ingredient;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
class RecipeController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
return view('recipe.index');
}
/**
* Show the form for creating a new resource.
*/
public function create()
{
$recipe_ingredient = [];
return view('recipe.upsert', compact('recipe_ingredient'));
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
$data = $request->all();
$recipe = Recipe::create($data);
// Create directory for the gallery if it doesn't exist
$recipeDirectory = public_path('storage/recipe/' . $recipe->id);
if (!file_exists($recipeDirectory)) {
mkdir($recipeDirectory, 0777, true);
}
if (!empty($data['image'])) {
foreach ($request->image as $i => $img) {
if ($request->hasFile("image.$i")) {
$pro_image = $request->file("image.$i");
$imageName = time() . '_' . uniqid() . '.' . $pro_image->getClientOriginalExtension();
$pro_image->move($recipeDirectory, $imageName);
$ingredient = Ingredient::create([
'recipe_id' => $recipe->id,
'name' => $request->i_name[$i],
'quantity' => $request->quantity[$i],
'unit' => $request->unit[$i],
'image' => $imageName,
]);
}
}
}
return redirect()->route('recipe.index')->with('success', 'Recipe has been successfully created.');
}
/**
* Display the specified resource.
*/
public function show(string $id)
{
//
}
/**
* Show the form for editing the specified resource.
*/
public function edit(string $id)
{
$recipe = Recipe::findOrFail($id);
$recipe_ingredient = Ingredient::where('recipe_id', $id)->get();
return view('recipe.upsert', compact('recipe', 'recipe_ingredient'));
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, string $id)
{
$data = $request->all();
$recipe = Recipe::findOrFail($id);
$recipe->update($data);
$path_gallery = public_path('storage/recipe/' . $id);
if (!file_exists($path_gallery)) {
mkdir($path_gallery, 0777, true);
}
$new_ingredient_ids = $data['recipe_id'];
// Delete removed ingredients
$old_ingredient_ids = Ingredient::where('recipe_id', $id)->pluck('id')->toArray();
$to_delete_ids = array_diff($old_ingredient_ids, $new_ingredient_ids);
if (count($to_delete_ids)) {
$images_to_delete = Ingredient::whereIn('id', $to_delete_ids)->pluck('image')->toArray();
Ingredient::whereIn('id', $to_delete_ids)->delete();
foreach ($images_to_delete as $image_path) {
$image_full_path = $path_gallery . '/' . $image_path;
if (file_exists($image_full_path)) {
unlink($image_full_path);
}
}
}
if (!empty($data['i_name'])) {
foreach ($request->i_name as $index => $name) {
$ingredientId = $new_ingredient_ids[$index] ?? null;
$quantity = $request->quantity[$index] ?? null;
$unit = $request->unit[$index] ?? null;
if ($ingredientId) {
// Update existing ingredient
$ingredient = Ingredient::find($ingredientId);
if ($ingredient) {
$ingredient->update([
'name' => $name,
'quantity' => $quantity,
'unit' => $unit,
]);
}
} else {
// Create new ingredient
$imageName = null;
if ($request->hasFile("image.$index")) {
$image = $request->file("image.$index");
$imageName = time() . '_' . uniqid() . '.' . $image->getClientOriginalExtension();
$image->move($path_gallery, $imageName);
}
Ingredient::create([
'recipe_id' => $recipe->id,
'name' => $name,
'quantity' => $quantity,
'unit' => $unit,
'image' => $imageName,
]);
}
}
}
// Update images if any new file is uploaded
if (!empty($data['image'])) {
foreach ($data['image'] as $index => $file) {
if ($request->hasFile("image.$index") && !empty($new_ingredient_ids[$index])) {
$ingredient = Ingredient::find($new_ingredient_ids[$index]);
if ($ingredient) {
// Delete old image
$oldImagePath = $path_gallery . '/' . $ingredient->image;
if (file_exists($oldImagePath)) {
unlink($oldImagePath);
}
// Save new image
$newImage = $request->file("image.$index");
$newImageName = time() . '_' . uniqid() . '.' . $newImage->getClientOriginalExtension();
$newImage->move($path_gallery, $newImageName);
$ingredient->update([
'image' => $newImageName,
]);
}
}
}
}
return redirect()->route('recipe.index')->with('success', 'Recipe has been successfully updated.');
}
/**
* Remove the specified resource from storage.
*/
public function destroy(string $id)
{
$recipe = Recipe::findOrFail($id);
// Delete images from the storage directory
$galleryDirectory = public_path('storage/recipe/' . $id);
if (File::exists($galleryDirectory)) {
File::deleteDirectory($galleryDirectory);
}
// Delete records from the gallery_images table
Ingredient::where('recipe_id', $id)->delete();
// Delete the gallery record
$recipe->delete();
return response()->json(['success' => true, 'message' => 'Recipe deleted successfully.']);
}
public function fetch()
{
$contacts = Recipe::all();
return response()->json(['data' => $contacts]);
}
}
🚀 If you found this blog helpful, don’t forget to follow SD Code for more Laravel tips and tricks!
💬 Have questions or suggestions? Drop a comment below — I’d love to hear your thoughts!
🔁 Share this with your dev circle who might find it useful.
Subscribe to my newsletter
Read articles from CodeWithSdCode directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

CodeWithSdCode
CodeWithSdCode
I’m SdCode, a passionate Laravel developer sharing simple tutorials and practical coding tips to help beginners and intermediate devs grow their skills and build great projects.