Dynamic Add & Remove Row in Laravel: A Complete CRUD Guide
"Learn how to dynamically add and remove input rows in Laravel using Blade, jQuery, and validation.
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.
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.




