How to Build Dynamic Forms in Laravel

The source code for this website can be found at GitHub.

Below are instruction on how to set up in Laravel 8 this dynamic form here. The form contains several dynamic sections (fields added by JavaScript). It also has dynamic elements within other dynamic blocks. This makes building and validating the form more complex. Some quite complex validation rules are applied.

To follow the instructions below you also need to use the GitHub repository as they frequently require you to copy files or blocks of code from it.

  • Have a working instance of Laravel 8
  • Install Laravel Collective and create some form components
    • Composer install ("laravelcollective/html": "*")
    • config/app.php - add Collective\Html\HtmlServiceProvider::class to 'providers' array
    • Create app/Model directory. Add FormFieldHelper.php
    • Create directory "resources/views/components/form" and add files: Each of these files adds an error message alongside the form field and adds an "error" class (so it can be highlighted) to the form field if it has an error. myCheckboxList.blade.php and myRadioList.blade.php create a <ul> list of related radio or checkboxes.
      Using these components results in much cleaner view templates and reduces code repetition.
      These files should be customized for your projects particular needs.
    • app/Providers - add FormServiceProvider.php as follows:
      <?php
      
      namespace App\Providers;
      
      use Illuminate\Support\ServiceProvider;
      use Form;
      
      class FormServiceProvider extends ServiceProvider
      {
          /**
           * Register services.
           *
           * @return  void
           */
          public function register()
          {
          }
      
          /**
           * Bootstrap services.
           *
           * @return  void
           */
          public function boot()
          {
              Form::component('myInput', 'components.form.myInput', ['type', 'name', 'value' => null, 'attributes' => []]);
              Form::component('myTextarea', 'components.form.myTextarea', ['name', 'value' => null, 'attributes' => []]);
              Form::component('mySelect', 'components.form.mySelect', ['name', 'options', 'value' => null, 'attributes' => []]);
              Form::component('myCheckboxList', 'components.form.myCheckboxList', ['name', 'options', 'attributes' => []]);
              Form::component('myRadioList', 'components.form.myRadioList', ['name', 'options', 'attributes' => []]);
          }
      }
      
    • config/app.php - add App\Providers\FormServiceProvider::class to 'providers' array
  • Start building the form HTML
    • Create the Controller class (i.e. ProgrammerExperienceController) with the following 4 actions.
      public function add()
      {
      
      }
      
      public function store()
      {
      
      }
      
      public function edit()
      {
      
      }
      
      public function update()
      {
      
      }
      
    • In routes/web.php create routes to each of the above actions. add() and edit() should be reached by GET requests method, store() by POST request and update() by Laravels PUT method.
    • Create the view file "edit.blade.php" which will be shared by the add and edit pages.
    • Start the form on edit.blade.php. Use Laravel Collectives Form object to create the form opening and closing tags. The form should use method "post" in this case and should post to the store() action.
      {!! Form::open(['route'=>['programmer.store'], 'method'=>'post', 'id'=>"experience"]) !!}
      
      {!! Form::close() !!}
      
    • Add an "if" statement that lets users know if there are any errors in the form.
      @if ($errors->any())
          <p class="errors">Please fix the errors in the form below.</p>
      @endif
      
    • Add the fields "Full Name", "Email" and "Address".
      <div class="row">
          <div class="label-col">
              <label for="fullName">Full Name</label>
          </div>
          <div class="input-col">
              {!! Form::myInput('text', 'fullName', null, ['id'=>'fullName']) !!}
          </div>
      </div>
      <div class="row">
          <div class="label-col">
              <label for="email">Email</label>
          </div>
          <div class="input-col">
              {!! Form::myInput('email', 'email', null, ['id'=>'email']) !!}
          </div>
      </div>
      <div class="row">
          <div class="label-col">
              <label for="address">Address</label>
          </div>
          <div class="input-col">
              {{ Form::myTextarea('address', null, ['id'=>'address']) } }
          </div>
      </div>
      
  • Make form Multi-lingual
    • app/Http/Middleware add SetLocale.php
    • app/Http/kernel.php add line "'setlocale' => \App\Http\Middleware\SetLocale::class" to $routeMiddleware array
    • For your add, store, edit, update routes add '{locale}/' to the base of the route path and add "'middleware' => 'setlocale'" to the route. Adjust all links (& form action) to the routes.
      Example:
      Route::group([
          'prefix' => '{locale}/programmer',
          'name' => 'programmer.',
          'where' => ['locale' => '^(en_US|de_DE)$'],
          'middleware' => 'setlocale'
          ], function() {
      
              Route::get('/create', 'ProgrammerExperienceController@add')->name('programmer.create');
              Route::get('/{id}/edit', 'ProgrammerExperienceController@edit')->where(['id'=>'[0-9]+'])->name('programmer.edit');
              Route::post('/', 'ProgrammerExperienceController@store')->name('programmer.store');
              Route::put('/{id}', 'ProgrammerExperienceController@update')->where(['id'=>'[0-9]+'])->name('programmer.update');
      });
      
    • Rename directory resources/lang/en to resources/lang/en_US. Create directory resources/lang/de_DE or directories for whichever languages you want to use.
    • In resources/lang/en_US add the messages.php file (validation.php should already exist). In resources/lang/de_DE add the messages.php and validation.php files.
    • Implement the translator for existing form labels, form error message and submit button.
      @lang('messages.fullName'), @lang('messages.email'), @lang('messages.address'), @lang('messages.formErrors') & @lang('messages.formSave')
    • Create language switch links:
      • Create file app/helpers.php. Add function languageSwitch().
        function languageSwitch()
        {
            $route = request()->route();
            $currentLocale = $route->parameter('locale', 'en_US');
        
            if($currentLocale=='en_US'){
                $newLocale = 'de_DE';
                $text = 'Deutsch (German)';
            }else{
                $newLocale = 'en_US';
                $text = 'English';
            }
        
            $route->setParameter('locale', $newLocale);
            $url = route($route->getName(), $route->parameters);
        
            return '<a href="'.$url.'">'.$text.'</a>';
        }
        
      • In composer.json autoload section add:
        "files": [
            "app/helpers.php"
        ]
        Run command: "composer dump-autoload"
      • In app/Providers/AppServiceProvider boot() method add:
        Blade::directive('languageSwitch', function () {
            return "<?php echo languageSwitch(); ?>";
        });
      • In edit.blade.php create a language switch button by adding "@languageSwitch()"
  • Make Countries (used as <select> options), Programming Languages (used as checkbox options) and Work Types (radio button options) available to the form
    • Create Repository classes for Countries and Programming languages.
      \App\Repository\CountriesRepository
      \App\Repository\ProgrammingLanguagesRepository
    • Create \App\Model\ProgrammingExperienceFormOptions class
    • Inject \App\Model\ProgrammingExperienceFormOptions as a dependency into the controller add() method and use this object to send "countries", "languages" and "workTypes" to the view. Used as options in the form.
      public function add(\App\Model\ProgrammingExperienceFormOptions $formOptions)
      {
          return view('programmer/edit', [
              'countries' => $formOptions->getCountries(request('locale', 'en_US')),
              'languages' => $formOptions->getProgrammingLanguages(),
              'workTypes' => $formOptions->getWorkTypeOptions(),
          ]);
      }
      
  • Add to edit.blade.php fields Country and Programming Languages
    <div class="row">
        <div class="label-col">
            <label for="countryId">@lang('messages.country')</label>
        </div>
        <div class="input-col">
            {{ Form::mySelect('countryId', $countries, null, ['id'=>'countryId', 'placeholder'=>trans('messages.Select country')]) }}
        </div>
    </div>
    <div class="row">
        <div class="label-col">
            @lang('messages.programmingLanguages')
        </div>
        <div class="input-col">
            {!! Form::myCheckboxList('languages', $languages, ['class'=>'checkboxList', 'style'=>'column-count:3;']) !!}
        </div>
    </div>
    
  • Build the dynamic "Additional Languages" section
    • In composer.json require - "laminas/laminas-escaper": "~2.5"
      Command: composer update
    • If not already created, create the file app/helpers.php
      • Create file app/helpers.php.
      • In composer.json autoload section add:
        "files": [
            "app/helpers.php"
        ]
        Run command: "composer dump-autoload"
    • Add to app/helpers.php the line:
      use Illuminate\Database\Eloquent;
      and functions attributeTemplate() and formIterator()
      function attributeTemplate($view, $attributes=[])
      {
          $t =  view()->make($view, $attributes);
          $esc = new \Laminas\Escaper\Escaper('utf-8');
      
          return $esc->escapeHtmlAttr($t);
      }
      
      /**
       * Used on a laravelcollective/html form to iterate through an array of fields
       */
       function formIterator(?Eloquent\Model $model, string $field): array
       {
           $array = old($field, data_get($model, $field, []));
      
           return is_array($array) ? array_keys($array) : [];
       }
      
    • In App\Providers\AppServiceProvider boot() method add:
      Blade::directive('attributeTemplate', function ($expression) {
          return "<?php echo attributeTemplate({$expression}); ?>";
      });
    • Create a partial view blade file for the items that will be added to "Additional Languages".
      Example: resources/views/programmer/_list-row.blade.php
      Note:
      • We are relying on the way PHP converts submitted field names with square brackets into arrays. The HTML name attribute for the field here is name="additionalLanguages[]". This will give us an "additionalLanguages" array in the PHP if any of these rows are present when the form is submitted.
      • There are 2 different names used in creating this row. The name belonging to the attributes is used to create the actual value for the name attribute in the HTML, e.g. name="additionalLanguages[]". The other $name (e.g. additionalLanguages[0]) is used for retrieving the value for the field and could be used to retrieve errors for that field.
    • Add "Additional Languages" to edit.blade.php
      <div class="row">
          <div class="label-col">
              @lang('messages.additionalProgrammingLanguages')
          </div>
          <div class="input-col">
              @error('additionalLanguages')
              <span class="error msgAbove">{{ $message }}</span><br>
              @enderror
              <table
                  id="additionalLanguages"
                  data-template="{!! attributeTemplate('programmer._list-row', ['name'=>'additionalLanguages.__index__', 'nameAttribute'=>'additionalLanguages[]', 'placeholder'=>trans('messages.Programming Language')]) !!}"
              >
                  <tbody>
                  @foreach(formIterator(($personExperience ?? null), 'additionalLanguages') as $key)
                      @include('programmer._list-row', ['name'=>'additionalLanguages['.$key.']', 'nameAttribute'=>'additionalLanguages[]', 'placeholder'=>trans('messages.Programming Language')])
                  @endforeach
                  </tbody>
                  <tfoot>
                      <tr>
                          <td colspan="3" align="right">
                              <button type="button" id="additionalLanguageBtn" class="newTag">@lang('messages.Add')</button>
                          </td>
                      </tr>
                  </tfoot>
              </table>
          </div>
      </div>
      
      NOTE:
      • The function attributeTemplate() was used to create a value for the data-template attribute. The data-template attribute will be used by JavaScript to add new rows to the "Additional Languages". Laravel does not come with a built-in HTML attribute escaper, hence the need for the attributeTemplate() function.
      • Any existing "Additional Languages" are iterated through with help from the formIterator() function. If the form has been submitted then it iterates through the submitted values (from old() or session) otherwise if an Eloquent\Model is present then it iterates through values from it. The last option is to iterate through an empty array.
        The argument "($personExperience ?? null)" is there because the Eloquent\Model $personExperience will exist when the form is being used to edit an existing person entry but not when a new person entry is being first created.
    • Create the JavaScript (resources/js/programmer/edit.js) for the edit.blade.php and link to it.
  • Create Job Experience dynamic section
    • Create the partial view blade template used to input each IT job (experience) the programmer has had (i.e. resources/views/programmer/_experience.blade.php).
      Each job will become part of the experience[] array. Each job in the array will be given a unique integer key. Example: experience[0] will be an array containing data on the first job (experience[0][companyName], experience[0][officeLocation], etc). It in turn contains a couple of arrays (experience[0][languagesUsed][] and experience[0][additionalLanguagesUsed][]).
    • Add the "Work Experience" section to edit.blade.php.
      <div class="row">
          <h3>@lang('messages.workExperience')</h3>
      </div>
      @foreach (formIterator(($personExperience ?? null), 'experience') as $key)
          @include('programmer._experience', ['k'=>$key, 'countries'=>$countries, 'languages'=>$languages, 'workTypes'=>$workTypes])
      @endforeach
      
      • The function formIterator() is used while iterating through the entered work experience and adding the _experience.blade.php for each job.
        $personExperience is a \Illuminate\Database\Eloquent\Model and will not exist in the add/create form but will be there for the edit form, hence the "($personExperience ?? null)". $personExperience will be used to iterate through values from the database.
      • _experience.blade.php needs to be assigned variables: 'k' is a unique integer key for each experience; 'countries', 'languages' and 'workTypes' are for select, radio and checkbox options.
      • Add a button that will have the JavaScript "Add Experience" (or Job) event attached to it.
  • Make the _experience.blade.php template available to JavaScript so as to add new Job Experience blocks
    • To the HTML container that is parent to the "Work Experience" section add a data attribute for the _experience.blade.php template and use the attributeTemplate() function to give it a value, e.g. data-template="@attributeTemplate('programmer._experience', ['k'=>'__index1__', 'countries'=>$countries, 'languages'=>$languages, 'workTypes'=>$workTypes])"
      The 'k' variable (above) is assigned a placeholder that JavaScript will replace with an integer key when adding a new job experience block to the form.
    • Add to /app/helpers.php the function nextKey().
      /**
       * Used on a laravelcollective/html form to provide JavaScript with next key to use when adding to array of fields
       */
       function nextKey(?Eloquent\Model $model, string $parentField): int
       {
           $parentValue = old($parentField, data_get($model, $parentField, []));
      
           if ($parentValue === null || is_array($parentValue) === false) {
               return 0;
           }
      
           /* all array keys that are positive integers */
           $intKeys = array_map('intval', array_filter(array_keys($parentValue), function ($key) {
               return filter_var($key, FILTER_VALIDATE_INT) !== false && abs($key) == $key;
           }));
      
           return count($intKeys) ? (max($intKeys) + 1) : 0;
       }
      
    • In app/Providers/AppServiceProvider boot() method add:
      Blade::directive('nextKey', function ($expression) {
          return "<?php echo nextKey({$expression}); ?>";
      });
    • To the HTML container that is parent to the "Work Experience" section add the data attribute:
      data-nextkey="{!! nextKey(($personExperience ?? null), 'experience') !!}"

      The purpose of this is to allow JavaScript to know which array key to use when adding a new job experience block. Each integer key must be unique so it needs to know the minimum integer it can increment from.
    • Also add to the HTML container that is parent to the "Work Experience" section the following data attributes:
      data-translate-remove-tag="{{ trans('messages.confirmRemoveTag') }}"
      data-translate-remove-job="{{ trans('messages.confirmRemoveJob') }}"
      These are used by JavaScript to give confirm() messages in the appropriate language.
    • Add the necessary JavaScript for adding and removing Job Experience blocks.
  • Add reCAPTCHA
    • Follow the instructions from reCAPTCHA to install Version 2 onto the form
    • In the .env file add the reCAPTCHA key (as RECAPTCHA_KEY) and secret (as RECAPTCHA_SECRET).
    • To show an error message when reCAPTCHA fails to verify the user is human place the following code at the top of the form
      @error('g-recaptcha-response')
          <p class="error">{{ $message }}</p>
      @enderror
    • How to validate the reCAPTCHA will be shown later in the validation section.
  • Building the form HTML is complete. We now move on to validating the form for the controller store() method.
  • Run command "php artisan make:model PersonExperience" to create the file /app/PersonExperience.php. Fill in the details of this Eloquent\Model. In my case I have moved it into the Repository directory.
  • Create the validator for reCAPTCHA:
    • In composer.json require - "guzzlehttp/guzzle": "^7.0.1"
      Command: composer update
    • Create the class App\Entity\IpAddress.
    • Create the class App\Model\ReCaptchaV3.
    • In App\Providers\FormServiceProvider above the class declaration add:
      use App\Model\ReCaptchaV3;
      use GuzzleHttp\Client;

      In the register() method add:
          $this->app->bind(ReCaptchaV3::class, function ($app) {
              return new ReCaptchaV3(new Client(), env('RECAPTCHA_SECRET'), \Log::getLogger(), null);
          });
      
      The reCAPTCHA secret (RECAPTCHA_SECRET) is to be held in the .env file.
  • Run command "php artisan make:request ProgrammingExperienceSave" to create the file /app/Http/Requests/ProgrammingExperienceSave.php with a skeleton ProgrammingExperienceSave class in it. This class will contain the form validation logic.

    Add the contents from the GitHub Repository to this file. The code here is quite self-explanatory if you are already familiar with writing Laravel Validation logic.
  • Above the Controller (i.e. ProgrammerExperienceController) add the lines:
    use App\Repository\PersonExperience;
    use App\Http\Requests\ProgrammingExperienceSave;
    
    Then inside the Controller add the method savePrepared(). This method would very much need to be customised to your database set up.
    private function savePrepared(array $data) : array
    {
        unset($data['g-recaptcha-response']);
        $data['lastEdit'] = date('Y-m-d H:i:s');
        $data['additionalLanguages'] = array_values($data['additionalLanguages']);
        $data['experience'] = array_values($data['experience']);
    
        foreach($data['experience'] AS &$experience){
            $experience['additionalLanguagesUsed'] = array_values($experience['additionalLanguagesUsed']);
        }
    
        return $data;
    }
    
    Then change the store() action to:
    public function store(ProgrammingExperienceSave $request)
    {
        $validatedData = $this->savePrepared($request->validated());
        $save = array_merge($validatedData, ['sessionToken'=>session('sessionToken')]);
        PersonExperience::create($save)->save();
    
        return redirect()->route('programmer.list');
    }
    
    Because the request object is type-hinted (i.e. ProgrammingExperienceSave) as an argument for the store() action, Laravel validates the request before the controller method is called. If validation fails, a redirect response will be generated to send the user back to their previous location. The errors will also be flashed to the session so they are available for display.
  • We now move on to being able to edit an existing record
  • Change the edit() action to:
    public function edit(FormOptions $formOptions)
    {
        // Customize to your purposes
        $personExp = PersonExperience::where(['sessionToken'=>session('sessionToken'), 'id'=>request('id')])->firstOrFail();
    
        return view('programmer/edit', [
            'personExperience' => $personExp,
            'countries' => $formOptions->getCountries(request('locale', 'en_US')),
            'languages' => $formOptions->getProgrammingLanguages(),
            'workTypes' => $formOptions->getWorkTypeOptions(),
        ]);
    }
    
  • Change the update() action to:
    public function update(ProgrammingExperienceSave $request)
    {
        $personExp = PersonExperience::where(['sessionToken'=>session('sessionToken'), 'id'=>request('id')])->firstOrFail(); // Customize to your needs
        $validatedData = $this->savePrepared($request->validated());
        $personExp->update($validatedData);
    
        return redirect()->route('programmer.list');
    }
    
  • In the view file "edit.blade.php" replace the code that creates the form opening tags with:
    @if(!empty($personExperience))
    {!! Form::model($personExperience, [
                    'route'=>['programmer.update', 'id'=>$personExperience->id, 'locale'=>app()->getLocale()],
                    'method'=>'put',
                    'id'=>"experience"
        ])
    !!}
    @else
    {!! Form::open(['route'=>['programmer.store', 'locale'=>app()->getLocale()], 'method'=>'post', 'id'=>"experience"]) !!}
    @endif
    
  • Finished!