diff --git a/composer.json b/composer.json
index 95be4408..43451fe1 100644
--- a/composer.json
+++ b/composer.json
@@ -28,7 +28,8 @@
     "drupal/token" : "^1.3",
     "drupal/flysystem" : "^2.0@beta",
     "islandora/crayfish-commons": "^2",
-    "drupal/file_replace": "^1.1"
+    "drupal/file_replace": "^1.1",
+    "drupal/ctools": "^3.8 || ^4"
   },
   "require-dev": {
     "phpunit/phpunit": "^6",
@@ -37,7 +38,7 @@
     "sebastian/phpcpd": "*"
   },
   "suggest": {
-    "drupal/transliterate_filenames": "Sanitizes filenames when they are uploaded so they don't break your repository." 
+    "drupal/transliterate_filenames": "Sanitizes filenames when they are uploaded so they don't break your repository."
   },
   "license": "GPL-2.0-or-later",
   "authors": [
diff --git a/islandora.info.yml b/islandora.info.yml
index 1228e80e..a1f2b1f4 100644
--- a/islandora.info.yml
+++ b/islandora.info.yml
@@ -12,22 +12,23 @@ dependencies:
   - drupal:text
   - drupal:options
   - drupal:link
-  - drupal:jsonld
-  - drupal:search_api
-  - drupal:jwt
+  - jsonld:jsonld
+  - search_api:search_api
+  - jwt:jwt
   - drupal:rest
-  - drupal:filehash
+  - filehash:filehash
   - drupal:basic_auth
-  - drupal:context_ui
+  - context:context_ui
   - drupal:action
-  - drupal:eva
+  - eva:eva
   - drupal:taxonomy
   - drupal:views_ui
   - drupal:media
-  - drupal:prepopulate
-  - drupal:features_ui
-  - drupal:migrate_source_csv
+  - prepopulate:prepopulate
+  - features:features_ui
+  - migrate_source_csv:migrate_source_csv
   - drupal:content_translation
-  - drupal:flysystem
-  - drupal:token
-  - drupal:file_replace
+  - flysystem:flysystem
+  - token:token
+  - file_replace:file_replace
+  - ctools:ctools
diff --git a/islandora.install b/islandora.install
index f9eb1225..ad2eb8e1 100644
--- a/islandora.install
+++ b/islandora.install
@@ -5,6 +5,10 @@
  * Install/update hook implementations.
  */
 
+use Drupal\Core\Extension\ExtensionNameLengthException;
+use Drupal\Core\Extension\MissingDependencyException;
+use Drupal\Core\Utility\UpdateException;
+
 /**
  * Adds common namespaces to jsonld.settings.
  */
@@ -174,3 +178,37 @@ function update_jsonld_included_namespaces() {
       ->warning("Could not find required jsonld.settings to add default RDF namespaces.");
   }
 }
+
+/**
+ * Ensure that ctools is enabled.
+ */
+function islandora_update_8007() {
+  $module_handler = \Drupal::moduleHandler();
+  if ($module_handler->moduleExists('ctools')) {
+    return t('The "@module_name" module is already enabled, no action necessary.', [
+      '@module_name' => 'ctools',
+    ]);
+  }
+
+  /** @var \Drupal\Core\Extension\ModuleInstallerInterface $installer */
+  $installer = \Drupal::service('module_installer');
+
+  try {
+    if ($installer->install(['ctools'], TRUE)) {
+      return t('The "@module_name" module was enabled successfully.', [
+        '@module_name' => 'ctools',
+      ]);
+    }
+  }
+  catch (ExtensionNameLengthException | MissingDependencyException $e) {
+    throw new UpdateException('Failed; ensure that the ctools module is available in the Drupal installation.', 0, $e);
+  }
+  catch (\Exception $e) {
+    throw new UpdateException('Failed; encountered an exception while trying to enable ctools.', 0, $e);
+  }
+
+  // Theoretically impossible to hit, as ModuleInstaller::install() only returns
+  // TRUE (or throws/propagates an exception), but... probably a good idea to
+  // have the here, just in case?
+  throw new UpdateException('Failed; hit the end of the update hook implementation, which is not expected.');
+}
diff --git a/islandora.routing.yml b/islandora.routing.yml
index 5387e9a4..86d13482 100644
--- a/islandora.routing.yml
+++ b/islandora.routing.yml
@@ -37,14 +37,15 @@ islandora.add_member_to_node_page:
     _entity_create_any_access: 'node'
 
 islandora.upload_children:
-  path: '/node/{node}/members/upload'
+  path: '/node/{node}/members/upload/{step}'
   defaults:
-    _form: '\Drupal\islandora\Form\AddChildrenForm'
+    _wizard: '\Drupal\islandora\Form\AddChildrenWizard\ChildForm'
     _title: 'Upload Children'
+    step: 'type_selection'
   options:
     _admin_route: 'TRUE'
   requirements:
-    _custom_access: '\Drupal\islandora\Form\AddChildrenForm::access'
+    _custom_access: '\Drupal\islandora\Form\AddChildrenWizard\Access::childAccess'
 
 islandora.add_media_to_node_page:
   path: '/node/{node}/media/add'
@@ -58,14 +59,15 @@ islandora.add_media_to_node_page:
     _entity_create_any_access: 'media'
 
 islandora.upload_media:
-  path: '/node/{node}/media/upload'
+  path: '/node/{node}/media/upload/{step}'
   defaults:
-    _form: '\Drupal\islandora\Form\AddMediaForm'
+    _wizard: '\Drupal\islandora\Form\AddChildrenWizard\MediaForm'
     _title: 'Add media'
+    step: 'type_selection'
   options:
     _admin_route: 'TRUE'
   requirements:
-    _custom_access: '\Drupal\islandora\Form\AddMediaForm::access'
+    _custom_access: '\Drupal\islandora\Form\AddChildrenWizard\Access::mediaAccess'
 
 islandora.media_source_update:
   path: '/media/{media}/source'
diff --git a/islandora.services.yml b/islandora.services.yml
index 4b3a9d16..4108e244 100644
--- a/islandora.services.yml
+++ b/islandora.services.yml
@@ -59,3 +59,19 @@ services:
     arguments: ['@jwt.authentication.jwt']
     tags:
       - { name: event_subscriber }
+  islandora.upload_children.batch_processor:
+    class: Drupal\islandora\Form\AddChildrenWizard\ChildBatchProcessor
+    arguments:
+      - '@entity_type.manager'
+      - '@database'
+      - '@current_user'
+      - '@messenger'
+      - '@date.formatter'
+  islandora.upload_media.batch_processor:
+    class: Drupal\islandora\Form\AddChildrenWizard\MediaBatchProcessor
+    arguments:
+      - '@entity_type.manager'
+      - '@database'
+      - '@current_user'
+      - '@messenger'
+      - '@date.formatter'
diff --git a/src/Form/AddChildrenForm.php b/src/Form/AddChildrenForm.php
index 0ff72496..528b4283 100644
--- a/src/Form/AddChildrenForm.php
+++ b/src/Form/AddChildrenForm.php
@@ -229,7 +229,7 @@ class AddChildrenForm extends AddMediaForm {
    * @param \Drupal\Core\Routing\RouteMatch $route_match
    *   The current routing match.
    *
-   * @return \Drupal\Core\Access\AccessResultAllowed|\Drupal\Core\Access\AccessResultForbidden
+   * @return \Drupal\Core\Access\AccessResultInterface
    *   Whether we can or can't show the "thing".
    */
   public function access(RouteMatch $route_match) {
diff --git a/src/Form/AddChildrenWizard/AbstractBatchProcessor.php b/src/Form/AddChildrenWizard/AbstractBatchProcessor.php
new file mode 100644
index 00000000..6193c0c3
--- /dev/null
+++ b/src/Form/AddChildrenWizard/AbstractBatchProcessor.php
@@ -0,0 +1,258 @@
+<?php
+
+namespace Drupal\islandora\Form\AddChildrenWizard;
+
+use Drupal\Core\Database\Connection;
+use Drupal\Core\Datetime\DateFormatterInterface;
+use Drupal\Core\DependencyInjection\DependencySerializationTrait;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Messenger\MessengerInterface;
+use Drupal\Core\Session\AccountProxyInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\Url;
+use Drupal\file\FileInterface;
+use Drupal\islandora\IslandoraUtils;
+use Drupal\media\MediaInterface;
+use Drupal\node\NodeInterface;
+use Symfony\Component\HttpKernel\Exception\HttpException;
+use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
+
+/**
+ * Abstract addition batch processor.
+ */
+abstract class AbstractBatchProcessor {
+
+  use FieldTrait;
+  use DependencySerializationTrait;
+  use StringTranslationTrait;
+
+  /**
+   * The entity type manager service.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface|null
+   */
+  protected ?EntityTypeManagerInterface $entityTypeManager = NULL;
+
+  /**
+   * The database connection serivce.
+   *
+   * @var \Drupal\Core\Database\Connection|null
+   */
+  protected ?Connection $database;
+
+  /**
+   * The current user.
+   *
+   * @var \Drupal\Core\Session\AccountProxyInterface|null
+   */
+  protected ?AccountProxyInterface $currentUser;
+
+  /**
+   * The messenger service.
+   *
+   * @var \Drupal\Core\Messenger\MessengerInterface
+   */
+  protected MessengerInterface $messenger;
+
+  /**
+   * The date formatter service.
+   *
+   * @var \Drupal\Core\Datetime\DateFormatterInterface
+   */
+  protected DateFormatterInterface $dateFormatter;
+
+  /**
+   * Constructor.
+   */
+  public function __construct(
+    EntityTypeManagerInterface $entity_type_manager,
+    Connection $database,
+    AccountProxyInterface $current_user,
+    MessengerInterface $messenger,
+    DateFormatterInterface $date_formatter
+  ) {
+    $this->entityTypeManager = $entity_type_manager;
+    $this->database = $database;
+    $this->currentUser = $current_user;
+    $this->messenger = $messenger;
+    $this->dateFormatter = $date_formatter;
+  }
+
+  /**
+   * Implements callback_batch_operation() for our child addition batch.
+   */
+  public function batchOperation($delta, $info, array $values, &$context) {
+    $transaction = $this->database->startTransaction();
+
+    try {
+      $entities[] = $node = $this->getNode($info, $values);
+      $entities[] = $this->createMedia($node, $info, $values);
+
+      $context['results'] = array_merge_recursive($context['results'], [
+        'validation_violations' => $this->validationClassification($entities),
+      ]);
+      $context['results']['count'] = ($context['results']['count'] ?? 0) + 1;
+    }
+    catch (HttpExceptionInterface $e) {
+      $transaction->rollBack();
+      throw $e;
+    }
+    catch (\Exception $e) {
+      $transaction->rollBack();
+      throw new HttpException(500, $e->getMessage(), $e);
+    }
+  }
+
+  /**
+   * Loads the file indicated.
+   *
+   * @param mixed $info
+   *   Widget values.
+   *
+   * @return \Drupal\file\FileInterface|null
+   *   The loaded file.
+   *
+   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
+   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
+   */
+  protected function getFile($info) : ?FileInterface {
+    return (is_array($info) && isset($info['target_id'])) ?
+      $this->entityTypeManager->getStorage('file')->load($info['target_id']) :
+      NULL;
+  }
+
+  /**
+   * Get the node to which to attach our media.
+   *
+   * @param mixed $info
+   *   Info from the widget used to create the request.
+   * @param array $values
+   *   Additional form inputs.
+   *
+   * @return \Drupal\node\NodeInterface
+   *   The node to which to attach the created media.
+   */
+  abstract protected function getNode($info, array $values) : NodeInterface;
+
+  /**
+   * Get a name to use for bulk-created assets.
+   *
+   * @param mixed $info
+   *   Widget values.
+   * @param array $values
+   *   Form values.
+   *
+   * @return string
+   *   An applicable name.
+   *
+   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
+   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
+   */
+  protected function getName($info, array $values) : string {
+    $file = $this->getFile($info);
+    return $file ? $file->getFilename() : strtr('Bulk ingest, {date}', [
+      '{date}' => $this->dateFormatter->format(time(), 'long'),
+    ]);
+  }
+
+  /**
+   * Create a media referencing the given file, associated with the given node.
+   *
+   * @param \Drupal\node\NodeInterface $node
+   *   The node to which the media should be associated.
+   * @param mixed $info
+   *   The widget info for the media source field.
+   * @param array $values
+   *   Values from the wizard, which should contain at least:
+   *   - media_type: The machine name/ID of the media type as which to create
+   *     the media
+   *   - use: An array of the selected "media use" terms.
+   *
+   * @return \Drupal\media\MediaInterface
+   *   The created media entity.
+   *
+   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
+   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
+   * @throws \Drupal\Core\Entity\EntityStorageException
+   */
+  protected function createMedia(NodeInterface $node, $info, array $values) : MediaInterface {
+    $taxonomy_term_storage = $this->entityTypeManager->getStorage('taxonomy_term');
+
+    // Create a media with the file attached and also pointing at the node.
+    $field = $this->getField($values);
+
+    $media_values = array_merge(
+      [
+        'bundle' => $values['media_type'],
+        'name' => $this->getName($info, $values),
+        IslandoraUtils::MEDIA_OF_FIELD => $node,
+        IslandoraUtils::MEDIA_USAGE_FIELD => ($values['use'] ?
+          $taxonomy_term_storage->loadMultiple($values['use']) :
+          NULL),
+        'uid' => $this->currentUser->id(),
+        // XXX: Published... no constant?
+        'status' => 1,
+      ],
+      [
+        $field->getName() => [
+          $info,
+        ],
+      ]
+    );
+    $media = $this->entityTypeManager->getStorage('media')->create($media_values);
+    if ($media->save() !== SAVED_NEW) {
+      throw new \Exception("Failed to create media.");
+    }
+
+    return $media;
+  }
+
+  /**
+   * Helper to bulk process validatable entities.
+   *
+   * @param array $entities
+   *   An array of entities to scan for validation violations.
+   *
+   * @return array
+   *   An associative array mapping entity type IDs to entity IDs to a count
+   *   of validation violations found on then given entity.
+   */
+  protected function validationClassification(array $entities) {
+    $violations = [];
+
+    foreach ($entities as $entity) {
+      $entity_violations = $entity->validate();
+      if ($entity_violations->count() > 0) {
+        $violations[$entity->getEntityTypeId()][$entity->id()] = $entity_violations->count();
+      }
+    }
+
+    return $violations;
+  }
+
+  /**
+   * Implements callback_batch_finished() for our child addition batch.
+   */
+  public function batchProcessFinished($success, $results, $operations): void {
+    if ($success) {
+      foreach ($results['validation_violations'] ?? [] as $entity_type => $info) {
+        foreach ($info as $id => $count) {
+          $this->messenger->addWarning($this->formatPlural(
+            $count,
+            '1 validation error present in <a target="_blank" href=":uri">bulk created entity of type %type, with ID %id</a>.',
+            '@count validation errors present in <a target="_blank" href=":uri">bulk created entity of type %type, with ID %id</a>.',
+            [
+              '%type' => $entity_type,
+              ':uri' => Url::fromRoute("entity.{$entity_type}.canonical", [$entity_type => $id])->toString(),
+              '%id' => $id,
+            ]
+          ));
+        }
+      }
+    }
+    else {
+      $this->messenger->addError($this->t('Encountered an error when processing.'));
+    }
+  }
+
+}
diff --git a/src/Form/AddChildrenWizard/AbstractFileSelectionForm.php b/src/Form/AddChildrenWizard/AbstractFileSelectionForm.php
new file mode 100644
index 00000000..6aeed879
--- /dev/null
+++ b/src/Form/AddChildrenWizard/AbstractFileSelectionForm.php
@@ -0,0 +1,157 @@
+<?php
+
+namespace Drupal\islandora\Form\AddChildrenWizard;
+
+use Drupal\Core\Batch\BatchBuilder;
+use Drupal\Core\Field\BaseFieldDefinition;
+use Drupal\Core\Field\FieldDefinitionInterface;
+use Drupal\Core\Field\FieldItemList;
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+use Drupal\Core\Field\WidgetInterface;
+use Drupal\Core\Form\FormBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Session\AccountProxyInterface;
+use Drupal\field\FieldStorageConfigInterface;
+use Drupal\media\MediaTypeInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Children addition wizard's second step.
+ */
+abstract class AbstractFileSelectionForm extends FormBase {
+
+  use WizardTrait;
+
+  const BATCH_PROCESSOR = 'abstract.abstract';
+
+  /**
+   * The current user.
+   *
+   * @var \Drupal\Core\Session\AccountProxyInterface|null
+   */
+  protected ?AccountProxyInterface $currentUser;
+
+  /**
+   * The batch processor service.
+   *
+   * @var \Drupal\islandora\Form\AddChildrenWizard\AbstractBatchProcessor|null
+   */
+  protected ?AbstractBatchProcessor $batchProcessor;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container): self {
+    $instance = parent::create($container);
+
+    $instance->entityTypeManager = $container->get('entity_type.manager');
+    $instance->widgetPluginManager = $container->get('plugin.manager.field.widget');
+    $instance->entityFieldManager = $container->get('entity_field.manager');
+    $instance->currentUser = $container->get('current_user');
+
+    $instance->batchProcessor = $container->get(static::BATCH_PROCESSOR);
+
+    return $instance;
+  }
+
+  /**
+   * Helper; get the media type, based off discovering from form state.
+   *
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The form state.
+   *
+   * @return \Drupal\media\MediaTypeInterface
+   *   The target media type.
+   *
+   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
+   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
+   */
+  protected function getMediaTypeFromFormState(FormStateInterface $form_state): MediaTypeInterface {
+    return $this->getMediaType($form_state->getTemporaryValue('wizard'));
+  }
+
+  /**
+   * Helper; get field instance, based off discovering from form state.
+   *
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The form state.
+   *
+   * @return \Drupal\Core\Field\FieldDefinitionInterface
+   *   The field definition.
+   */
+  protected function getFieldFromFormState(FormStateInterface $form_state): FieldDefinitionInterface {
+    $cached_values = $form_state->getTemporaryValue('wizard');
+
+    $field = $this->getField($cached_values);
+    $def = $field->getFieldStorageDefinition();
+    if ($def instanceof FieldStorageConfigInterface) {
+      $def->set('cardinality', FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED);
+    }
+    elseif ($def instanceof BaseFieldDefinition) {
+      $def->setCardinality(FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED);
+    }
+    else {
+      throw new \Exception('Unable to remove cardinality limit.');
+    }
+
+    return $field;
+  }
+
+  /**
+   * Helper; get widget for the field, based on discovering from form state.
+   *
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The form state.
+   *
+   * @return \Drupal\Core\Field\WidgetInterface
+   *   The widget.
+   */
+  protected function getWidgetFromFormState(FormStateInterface $form_state): WidgetInterface {
+    return $this->getWidget($this->getFieldFromFormState($form_state));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildForm(array $form, FormStateInterface $form_state): array {
+    // Using the media type selected in the previous step, grab the
+    // media bundle's "source" field, and create a multi-file upload widget
+    // for it, with the same kind of constraints.
+    $field = $this->getFieldFromFormState($form_state);
+    $items = FieldItemList::createInstance($field, $field->getName(), $this->getMediaTypeFromFormState($form_state)->getTypedData());
+
+    $form['#tree'] = TRUE;
+    $form['#parents'] = [];
+    $widget = $this->getWidgetFromFormState($form_state);
+    $form['files'] = $widget->form(
+      $items,
+      $form,
+      $form_state
+    );
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    $cached_values = $form_state->getTemporaryValue('wizard');
+
+    $widget = $this->getWidgetFromFormState($form_state);
+    $builder = (new BatchBuilder())
+      ->setTitle($this->t('Bulk creating...'))
+      ->setInitMessage($this->t('Initializing...'))
+      ->setFinishCallback([$this->batchProcessor, 'batchProcessFinished']);
+    $values = $form_state->getValue($this->getField($cached_values)->getName());
+    $massaged_values = $widget->massageFormValues($values, $form, $form_state);
+    foreach ($massaged_values as $delta => $info) {
+      $builder->addOperation(
+        [$this->batchProcessor, 'batchOperation'],
+        [$delta, $info, $cached_values]
+      );
+    }
+    batch_set($builder->toArray());
+  }
+
+}
diff --git a/src/Form/AddChildrenWizard/AbstractForm.php b/src/Form/AddChildrenWizard/AbstractForm.php
new file mode 100644
index 00000000..e9fac387
--- /dev/null
+++ b/src/Form/AddChildrenWizard/AbstractForm.php
@@ -0,0 +1,125 @@
+<?php
+
+namespace Drupal\islandora\Form\AddChildrenWizard;
+
+use Drupal\Core\DependencyInjection\ClassResolverInterface;
+use Drupal\Core\Form\FormBuilderInterface;
+use Drupal\Core\Render\RendererInterface;
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\Core\Session\AccountProxyInterface;
+use Drupal\Core\TempStore\SharedTempStoreFactory;
+use Drupal\ctools\Wizard\FormWizardBase;
+use Drupal\islandora\IslandoraUtils;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+
+/**
+ * Bulk children addition wizard base form.
+ */
+abstract class AbstractForm extends FormWizardBase {
+
+  const TEMPSTORE_ID = 'abstract.abstract';
+  const TYPE_SELECTION_FORM = MediaTypeSelectionForm::class;
+  const FILE_SELECTION_FORM = AbstractFileSelectionForm::class;
+
+  /**
+   * The Islandora Utils service.
+   *
+   * @var \Drupal\islandora\IslandoraUtils
+   */
+  protected IslandoraUtils $utils;
+
+  /**
+   * The current node ID.
+   *
+   * @var mixed|null
+   */
+  protected $nodeId;
+
+  /**
+   * The current route match.
+   *
+   * @var \Drupal\Core\Routing\RouteMatchInterface
+   */
+  protected RouteMatchInterface $currentRoute;
+
+  /**
+   * The current user.
+   *
+   * @var \Drupal\Core\Session\AccountProxyInterface
+   */
+  protected AccountProxyInterface $currentUser;
+
+  /**
+   * Constructor.
+   */
+  public function __construct(
+    SharedTempStoreFactory $tempstore,
+    FormBuilderInterface $builder,
+    ClassResolverInterface $class_resolver,
+    EventDispatcherInterface $event_dispatcher,
+    RouteMatchInterface $route_match,
+    RendererInterface $renderer,
+    $tempstore_id,
+    AccountProxyInterface $current_user,
+    $machine_name = NULL,
+    $step = NULL
+  ) {
+    parent::__construct($tempstore, $builder, $class_resolver, $event_dispatcher, $route_match, $renderer, $tempstore_id,
+      $machine_name, $step);
+
+    $this->nodeId = $this->routeMatch->getParameter('node');
+    $this->currentUser = $current_user;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getParameters() : array {
+    return array_merge(
+      parent::getParameters(),
+      [
+        'tempstore_id' => static::TEMPSTORE_ID,
+        'current_user' => \Drupal::service('current_user'),
+      ]
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getOperations($cached_values) {
+    $ops = [];
+
+    $ops['type_selection'] = [
+      'title' => $this->t('Type Selection'),
+      'form' => static::TYPE_SELECTION_FORM,
+      'values' => [
+        'node' => $this->nodeId,
+      ],
+    ];
+    $ops['file_selection'] = [
+      'title' => $this->t('Widget Input for Selected Type'),
+      'form' => static::FILE_SELECTION_FORM,
+      'values' => [
+        'node' => $this->nodeId,
+      ],
+    ];
+
+    return $ops;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getNextParameters($cached_values) {
+    return parent::getNextParameters($cached_values) + ['node' => $this->nodeId];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getPreviousParameters($cached_values) {
+    return parent::getPreviousParameters($cached_values) + ['node' => $this->nodeId];
+  }
+
+}
diff --git a/src/Form/AddChildrenWizard/Access.php b/src/Form/AddChildrenWizard/Access.php
new file mode 100644
index 00000000..0adafde5
--- /dev/null
+++ b/src/Form/AddChildrenWizard/Access.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace Drupal\islandora\Form\AddChildrenWizard;
+
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Access\AccessResultInterface;
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\Routing\RouteMatch;
+use Drupal\islandora\IslandoraUtils;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Access checker.
+ *
+ * The _wizard/_form route enhancers do not really allow for access checking
+ * things, so let's roll it separately for now.
+ */
+class Access implements ContainerInjectionInterface {
+
+  /**
+   * The Islandora utils service.
+   *
+   * @var \Drupal\islandora\IslandoraUtils
+   */
+  protected IslandoraUtils $utils;
+
+  /**
+   * Constructor.
+   */
+  public function __construct(IslandoraUtils $utils) {
+    $this->utils = $utils;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) : self {
+    return new static(
+      $container->get('islandora.utils')
+    );
+  }
+
+  /**
+   * Check if the user can create any "Islandora" nodes and media.
+   *
+   * @param \Drupal\Core\Routing\RouteMatch $route_match
+   *   The current routing match.
+   *
+   * @return \Drupal\Core\Access\AccessResultInterface
+   *   Whether we can or cannot show the "thing".
+   */
+  public function childAccess(RouteMatch $route_match) : AccessResultInterface {
+    return AccessResult::allowedIf($this->utils->canCreateIslandoraEntity('node', 'node_type'))
+      ->andIf($this->mediaAccess($route_match));
+
+  }
+
+  /**
+   * Check if the user can create any "Islandora" media.
+   *
+   * @param \Drupal\Core\Routing\RouteMatch $route_match
+   *   The current routing match.
+   *
+   * @return \Drupal\Core\Access\AccessResultInterface
+   *   Whether we can or cannot show the "thing".
+   */
+  public function mediaAccess(RouteMatch $route_match) : AccessResultInterface {
+    return AccessResult::allowedIf($this->utils->canCreateIslandoraEntity('media', 'media_type'));
+  }
+
+}
diff --git a/src/Form/AddChildrenWizard/ChildBatchProcessor.php b/src/Form/AddChildrenWizard/ChildBatchProcessor.php
new file mode 100644
index 00000000..084e7816
--- /dev/null
+++ b/src/Form/AddChildrenWizard/ChildBatchProcessor.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace Drupal\islandora\Form\AddChildrenWizard;
+
+use Drupal\islandora\IslandoraUtils;
+use Drupal\node\NodeInterface;
+
+/**
+ * Children addition batch processor.
+ */
+class ChildBatchProcessor extends AbstractBatchProcessor {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNode($info, array $values) : NodeInterface {
+    $taxonomy_term_storage = $this->entityTypeManager->getStorage('taxonomy_term');
+    $node_storage = $this->entityTypeManager->getStorage('node');
+    $parent = $node_storage->load($values['node']);
+
+    // Create a node (with the filename?) (and also belonging to the target
+    // node).
+    /** @var \Drupal\node\NodeInterface $node */
+    $node = $node_storage->create([
+      'type' => $values['bundle'],
+      'title' => $this->getName($info, $values),
+      IslandoraUtils::MEMBER_OF_FIELD => $parent,
+      'uid' => $this->currentUser->id(),
+      'status' => NodeInterface::PUBLISHED,
+      IslandoraUtils::MODEL_FIELD => ($values['model'] ?
+        $taxonomy_term_storage->load($values['model']) :
+        NULL),
+    ]);
+
+    if ($node->save() !== SAVED_NEW) {
+      throw new \Exception("Failed to create node.");
+    }
+
+    return $node;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function batchProcessFinished($success, $results, $operations): void {
+    if ($success) {
+      $this->messenger->addMessage($this->formatPlural(
+        $results['count'],
+        'Added 1 child node.',
+        'Added @count child nodes.'
+      ));
+    }
+
+    parent::batchProcessFinished($success, $results, $operations);
+  }
+
+}
diff --git a/src/Form/AddChildrenWizard/ChildFileSelectionForm.php b/src/Form/AddChildrenWizard/ChildFileSelectionForm.php
new file mode 100644
index 00000000..9783d082
--- /dev/null
+++ b/src/Form/AddChildrenWizard/ChildFileSelectionForm.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace Drupal\islandora\Form\AddChildrenWizard;
+
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Url;
+
+/**
+ * Children addition wizard's second step.
+ */
+class ChildFileSelectionForm extends AbstractFileSelectionForm {
+
+  public const BATCH_PROCESSOR = 'islandora.upload_children.batch_processor';
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return 'islandora_add_children_wizard_file_selection';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    parent::submitForm($form, $form_state);
+
+    $cached_values = $form_state->getTemporaryValue('wizard');
+    $form_state->setRedirectUrl(Url::fromUri("internal:/node/{$cached_values['node']}/members"));
+  }
+
+}
diff --git a/src/Form/AddChildrenWizard/ChildForm.php b/src/Form/AddChildrenWizard/ChildForm.php
new file mode 100644
index 00000000..0b9a197f
--- /dev/null
+++ b/src/Form/AddChildrenWizard/ChildForm.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace Drupal\islandora\Form\AddChildrenWizard;
+
+/**
+ * Bulk children addition wizard base form.
+ */
+class ChildForm extends AbstractForm {
+
+  const TEMPSTORE_ID = 'islandora.upload_children';
+  const TYPE_SELECTION_FORM = ChildTypeSelectionForm::class;
+  const FILE_SELECTION_FORM = ChildFileSelectionForm::class;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getMachineName() {
+    return strtr("islandora_add_children_wizard__{userid}__{nodeid}", [
+      '{userid}' => $this->currentUser->id(),
+      '{nodeid}' => $this->nodeId,
+    ]);
+  }
+
+}
diff --git a/src/Form/AddChildrenWizard/ChildTypeSelectionForm.php b/src/Form/AddChildrenWizard/ChildTypeSelectionForm.php
new file mode 100644
index 00000000..f5795997
--- /dev/null
+++ b/src/Form/AddChildrenWizard/ChildTypeSelectionForm.php
@@ -0,0 +1,157 @@
+<?php
+
+namespace Drupal\islandora\Form\AddChildrenWizard;
+
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\islandora\IslandoraUtils;
+
+/**
+ * Children addition wizard's first step.
+ */
+class ChildTypeSelectionForm extends MediaTypeSelectionForm {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() : string {
+    return 'islandora_add_children_type_selection';
+  }
+
+  /**
+   * Memoization for ::getNodeBundleOptions().
+   *
+   * @var array|null
+   */
+  protected ?array $nodeBundleOptions = NULL;
+
+  /**
+   * Indicate presence of model field on node bundles.
+   *
+   * Populated as a side effect of ::getNodeBundleOptions().
+   *
+   * @var array|null
+   */
+  protected ?array $nodeBundleHasModelField = NULL;
+
+  /**
+   * Helper; get the node bundle options available to the current user.
+   *
+   * @return array
+   *   An associative array mapping node bundle machine names to their human-
+   *   readable labels.
+   */
+  protected function getNodeBundleOptions() : array {
+    if ($this->nodeBundleOptions === NULL) {
+      $this->nodeBundleOptions = [];
+      $this->nodeBundleHasModelField = [];
+
+      $access_handler = $this->entityTypeManager->getAccessControlHandler('node');
+      foreach ($this->entityTypeBundleInfo->getBundleInfo('node') as $bundle => $info) {
+        $access = $access_handler->createAccess(
+          $bundle,
+          NULL,
+          [],
+          TRUE
+        );
+        $this->cacheableMetadata->addCacheableDependency($access);
+        if (!$access->isAllowed()) {
+          continue;
+        }
+        $this->nodeBundleOptions[$bundle] = $info['label'];
+        $fields = $this->entityFieldManager->getFieldDefinitions('node', $bundle);
+        $this->nodeBundleHasModelField[$bundle] = array_key_exists(IslandoraUtils::MODEL_FIELD, $fields);
+      }
+    }
+
+    return $this->nodeBundleOptions;
+  }
+
+  /**
+   * Generates a mapping of taxonomy term IDs to their names.
+   *
+   * @return \Generator
+   *   The mapping of taxonomy term IDs to their names.
+   *
+   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
+   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
+   */
+  protected function getModelOptions() : \Generator {
+    $terms = $this->entityTypeManager->getStorage('taxonomy_term')
+      ->loadTree('islandora_models', 0, NULL, TRUE);
+    foreach ($terms as $term) {
+      yield $term->id() => $term->getName();
+    }
+  }
+
+  /**
+   * Helper; map node bundles supporting the "has model" field, for #states.
+   *
+   * @return \Generator
+   *   Yields associative array mapping the string 'value' to the bundles which
+   *   have the given field.
+   */
+  protected function mapModelStates() : \Generator {
+    $this->getNodeBundleOptions();
+    foreach (array_keys(array_filter($this->nodeBundleHasModelField)) as $bundle) {
+      yield ['value' => $bundle];
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildForm(array $form, FormStateInterface $form_state) {
+    $this->cacheableMetadata = CacheableMetadata::createFromRenderArray($form)
+      ->addCacheContexts([
+        'url',
+        'url.query_args',
+      ]);
+    $cached_values = $form_state->getTemporaryValue('wizard');
+
+    $form['bundle'] = [
+      '#type' => 'select',
+      '#title' => $this->t('Content Type'),
+      '#description' => $this->t('Each child created will have this content type.'),
+      '#empty_value' => '',
+      '#default_value' => $cached_values['bundle'] ?? '',
+      '#options' => $this->getNodeBundleOptions(),
+      '#required' => TRUE,
+    ];
+
+    $model_states = iterator_to_array($this->mapModelStates());
+    $form['model'] = [
+      '#type' => 'select',
+      '#title' => $this->t('Model'),
+      '#description' => $this->t('Each child will be tagged with this model.'),
+      '#options' => iterator_to_array($this->getModelOptions()),
+      '#empty_value' => '',
+      '#default_value' => $cached_values['model'] ?? '',
+      '#states' => [
+        'visible' => [
+          ':input[name="bundle"]' => $model_states,
+        ],
+        'required' => [
+          ':input[name="bundle"]' => $model_states,
+        ],
+      ],
+    ];
+
+    $this->cacheableMetadata->applyTo($form);
+    return parent::buildForm($form, $form_state);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static function keysToSave() : array {
+    return array_merge(
+      parent::keysToSave(),
+      [
+        'bundle',
+        'model',
+      ]
+    );
+  }
+
+}
diff --git a/src/Form/AddChildrenWizard/FieldTrait.php b/src/Form/AddChildrenWizard/FieldTrait.php
new file mode 100644
index 00000000..830f95cd
--- /dev/null
+++ b/src/Form/AddChildrenWizard/FieldTrait.php
@@ -0,0 +1,66 @@
+<?php
+
+namespace Drupal\islandora\Form\AddChildrenWizard;
+
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\Core\Field\FieldDefinitionInterface;
+
+/**
+ * Field lookup helper trait.
+ */
+trait FieldTrait {
+
+  use MediaTypeTrait;
+
+  /**
+   * The entity field manager service.
+   *
+   * @var \Drupal\Core\Entity\EntityFieldManagerInterface|null
+   */
+  protected ?EntityFieldManagerInterface $entityFieldManager = NULL;
+
+  /**
+   * Helper; get field instance, given our required values.
+   *
+   * @param array $values
+   *   See ::getMediaType() for which values are required.
+   *
+   * @return \Drupal\Core\Field\FieldDefinitionInterface
+   *   The target field.
+   *
+   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
+   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
+   */
+  protected function getField(array $values): FieldDefinitionInterface {
+    $media_type = $this->getMediaType($values);
+    $media_source = $media_type->getSource();
+    $source_field = $media_source->getSourceFieldDefinition($media_type);
+
+    $fields = $this->entityFieldManager()->getFieldDefinitions('media', $media_type->id());
+
+    return $fields[$source_field->getFieldStorageDefinition()->getName()] ??
+      $media_source->createSourceField($media_type);
+  }
+
+  /**
+   * Lazy-initialization of the entity field manager service.
+   *
+   * @return \Drupal\Core\Entity\EntityFieldManagerInterface
+   *   The entity field manager service.
+   */
+  protected function entityFieldManager() : EntityFieldManagerInterface {
+    if ($this->entityFieldManager === NULL) {
+      $this->setEntityFieldManager(\Drupal::service('entity_field.manager'));
+    }
+    return $this->entityFieldManager;
+  }
+
+  /**
+   * Setter for entity field manager.
+   */
+  public function setEntityFieldManager(EntityFieldManagerInterface $entity_field_manager) : self {
+    $this->entityFieldManager = $entity_field_manager;
+    return $this;
+  }
+
+}
diff --git a/src/Form/AddChildrenWizard/MediaBatchProcessor.php b/src/Form/AddChildrenWizard/MediaBatchProcessor.php
new file mode 100644
index 00000000..9a54f03b
--- /dev/null
+++ b/src/Form/AddChildrenWizard/MediaBatchProcessor.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Drupal\islandora\Form\AddChildrenWizard;
+
+use Drupal\node\NodeInterface;
+
+/**
+ * Media addition batch processor.
+ */
+class MediaBatchProcessor extends AbstractBatchProcessor {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNode($info, array $values) : NodeInterface {
+    return $this->entityTypeManager->getStorage('node')->load($values['node']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function batchProcessFinished($success, $results, $operations): void {
+    if ($success) {
+      $this->messenger->addMessage($this->formatPlural(
+        $results['count'],
+        'Added 1 media.',
+        'Added @count media.'
+      ));
+    }
+
+    parent::batchProcessFinished($success, $results, $operations);
+  }
+
+}
diff --git a/src/Form/AddChildrenWizard/MediaFileSelectionForm.php b/src/Form/AddChildrenWizard/MediaFileSelectionForm.php
new file mode 100644
index 00000000..534c7309
--- /dev/null
+++ b/src/Form/AddChildrenWizard/MediaFileSelectionForm.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace Drupal\islandora\Form\AddChildrenWizard;
+
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Url;
+
+/**
+ * Media addition wizard's second step.
+ */
+class MediaFileSelectionForm extends AbstractFileSelectionForm {
+
+  public const BATCH_PROCESSOR = 'islandora.upload_media.batch_processor';
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return 'islandora_add_media_wizard_file_selection';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    parent::submitForm($form, $form_state);
+
+    $cached_values = $form_state->getTemporaryValue('wizard');
+    $form_state->setRedirectUrl(Url::fromUri("internal:/node/{$cached_values['node']}/media"));
+  }
+
+}
diff --git a/src/Form/AddChildrenWizard/MediaForm.php b/src/Form/AddChildrenWizard/MediaForm.php
new file mode 100644
index 00000000..2e6fa217
--- /dev/null
+++ b/src/Form/AddChildrenWizard/MediaForm.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace Drupal\islandora\Form\AddChildrenWizard;
+
+/**
+ * Bulk children addition wizard base form.
+ */
+class MediaForm extends AbstractForm {
+
+  const TEMPSTORE_ID = 'islandora.upload_media';
+  const TYPE_SELECTION_FORM = MediaTypeSelectionForm::class;
+  const FILE_SELECTION_FORM = MediaFileSelectionForm::class;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getMachineName() {
+    return strtr("islandora_add_media_wizard__{userid}__{nodeid}", [
+      '{userid}' => $this->currentUser->id(),
+      '{nodeid}' => $this->nodeId,
+    ]);
+  }
+
+}
diff --git a/src/Form/AddChildrenWizard/MediaTypeSelectionForm.php b/src/Form/AddChildrenWizard/MediaTypeSelectionForm.php
new file mode 100644
index 00000000..b06d004d
--- /dev/null
+++ b/src/Form/AddChildrenWizard/MediaTypeSelectionForm.php
@@ -0,0 +1,227 @@
+<?php
+
+namespace Drupal\islandora\Form\AddChildrenWizard;
+
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Form\FormBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\islandora\IslandoraUtils;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Children addition wizard's first step.
+ */
+class MediaTypeSelectionForm extends FormBase {
+
+  /**
+   * Cacheable metadata that is instantiated and used internally.
+   *
+   * @var \Drupal\Core\Cache\CacheableMetadata|null
+   */
+  protected ?CacheableMetadata $cacheableMetadata = NULL;
+
+  /**
+   * The entity type bundle info service.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeBundleInfoInterface|null
+   */
+  protected ?EntityTypeBundleInfoInterface $entityTypeBundleInfo;
+
+  /**
+   * The entity type manager service.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface|null
+   */
+  protected ?EntityTypeManagerInterface $entityTypeManager;
+
+  /**
+   * The entity field manager service.
+   *
+   * @var \Drupal\Core\Entity\EntityFieldManagerInterface|null
+   */
+  protected ?EntityFieldManagerInterface $entityFieldManager;
+
+  /**
+   * The Islandora Utils service.
+   *
+   * @var \Drupal\islandora\IslandoraUtils|null
+   */
+  protected ?IslandoraUtils $utils;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) : self {
+    $instance = parent::create($container);
+
+    $instance->entityTypeBundleInfo = $container->get('entity_type.bundle.info');
+    $instance->entityTypeManager = $container->get('entity_type.manager');
+    $instance->entityFieldManager = $container->get('entity_field.manager');
+    $instance->utils = $container->get('islandora.utils');
+
+    return $instance;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() : string {
+    return 'islandora_add_media_type_selection';
+  }
+
+  /**
+   * Memoization for ::getMediaBundleOptions().
+   *
+   * @var array|null
+   */
+  protected ?array $mediaBundleOptions = NULL;
+
+  /**
+   * Indicate presence of usage field on media bundles.
+   *
+   * Populated as a side effect in ::getMediaBundleOptions().
+   *
+   * @var array|null
+   */
+  protected ?array $mediaBundleUsageField = NULL;
+
+  /**
+   * Helper; get options for media types.
+   *
+   * @return array
+   *   An associative array mapping the machine name of the media type to its
+   *   human-readable label.
+   */
+  protected function getMediaBundleOptions() : array {
+    if ($this->mediaBundleOptions === NULL) {
+      $this->mediaBundleOptions = [];
+      $this->mediaBundleUsageField = [];
+
+      $access_handler = $this->entityTypeManager->getAccessControlHandler('media');
+      foreach ($this->entityTypeBundleInfo->getBundleInfo('media') as $bundle => $info) {
+        if (!$this->utils->isIslandoraType('media', $bundle)) {
+          continue;
+        }
+        $access = $access_handler->createAccess(
+          $bundle,
+          NULL,
+          [],
+          TRUE
+        );
+        $this->cacheableMetadata->addCacheableDependency($access);
+        if (!$access->isAllowed()) {
+          continue;
+        }
+        $this->mediaBundleOptions[$bundle] = $info['label'];
+        $fields = $this->entityFieldManager->getFieldDefinitions('media', $bundle);
+        $this->mediaBundleUsageField[$bundle] = array_key_exists(IslandoraUtils::MEDIA_USAGE_FIELD, $fields);
+      }
+    }
+
+    return $this->mediaBundleOptions;
+  }
+
+  /**
+   * Helper; list the terms of the "islandora_media_use" vocabulary.
+   *
+   * @return \Generator
+   *   Generates term IDs as keys mapping to term names.
+   *
+   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
+   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
+   */
+  protected function getMediaUseOptions() : \Generator {
+    /** @var \Drupal\taxonomy\TermInterface[] $terms */
+    $terms = $this->entityTypeManager->getStorage('taxonomy_term')
+      ->loadTree('islandora_media_use', 0, NULL, TRUE);
+
+    foreach ($terms as $term) {
+      yield $term->id() => $term->getName();
+    }
+  }
+
+  /**
+   * Helper; map media types supporting the usage field for use with #states.
+   *
+   * @return \Generator
+   *   Yields associative array mapping the string 'value' to the bundles which
+   *   have the given field.
+   */
+  protected function mapUseStates(): \Generator {
+    $this->getMediaBundleOptions();
+    foreach (array_keys(array_filter($this->mediaBundleUsageField)) as $bundle) {
+      yield ['value' => $bundle];
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildForm(array $form, FormStateInterface $form_state) {
+    $this->cacheableMetadata = CacheableMetadata::createFromRenderArray($form)
+      ->addCacheContexts([
+        'url',
+        'url.query_args',
+      ]);
+    $cached_values = $form_state->getTemporaryValue('wizard');
+
+    $form['media_type'] = [
+      '#type' => 'select',
+      '#title' => $this->t('Media Type'),
+      '#description' => $this->t('Each media created will have this type.'),
+      '#empty_value' => '',
+      '#default_value' => $cached_values['media_type'] ?? '',
+      '#options' => $this->getMediaBundleOptions(),
+      '#required' => TRUE,
+    ];
+    $use_states = iterator_to_array($this->mapUseStates());
+    $form['use'] = [
+      '#type' => 'checkboxes',
+      '#title' => $this->t('Usage'),
+      '#description' => $this->t('Defined by <a target="_blank" href=":url">Portland Common Data Model: Use Extension</a>. "Original File" will trigger creation of derivatives.', [
+        ':url' => 'https://pcdm.org/2015/05/12/use',
+      ]),
+      '#options' => iterator_to_array($this->getMediaUseOptions()),
+      '#default_value' => $cached_values['use'] ?? [],
+      '#states' => [
+        'visible' => [
+          ':input[name="media_type"]' => $use_states,
+        ],
+        'required' => [
+          ':input[name="media_type"]' => $use_states,
+        ],
+      ],
+    ];
+
+    $this->cacheableMetadata->applyTo($form);
+    return $form;
+  }
+
+  /**
+   * Helper; enumerate keys to persist in form state.
+   *
+   * @return string[]
+   *   The keys to be persisted in our temp value in form state.
+   */
+  protected static function keysToSave() : array {
+    return [
+      'media_type',
+      'use',
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    $cached_values = $form_state->getTemporaryValue('wizard');
+    foreach (static::keysToSave() as $key) {
+      $cached_values[$key] = $form_state->getValue($key);
+    }
+    $form_state->setTemporaryValue('wizard', $cached_values);
+  }
+
+}
diff --git a/src/Form/AddChildrenWizard/MediaTypeTrait.php b/src/Form/AddChildrenWizard/MediaTypeTrait.php
new file mode 100644
index 00000000..36cf6ff2
--- /dev/null
+++ b/src/Form/AddChildrenWizard/MediaTypeTrait.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace Drupal\islandora\Form\AddChildrenWizard;
+
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\media\MediaTypeInterface;
+
+/**
+ * Media type lookup helper trait.
+ */
+trait MediaTypeTrait {
+
+  /**
+   * The entity type manager service.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface|null
+   */
+  protected ?EntityTypeManagerInterface $entityTypeManager = NULL;
+
+  /**
+   * Helper; get media type, given our required values.
+   *
+   * @param array $values
+   *   An associative array which must contain at least:
+   *   - media_type: The machine name of the media type to load.
+   *
+   * @return \Drupal\media\MediaTypeInterface
+   *   The loaded media type.
+   *
+   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
+   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
+   */
+  protected function getMediaType(array $values): MediaTypeInterface {
+    return $this->entityTypeManager()->getStorage('media_type')->load($values['media_type']);
+  }
+
+  /**
+   * Lazy-initialization of the entity type manager service.
+   *
+   * @return \Drupal\Core\Entity\EntityTypeManagerInterface
+   *   The entity type manager service.
+   */
+  protected function entityTypeManager() : EntityTypeManagerInterface {
+    if ($this->entityTypeManager === NULL) {
+      $this->setEntityTypeManager(\Drupal::service('entity_type.manager'));
+    }
+    return $this->entityTypeManager;
+  }
+
+  /**
+   * Setter for the entity type manager service.
+   */
+  public function setEntityTypeManager(EntityTypeManagerInterface $entity_type_manager) : self {
+    $this->entityTypeManager = $entity_type_manager;
+    return $this;
+  }
+
+}
diff --git a/src/Form/AddChildrenWizard/WizardTrait.php b/src/Form/AddChildrenWizard/WizardTrait.php
new file mode 100644
index 00000000..dd56450f
--- /dev/null
+++ b/src/Form/AddChildrenWizard/WizardTrait.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Drupal\islandora\Form\AddChildrenWizard;
+
+use Drupal\Component\Plugin\PluginManagerInterface;
+use Drupal\Core\Field\FieldDefinitionInterface;
+use Drupal\Core\Field\WidgetInterface;
+
+/**
+ * Wizard/widget lookup helper trait.
+ */
+trait WizardTrait {
+
+  use FieldTrait;
+
+  /**
+   * The widget plugin manager service.
+   *
+   * @var \Drupal\Core\Field\WidgetPluginManager
+   */
+  protected PluginManagerInterface $widgetPluginManager;
+
+  /**
+   * Helper; get the base widget for the given field.
+   *
+   * @param \Drupal\Core\Field\FieldDefinitionInterface $field
+   *   The field for which get obtain the widget.
+   *
+   * @return \Drupal\Core\Field\WidgetInterface
+   *   The widget.
+   */
+  protected function getWidget(FieldDefinitionInterface $field): WidgetInterface {
+    return $this->widgetPluginManager->getInstance([
+      'field_definition' => $field,
+      'form_mode' => 'default',
+      'prepare' => TRUE,
+    ]);
+  }
+
+}