I do this all the time, making use of migration configuration entities (provided by the [migrate_plus module][1]). Define a migration plugin in your migration module's config/install directory, using the CSV source plugin from the [migrate_source_csv module][2] - omitting the 'path' source configuration, which will be filled in from the form. Let's say the ID of this migration is example_csv. Create a form with a file upload element (named 'csv_file' in this case), and in the submitForm() method: public function submitForm(array &$form, FormStateInterface $form_state) { $all_files = $this->getRequest()->files->get('files', []); if (!empty($all_files['csv_file'])) { $validators = ['file_validate_extensions' => ['csv']]; if ($file = file_save_upload('csv_file', $validators, 'public://', 0)) { $csv_migration = Migration::load('example_csv'); $source = $csv_migration->get('source'); $source['path'] = $file->getFileUri(); $csv_migration->set('source', $source); $csv_migration->save(); drupal_set_message($this->t('File uploaded as @uri.', ['@uri' => $file->getFileUri()])); } else { drupal_set_message($this->t('File upload failed.')); } } } This updates the migration settings with the new file. You still have to run the migration using `drush mi example_csv` to actually import the content. Or add [some code to the function][3] to actually execute the import: $migration_instance = \Drupal::service('plugin.manager.migration')->createInstance('example_csv'); $executable = new MigrateExecutable($migration_instance, new MigrateMessage()); try { $migration_status = $executable->import(); } catch (\Exception $e) { \Drupal::logger('migrate_drupal_ui')->error($e->getMessage()); $migration_status = MigrationInterface::RESULT_FAILED; } if ($migration_status) { drupal_set_message($this->t('Import Successful')); } else { drupal_set_message($migration_status, 'error'); } [1]: https://www.drupal.org/project/migrate_plus [2]: https://www.drupal.org/project/migrate_source_csv [3]: https://www.drupal.org/node/2842505#comment-12162525