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.