diff --git a/islandora.routing.yml b/islandora.routing.yml
index e2cb7d35..86d13482 100644
--- a/islandora.routing.yml
+++ b/islandora.routing.yml
@@ -39,13 +39,13 @@ islandora.add_member_to_node_page:
 islandora.upload_children:
   path: '/node/{node}/members/upload/{step}'
   defaults:
-    _wizard: '\Drupal\islandora\Form\AddChildrenWizard\Form'
+    _wizard: '\Drupal\islandora\Form\AddChildrenWizard\ChildForm'
     _title: 'Upload Children'
-    step: 'child_type'
+    step: 'type_selection'
   options:
     _admin_route: 'TRUE'
   requirements:
-    _custom_access: '\Drupal\islandora\Form\AddChildrenWizard\Access::checkAccess'
+    _custom_access: '\Drupal\islandora\Form\AddChildrenWizard\Access::childAccess'
 
 islandora.add_media_to_node_page:
   path: '/node/{node}/media/add'
@@ -59,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 1dbed028..6dfa158d 100644
--- a/islandora.services.yml
+++ b/islandora.services.yml
@@ -65,3 +65,11 @@ services:
       - '@entity_type.manager'
       - '@database'
       - '@current_user'
+      - '@messenger'
+  islandora.upload_media.batch_processor:
+    class: Drupal\islandora\Form\AddChildrenWizard\MediaBatchProcessor
+    arguments:
+      - '@entity_type.manager'
+      - '@database'
+      - '@current_user'
+      - '@messenger'
diff --git a/src/Form/AddChildrenForm.php b/src/Form/AddChildrenForm.php
index adad9dae..b349f570 100644
--- a/src/Form/AddChildrenForm.php
+++ b/src/Form/AddChildrenForm.php
@@ -13,7 +13,7 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
 /**
  * Form that lets users upload one or more files as children to a resource node.
  *
- * @deprecated Replaced with the "wizard" appraach.
+ * @deprecated Use the \Drupal\islandora\Form\AddChildrenWizard\ChildForm instead.
  */
 class AddChildrenForm extends AddMediaForm {
 
diff --git a/src/Form/AddChildrenWizard/AbstractBatchProcessor.php b/src/Form/AddChildrenWizard/AbstractBatchProcessor.php
new file mode 100644
index 00000000..f5b63500
--- /dev/null
+++ b/src/Form/AddChildrenWizard/AbstractBatchProcessor.php
@@ -0,0 +1,254 @@
+<?php
+
+namespace Drupal\islandora\Form\AddChildrenWizard;
+
+use Drupal\Core\Database\Connection;
+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;
+
+/**
+ * Children 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;
+
+  /**
+   * Constructor.
+   */
+  public function __construct(
+    EntityTypeManagerInterface $entity_type_manager,
+    Connection $database,
+    AccountProxyInterface $current_user,
+    MessengerInterface $messenger
+  ) {
+    $this->entityTypeManager = $entity_type_manager;
+    $this->database = $database;
+    $this->currentUser = $current_user;
+    $this->messenger = $messenger;
+  }
+
+  /**
+   * Implements callback_batch_operation() for our child addition batch.
+   */
+  public function batchOperation($delta, $info, array $values, &$context) {
+    $transaction = $this->database->startTransaction();
+
+    try {
+      $entities[] = $this->persistFile($info, $values);
+      $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'] += 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 array $info
+   *   An associative array containing at least:
+   *   - target_id: The id of the file to load.
+   *
+   * @return \Drupal\file\FileInterface
+   *   The loaded file.
+   *
+   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
+   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
+   */
+  protected function getFile(array $info) : FileInterface {
+    return $this->entityTypeManager->getStorage('file')->load($info['target_id']);
+  }
+
+  /**
+   * Loads and marks the target file as permanent.
+   *
+   * @param array $info
+   *   An associative array containing at least:
+   *   - target_id: The id of the file to load.
+   *
+   * @return \Drupal\file\FileInterface
+   *   The loaded file, after it has been marked as permanent.
+   *
+   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
+   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
+   * @throws \Drupal\Core\Entity\EntityStorageException
+   */
+  protected function persistFile(array $info) : FileInterface {
+    $file = $this->getFile($info);
+    $file->setPermanent();
+    if ($file->save() !== SAVED_UPDATED) {
+      throw new \Exception("Failed to update file '{$file->id()}' to be permanent.");
+    }
+
+    return $file;
+  }
+
+  /**
+   * Get the node to which to attach our media.
+   *
+   * @param array $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(array $info, array $values) : NodeInterface;
+
+  /**
+   * 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 array $info
+   *   The widget info, which should have a 'target_id' identifying the target
+   *   file.
+   * @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, array $info, array $values) : MediaInterface {
+    $taxonomy_term_storage = $this->entityTypeManager->getStorage('taxonomy_term');
+
+    $file = $this->getFile($info);
+
+    // 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' => $file->getFilename(),
+        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 for file '{$file->id()}.");
+    }
+
+    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/FileSelectionForm.php b/src/Form/AddChildrenWizard/AbstractFileSelectionForm.php
similarity index 67%
rename from src/Form/AddChildrenWizard/FileSelectionForm.php
rename to src/Form/AddChildrenWizard/AbstractFileSelectionForm.php
index 2e505c09..c8436a1a 100644
--- a/src/Form/AddChildrenWizard/FileSelectionForm.php
+++ b/src/Form/AddChildrenWizard/AbstractFileSelectionForm.php
@@ -3,7 +3,6 @@
 namespace Drupal\islandora\Form\AddChildrenWizard;
 
 use Drupal\Core\Batch\BatchBuilder;
-use Drupal\Core\Database\Connection;
 use Drupal\Core\Field\FieldDefinitionInterface;
 use Drupal\Core\Field\FieldItemList;
 use Drupal\Core\Field\FieldStorageDefinitionInterface;
@@ -11,27 +10,17 @@ use Drupal\Core\Field\WidgetInterface;
 use Drupal\Core\Form\FormBase;
 use Drupal\Core\Form\FormStateInterface;
 use Drupal\Core\Session\AccountProxyInterface;
-use Drupal\Core\Url;
 use Drupal\media\MediaTypeInterface;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 
 /**
  * Children addition wizard's second step.
  */
-class FileSelectionForm extends FormBase {
+abstract class AbstractFileSelectionForm extends FormBase {
 
-  use WizardTrait {
-    WizardTrait::getField as doGetField;
-    WizardTrait::getMediaType as doGetMediaType;
-    WizardTrait::getWidget as doGetWidget;
-  }
+  use WizardTrait;
 
-  /**
-   * The database connection serivce.
-   *
-   * @var \Drupal\Core\Database\Connection|null
-   */
-  protected ?Connection $database;
+  const BATCH_PROCESSOR = 'abstract.abstract';
 
   /**
    * The current user.
@@ -43,9 +32,9 @@ class FileSelectionForm extends FormBase {
   /**
    * The batch processor service.
    *
-   * @var \Drupal\islandora\Form\AddChildrenWizard\ChildBatchProcessor|null
+   * @var \Drupal\islandora\Form\AddChildrenWizard\AbstractBatchProcessor|null
    */
-  protected ?ChildBatchProcessor $batchProcessor;
+  protected ?AbstractBatchProcessor $batchProcessor;
 
   /**
    * {@inheritdoc}
@@ -56,21 +45,13 @@ class FileSelectionForm extends FormBase {
     $instance->entityTypeManager = $container->get('entity_type.manager');
     $instance->widgetPluginManager = $container->get('plugin.manager.field.widget');
     $instance->entityFieldManager = $container->get('entity_field.manager');
-    $instance->database = $container->get('database');
     $instance->currentUser = $container->get('current_user');
 
-    $instance->batchProcessor = $container->get('islandora.upload_children.batch_processor');
+    $instance->batchProcessor = $container->get(static::BATCH_PROCESSOR);
 
     return $instance;
   }
 
-  /**
-   * {@inheritdoc}
-   */
-  public function getFormId() {
-    return 'islandora_add_children_wizard_file_selection';
-  }
-
   /**
    * Helper; get the media type, based off discovering from form state.
    *
@@ -83,8 +64,8 @@ class FileSelectionForm extends FormBase {
    * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
    * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
    */
-  protected function getMediaType(FormStateInterface $form_state): MediaTypeInterface {
-    return $this->doGetMediaType($form_state->getTemporaryValue('wizard'));
+  protected function getMediaTypeFromFormState(FormStateInterface $form_state): MediaTypeInterface {
+    return $this->getMediaType($form_state->getTemporaryValue('wizard'));
   }
 
   /**
@@ -96,10 +77,10 @@ class FileSelectionForm extends FormBase {
    * @return \Drupal\Core\Field\FieldDefinitionInterface
    *   The field definition.
    */
-  protected function getField(FormStateInterface $form_state): FieldDefinitionInterface {
+  protected function getFieldFromFormState(FormStateInterface $form_state): FieldDefinitionInterface {
     $cached_values = $form_state->getTemporaryValue('wizard');
 
-    $field = $this->doGetField($cached_values);
+    $field = $this->getField($cached_values);
     $field->getFieldStorageDefinition()->set('cardinality', FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED);
 
     return $field;
@@ -114,8 +95,8 @@ class FileSelectionForm extends FormBase {
    * @return \Drupal\Core\Field\WidgetInterface
    *   The widget.
    */
-  protected function getWidget(FormStateInterface $form_state): WidgetInterface {
-    return $this->doGetWidget($this->getField($form_state));
+  protected function getWidgetFromFormState(FormStateInterface $form_state): WidgetInterface {
+    return $this->getWidget($this->getFieldFromFormState($form_state));
   }
 
   /**
@@ -125,12 +106,12 @@ class FileSelectionForm extends FormBase {
     // 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->getField($form_state);
-    $items = FieldItemList::createInstance($field, $field->getName(), $this->getMediaType($form_state)->getTypedData());
+    $field = $this->getFieldFromFormState($form_state);
+    $items = FieldItemList::createInstance($field, $field->getName(), $this->getMediaTypeFromFormState($form_state)->getTypedData());
 
     $form['#tree'] = TRUE;
     $form['#parents'] = [];
-    $widget = $this->getWidget($form_state);
+    $widget = $this->getWidgetFromFormState($form_state);
     $form['files'] = $widget->form(
       $items,
       $form,
@@ -146,21 +127,20 @@ class FileSelectionForm extends FormBase {
   public function submitForm(array &$form, FormStateInterface $form_state) {
     $cached_values = $form_state->getTemporaryValue('wizard');
 
-    $widget = $this->getWidget($form_state);
+    $widget = $this->getWidgetFromFormState($form_state);
     $builder = (new BatchBuilder())
       ->setTitle($this->t('Creating children...'))
       ->setInitMessage($this->t('Initializing...'))
       ->setFinishCallback([$this->batchProcessor, 'batchProcessFinished']);
-    $values = $form_state->getValue($this->doGetField($cached_values)->getName());
+    $values = $form_state->getValue($this->getField($cached_values)->getName());
     $massaged_values = $widget->massageFormValues($values, $form, $form_state);
-    foreach ($massaged_values as $delta => $file) {
+    foreach ($massaged_values as $delta => $info) {
       $builder->addOperation(
         [$this->batchProcessor, 'batchOperation'],
-        [$delta, $file, $cached_values]
+        [$delta, $info, $cached_values]
       );
     }
     batch_set($builder->toArray());
-    $form_state->setRedirectUrl(Url::fromUri("internal:/node/{$cached_values['node']}/members"));
   }
 
 }
diff --git a/src/Form/AddChildrenWizard/Form.php b/src/Form/AddChildrenWizard/AbstractForm.php
similarity index 82%
rename from src/Form/AddChildrenWizard/Form.php
rename to src/Form/AddChildrenWizard/AbstractForm.php
index 97ac8b23..251953f6 100644
--- a/src/Form/AddChildrenWizard/Form.php
+++ b/src/Form/AddChildrenWizard/AbstractForm.php
@@ -14,7 +14,12 @@ use Symfony\Component\EventDispatcher\EventDispatcherInterface;
 /**
  * Bulk children addition wizard base form.
  */
-class Form extends FormWizardBase {
+abstract class AbstractForm extends FormWizardBase {
+
+  const TEMPSTORE_ID = 'abstract.abstract';
+  const TYPE_SELECTION_FORM = MediaTypeSelectionForm::class;
+  const FILE_SELECTION_FORM = AbstractFileSelectionForm::class;
+  const BATCH_PROCESSOR_SERVICE_NAME = 'abstract.abstract';
 
   /**
    * The Islandora Utils service.
@@ -72,39 +77,28 @@ class Form extends FormWizardBase {
     return array_merge(
       parent::getParameters(),
       [
-        'tempstore_id' => 'islandora.upload_children',
+        'tempstore_id' => static::TEMPSTORE_ID,
         'current_user' => \Drupal::service('current_user'),
-        'batch_processor' => \Drupal::service('islandora.upload_children.batch_processor'),
       ]
     );
   }
 
-  /**
-   * {@inheritdoc}
-   */
-  public function getMachineName() {
-    return strtr("islandora_add_children_wizard__{userid}__{nodeid}", [
-      '{userid}' => $this->currentUser->id(),
-      '{nodeid}' => $this->nodeId,
-    ]);
-  }
-
   /**
    * {@inheritdoc}
    */
   public function getOperations($cached_values) {
     $ops = [];
 
-    $ops['child_type'] = [
+    $ops['type_selection'] = [
       'title' => $this->t('Type of children'),
-      'form' => TypeSelectionForm::class,
+      'form' => static::TYPE_SELECTION_FORM,
       'values' => [
         'node' => $this->nodeId,
       ],
     ];
-    $ops['child_files'] = [
+    $ops['file_selection'] = [
       'title' => $this->t('Files for children'),
-      'form' => FileSelectionForm::class,
+      'form' => static::FILE_SELECTION_FORM,
       'values' => [
         'node' => $this->nodeId,
       ],
diff --git a/src/Form/AddChildrenWizard/Access.php b/src/Form/AddChildrenWizard/Access.php
index 2a5cdbe2..0adafde5 100644
--- a/src/Form/AddChildrenWizard/Access.php
+++ b/src/Form/AddChildrenWizard/Access.php
@@ -49,15 +49,23 @@ class Access implements ContainerInjectionInterface {
    * @return \Drupal\Core\Access\AccessResultInterface
    *   Whether we can or cannot show the "thing".
    */
-  public function checkAccess(RouteMatch $route_match) : AccessResultInterface {
-    $can_create_media = $this->utils->canCreateIslandoraEntity('media', 'media_type');
-    $can_create_node = $this->utils->canCreateIslandoraEntity('node', 'node_type');
+  public function childAccess(RouteMatch $route_match) : AccessResultInterface {
+    return AccessResult::allowedIf($this->utils->canCreateIslandoraEntity('node', 'node_type'))
+      ->andIf($this->mediaAccess($route_match));
 
-    if ($can_create_media && $can_create_node) {
-      return AccessResult::allowed();
-    }
+  }
 
-    return AccessResult::forbidden();
+  /**
+   * 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
index ccd07b99..9be9902a 100644
--- a/src/Form/AddChildrenWizard/ChildBatchProcessor.php
+++ b/src/Form/AddChildrenWizard/ChildBatchProcessor.php
@@ -2,189 +2,57 @@
 
 namespace Drupal\islandora\Form\AddChildrenWizard;
 
-use Drupal\Core\Database\Connection;
-use Drupal\Core\Entity\EntityTypeManagerInterface;
-use Drupal\Core\Session\AccountProxyInterface;
-use Drupal\Core\Url;
 use Drupal\islandora\IslandoraUtils;
 use Drupal\node\NodeInterface;
-use Symfony\Component\HttpKernel\Exception\HttpException;
-use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
 
 /**
  * Children addition batch processor.
  */
-class ChildBatchProcessor {
-
-  use FieldTrait;
-
-  /**
-   * The entity type manager service.
-   *
-   * @var \Drupal\Core\Entity\EntityTypeManagerInterface|null
-   */
-  protected ?EntityTypeManagerInterface $entityTypeManager;
-
-  /**
-   * 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;
-
-  /**
-   * Constructor.
-   */
-  public function __construct(
-    EntityTypeManagerInterface $entity_type_manager,
-    Connection $database,
-    AccountProxyInterface $current_user
-  ) {
-    $this->entityTypeManager = $entity_type_manager;
-    $this->database = $database;
-    $this->currentUser = $current_user;
-  }
-
-  /**
-   * Implements callback_batch_operation() for our child addition batch.
-   */
-  public function batchOperation($delta, $info, array $values, &$context) {
-    $transaction = $this->database->startTransaction();
-
-    try {
-      $taxonomy_term_storage = $this->entityTypeManager->getStorage('taxonomy_term');
-
-      /** @var \Drupal\file\FileInterface $file */
-      $file = $this->entityTypeManager->getStorage('file')->load($info['target_id']);
-      $file->setPermanent();
-      if ($file->save() !== SAVED_UPDATED) {
-        throw new \Exception("Failed to update file '{$file->id()}' to be permanent.");
-      }
-
-      $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' => $file->getFilename(),
-        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 for file '{$file->id()}'.");
-      }
-
-      // 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' => $file->getFilename(),
-          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 for file '{$file->id()}.");
-      }
-
-      $context['results'] = array_merge_recursive($context['results'], [
-        'validation_violations' => $this->validationClassification([
-          $file,
-          $media,
-          $node,
-        ]),
-      ]);
-      $context['results']['count'] += 1;
-    }
-    catch (HttpExceptionInterface $e) {
-      $transaction->rollBack();
-      throw $e;
-    }
-    catch (\Exception $e) {
-      $transaction->rollBack();
-      throw new HttpException(500, $e->getMessage(), $e);
-    }
-  }
+class ChildBatchProcessor extends AbstractBatchProcessor {
 
   /**
-   * 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.
+   * {@inheritdoc}
    */
-  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();
-      }
+  protected function getNode(array $info, array $values) : NodeInterface {
+    $taxonomy_term_storage = $this->entityTypeManager->getStorage('taxonomy_term');
+    $node_storage = $this->entityTypeManager->getStorage('node');
+    $parent = $node_storage->load($values['node']);
+    $file = $this->getFile($info);
+
+    // 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' => $file->getFilename(),
+      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 for file '{$file->id()}'.");
     }
 
-    return $violations;
+    return $node;
   }
 
   /**
-   * Implements callback_batch_finished() for our child addition batch.
+   * {@inheritdoc}
    */
   public function batchProcessFinished($success, $results, $operations): void {
     if ($success) {
-      $this->messenger()->addMessage($this->formatPlural(
+      $this->messenger->addMessage($this->formatPlural(
         $results['count'],
         'Added 1 child node.',
         'Added @count child nodes.'
       ));
-      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 adding children.'));
     }
+
+    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..843cc85b
--- /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() {
+    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() {
+    return array_merge(
+      parent::keysToSave(),
+      [
+        'bundle',
+        'model',
+      ]
+    );
+  }
+
+}
diff --git a/src/Form/AddChildrenWizard/FieldTrait.php b/src/Form/AddChildrenWizard/FieldTrait.php
index 156000e3..743fc619 100644
--- a/src/Form/AddChildrenWizard/FieldTrait.php
+++ b/src/Form/AddChildrenWizard/FieldTrait.php
@@ -17,7 +17,7 @@ trait FieldTrait {
    *
    * @var \Drupal\Core\Entity\EntityFieldManagerInterface|null
    */
-  protected ?EntityFieldManagerInterface $entityFieldManager;
+  protected ?EntityFieldManagerInterface $entityFieldManager = NULL;
 
   /**
    * Helper; get field instance, given our required values.
diff --git a/src/Form/AddChildrenWizard/MediaBatchProcessor.php b/src/Form/AddChildrenWizard/MediaBatchProcessor.php
new file mode 100644
index 00000000..f069d0bf
--- /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(array $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/TypeSelectionForm.php b/src/Form/AddChildrenWizard/MediaTypeSelectionForm.php
similarity index 62%
rename from src/Form/AddChildrenWizard/TypeSelectionForm.php
rename to src/Form/AddChildrenWizard/MediaTypeSelectionForm.php
index 68237711..0e687d58 100644
--- a/src/Form/AddChildrenWizard/TypeSelectionForm.php
+++ b/src/Form/AddChildrenWizard/MediaTypeSelectionForm.php
@@ -14,7 +14,7 @@ use Symfony\Component\DependencyInjection\ContainerInterface;
 /**
  * Children addition wizard's first step.
  */
-class TypeSelectionForm extends FormBase {
+class MediaTypeSelectionForm extends FormBase {
 
   /**
    * Cacheable metadata that is instantiated and used internally.
@@ -61,87 +61,7 @@ class TypeSelectionForm extends FormBase {
    * {@inheritdoc}
    */
   public function getFormId() {
-    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];
-    }
+    return 'islandora_add_media_type_selection';
   }
 
   /**
@@ -237,33 +157,6 @@ class TypeSelectionForm extends FormBase {
       ]);
     $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,
-        ],
-      ],
-    ];
     $form['media_type'] = [
       '#type' => 'select',
       '#title' => $this->t('Media Type'),
@@ -297,17 +190,24 @@ class TypeSelectionForm extends FormBase {
   }
 
   /**
-   * {@inheritdoc}
+   * Helper; enumerate keys to persist in form state.
+   *
+   * @return string[]
+   *   The keys to be persisted in our temp value in form state.
    */
-  public function submitForm(array &$form, FormStateInterface $form_state) {
-    $keys = [
-      'bundle',
-      'model',
+  protected static function keysToSave() {
+    return [
       'media_type',
       'use',
     ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
     $cached_values = $form_state->getTemporaryValue('wizard');
-    foreach ($keys as $key) {
+    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
index e2caf2c2..5600210b 100644
--- a/src/Form/AddChildrenWizard/MediaTypeTrait.php
+++ b/src/Form/AddChildrenWizard/MediaTypeTrait.php
@@ -15,7 +15,7 @@ trait MediaTypeTrait {
    *
    * @var \Drupal\Core\Entity\EntityTypeManagerInterface|null
    */
-  protected ?EntityTypeManagerInterface $entityTypeManager;
+  protected ?EntityTypeManagerInterface $entityTypeManager = NULL;
 
   /**
    * Helper; get media type, given our required values.
diff --git a/src/Form/AddMediaForm.php b/src/Form/AddMediaForm.php
index 281abd9a..5078d1a8 100644
--- a/src/Form/AddMediaForm.php
+++ b/src/Form/AddMediaForm.php
@@ -23,6 +23,8 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
 
 /**
  * Form that lets users upload one or more files as children to a resource node.
+ *
+ * @deprecated Use the \Drupal\islandora\Form\AddChildrenWizard\MediaForm instead.
  */
 class AddMediaForm extends FormBase {