Now that we looked at static multi-dimension forms, it would be cool to have them dynamic. We can use Livewire to add and remove fields from the form. As a bonus, it even handles real-time validation for us.
To add dynamic elements, we need to create a Livewire component. We can do that with the following command:
php artisan make:livewire TeamPlayerSelection
We'll modify the TeamPlayerSelection
component to look like this:
use App\Models\Team;use App\Models\User;use Livewire\Component; class TeamPlayerSelection extends Component{ // Team property used for Edit public ?Team $team = null; // Public array of users for our select dropdown public array $users = []; // Public array of positions for our select dropdown public array $positions = [ 'Goalkeeper' => 'Goalkeeper', 'Defender' => 'Defender', 'Midfielder' => 'Midfielder', 'Forward' => 'Forward', 'Coach' => 'Coach', 'Assistant Coach' => 'Assistant Coach', 'Physiotherapist' => 'Physiotherapist', 'Doctor' => 'Doctor', 'Manager' => 'Manager', 'President' => 'President', ]; // These are the players we are going to save. We'll use this in our validation rules public array $players = []; // This is the template for a new user with empty defaults public array $userTemplate = [ 'user_id' => "", 'position' => "" ]; // Validation rules following the array structure protected $rules = [ 'players' => ['required', 'array', 'min:3', 'max:10'], 'players.*.id' => ['required', 'exists:users,id', 'integer', 'distinct'], 'players.*.position' => ['required', 'string', 'max:200', 'distinct'], ]; // Custom messages for validation rules to make them more user-friendly protected $messages = [ 'players.*.id.required' => 'The player field is required.', 'players.*.id.exists' => 'The selected player is invalid.', 'players.*.id.distinct' => 'Player cannot be selected twice', 'players.*.position.required' => 'Player position is required', 'players.*.position.distinct' => 'Player positions must be unique', ]; // On update, we want to re-validate the whole component public function updated($propertyName) { $this->validate(); } public function mount() { // We load all users in the select dropdown $this->users = User::pluck('name', 'id')->toArray(); // If we have any old input, we'll load it into our players' array if (count(old('players', [])) > 0) { foreach (old('players', []) as $index => $player) { $this->players[$index] = [ 'id' => $player['id'], 'position' => $player['position'] ]; } } elseif ($this->team) { // Otherwise, if we are editing a team, we'll load the players from the database $this->team->load(['users']); foreach ($this->team->users as $index => $player) { $this->players[$index] = [ 'id' => $player->id, 'position' => $player->pivot->position ]; } } } public function render() { return view('livewire.team-player-selection'); } // This adds a new line to our players' array and re-validates the component public function addUser(): void { $this->players[] = $this->userTemplate; $this->validate(); } // This removes a line from our players' array and re-validates the component public function removeUser($index) { unset($this->players[$index]); $this->validate(); }}
And our view will look like this:
<div> @foreach($players as $i => $user) <div class="mb-4 grid grid-cols-3 gap-4"> <div class=""> <label class="text-xl text-gray-600" for="players[{{$i}}][id]">Player<span class="text-red-500">*</span></label> <select name="players[{{$i}}][id]" id="players[{{$i}}][id]" wire:model="players.{{$i}}.id" class="border-2 border-gray-300 p-2 w-full"> <option value="">Select Player</option> @foreach($users as $id => $name) <option value="{{ $id }}" @if(in_array($id, collect($players)->filter(fn($value, $key) => $key != $i)->pluck('id')->toArray())) disabled @endif @selected(old('players.'.$i.'.id') == $id) >{{ $name }}</option> @endforeach </select> @error('players.'.$i.'.id') <div class="text-red-500 mt-2 text-sm"> {{ $message }} </div> @enderror </div> <div class=""> <label class="text-xl text-gray-600" for="players[{{$i}}][position]">Position<span class="text-red-500">*</span></label> <select name="players[{{$i}}][position]" id="players[{{$i}}][position]" wire:model="players.{{$i}}.position" class="border-2 border-gray-300 p-2 w-full"> <option value="">Select Position</option> @foreach($positions as $id => $name) <option value="{{ $id }}" @if(in_array($id, collect($players)->filter(fn($value, $key) => $key != $i)->pluck('position')->toArray())) disabled @endif @selected(old('players.'.$i.'.position') == $id) >{{ $name }}</option> @endforeach </select> @error('players.'.$i.'.position') <div class="text-red-500 mt-2 text-sm"> {{ $message }} </div> @enderror </div> <div class=""> <button wire:click.prevent="removeUser({{$i}})" class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded mt-8"> Remove </button> </div> </div> @endforeach <div class="mb-4"> @error('players') <div class="text-red-500 mt-2 text-sm"> {{ $message }} </div> @enderror </div> <button wire:click.prevent="addUser" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded mb-4"> Add Player </button></div>
Here, we have a few things to note:
We are using a wire:click
to trigger an addUser()
function in our component:
<button wire:click.prevent="addUser" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded mb-4"> Add Player</button>
The exact use case for the removal of a player with removeUser($index)
:
<button wire:click.prevent="removeUser({{$i}})" class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded mt-8"> Remove</button>
And as a bonus with Livewire, we are disabling the select options that are already selected in the other lines:
{{-- ... --}} @if(in_array($id, collect($players)->filter(fn($value, $key) => $key != $i)->pluck('id')->toArray())) disabled@endif {{-- ... --}} @if(in_array($id, collect($players)->filter(fn($value, $key) => $key != $i)->pluck('position')->toArray())) disabled@endif {{-- ... --}}
Using the component is pretty simple, and we need to call it on our form:
resources/views/teams/create.blade.php
<form method="POST" action="{{ route('teams.store') }}"> @csrf <div class="mb-4"> <label class="text-xl text-gray-600" for="name">Name <span class="text-red-500">*</span></label> <input type="text" class="border-2 border-gray-300 p-2 w-full" name="name" id="name" value="{{ old('name') }}"> @error('name') <div class="text-red-500 mt-2 text-sm"> {{ $message }} </div> @enderror </div> <livewire:team-player-selection/> <div class="mb-4"> <button type="submit" class="bg-blue-500 text-white px-4 py-2 rounded font-medium">Create Team </button> </div></form>
And in the edit, we need to pass the team to the component:
resources/views/teams/edit.blade.php
<form method="POST" action="{{ route('teams.update', $team->id) }}"> @csrf @method('PUT') <div class="mb-4"> <label class="text-xl text-gray-600" for="name">Name <span class="text-red-500">*</span></label> <input type="text" class="border-2 border-gray-300 p-2 w-full" name="name" id="name" value="{{ old('name', $team->name) }}"> @error('name') <div class="text-red-500 mt-2 text-sm"> {{ $message }} </div> @enderror </div> <livewire:team-player-selection :team="$team"/> <div class="mb-4"> <button type="submit" class="bg-blue-500 text-white px-4 py-2 rounded font-medium">Update Team </button> </div></form>
That's it! Now our form is reactive, and we can add and remove players as we wish, along with validation:
You might have noticed that we didn't submit the form within the Livewire component. This is an intentional decision, as we've built a reactive form that validates the data as we type. This allows us to replace a small part of the form, not the entire one.
You can find the source code for this tutorial on GitHub.