Laravel BelongsToMany: Add Extra Fields to Pivot Table

Founder of QuickAdminPanel
Our QuickAdminPanel has belongsToMany relationship field, but we don’t have the ability to add extra columns to pivot tables. No worries, this article will show you how to make this change manually, or implement it in your Laravel project that isn’t generated by QuickAdminPanel.
Imagine a scenario: you have a Recipe website, and every Recipe may consist of multiple Ingredients, so you create a many-to-many relationship, and add ingredients with our generated Select2 multiple dropdown:
But it makes more sense to add ingredients with their QUANTITY, right? So how much of each ingredient do we need in the recipe. For that, we need an extra field in ingredient_recipe pivot table.
Let’s implement that in this article, and also change the form to be able to specify this quantity. Here’s the final result:
Or, if you prefer a video demo:
So, let’s perform the change, step-by-step.
Step 1. New field Amount: Migration/Model
From the default Generated QuickAdminPanel, we have this structure.
app/Models/Recipe.php:
public function ingredients() { return $this->belongsToMany(Ingredient::class); }
Let’s add the amount field here.
php artisan make:migration add_amount_to_ingredient_recipe_table
And in the migration:
public function up() { Schema::table('ingredient_recipe', function (Blueprint $table) { $table->string('amount'); }); }
And then we need to specify it in app/Models/Recipe.php:
public function ingredients() { return $this->belongsToMany(Ingredient::class)->withPivot('amount'); }
You can read more about those Eloquent changes in the official Laravel documentation.
Step 2. Blade+Controller: Create/Edit Forms
In the controller, we need to pass the Ingredients in create() method:
public function create() { return view('admin.recipes.create', [ 'ingredients' => Ingredient::all(), ]); }
And then in resources/views/admin/recipes/create.blade.php, instead of that Select2 field, we will create a partial blade that will be re-used in Create and Store views. So this part will be identical in recipes/create.blade.php and recipes/edit.blade.php:
<div class="form-group"> <label class="required" for="ingredients">{{ trans('cruds.recipe.fields.ingredients') }}</label> @include('admin.recipes.partials.ingredients') @if($errors->has('ingredients')) <div class="invalid-feedback"> {{ $errors->first('ingredients') }} </div> @endif <span class="help-block">{{ trans('cruds.recipe.fields.ingredients_helper') }}</span> </div>
Now, what’s inside of that resources/views/admin/recipes/partials/ingredients.blade.php?
<table> @foreach($ingredients as $ingredient) <tr> <td><input {{ $ingredient->value ? 'checked' : null }} data-id="{{ $ingredient->id }}" type="checkbox" class="ingredient-enable"></td> <td>{{ $ingredient->name }}</td> <td><input value="{{ $ingredient->value ?? null }}" {{ $ingredient->value ? null : 'disabled' }} data-id="{{ $ingredient->id }}" name="ingredients[{{ $ingredient->id }}]" type="text" class="ingredient-amount form-control" placeholder="Amount"></td> </tr> @endforeach </table> @section('scripts') @parent <script> $('document').ready(function () { $('.ingredient-enable').on('click', function () { let id = $(this).attr('data-id') let enabled = $(this).is(":checked") $('.ingredient-amount[data-id="' + id + '"]').attr('disabled', !enabled) $('.ingredient-amount[data-id="' + id + '"]').val(null) }) }); </script> @endsection
So, we’re loading $ingredients as a table, checking if each amount has value or set to null and whether we need to disable that input.
Also, there’s a @section(‘scripts’) that adds jQuery snippet of enabling/disabling the amount field on the row, if some ingredient’s checkbox is ticked.
Now, where do we get that $ingredient->value? In RecipesController method edit(), here’s what we have:
public function edit(Recipe $recipe) { abort_if(Gate::denies('recipe_edit'), Response::HTTP_FORBIDDEN, '403 Forbidden'); $recipe->load('ingredients'); $ingredients = Ingredient::get()->map(function($ingredient) use ($recipe) { $ingredient->value = data_get($recipe->ingredients->firstWhere('id', $ingredient->id), 'pivot.amount') ?? null; return $ingredient; }); return view('admin.recipes.edit', [ 'ingredients' => $ingredients, 'recipe' => $recipe, ]); }
As you can see, we’re using Collection’s method map() and then get the value from pivot.amount structure.
Step 3. Validating/Saving the Ingredients
First, small change from the default QuickAdminPanel’s code: we need to change the validation rule: from integer to string here, because our amount is string.
app/Http/Requests/StoreRecipeRequest.php, and identically UpdateRecipeRequest.php:
public function rules() { return [ 'name' => [ 'string', 'required', ], 'ingredients.*' => [ 'string', ], 'ingredients' => [ 'required', 'array', ], ]; }
Finally, how we store and update the data, in RecipesController:
public function store(StoreRecipeRequest $request) { $data = $request->validated(); $recipe = Recipe::create($data); $recipe->ingredients()->sync($this->mapIngredients($data['ingredients'])); return redirect()->route('admin.recipes.index'); } public function update(UpdateRecipeRequest $request, Recipe $recipe) { $data = $request->validated(); $recipe->update($data); $recipe->ingredients()->sync($this->mapIngredients($data['ingredients'])); return redirect()->route('admin.recipes.index'); } private function mapIngredients($ingredients) { return collect($ingredients)->map(function ($i) { return ['amount' => $i]; }); }
Again, some Collection’s “magic” in method mapIngredients to make the store/update methods more readable and shorter.
Step 4. Show Ingredients with Amount
Final small change should be in resources/views/admin/recipes/show.blade.php:
<tr> <th> {{ trans('cruds.recipe.fields.ingredients') }} </th> <td> @foreach($recipe->ingredients as $key => $ingredients) <div class="label label-info">{{ $ingredients->name }} ({{ $ingredients->pivot->amount }})</div> @endforeach </td> </tr>
As you can see, we’re using $ingredients->pivot->amount to show the amount of each ingredient.
And, that’s it!
You can check out the transformation in this public demo-repository here.
Try our QuickAdminPanel generator!
9 Comments
Leave a Reply Cancel reply
Recent Posts
Try our QuickAdminPanel Generator!
How it works:
1. Generate panel online
No coding required, you just choose menu items.
2. Download code & install locally
Install with simple "composer install" and "php artisan migrate".
3. Customize anything!
We give all the code, so you can change anything after download.
Sir can you elaborate on mapIngredients method please. Too hard to follow :).
Dear Sir, can you meet with the functionality, when, for example, you had to display all the recipes and calculate the average number of ingredients by the extra field (in your example amount) + sort them from larger to smaller by this field?
This can be done with Laravel Eloquent?
As it turned out, the solution is simple:
Recipe::withCount([‘ingredients as amount_sum’ => function($query) {
$query->select(\DB::raw(‘SUM(amount)’));
}])->orderByDesc(‘amount_sum’)->get();
how to add more extra column to the pivot
That is a good question. I am working on a project where I need also two dates and a price as additional fields in a pivot table. I think it can be added to the Controller, but I am not sure if it works with the map function.
Yes, it is possible to add them via map function.
$faultParts = collect($request->input(‘faultParts’, []))
->map(function ($part) {
return [
‘part_id’ => $part[“partId”],
‘quantity’ => $part[“quantity”],
‘need_replacement’ => $part[“needReplacement”],
‘is_fixed’ => $part[“isFixed”],
‘notes’ => $part[“notes”],
];
});
//Save machine categories…
$fault->parts()->sync($faultParts);
Thank you for this tutorial.
I have an additional question. How about Edit data?
At first, how to show for each recipe their ingredients checkbox as checked, linked with their amount and than update them.
Thank you in advance
I also have a problem with editing data. I think it will be a great if he make an additional video how to editing the data.
Exelent guide, helped me a lot, one thing bothers me…, – the function below working only if its placed inside the UPDATE function, its my bug or i missed someth?
public function update(Request $request, int $id)
{
function mapIngredients($attributes)
{
return collect($attributes)->map(function ($i) {
return [‘value’ => $i];
});
}
$user = Auth::user();
if (!$user) {
return redirect()->back();
}
$slug = Str::slug($request->title);
$place = Place::find($id);