diff --git a/.cspell-project-words.txt b/.cspell-project-words.txt
index 4b16590c245543f44cb05056bb24c8c943d4d766..524c6feaa3bf04f438f0e331e7959075b8971b8f 100644
--- a/.cspell-project-words.txt
+++ b/.cspell-project-words.txt
@@ -1,3 +1,4 @@
+flac
 ~flexbox
 ~flexboxes
 graphqls
diff --git a/README.md b/README.md
index 950ea5b5c1cb0ac4322885be16e4767330b78e92..d1bc70ead2525b4de16383e2acc084ead46051c9 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
 # GraphQL Webform Drupal module
 A module to integrate webform with the graphql module.
 
-IMPORTANT: This is a module under active development and it does not support all 
-webform features and elements yet. Feel free to raise Feature Requests and 
+IMPORTANT: This is a module under active development and it does not support all
+webform features and elements yet. Feel free to raise Feature Requests and
 to contribute :)
 
 ## Pre-Requisites
@@ -205,34 +205,51 @@ submission failed because the form is closed.
 
 
 ### Create a webform submission when webform contains File elements
-If the webform contains a File field, you need to submit/create the file before 
-creating the submission itself. There is a `webformFileUpload` mutation 
-available.
+If the webform contains managed file fields, you can attach the uploaded files
+by using a multipart form request and including a 'map' field that maps the
+uploaded files to the webform elements.
 
-    mutation createFile($file: Upload!){
-      webformFileUpload(file: $file, id:"contact", webform_element_id: "upload_your_file") {
-        errors
-        violations
-        entity {
-          entityId
-        }
-        ... on WebformFileUploadOutput {
-      	  fid
-      	}
-      }
-    }
+For more information on how to upload files using GraphQL, see the
+[GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec).
 
+```
+mutation submitMyForm(
+  $id: String!,
+  $elements: [WebformSubmissionElement]!,
+  $files: [WebformSubmissionFile]!,
+) {
+  submitWebform(
+    id: $id,
+    elements: $elements,
+    files: $files,
+  ) {
+    errors
+    validationErrors {
+      element
+      messages
+    }
+    submission {
+      id
+    }
+  }
+}
+```
 
-As the result you can check for errors, violations, entity and entityId. You can 
-query for `entity > entityId` or `fid` to get the file id that was just created. 
-`fid` is a necessary GraphQL field for cases where the graphql is performed by 
-anonymous users and the file has been uploaded to the private folder.
-
-When you get the fid (e.g. 1910) you then update the `$values` variable with it:
+Example CURL request. Note that the value of the `file` field is `null` in the
+`variables` object. It will be replaced by the file linked in the `map` object.
 
-    {
-      "values": "{\"id\":\"contact\",\"subject\":\"This is the subject\",\"message\":\"Hey, I have a question\",\"date_of_birth\":\"05\/01\/1991\",\"email\":\"email@example.com\",\"upload_your_file\":\"1910\"}"
+```
+curl localhost:3001/graphql \
+  -F operations='{
+    "query": "mutation ($id: String!, $files: [WebformSubmissionFile]!) { submitWebform(id: $id, files: $files) { errors validationErrors { element messages } submission { id } } }",
+    "variables": {
+      "id": "my_webform",
+      "files": [{"element": "my_file_element", "file": null}]
     }
+  }' \
+  -F map='{"0": ["variables.files.0.file"]}' \
+  -F 0=@a.txt
+```
 
 ### Create a webform submission with a source entity
 If the webform supports a source entity, you can pass in the variables with the
diff --git a/composer.json b/composer.json
index 678a96fd158a0d1a40ab535b3362791d8225ee72..30dbb953b4f3bf318cd7e90e85705a6cbe78bae2 100644
--- a/composer.json
+++ b/composer.json
@@ -11,7 +11,7 @@
     "require": {
         "php": "^8.1",
         "drupal/graphql": "^4.6",
-        "drupal/webform": "^6.0",
+        "drupal/webform": "^6.3@beta",
         "symfony/string": "^6 || ^7"
     },
     "require-dev": {
diff --git a/graphql/webform.base.graphqls b/graphql/webform.base.graphqls
index 2b04f26240924db3837301d8f33af06ff6332b6d..7ced00a1e86c8c8bb43690bdaf106ce0edaea841 100644
--- a/graphql/webform.base.graphqls
+++ b/graphql/webform.base.graphqls
@@ -1,9 +1,11 @@
+scalar WebformSubmissionFileUpload
 scalar WebformSubmissionValue
 
 type Mutation {
   submitWebform(
     id: String!
     elements: [WebformSubmissionElement]
+    files: [WebformSubmissionFile]
     sourceEntityId: String
     sourceEntityType: String
   ): WebformSubmissionResult!
@@ -52,3 +54,16 @@ input WebformSubmissionElement {
   """
   value: WebformSubmissionValue!
 }
+
+input WebformSubmissionFile {
+  """
+  The name of the webform element.
+  """
+  element: String!
+
+  """
+  The file to upload. This is usually null and the file is appended via
+  multipart form data maps.
+  """
+  file: WebformSubmissionFileUpload
+}
diff --git a/src/GraphQL/WebformFileUploadOutputWrapper.php b/src/GraphQL/WebformFileUploadOutputWrapper.php
deleted file mode 100644
index e69c327a71183c23ebb71ff2bb915dc2ebedd4a5..0000000000000000000000000000000000000000
--- a/src/GraphQL/WebformFileUploadOutputWrapper.php
+++ /dev/null
@@ -1,49 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Drupal\graphql_webform\GraphQL;
-
-use Drupal\Core\Entity\EntityInterface;
-use Drupal\graphql_core\GraphQL\EntityCrudOutputWrapper;
-use Symfony\Component\Validator\ConstraintViolationListInterface;
-
-/**
- * Extends EntityCrudOutputWrapper to add fid (File id) to the helper class.
- */
-class WebformFileUploadOutputWrapper extends EntityCrudOutputWrapper {
-
-  /**
-   * Constructs a new WebformFileUploadOutputWrapper object.
-   *
-   * @param \Drupal\Core\Entity\EntityInterface|null $entity
-   *   The entity object that has been created or NULL if creation failed.
-   * @param int|null $fid
-   *   The file ID, or NULL if the file was not created.
-   * @param \Symfony\Component\Validator\ConstraintViolationListInterface|null $violations
-   *   The validation errors that occurred during creation or NULL if validation
-   *   succeeded.
-   * @param array|null $errors
-   *   An array of non validation error messages. Can be used to provide
-   *   additional error messages e.g. for access restrictions.
-   */
-  public function __construct(
-    ?EntityInterface $entity = NULL,
-    protected ?int $fid = NULL,
-    ?ConstraintViolationListInterface $violations = NULL,
-    ?array $errors = NULL
-  ) {
-    parent::__construct($entity, $violations, $errors);
-  }
-
-  /**
-   * Returns the fid.
-   *
-   * @return int|null
-   *   The file id or NULL if creation failed.
-   */
-  public function getFid(): ?int {
-    return $this->fid;
-  }
-
-}
diff --git a/src/Model/WebformSubmissionResult.php b/src/Model/WebformSubmissionResult.php
index 28efb43c9ac2653d5b5908cf4db1564b051c45b0..69ac6a4c72c3e170e217583bab03e505c58a4aef 100644
--- a/src/Model/WebformSubmissionResult.php
+++ b/src/Model/WebformSubmissionResult.php
@@ -94,14 +94,49 @@ class WebformSubmissionResult {
     return $this->validationErrors;
   }
 
+  /**
+   * Returns the validation error that corresponds to the given element.
+   *
+   * @param string|null $elementId
+   *   The ID of the Webform element for which to return the validation error,
+   *   or NULL to return the general validation error.
+   *
+   * @return \Drupal\graphql_webform\Model\WebformSubmissionValidationError
+   *   The validation error. If no error exists for the given element ID yet, an
+   *   empty error object is returned.
+   */
+  public function getOrCreateValidationError(?string $elementId): WebformSubmissionValidationError {
+    return $this->validationErrors[$elementId] ?? new WebformSubmissionValidationError([], $elementId);
+  }
+
+  /**
+   * Returns whether a validation error exists for the given element.
+   *
+   * @param string|null $elementId
+   *   The ID of the Webform element to check for a validation error, or NULL to
+   *   check for a general validation error.
+   *
+   * @return bool
+   *   TRUE if a validation error exists for the given element.
+   */
+  public function hasValidationError(?string $elementId): bool {
+    return isset($this->validationErrors[$elementId]);
+  }
+
   /**
    * Adds a validation error.
    *
-   * @param \Drupal\graphql_webform\Model\WebformSubmissionValidationError $validation_error
-   *   The validation error to add.
+   * @param string $errorMessage
+   *   The error message to add.
+   * @param string|null $elementId
+   *   The ID of the Webform element the error refers to, or NULL if the error
+   *   is not element-specific.
    */
-  public function addValidationError(WebformSubmissionValidationError $validation_error): static {
-    $this->validationErrors[] = $validation_error;
+  public function addValidationError(string $errorMessage, ?string $elementId): static {
+    $validationError = $this->getOrCreateValidationError($elementId);
+    $validationError->addMessage($errorMessage);
+    $this->validationErrors[$elementId] = $validationError;
+
     return $this;
   }
 
diff --git a/src/Plugin/GraphQL/DataProducer/WebformSubmit.php b/src/Plugin/GraphQL/DataProducer/WebformSubmit.php
index d3339daf0aee876677afa03b533603977290cd95..e97b2bd08e2e221ef6a70b2356213c12da9eb1ec 100644
--- a/src/Plugin/GraphQL/DataProducer/WebformSubmit.php
+++ b/src/Plugin/GraphQL/DataProducer/WebformSubmit.php
@@ -9,11 +9,11 @@ use Drupal\Core\Entity\EntityTypeManagerInterface;
 use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
 use Drupal\Core\Render\RenderContext;
 use Drupal\Core\Render\RendererInterface;
+use Drupal\file\FileInterface;
 use Drupal\graphql\GraphQL\Execution\FieldContext;
 use Drupal\graphql\GraphQL\Utility\FileUpload;
 use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerPluginBase;
 use Drupal\graphql_webform\Model\WebformSubmissionResult;
-use Drupal\graphql_webform\Model\WebformSubmissionValidationError;
 use Drupal\webform\Element\Webform;
 use Drupal\webform\Plugin\WebformElementManagerInterface;
 use Drupal\webform\WebformEntityStorageInterface;
@@ -38,7 +38,12 @@ use Symfony\Component\DependencyInjection\ContainerInterface;
  *       label = @Translation("Webform ID")
  *     ),
  *     "elements" = @ContextDefinition("any",
- *       label = @Translation("Array of WebformSubmissionElement objects.")
+ *       label = @Translation("Array of WebformSubmissionElement objects."),
+ *       multiple = TRUE
+ *     ),
+ *     "files" = @ContextDefinition("any",
+ *       label = @Translation("Array of WebformSubmissionFile objects."),
+ *       multiple = TRUE
  *     ),
  *     "sourceEntityType" = @ContextDefinition("string",
  *       label = @Translation("Source entity type"),
@@ -109,6 +114,8 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl
    *   The Webform ID for which to create a submission.
    * @param array $elements
    *   Submitted values for Webform elements.
+   * @param array $files
+   *   Submitted file uploads.
    * @param string|null $sourceEntityType
    *   The entity type of the entity that is the source of the submission.
    * @param string|null $sourceEntityId
@@ -121,9 +128,8 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl
    */
   public function resolve(
     string $id,
-    // The GraphQL field context is only populated if it is at the end of the
-    // argument list. Suppress the coding standards warning for this.
     array $elements,
+    array $files,
     ?string $sourceEntityType,
     ?string $sourceEntityId,
     FieldContext $field,
@@ -131,7 +137,7 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl
     // Create context and execute validation and submission in render context to
     // capture caching metadata.
     $renderContext = new RenderContext();
-    $renderResult = $this->renderer->executeInRenderContext($renderContext, function () use ($id, $elements, $field, $sourceEntityId, $sourceEntityType) {
+    $renderResult = $this->renderer->executeInRenderContext($renderContext, function () use ($id, $elements, $files, $field, $sourceEntityId, $sourceEntityType) {
       // Create the result object.
       $result = new WebformSubmissionResult();
 
@@ -172,6 +178,12 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl
         $dataElements[$element['element']] = $element['value'];
       }
 
+      // Handle file uploads. This will add validation errors if necessary.
+      $createdFileIds = $this->handleFileElements($webform, $files, $result);
+      foreach ($createdFileIds as $elementKey => $fileIds) {
+        $dataElements[$elementKey] = $fileIds;
+      }
+
       // Build the form data object for the form submission.
       $formData = [
         'webform_id' => $id,
@@ -225,130 +237,129 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl
    *   The result object to populate.
    */
   protected static function handleFormErrors(array $errors, WebformSubmissionResult $result): void {
-    foreach ($errors as $element => $message) {
+    foreach ($errors as $elementId => $message) {
       // The Webform module returns error messages intended to be shown in a web
       // page. Strip HTML tags to make them suitable for general use.
       $message = strip_tags((string) $message);
-      $result->addValidationError(new WebformSubmissionValidationError([$message], $element));
+      $result->addValidationError($message, $elementId);
     }
   }
 
   /**
-   * Handle possible file uploads.
+   * Handles possible file uploads.
    *
    * @param \Drupal\webform\WebformInterface $webform
    *   The Webform entity.
-   * @param array $files
-   *   The array of WebformSubmissionFile items.
+   * @param array $uploadedFiles
+   *   The array of files that were uploaded in the form submission.
    * @param \Drupal\graphql_webform\Model\WebformSubmissionResult $result
    *   The result object to populate with validation errors.
    *
    * @return array
-   *   The mapped array of WebformSubmissionElement items.
+   *   An array of successfully uploaded file IDs, grouped by element key.
    */
-  protected function handleFileElements(WebformInterface $webform, array $files, WebformSubmissionResult $result): array {
-    // Array of all the managed file element keys.
-    $managedFileElements = array_keys($webform->getElementsManagedFiles());
-
-    // Contains the mapped submission values. The original $files array
-    // contains the actual UploadedFile object. The mapped item contains the
-    // FID, if upload was successfull.
-    $filesMapped = [];
-
-    $elementsMultiple = [];
-    $elementsSingle = [];
+  protected function handleFileElements(WebformInterface $webform, array $uploadedFiles, WebformSubmissionResult $result): array {
+    // Group the files by element so we can process each element separately.
+    $filesByElement = [];
+    foreach ($uploadedFiles as $file) {
+      $filesByElement[$file['element']][] = $file['file'];
+    }
 
-    $input = [];
+    // Retrieve all the managed file elements so we can check if the uploaded
+    // files are correctly associated with a managed file element.
+    $managedFileElements = array_keys($webform->getElementsManagedFiles());
 
-    foreach ($files as $item) {
-      $elementKey = $item['element'];
+    // Keep track of File entity IDs that were created by valid uploads. This
+    // will be returned at the end of the method.
+    $fileIds = [];
 
-      // Check if the element is actually a managed file.
+    foreach ($filesByElement as $elementKey => $files) {
+      // Check if the element is actually a managed file element. If not, report
+      // this as a generic error rather than a validation error. This is
+      // probably not the end user's mistake but rather a bug in the GraphQL
+      // client code.
       if (!in_array($elementKey, $managedFileElements)) {
-        $validation = new WebformSubmissionValidationError(['Given element ID is not a managed file element.'], $item['element']);
-        $result->addValidationError($validation);
+        $error = sprintf('Files cannot be uploaded to the "%s" element since it is not a managed file element.', $elementKey);
+        $result->addError($error);
         continue;
       }
 
-      // Get the configuration for the element.
-      $configuration = $webform->getElement($item['element']);
-
-      /** @var \Drupal\webform\Plugin\WebformElement\WebformManagedFileBase $element */
-      $element = $this->elementManager->getElementInstance($configuration);
-
-      // Get file validation and upload settings.
-      $fileExtensions = $element->getElementProperty($configuration, 'file_extensions');
-      $maxFilesize = $element->getElementProperty($configuration, 'max_filesize');
-      $uriScheme = $element->getElementProperty($configuration, 'uri_scheme');
-      $multiple = $element->getElementProperty($configuration, 'multiple');
-
-      if ($multiple === FALSE || $multiple === NULL || $multiple === 1) {
-        $elementsSingle[] = $elementKey;
-
-        // Check if we already have one file for this element.
-        if (!empty($filesMapped[$elementKey])) {
-          $validation = new WebformSubmissionValidationError(['Only one file is allowed.'], $item['element']);
-          $result->addValidationError($validation);
+      // Get the initialized render element and the plugin instance that
+      // represents an OOP interface to the element configuration.
+      $element = $webform->getElement($elementKey);
+      /** @var \Drupal\webform\Plugin\WebformElement\WebformManagedFileBase $instance */
+      $instance = $this->elementManager->getElementInstance($element);
+      $instance->prepare($element);
+
+      // Get the number of allowed files for this element.
+      $allowedNumberOfFiles = $instance->hasMultipleValues($element);
+
+      // Don't check the number of uploaded files if an unlimited number of
+      // files is allowed.
+      if ($allowedNumberOfFiles !== TRUE) {
+        $allowedNumberOfFiles = is_numeric($allowedNumberOfFiles) ? (int) $allowedNumberOfFiles : 1;
+        if (count($files) > $allowedNumberOfFiles) {
+          $message = match ($allowedNumberOfFiles) {
+            1 => 'Only one file can be uploaded.',
+            default => sprintf('The number of files uploaded exceeds the maximum of %d.', $allowedNumberOfFiles),
+          };
+          $result->addValidationError($message, $elementKey);
           continue;
         }
       }
-      else {
-        $elementsMultiple[] = $elementKey;
-      }
 
-      // Perform the upload.
-      $upload = $this->fileUpload->saveFileUpload($item['file'], [
-        'file_extensions' => $fileExtensions,
-        'max_filesize' => $maxFilesize,
-        'uri_scheme' => $uriScheme,
-        'file_directory' => 'webform',
-      ]);
-
-      // Get the upload violations.
-      $violations = $upload->getViolations();
-
-      // Convert the upload violations to validation errors.
-      if (!empty($violations)) {
-        $validation = new WebformSubmissionValidationError([], $item['element']);
-        foreach ($violations as $violation) {
-          $validation->addMessage($violation['message']);
+      // Retrieve the validation criteria for the file upload element. Note that
+      // the methods to retrieve the file size and allowed extensions are
+      // protected, so we need to get them from the render array instead.
+      // @see \Drupal\webform\Plugin\WebformElement\WebformManagedFileBase::getFileExtensions()
+      // @see \Drupal\webform\Plugin\WebformElement\WebformManagedFileBase::getMaxFileSize()
+      $uriScheme = $instance->getElementProperty($element, 'uri_scheme');
+      $maxFilesize = $element['#upload_validators']['FileSizeLimit']['fileLimit'];
+      $fileExtensions = $element['#upload_validators']['FileExtension']['extensions'];
+
+      // Attempt to save the uploaded files, leveraging the validation checks of
+      // the file upload service from the GraphQL module.
+      foreach ($files as $file) {
+        $uploadResponse = $this->fileUpload->saveFileUpload($file, [
+          'file_extensions' => $fileExtensions,
+          'max_filesize' => $maxFilesize,
+          'uri_scheme' => $uriScheme,
+          'file_directory' => 'webform',
+        ]);
+
+        // If there are any violations, add them as form validation errors.
+        $violations = $uploadResponse->getViolations();
+        if (!empty($violations)) {
+          foreach ($violations as $violation) {
+            $result->addValidationError($violation['message'], $elementKey);
+          }
+          continue;
         }
-        $result->addValidationError($validation);
-        continue;
-      }
 
-      $file = $upload->getFileEntity();
-      // If there is no file and no validation errors, add an unexpected error.
-      if (empty($file) && $result->isValid()) {
-        $validation = new WebformSubmissionValidationError(['Unexpected error while uploading file.'], $item['element']);
-        $result->addValidationError($validation);
-        continue;
-      }
-
-      // Upload and saving was successful. Add the mapped form item, with the
-      // FID as the value.
-      $filesMapped[$elementKey][] = $file->id();
-    }
+        $file = $uploadResponse->getFileEntity();
+        // At this point we should have a file. If we don't, inform the client
+        // that the file was not uploaded.
+        // @todo If this ever happens in practice, we should probably log this.
+        if (!$file instanceof FileInterface) {
+          $result->addValidationError('Unexpected error occurred while uploading file. Please try again later.', $elementKey);
+          continue;
+        }
 
-    foreach ($filesMapped as $elementKey => $fileIds) {
-      // Element supports multiple uploads.
-      if (in_array($elementKey, $elementsMultiple)) {
-        foreach ($fileIds as $index => $id) {
-          $input[] = [
-            'element' => $elementKey . "[$index]",
-            'value' => $id,
-          ];
+        // The webform module expects that values submitted for multi-value
+        // elements are passed as arrays, while values for single value elements
+        // are passed as a scalar. Avoid array to string conversion errors by
+        // casting the file ID accordingly.
+        // @see \Drupal\webform\WebformSubmissionStorage::saveData()
+        if ($allowedNumberOfFiles === 1) {
+          $fileIds[$elementKey] = $file->id();
+        }
+        else {
+          $fileIds[$elementKey][] = $file->id();
         }
-      }
-      else {
-        $input[] = [
-          'element' => $elementKey,
-          'value' => $fileIds[0],
-        ];
       }
     }
 
-    return $input;
+    return $fileIds;
   }
 
   /**
diff --git a/src/Plugin/GraphQL/Fields/File/WebformFileUploadOutputFid.php b/src/Plugin/GraphQL/Fields/File/WebformFileUploadOutputFid.php
deleted file mode 100644
index 15a4a86bdd619c60cf956c1b2d030b10f7c66bc8..0000000000000000000000000000000000000000
--- a/src/Plugin/GraphQL/Fields/File/WebformFileUploadOutputFid.php
+++ /dev/null
@@ -1,34 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Drupal\graphql_webform\Plugin\GraphQL\Fields\File;
-
-use Drupal\graphql\GraphQL\Execution\ResolveContext;
-use Drupal\graphql\Plugin\GraphQL\Fields\FieldPluginBase;
-use Drupal\graphql_webform\GraphQL\WebformFileUploadOutputWrapper;
-use GraphQL\Type\Definition\ResolveInfo;
-
-/**
- * Retrieve date max property from Date form element.
- *
- * @GraphQLField(
- *   secure = true,
- *   parents = {"WebformFileUploadOutput"},
- *   id = "webform_file_upload_fid",
- *   name = "fid",
- *   type = "Int",
- * )
- */
-class WebformFileUploadOutputFid extends FieldPluginBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  public function resolveValues($value, array $args, ResolveContext $context, ResolveInfo $info) {
-    if ($value instanceof WebformFileUploadOutputWrapper) {
-      yield $value->getFid();
-    }
-  }
-
-}
diff --git a/src/Plugin/GraphQL/Mutations/WebformFileUpload.php b/src/Plugin/GraphQL/Mutations/WebformFileUpload.php
deleted file mode 100644
index fbb87942ca395d727853f70b9a7033c7f03afc3c..0000000000000000000000000000000000000000
--- a/src/Plugin/GraphQL/Mutations/WebformFileUpload.php
+++ /dev/null
@@ -1,258 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Drupal\graphql_webform\Plugin\GraphQL\Mutations;
-
-use Drupal\Core\DependencyInjection\DependencySerializationTrait;
-use Drupal\Core\Entity\EntityTypeManagerInterface;
-use Drupal\Core\File\FileSystemInterface;
-use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
-use Drupal\Core\Session\AccountProxyInterface;
-use Drupal\Core\StringTranslation\StringTranslationTrait;
-use Drupal\graphql\GraphQL\Execution\ResolveContext;
-use Drupal\graphql\Plugin\GraphQL\Mutations\MutationPluginBase;
-use Drupal\graphql_webform\GraphQL\WebformFileUploadOutputWrapper;
-use Drupal\webform\Entity\Webform;
-use GraphQL\Type\Definition\ResolveInfo;
-use Symfony\Component\DependencyInjection\ContainerInterface;
-use Symfony\Component\HttpFoundation\File\UploadedFile;
-use Symfony\Component\Mime\MimeTypeGuesserInterface;
-
-/**
- * Uploads a file to a webform element.
- *
- * @todo Add the whole range of file upload validations from file_save_upload().
- *
- * @GraphQLMutation(
- *   id = "webform_file_upload",
- *   secure = "false",
- *   name = "webformFileUpload",
- *   type = "WebformFileUploadOutput",
- *   arguments = {
- *     "file" = "Upload!",
- *     "id" = "String!",
- *     "webform_element_id" = "String!"
- *   }
- * )
- */
-class WebformFileUpload extends MutationPluginBase implements ContainerFactoryPluginInterface {
-  use DependencySerializationTrait;
-  use StringTranslationTrait;
-
-  /**
-   * The entity type manager.
-   *
-   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
-   */
-  protected $entityTypeManager;
-
-  /**
-   * The current user.
-   *
-   * @var \Drupal\Core\Session\AccountProxyInterface
-   */
-  protected $currentUser;
-
-  /**
-   * The mime type guesser service.
-   *
-   * @var \Symfony\Component\Mime\MimeTypeGuesserInterface
-   */
-  protected $mimeTypeGuesser;
-
-  /**
-   * The file system service.
-   *
-   * @var \Drupal\Core\File\FileSystemInterface
-   */
-  protected $fileSystem;
-
-  /**
-   * {@inheritdoc}
-   */
-  public function __construct(
-    array $configuration,
-    $pluginId,
-    $pluginDefinition,
-    EntityTypeManagerInterface $entityTypeManager,
-    AccountProxyInterface $currentUser,
-    MimeTypeGuesserInterface $mimeTypeGuesser,
-    FileSystemInterface $fileSystem
-  ) {
-    parent::__construct($configuration, $pluginId, $pluginDefinition);
-    $this->entityTypeManager = $entityTypeManager;
-    $this->currentUser = $currentUser;
-    $this->mimeTypeGuesser = $mimeTypeGuesser;
-    $this->fileSystem = $fileSystem;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public static function create(ContainerInterface $container, array $configuration, $pluginId, $pluginDefinition) {
-    return new static(
-      $configuration,
-      $pluginId,
-      $pluginDefinition,
-      $container->get('entity_type.manager'),
-      $container->get('current_user'),
-      $container->get('file.mime_type.guesser'),
-      $container->get('file_system')
-    );
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function resolve($value, array $args, ResolveContext $context, ResolveInfo $info) {
-    /** @var \Symfony\Component\HttpFoundation\File\UploadedFile $file */
-    $file = $args['file'];
-
-    // Do not proceed if file argument is invalid.
-    if (!($file instanceof UploadedFile)) {
-      return new WebformFileUploadOutputWrapper(NULL, NULL, NULL, [
-        'File argument is invalid. Expected \Symfony\Component\HttpFoundation\File\UploadedFile.',
-      ]);
-    }
-
-    // Check for file upload errors and return FALSE for this file if a lower
-    // level system error occurred.
-    //
-    // @see http://php.net/manual/features.file-upload.errors.php.
-    switch ($file->getError()) {
-      case UPLOAD_ERR_INI_SIZE:
-      case UPLOAD_ERR_FORM_SIZE:
-        $max_filesize = \Drupal::config('webform.settings')->get('file.default_max_filesize') ?: Environment::getUploadMaxSize();
-        return new WebformFileUploadOutputWrapper(NULL, NULL, NULL, [
-          $this->t('The file %file could not be saved because it exceeds %maxsize, the maximum allowed size for uploads.', [
-            '%file' => $file->getFilename(),
-            '%maxsize' => format_size($max_filesize),
-          ]),
-        ]);
-
-      case UPLOAD_ERR_PARTIAL:
-      case UPLOAD_ERR_NO_FILE:
-        return new WebformFileUploadOutputWrapper(NULL, NULL, NULL, [
-          $this->t('The file %file could not be saved because the upload did not complete.', [
-            '%file' => $file->getFilename(),
-          ]),
-        ]);
-
-      case UPLOAD_ERR_OK:
-        // Final check that this is a valid upload, if it isn't, use the
-        // default error handler.
-        if (is_uploaded_file($file->getRealPath())) {
-          break;
-        }
-
-      default:
-        // Unknown error.
-        return new WebformFileUploadOutputWrapper(NULL, NULL, NULL, [
-          $this->t('The file %file could not be saved. An unknown error has occurred.', [
-            '%file' => $file->getFilename(),
-          ]),
-        ]);
-    }
-
-    // Load the webform.
-    $webform = Webform::load($args['id']);
-
-    // Validate the if id argument is valid.
-    if (!$webform) {
-      return new WebformFileUploadOutputWrapper(NULL, NULL, NULL, [
-        $this->t('The webform %webform_id does not exist.', [
-          '%webform_id' => $args['id'],
-        ]),
-      ]);
-    }
-
-    // Get the webform element.
-    $file_element = $webform->getElement($args['webform_element_id']);
-    if (!$file_element) {
-      return new WebformFileUploadOutputWrapper(NULL, NULL, NULL, [
-        $this->t('The webform_element_id %webform_element_id does not exist.', [
-          '%webform_element_id' => $args['webform_element_id'],
-        ]),
-      ]);
-    }
-
-    $filename = $file->getClientOriginalName();
-    $mime = $this->mimeTypeGuesser->guessMimeType($filename);
-    $scheme = $file_element['#uri_scheme'] ?? 'private';
-
-    $upload_location = $scheme . '://webform/' . $webform->id() . '/_sid_';
-
-    // Make sure the upload location exists and is writable.
-    $this->fileSystem->prepareDirectory($upload_location, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS);
-
-    $destination = $this->fileSystem->getDestinationFilename("{$upload_location}/{$filename}", FileSystemInterface::EXISTS_RENAME);
-
-    // Begin building file entity.
-    $values = [
-      'uid' => $this->currentUser->id(),
-      'status' => 0,
-      'filename' => $filename,
-      'uri' => $destination,
-      'filesize' => $file->getSize(),
-      'filemime' => $mime,
-    ];
-
-    $storage = $this->entityTypeManager->getStorage('file');
-    /** @var \Drupal\file\FileInterface $entity */
-    $entity = $storage->create($values);
-
-    // Validate the file name length.
-    if ($errors = file_validate($entity, ['file_validate_name_length' => []])) {
-      return new WebformFileUploadOutputWrapper(NULL, NULL, NULL, [
-        $this->t('The specified file %name could not be uploaded.', [
-          '%file' => $filename,
-        ]),
-      ]);
-    }
-
-    // Validate allowed extensions.
-    if ($file_element['#file_extensions']) {
-      $allowed_extensions = $file_element['#file_extensions'];
-    }
-    else {
-      $file_type = str_replace('webform_', '', $file_element['#type']);
-      $allowed_extensions = \Drupal::config('webform.settings')->get("file.default_{$file_type}_extensions");
-    }
-    $errors = file_validate_extensions($entity, $allowed_extensions);
-    if ($errors) {
-      return new WebformFileUploadOutputWrapper(NULL, NULL, NULL, $errors);
-    }
-
-    // Move uploaded files from PHP's upload_tmp_dir to Drupal's temporary
-    // directory. This overcomes open_basedir restrictions for future file
-    // operations.
-    if (!$this->fileSystem->moveUploadedFile($file->getRealPath(), $entity->getFileUri())) {
-      return new WebformFileUploadOutputWrapper(NULL, NULL, NULL, [
-        $this->t('Could not move uploaded file %name.', [
-          '%file' => $file->getFilename(),
-        ]),
-      ]);
-    }
-
-    // Set the permissions on the new file.
-    $this->fileSystem->chmod($entity->getFileUri());
-
-    // Update the filename with any changes as a result of security or renaming
-    // due to an existing file.
-    $entity->setFilename(\Drupal::service('file_system')->basename($destination));
-
-    // Validate the entity values.
-    if (($violations = $entity->validate()) && $violations->count()) {
-      return new WebformFileUploadOutputWrapper(NULL, NULL, $violations);
-    }
-
-    // If we reached this point, we can save the file.
-    if (($status = $entity->save()) && $status === SAVED_NEW) {
-      return new WebformFileUploadOutputWrapper($entity, $entity->id(), NULL, []);
-    }
-
-    return NULL;
-  }
-
-}
diff --git a/src/Plugin/GraphQL/SchemaExtension/WebformExtension.php b/src/Plugin/GraphQL/SchemaExtension/WebformExtension.php
index 1f7de8ff5e41c33e6e9dfcd6cab02970e26646b8..0f02c594e70d65c7915cbaf2628eaf6acdb26a84 100644
--- a/src/Plugin/GraphQL/SchemaExtension/WebformExtension.php
+++ b/src/Plugin/GraphQL/SchemaExtension/WebformExtension.php
@@ -298,16 +298,10 @@ class WebformExtension extends SdlSchemaExtensionPluginBase {
         }
 
         // Consider the other possible sources for the max filesize: the PHP
-        // limit and the default webform setting.
+        // limit and the (optional) default webform setting.
         $sizes = array_filter([
           $size,
-          // Ignore the PHP limit if a default max filesize is set on the form.
-          // This follows the functionality of the Webform module, but in case
-          // the PHP limit is lower than the default max filesize, the reported
-          // value will be incorrect.
-          // @todo Change this behavior when the bug is fixed in the Webform
-          //   module.
-          // @see https://www.drupal.org/project/webform/issues/3482402
+          Environment::getUploadMaxSize(),
           $this->getWebformSetting('file.default_max_filesize') ?: Environment::getUploadMaxSize(),
         ]);
 
@@ -375,6 +369,7 @@ class WebformExtension extends SdlSchemaExtensionPluginBase {
       $builder->produce('webform_submit')
         ->map('id', $builder->fromArgument('id'))
         ->map('elements', $builder->fromArgument('elements'))
+        ->map('files', $builder->fromArgument('files'))
         ->map('sourceEntityId', $builder->fromArgument('sourceEntityId'))
         ->map('sourceEntityType', $builder->fromArgument('sourceEntityType'))
     ));
diff --git a/src/Plugin/GraphQL/Types/WebformFileUploadOutput.php b/src/Plugin/GraphQL/Types/WebformFileUploadOutput.php
deleted file mode 100644
index 7f2dff2923168c3755e51f2adb6855d93d3d7c45..0000000000000000000000000000000000000000
--- a/src/Plugin/GraphQL/Types/WebformFileUploadOutput.php
+++ /dev/null
@@ -1,30 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Drupal\graphql_webform\Plugin\GraphQL\Types;
-
-use Drupal\graphql\GraphQL\Execution\ResolveContext;
-use Drupal\graphql\Plugin\GraphQL\Types\TypePluginBase;
-use Drupal\graphql_webform\GraphQL\WebformFileUploadOutputWrapper;
-use GraphQL\Type\Definition\ResolveInfo;
-
-/**
- * A GraphQL type for a WebformFileUploadOutputWrapper object.
- *
- * @GraphQLType(
- *   id = "webform_file_upload_output",
- *   name = "WebformFileUploadOutput",
- *   interfaces = {"EntityCrudOutput"}
- * )
- */
-class WebformFileUploadOutput extends TypePluginBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  public function applies($object, ResolveContext $context, ResolveInfo $info) {
-    return ($object instanceof WebformFileUploadOutputWrapper);
-  }
-
-}
diff --git a/tests/modules/graphql_webform_test/config/install/webform.webform.graphql_webform_test_form.yml b/tests/modules/graphql_webform_test/config/install/webform.webform.graphql_webform_test_form.yml
index 6ebbd1725e817ddb1b12ddba71ff525b746f1e49..b1ea531b203e7eaca01f8e2bae27b0094669adb5 100644
--- a/tests/modules/graphql_webform_test/config/install/webform.webform.graphql_webform_test_form.yml
+++ b/tests/modules/graphql_webform_test/config/install/webform.webform.graphql_webform_test_form.yml
@@ -55,12 +55,11 @@ elements: |-
   file_upload:
     '#type': managed_file
     '#title': 'File upload'
-    '#multiple': true
     '#description': Description
     '#help_title': 'Help title'
     '#help': 'Help text'
     '#file_placeholder': Placeholder
-    '#max_filesize': '2'
+    '#max_filesize': '1'
     '#file_extensions': 'gif jpg png txt'
     '#uri_scheme': public
   radios:
@@ -102,7 +101,7 @@ elements: |-
   audio_files:
     '#type': webform_audio_file
     '#title': 'Audio files'
-    '#multiple': true
+    '#multiple': 2
     '#file_extensions': ''
   select_with_custom_empty_option:
     '#type': select
diff --git a/tests/queries/create_file.gql b/tests/queries/create_file.gql
deleted file mode 100644
index b8bba21da162fab7a8dfd3a0d597608106b64c0d..0000000000000000000000000000000000000000
--- a/tests/queries/create_file.gql
+++ /dev/null
@@ -1,7 +0,0 @@
-mutation createFile($file: Upload!, $id: String!, $webform_element_id: String!){
-  webformFileUpload(file: $file, id: $id, webform_element_id: $webform_element_id) {
-    ... on WebformFileUploadOutput {
-      fid
-    }
-  }
-}
diff --git a/tests/queries/submission_with_file_upload.gql b/tests/queries/submission_with_file_upload.gql
new file mode 100644
index 0000000000000000000000000000000000000000..3f37a8d94f8d39b5090939549954b97373801c9c
--- /dev/null
+++ b/tests/queries/submission_with_file_upload.gql
@@ -0,0 +1,23 @@
+mutation submit(
+  $id: String!,
+  $elements: [WebformSubmissionElement]!,
+  $files: [WebformSubmissionFile]!,
+) {
+  submitWebform(
+    id: $id,
+    elements: $elements,
+    files: $files,
+  ) {
+    errors
+    validationErrors {
+      element
+      messages
+    }
+    submission {
+      id
+      webform {
+        id
+      }
+    }
+  }
+}
diff --git a/tests/src/Kernel/Element/CreateFileTest.php b/tests/src/Kernel/Element/CreateFileTest.php
deleted file mode 100644
index 153b34e2cc7ebd3394df992bdd7cb42534cbdfb0..0000000000000000000000000000000000000000
--- a/tests/src/Kernel/Element/CreateFileTest.php
+++ /dev/null
@@ -1,145 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Drupal\Tests\graphql_webform\Kernel\Element;
-
-use Drupal\Core\File\FileSystemInterface;
-use Drupal\Tests\graphql_webform\Kernel\GraphQLWebformKernelTestBase;
-use Drupal\file\FileInterface;
-use Drupal\file\FileStorageInterface;
-use Symfony\Component\HttpFoundation\Request;
-
-/**
- * Tests uploading a file for a webform.
- *
- * @group graphql_webform
- */
-class CreateFileTest extends GraphQLWebformKernelTestBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  protected static $modules = [
-    'file',
-  ];
-
-  /**
-   * The file storage.
-   *
-   * @var \Drupal\file\FileStorageInterface|null
-   */
-  protected ?FileStorageInterface $fileStorage;
-
-  /**
-   * The file system.
-   *
-   * @var \Drupal\Core\File\FileSystemInterface|null
-   */
-  protected ?FileSystemInterface $fileSystem;
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function setUp(): void {
-    parent::setUp();
-
-    $this->installEntitySchema('file');
-
-    $this->fileStorage = $this->container->get('entity_type.manager')->getStorage('file');
-    $this->fileSystem = $this->container->get('file_system');
-  }
-
-  /**
-   * Tests uploading a file through the webformFileUpload mutation.
-   */
-  public function testFileUpload() {
-    $this->markTestSkipped('To be ported to GraphQL 4.x.');
-    // We are pretending to upload a file into temporary storage. Ensure a file
-    // is there because the Symfony UploadedFile component will check that.
-    $file = $this->fileSystem->getTempDirectory() . '/graphql_webform_upload_test.txt';
-    touch($file);
-
-    // Create a post request with file contents.
-    $request = Request::create('/graphql', 'POST', [
-      'query' => $this->getQueryFromFile('create_file.gql'),
-      // The variable has to be declared null.
-      'variables' => [
-        'file' => NULL,
-        'webform_element_id' => 'file_upload',
-        'id' => 'graphql_webform_test_form',
-      ],
-      // Then map the file upload name to the variable.
-      'map' => [
-        'test' => ['variables.file'],
-      ],
-    ], [], [
-      'test' => [
-        'name' => 'test.txt',
-        'type' => 'text/plain',
-        'size' => 42,
-        'tmp_name' => $file,
-        'error' => UPLOAD_ERR_OK,
-      ],
-    ]);
-
-    $request->headers->add(['content-type' => 'multipart/form-data']);
-    $response = $this->container->get('http_kernel')->handle($request);
-    $result = json_decode($response->getContent());
-
-    // Check that the file ID was returned.
-    $returned_fid = $result->data->webformFileUpload->fid ?? NULL;
-    $this->assertIsInt($returned_fid);
-
-    // Check that the file entity was created.
-    $file = $this->fileStorage->load($returned_fid);
-    $this->assertInstanceOf(FileInterface::class, $file);
-    $this->assertEquals('test.txt', $file->getFilename());
-    $this->assertEquals('text/plain', $file->getMimeType());
-  }
-
-}
-
-namespace Drupal\graphql_webform\Plugin\GraphQL\Mutations;
-
-/**
- * Mock the PHP function is_uploaded_file().
- *
- * Since we are not *really* uploading a file through the webserver, PHP will
- * not recognize the file as an uploaded file. We mock the function to return
- * TRUE for our test file.
- *
- * @param string $filename
- *   The filename being checked.
- *
- * @return bool
- *   Will return TRUE for our test file.
- */
-function is_uploaded_file($filename) {
-  $temp_dir = \Drupal::service('file_system')->getTempDirectory();
-  $test_file = $temp_dir . '/graphql_webform_upload_test.txt';
-  return $filename === $test_file;
-}
-
-namespace Drupal\Core\File;
-
-/**
- * Mock the PHP function move_uploaded_file().
- *
- * Since we are not *really* uploading a file through the webserver, PHP will
- * refuse to move the file and will return FALSE. We mock the function to return
- * TRUE for our test file.
- *
- * @param string $filename
- *   The filename being moved.
- * @param string $destination
- *   The destination path.
- *
- * @return bool
- *   Will return TRUE for our test file.
- */
-function move_uploaded_file($filename, $destination) {
-  $temp_dir = \Drupal::service('file_system')->getTempDirectory();
-  $test_file = $temp_dir . '/graphql_webform_upload_test.txt';
-  return $filename === $test_file;
-}
diff --git a/tests/src/Kernel/Element/ManagedFileTest.php b/tests/src/Kernel/Element/ManagedFileTest.php
index 3d970b860aa29b500b013a1d81457087f915049a..7eab946b473e747f97899d2b320f938ae3f0c55b 100644
--- a/tests/src/Kernel/Element/ManagedFileTest.php
+++ b/tests/src/Kernel/Element/ManagedFileTest.php
@@ -39,7 +39,7 @@ class ManagedFileTest extends GraphQLWebformKernelTestBase {
             'key' => 'file_upload',
             'type' => 'managed_file',
             'title' => 'File upload',
-            'description' => "Description<br />Unlimited number of files can be uploaded to this field.<br />$human_readable_file_limit limit.<br />Allowed types: gif jpg png txt.\n",
+            'description' => "Description<br />One file only.<br />$human_readable_file_limit limit.<br />Allowed types: gif jpg png txt.\n",
           ],
           11 => [
             '__typename' => 'WebformElementWebformAudioFile',
@@ -48,7 +48,7 @@ class ManagedFileTest extends GraphQLWebformKernelTestBase {
             'key' => 'audio_files',
             'type' => 'webform_audio_file',
             'title' => 'Audio files',
-            'description' => "Unlimited number of files can be uploaded to this field.<br />$human_readable_audio_file_limit limit.<br />Allowed types: mp3 ogg wav.\n",
+            'description' => "Maximum 2 files.<br />$human_readable_audio_file_limit limit.<br />Allowed types: mp3 ogg wav.\n",
           ],
         ],
       ],
@@ -72,27 +72,24 @@ class ManagedFileTest extends GraphQLWebformKernelTestBase {
       'global limit in bytes' => [
         // Webform global file limit set to 3 MB.
         3145728,
-        // The managed file upload is limited to 2 MB on element level.
-        2097152,
+        // The managed file upload is limited to 1 MB on element level.
+        1048576,
         // The audio file upload cannot exceed the global webform limit of 3 MB.
         3145728,
       ],
       'global limit exceeding PHP limit' => [
         // Webform global file limit set to 8 MB.
         8388608,
-        // The managed file upload is limited to 2 MB on element level.
-        2097152,
-        // The audio file upload will report the global file limit (even though
-        // the PHP limit is lower and should be used).
-        // @todo Fix this once the Webform module respects the PHP limit.
-        // @see https://www.drupal.org/project/webform/issues/3482402
-        8388608,
+        // The managed file upload is limited to 1 MB on element level.
+        1048576,
+        // The audio file upload cannot exceed the PHP limit of 4 MB.
+        4194304,
       ],
       'empty global limit' => [
         // Webform global file limit not set.
         NULL,
-        // The managed file upload is limited to 2 MB on element level.
-        2097152,
+        // The managed file upload is limited to 1 MB on element level.
+        1048576,
         // The audio file upload cannot exceed the PHP limit of 4 MB.
         4194304,
       ],
diff --git a/tests/src/Kernel/Element/MarkupTest.php b/tests/src/Kernel/Element/MarkupTest.php
index 77ec7ac012e740936b6d7e17790b94abd2d7fab4..ae5e72f3a3e897c3ab2d2b1a7a3c16cb21d9917c 100644
--- a/tests/src/Kernel/Element/MarkupTest.php
+++ b/tests/src/Kernel/Element/MarkupTest.php
@@ -4,8 +4,8 @@ declare(strict_types=1);
 
 namespace Drupal\Tests\graphql_webform\Kernel\Element;
 
-use Drupal\filter\Entity\FilterFormat;
 use Drupal\Tests\graphql_webform\Kernel\GraphQLWebformKernelTestBase;
+use Drupal\filter\Entity\FilterFormat;
 
 /**
  * Tests for the WebformElementMarkup type.
diff --git a/tests/src/Kernel/Element/MultipleValuesTest.php b/tests/src/Kernel/Element/MultipleValuesTest.php
index 1e550b82ae40ccd5e8ea211accdb93055875147f..2e55da6937fe78ce37dce982892ffa2120fa8711 100644
--- a/tests/src/Kernel/Element/MultipleValuesTest.php
+++ b/tests/src/Kernel/Element/MultipleValuesTest.php
@@ -64,30 +64,11 @@ class MultipleValuesTest extends GraphQLWebformKernelTestBase {
             ],
           ],
           4 => ['multipleValues' => NULL],
-          6 => [
-            'multipleValues' => [
-              'limit' => -1,
-              'message' => NULL,
-              'headerLabel' => NULL,
-              'minItems' => NULL,
-              'emptyItems' => NULL,
-              'addMore' => NULL,
-              'addMoreItems' => NULL,
-              'addMoreButtonLabel' => NULL,
-              'addMoreInput' => NULL,
-              'addMoreInputLabel' => NULL,
-              'itemLabel' => NULL,
-              'noItemsMessage' => NULL,
-              'sorting' => NULL,
-              'operations' => NULL,
-              'add' => NULL,
-              'remove' => NULL,
-            ],
-          ],
+          6 => ['multipleValues' => NULL],
           8 => ['multipleValues' => NULL],
           11 => [
             'multipleValues' => [
-              'limit' => -1,
+              'limit' => 2,
               'message' => NULL,
               'headerLabel' => NULL,
               'minItems' => NULL,
diff --git a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..0e6b533add1e5bf49f177666fdf68432ca543abd
--- /dev/null
+++ b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
@@ -0,0 +1,490 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\graphql_webform\Kernel\Mutation;
+
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\File\FileSystemInterface;
+use Drupal\file\Entity\File;
+use Drupal\file\FileInterface;
+use Drupal\file\FileStorageInterface;
+use Drupal\webform\Entity\WebformSubmission;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Test file uploads with GraphQL.
+ *
+ * @group graphql_webform
+ */
+final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'file',
+  ];
+
+  /**
+   * The file storage.
+   */
+  protected ?FileStorageInterface $fileStorage;
+
+  /**
+   * The file system.
+   */
+  protected ?FileSystemInterface $fileSystem;
+
+  /**
+   * An array of test files.
+   */
+  protected array $files = [];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    $this->installEntitySchema('file');
+    $this->installSchema('file', ['file_usage']);
+
+    $this->fileStorage = $this->container->get('entity_type.manager')->getStorage('file');
+    $this->fileSystem = $this->container->get('file_system');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function tearDown(): void {
+    // Clean up files that were created during the test.
+    foreach ($this->files as $file) {
+      if (file_exists($file)) {
+        unlink($file);
+      }
+    }
+
+    parent::tearDown();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function register(ContainerBuilder $container): void {
+    parent::register($container);
+
+    // Register the private stream wrapper so we can test whether files can be
+    // uploaded to the private filesystem as part of a webform submission.
+    $container->register('stream_wrapper.private', 'Drupal\Core\StreamWrapper\PrivateStream')
+      ->addTag('stream_wrapper', ['scheme' => 'private']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpFilesystem(): void {
+    // Set up the private filesystem in addition to the public filesystem.
+    $public_file_directory = $this->siteDirectory . '/files';
+    $private_file_directory = $this->siteDirectory . '/private';
+
+    mkdir($this->siteDirectory, 0775);
+    mkdir($this->siteDirectory . '/files', 0775);
+    mkdir($this->siteDirectory . '/private', 0775);
+    mkdir($this->siteDirectory . '/files/config/sync', 0775, TRUE);
+
+    $this->setSetting('file_public_path', $public_file_directory);
+    $this->setSetting('file_private_path', $private_file_directory);
+    $this->setSetting('config_sync_directory', $this->siteDirectory . '/files/config/sync');
+  }
+
+  /**
+   * Uploading a file using a wrong element name should return an error.
+   */
+  public function testUploadingFileToWrongElement(): void {
+    $query = $this->getQueryFromFile('submission_with_file_upload.gql');
+    $variables = [
+      'elements' => [],
+      'files' => [
+        // Try to upload to a non-file upload element.
+        ['element' => 'checkboxes', 'file' => NULL],
+      ],
+      'id' => 'graphql_webform_test_form',
+    ];
+
+    $this->assertResults($query, $variables, [
+      'submitWebform' => [
+        'errors' => [
+          'Files cannot be uploaded to the "checkboxes" element since it is not a managed file element.',
+        ],
+        'validationErrors' => [
+          [
+            'element' => 'required_text_field',
+            'messages' => [
+              'This field is required because it is important.',
+            ],
+          ],
+        ],
+        'submission' => NULL,
+      ],
+    ]);
+  }
+
+  /**
+   * Tests uploading a file as part of a webform submission.
+   */
+  public function testFileUpload(): void {
+    // Create some test files to upload.
+    foreach (['txt', 'mp3', 'ogg'] as $extension) {
+      $file = $this->fileSystem->getTempDirectory() . '/graphql_webform_upload_test.' . $extension;
+      // We are pretending to upload a file into temporary storage. Ensure the
+      // file exists because the Symfony UploadedFile component will check that.
+      touch($file);
+      $this->files[$extension] = $file;
+    }
+
+    // Create a POST request with the files attached as multipart form data.
+    $request = Request::create(
+      uri: '/graphql/test',
+      method: 'POST',
+      parameters: [
+        'query' => $this->getQueryFromFile('submission_with_file_upload.gql'),
+        'variables' => [
+          'elements' => [(object) ['element' => 'required_text_field', 'value' => 'A value.']],
+          'files' => [
+            // When using multipart form uploads, the parameter holding the file
+            // has to be declared NULL.
+            ['element' => 'file_upload', 'file' => NULL],
+            ['element' => 'audio_files', 'file' => NULL],
+            ['element' => 'audio_files', 'file' => NULL],
+          ],
+          'id' => 'graphql_webform_test_form',
+        ],
+        // The 'map' parameter is used to map attached files to variables.
+        // @see https://github.com/jaydenseric/graphql-multipart-request-spec
+        'map' => [
+          '0' => ['variables.files.0.file'],
+          '1' => ['variables.files.1.file'],
+          '2' => ['variables.files.2.file'],
+        ],
+      ],
+      files: [
+        '0' => [
+          'name' => 'test.txt',
+          'type' => 'text/plain',
+          'size' => 0,
+          'tmp_name' => $this->files['txt'],
+          'error' => UPLOAD_ERR_OK,
+        ],
+        '1' => [
+          'name' => 'audio_file.mp3',
+          'type' => 'audio/mpeg',
+          'size' => 0,
+          'tmp_name' => $this->files['mp3'],
+          'error' => UPLOAD_ERR_OK,
+        ],
+        '2' => [
+          'name' => 'audio_file.ogg',
+          'type' => 'audio/ogg',
+          'size' => 0,
+          'tmp_name' => $this->files['ogg'],
+          'error' => UPLOAD_ERR_OK,
+        ],
+      ],
+    );
+
+    $result = $this->executeMultiPartRequest($request);
+
+    $this->assertNotEmpty($result->data->submitWebform ?? [], 'The response contains information about the submission.');
+    $resultData = $result->data->submitWebform;
+
+    $this->assertEmpty($resultData->errors, 'There are no errors.');
+    $this->assertEmpty($resultData->validationErrors, 'There are no validation errors.');
+
+    $submissionId = $resultData->submission->id;
+    $this->assertIsInt($submissionId, 'A submission ID is returned.');
+
+    // Check that the submission entity was created.
+    $submission = WebformSubmission::load($submissionId);
+    $this->assertInstanceOf(WebformSubmission::class, $submission, 'A submission entity was created.');
+
+    // Check that the expected files are associated with the submission.
+    $expectedUploads = [
+      'file_upload' => [
+        ['filename' => 'test.txt', 'mime' => 'text/plain'],
+      ],
+      'audio_files' => [
+        ['filename' => 'audio_file.mp3', 'mime' => 'audio/mpeg'],
+        ['filename' => 'audio_file.ogg', 'mime' => 'audio/ogg'],
+      ],
+    ];
+    foreach ($expectedUploads as $elementName => $expectedFiles) {
+      $returnedFileIds = (array) $submission->getElementData($elementName);
+      $this->assertNotEmpty($returnedFileIds, 'One or more file IDs are associated with the webform submission.');
+      $this->assertCount(count($expectedFiles), $returnedFileIds, 'The correct number of files is associated with the webform submission.');
+      $expectedFilenames = array_column($expectedFiles, 'filename');
+      $expectedMimeTypes = array_column($expectedFiles, 'mime');
+      $expectedScheme = $elementName === 'file_upload' ? 'public' : 'private';
+      foreach ($expectedFiles as $i => $expectedFile) {
+        $file = File::load($returnedFileIds[$i]);
+        $this->assertInstanceOf(FileInterface::class, $file, sprintf('A file entity was created for upload #%d of element "%s".', $i, $elementName));
+        $this->assertEquals($expectedFilenames[$i], $file->getFilename(), sprintf('The file for upload #%d of element "%s" has the correct filename "%s".', $i, $elementName, $expectedFilenames[$i]));
+        $this->assertEquals($expectedMimeTypes[$i], $file->getMimeType(), sprintf('The file for upload #%d of element "%s" has the correct MIME type "%s".', $i, $elementName, $expectedMimeTypes[$i]));
+        $this->assertEquals(0, $file->getSize(), sprintf('The file for upload #%d of element "%s" has a size of 0 bytes.', $i, $elementName));
+        $actualScheme = parse_url($file->getFileUri(), PHP_URL_SCHEME);
+        $this->assertEquals($expectedScheme, $actualScheme, sprintf('The file for upload #%d of element "%s" is saved in the %s filesystem.', $i, $elementName, $expectedScheme));
+      }
+    }
+  }
+
+  /**
+   * Tests uploading files that do not meet the validation criteria.
+   */
+  public function testFileUploadWithValidationErrors(): void {
+    // Create some test files to upload.
+    foreach (['txt', 'mp3', 'flac'] as $extension) {
+      $this->files[$extension] = $this->fileSystem->getTempDirectory() . '/graphql_webform_upload_test.' . $extension;
+      touch($this->files[$extension]);
+      // Create a file with a size of 1.000001MB. This is intended to trigger a
+      // validation error for the file_upload element which is limited to 1MB.
+      if ($extension === 'txt') {
+        file_put_contents($this->files[$extension], str_repeat('a', 1024 * 1024 + 1));
+      }
+    }
+
+    $request = Request::create(
+      uri: '/graphql/test',
+      method: 'POST',
+      parameters: [
+        'query' => $this->getQueryFromFile('submission_with_file_upload.gql'),
+        'variables' => [
+          'elements' => [
+            (object) [
+              'element' => 'required_text_field',
+              'value' => 'A value.',
+            ],
+          ],
+          'files' => [
+            ['element' => 'file_upload', 'file' => NULL],
+            ['element' => 'audio_files', 'file' => NULL],
+            ['element' => 'audio_files', 'file' => NULL],
+          ],
+          'id' => 'graphql_webform_test_form',
+        ],
+        'map' => [
+          '0' => ['variables.files.0.file'],
+          '1' => ['variables.files.1.file'],
+          '2' => ['variables.files.2.file'],
+        ],
+      ],
+      files: [
+        '0' => [
+          'name' => 'test.txt',
+          'type' => 'text/plain',
+          'size' => 1024 * 1024 + 1,
+          'tmp_name' => $this->files['txt'],
+          'error' => UPLOAD_ERR_OK,
+        ],
+        '1' => [
+          'name' => 'audio_file.mp3',
+          'type' => 'audio/mpeg',
+          'size' => 0,
+          'tmp_name' => $this->files['mp3'],
+          'error' => UPLOAD_ERR_OK,
+        ],
+        '2' => [
+          'name' => 'audio_file.flac',
+          'type' => 'audio/flac',
+          'size' => 0,
+          'tmp_name' => $this->files['flac'],
+          'error' => UPLOAD_ERR_OK,
+        ],
+      ],
+    );
+
+    $result = $this->executeMultiPartRequest($request);
+
+    $this->assertNotEmpty($result->data->submitWebform ?? [], 'The response contains information about the submission.');
+    $resultData = $result->data->submitWebform;
+
+    $this->assertEmpty($resultData->errors, 'There are no errors.');
+    $expectedValidationErrors = [
+      'file_upload' => [
+        'The file is <em class="placeholder">1 MB</em> exceeding the maximum file size of <em class="placeholder">1 MB</em>.',
+      ],
+      'audio_files' => [
+        'Only files with the following extensions are allowed: <em class="placeholder">mp3 ogg wav</em>.',
+      ],
+    ];
+    $this->assertValidationErrors($result, $expectedValidationErrors);
+
+    $this->assertEmpty($resultData->submission, 'No submission was returned.');
+    $this->assertEmpty(WebformSubmission::loadMultiple(), 'No submission entity was created.');
+  }
+
+  /**
+   * Tests uploading too many files.
+   *
+   * The 'file_upload' field only accepts a single file, and the 'audio_files'
+   * field accepts up to two files. We will attempt to upload more.
+   */
+  public function testUploadTooManyFiles(): void {
+    // Create some test files to upload.
+    foreach (['txt', 'jpg', 'mp3', 'ogg', 'wav'] as $extension) {
+      $this->files[$extension] = $this->fileSystem->getTempDirectory() . '/graphql_webform_upload_test.' . $extension;
+      touch($this->files[$extension]);
+    }
+
+    $request = Request::create(
+      uri: '/graphql/test',
+      method: 'POST',
+      parameters: [
+        'query' => $this->getQueryFromFile('submission_with_file_upload.gql'),
+        'variables' => [
+          'elements' => [
+            (object) [
+              'element' => 'required_text_field',
+              'value' => 'A value.',
+            ],
+          ],
+          'files' => [
+            ['element' => 'file_upload', 'file' => NULL],
+            ['element' => 'file_upload', 'file' => NULL],
+            ['element' => 'audio_files', 'file' => NULL],
+            ['element' => 'audio_files', 'file' => NULL],
+            ['element' => 'audio_files', 'file' => NULL],
+          ],
+          'id' => 'graphql_webform_test_form',
+        ],
+        'map' => [
+          '0' => ['variables.files.0.file'],
+          '1' => ['variables.files.1.file'],
+          '2' => ['variables.files.2.file'],
+          '3' => ['variables.files.3.file'],
+          '4' => ['variables.files.4.file'],
+        ],
+      ],
+      files: [
+        '0' => [
+          'name' => 'test.txt',
+          'type' => 'text/plain',
+          'size' => 0,
+          'tmp_name' => $this->files['txt'],
+          'error' => UPLOAD_ERR_OK,
+        ],
+        '1' => [
+          'name' => 'test.jpg',
+          'type' => 'image/jpeg',
+          'size' => 0,
+          'tmp_name' => $this->files['jpg'],
+          'error' => UPLOAD_ERR_OK,
+        ],
+        '2' => [
+          'name' => 'audio_file.mp3',
+          'type' => 'audio/mpeg',
+          'size' => 0,
+          'tmp_name' => $this->files['mp3'],
+          'error' => UPLOAD_ERR_OK,
+        ],
+        '3' => [
+          'name' => 'audio_file.ogg',
+          'type' => 'audio/ogg',
+          'size' => 0,
+          'tmp_name' => $this->files['ogg'],
+          'error' => UPLOAD_ERR_OK,
+        ],
+        '4' => [
+          'name' => 'audio_file.wav',
+          'type' => 'audio/wav',
+          'size' => 0,
+          'tmp_name' => $this->files['wav'],
+          'error' => UPLOAD_ERR_OK,
+        ],
+      ],
+    );
+
+    $result = $this->executeMultiPartRequest($request);
+
+    $this->assertNotEmpty($result->data->submitWebform ?? [], 'The response contains information about the submission.');
+    $resultData = $result->data->submitWebform;
+
+    $this->assertEmpty($resultData->errors, 'There are no errors.');
+
+    $expectedValidationErrors = [
+      'file_upload' => [
+        'Only one file can be uploaded.',
+      ],
+      'audio_files' => [
+        'The number of files uploaded exceeds the maximum of 2.',
+      ],
+    ];
+    $this->assertValidationErrors($result, $expectedValidationErrors);
+
+    $this->assertEmpty($resultData->submission, 'No submission was returned.');
+    $this->assertEmpty(WebformSubmission::loadMultiple(), 'No submission entity was created.');
+    $this->assertEmpty($this->fileStorage->loadMultiple(), 'No file entities were created.');
+  }
+
+  /**
+   * Executes a request and returns the response.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request to execute.
+   *
+   * @return object
+   *   The response object.
+   */
+  protected function executeMultiPartRequest(Request $request): object {
+    $request->headers->add(['content-type' => 'multipart/form-data']);
+    $response = $this->container->get('http_kernel')->handle($request);
+    return json_decode($response->getContent());
+  }
+
+  /**
+   * Asserts that the validation errors in the response are as expected.
+   *
+   * @param object $result
+   *   The response object.
+   * @param array $expectedValidationErrors
+   *   An array of expected validation errors. The keys are the element names,
+   *   and the values are arrays of expected validation messages.
+   */
+  protected function assertValidationErrors(object $result, array $expectedValidationErrors): void {
+    $this->assertNotEmpty($result->data->submitWebform ?? [], 'The response contains information about the submission.');
+    $resultData = $result->data->submitWebform;
+    $this->assertIsArray($resultData->validationErrors, 'The response contains validation errors.');
+
+    foreach ($expectedValidationErrors as $elementName => $expectedMessages) {
+      $elementErrors = array_filter($resultData->validationErrors, static fn ($error): bool => $error->element === $elementName);
+      $this->assertCount(1, $elementErrors, sprintf('The validation errors for the "%s" element are grouped together.', $elementName));
+      $elementError = reset($elementErrors);
+      $this->assertEquals($elementName, $elementError->element, sprintf('The validation error for the "%s" element has the correct element ID.', $elementName));
+      $this->assertCount(count($expectedMessages), $elementError->messages, sprintf('There are %d validation messages for the "%s" element.', count($expectedMessages), $elementName));
+      foreach ($expectedMessages as $i => $expectedMessage) {
+        $this->assertEquals($expectedMessage, $elementError->messages[$i], sprintf('Validation message #%d for the "%s" element is correct.', $i, $elementName));
+      }
+    }
+  }
+
+}
+
+namespace Symfony\Component\HttpFoundation\File;
+
+/**
+ * Mock the PHP function is_uploaded_file().
+ *
+ * Since we are not *really* uploading a file through the webserver, PHP will
+ * not recognize the file as an uploaded file. We mock the function to return
+ * TRUE for our test files.
+ *
+ * @param string $filename
+ *   The filename being checked.
+ *
+ * @return bool
+ *   Will return TRUE for our test files.
+ */
+function is_uploaded_file($filename) {
+  $temp_dir = \Drupal::service('file_system')->getTempDirectory();
+  $prefix = $temp_dir . '/graphql_webform_upload_test.';
+  return str_starts_with($filename, $prefix);
+}
diff --git a/tests/src/Unit/WebformSubmissionResultTest.php b/tests/src/Unit/WebformSubmissionResultTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..3a3f3615060b537278c811b385f5252164c1c1e6
--- /dev/null
+++ b/tests/src/Unit/WebformSubmissionResultTest.php
@@ -0,0 +1,117 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\graphql_webform\Unit\Model;
+
+use Drupal\Tests\UnitTestCase;
+use Drupal\graphql_webform\Model\WebformSubmissionResult;
+use Drupal\webform\WebformSubmissionInterface;
+
+/**
+ * @coversDefaultClass \Drupal\graphql_webform\Model\WebformSubmissionResult
+ * @group graphql_webform
+ */
+class WebformSubmissionResultTest extends UnitTestCase {
+
+  /**
+   * @covers ::setSubmission
+   * @covers ::getSubmission
+   */
+  public function testSubmission() {
+    $submission = $this->createMock(WebformSubmissionInterface::class);
+    $result = new WebformSubmissionResult();
+    $result->setSubmission($submission);
+    $this->assertSame($submission, $result->getSubmission());
+  }
+
+  /**
+   * @covers ::addError
+   * @covers ::getErrors
+   */
+  public function testAddError() {
+    $result = new WebformSubmissionResult();
+    $result->addError('Test error');
+    $this->assertEquals(['Test error'], $result->getErrors());
+  }
+
+  /**
+   * @covers ::addValidationError
+   * @covers ::getOrCreateValidationError
+   * @covers ::getValidationErrors
+   * @covers ::hasValidationError
+   */
+  public function testAddValidationError() {
+    $result = new WebformSubmissionResult();
+    $this->assertEmpty($result->getValidationErrors(), 'A new result does not have validation errors.');
+    $this->assertFalse($result->hasValidationError('element_id'), 'A new result does not have validation errors for an element.');
+    $this->assertFalse($result->hasValidationError(NULL), 'A new result does not have a general validation error.');
+
+    // Add a first validation error.
+    $result->addValidationError('Validation error', 'element_id');
+    $errors = $result->getValidationErrors();
+    $this->assertCount(1, $errors);
+    $returnedError = reset($errors);
+    $this->assertEquals(['Validation error'], $returnedError->getMessages());
+    $this->assertEquals('element_id', $returnedError->getElementId());
+    $this->assertTrue($result->hasValidationError('element_id'), 'The result has a validation error for the element.');
+    $this->assertFalse($result->hasValidationError('another_element_id'), 'The result does not have a validation error for another element.');
+    $this->assertFalse($result->hasValidationError(NULL), 'The result does not have a general validation error.');
+
+    // Add another validation error for the same element.
+    $result->addValidationError('Another validation error', 'element_id');
+    $errors = $result->getValidationErrors();
+    $this->assertCount(1, $errors);
+    $returnedError = reset($errors);
+    $this->assertEquals(['Validation error', 'Another validation error'], $returnedError->getMessages());
+    $this->assertEquals('element_id', $returnedError->getElementId());
+    $this->assertTrue($result->hasValidationError('element_id'), 'The result has a validation error for the element.');
+    $this->assertFalse($result->hasValidationError('another_element_id'), 'The result does not have a validation error for another element.');
+    $this->assertFalse($result->hasValidationError(NULL), 'The result does not have a general validation error.');
+
+    // Add a validation error for a different element.
+    $result->addValidationError('Yet another validation error', 'another_element_id');
+    $errors = $result->getValidationErrors();
+    $this->assertCount(2, $errors);
+    $error_element_1 = $result->getOrCreateValidationError('element_id');
+    $this->assertEquals(['Validation error', 'Another validation error'], $error_element_1->getMessages());
+    $this->assertEquals('element_id', $error_element_1->getElementId());
+    $error_element_2 = $result->getOrCreateValidationError('another_element_id');
+    $this->assertEquals(['Yet another validation error'], $error_element_2->getMessages());
+    $this->assertEquals('another_element_id', $error_element_2->getElementId());
+    $this->assertTrue($result->hasValidationError('element_id'), 'The result has a validation error for the first element.');
+    $this->assertTrue($result->hasValidationError('another_element_id'), 'The result has a validation error for the second element.');
+    $this->assertFalse($result->hasValidationError(NULL), 'The result does not have a general validation error.');
+
+    // Add a validation error without an element ID.
+    $result->addValidationError('Validation error without element ID', NULL);
+    $errors = $result->getValidationErrors();
+    $this->assertCount(3, $errors);
+    $error_no_element = $result->getOrCreateValidationError(NULL);
+    $this->assertEquals(['Validation error without element ID'], $error_no_element->getMessages());
+    $this->assertNull($error_no_element->getElementId());
+    $this->assertTrue($result->hasValidationError('element_id'), 'The result has a validation error for the first element.');
+    $this->assertTrue($result->hasValidationError('another_element_id'), 'The result has a validation error for the second element.');
+    $this->assertTrue($result->hasValidationError(NULL), 'The result has a general validation error.');
+  }
+
+  /**
+   * @covers ::isValid
+   */
+  public function testIsValid() {
+    $result = new WebformSubmissionResult();
+    $this->assertTrue($result->isValid());
+
+    $result->addError('Test error');
+    $this->assertFalse($result->isValid());
+
+    $result = new WebformSubmissionResult();
+    $result->addValidationError('Validation error', 'element_id');
+    $this->assertFalse($result->isValid());
+
+    $result = new WebformSubmissionResult();
+    $result->addValidationError('Validation error', NULL);
+    $this->assertFalse($result->isValid());
+  }
+
+}