Implementing File Uploads Through GraphQL Mutations in Magento
AI generated
M2
di.xml
Magento 2 · GraphQL · File Upload
GraphQL File Upload Mutations
getting file uploads right despite missing multipart support

GraphQL has no built-in file upload, and Magento's /graphql endpoint only ever parses JSON bodies, so a direct multipart upload fails right at the controller. Using personalized products as an example, this article shows a practical two-step flow built from a dedicated upload controller and a referencing mutation, including validation, secure storage, and integration with quote and order.

15 min read Two-step upload pattern Custom attribute · personalized products

1. Why GraphQL has no native file upload and Magento does not close the gap

The GraphQL specification defines JSON-based requests exclusively and has no built-in concept for binary data. For file uploads, the community has settled on the informal multipart request convention, with operations and map fields plus the actual file parts, implemented by libraries such as graphql-upload or Apollo Upload.

Magento's own /graphql endpoint does not implement this convention: the controller Magento\GraphQl\Controller\GraphQl only ever reads the request body as JSON, and for GET requests only processes query parameters, a branch for multipart/form-data is entirely absent. A direct multipart POST to /graphql is simply not recognized as a valid GraphQL request.

Persisted queries add a second constraint on top: a request sent as GET against an already registered query only ever carries a hash and its variables as query parameters, there is simply no room for binary file content in that shape. Even a hypothetical, custom-built multipart branch on the /graphql endpoint would therefore only ever apply to POST requests and would still need special handling alongside persisted queries, one more reason the two-step approach has become the practical standard.

2. The schema gap illustrated by personalized products

For file type custom options, Magento's schema does model the read side, with CustomizableFileOption and CustomizableFileValue, including allowed file extension and maximum image size. The write side stays incomplete though, the mutation addProductsToCart expects entered_options as EnteredOptionInput with a plain string value.

A look into Magento\QuoteGraphQl\Model\Cart\BuyRequest\CustomizableOptionsDataProvider shows it only ever evaluates value_string. Neither a CustomizableFileInput type nor an uploaded_file_identifier field exists, anyone who wants to attach a file to a personalized cart item has to build the entire upload flow themselves.

The metadata already present in the schema, such as allowed file extension or maximum image size, stays purely informational, its only job is to tell the client ahead of time what kind of file even makes sense. No actual server-side enforcement of these rules happens at this point, simply because there is no write path, so the custom validation living in the upload controller has to rebuild these rules independently and completely.

3. Architecture decision: a two-step flow instead of a custom multipart endpoint

Two paths are conceivable in principle: running a custom, multipart-capable controller right next to /graphql, or building a classic upload endpoint that returns a token, which is then referenced through a regular GraphQL mutation.

The two-step flow is considerably more robust in practice, because it keeps GraphQL fully JSON based, plays cleanly with existing infrastructure such as persisted queries, and bundles file validation into a dedicated, easily testable controller instead of weaving it into GraphQL execution.

4. Step 1: a dedicated upload controller

The controller accepts a regular multipart/form-data POST, validates the file, and stores it temporarily under a generated, unguessable file name. It then returns a token that the client stores for the following mutation.

It is important that the token is not simply the generated file name, but an additional, session or customer bound, cryptographically random value, so that nobody can guess or intercept somebody else's token and abuse it in their own mutation.


<?php
declare(strict_types=1);

namespace Vendor\PersonalizedProducts\Controller\Upload;

use Magento\Framework\App\Action\Action;
use Magento\Framework\App\Action\Context;
use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\App\CacheInterface;
use Magento\Framework\Controller\Result\JsonFactory;
use Vendor\PersonalizedProducts\Model\CustomizableFileValidator;

/**
 * Accepts an uploaded file for a personalized cart item and returns a
 * short-lived token that is later referenced through a GraphQL mutation.
 */
class File extends Action implements HttpPostActionInterface
{
    private const TOKEN_TTL_SECONDS = 3600;

    /**
     * @param Context $context
     * @param JsonFactory $jsonFactory
     * @param CustomizableFileValidator $validator
     * @param CacheInterface $cache
     */
    public function __construct(
        Context $context,
        private readonly JsonFactory $jsonFactory,
        private readonly CustomizableFileValidator $validator,
        private readonly CacheInterface $cache
    ) {
        parent::__construct($context);
    }

    /**
     * Processes the multipart upload and returns an upload token as JSON.
     *
     * @return \Magento\Framework\Controller\Result\Json
     */
    public function execute()
    {
        $result = $this->jsonFactory->create();

        try {
            $storedFile = $this->validator->validateAndStore($this->getRequest()->getFiles('file'));
        } catch (\Exception $exception) {
            return $result->setHttpResponseCode(422)->setData(['message' => $exception->getMessage()]);
        }

        $token = bin2hex(random_bytes(24));
        $this->cache->save(json_encode($storedFile), 'upload_token_' . $token, [], self::TOKEN_TTL_SECONDS);

        return $result->setData(['upload_token' => $token]);
    }
}

5. Step 2: a custom mutation that references the token

The actual GraphQL mutation stays fully JSON based and only receives the previously generated token as a string argument. The resolver resolves the token server side, checks validity and ownership, and links the file to the cart item or custom attribute.

This split keeps the GraphQL side simple and type safe while all the binary complexity stays inside the upload controller. A client can even upload several files one after another and only fire a single mutation with every collected token at the end.


# app/code/Vendor/PersonalizedProducts/etc/schema.graphqls
input AttachUploadedFileInput {
    cart_id: String!
    cart_item_uid: String!
    upload_token: String!
}

type AttachUploadedFileOutput {
    cart_item_uid: String!
    file_name: String!
}

type Mutation {
    attachUploadedFile(input: AttachUploadedFileInput!): AttachUploadedFileOutput
        @resolver(class: "Vendor\\PersonalizedProducts\\Model\\Resolver\\AttachUploadedFile")
}

6. Validation: size, MIME type, and file extension

Validation follows the pattern of Magento\Catalog\Model\Product\Option\Type\File\ValidatorFile, which checks the actual content length against a configured maximum. For a custom upload, a comparable, explicit size check before any further processing is sufficient.

Just as important is checking the actual file signature rather than only the content type header sent by the client. A whitelist of allowed extensions and MIME types prevents an executable file from disguising itself as a harmless image and later being served through a different path.


<?php
declare(strict_types=1);

namespace Vendor\PersonalizedProducts\Model;

use Magento\MediaStorage\Model\File\UploaderFactory;
use Magento\Framework\Filesystem;
use Magento\Framework\App\Filesystem\DirectoryList;

/**
 * Validates an uploaded file for personalized products and stores it in
 * a staging directory that is not directly reachable over the web.
 */
class CustomizableFileValidator
{
    private const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
    private const ALLOWED_EXTENSIONS = ['png', 'jpg', 'jpeg', 'pdf'];
    private const ALLOWED_MIME_TYPES = ['image/png', 'image/jpeg', 'application/pdf'];

    /**
     * @param UploaderFactory $uploaderFactory
     * @param Filesystem $filesystem
     */
    public function __construct(
        private readonly UploaderFactory $uploaderFactory,
        private readonly Filesystem $filesystem
    ) {
    }

    /**
     * Checks size, MIME type, and extension, and stages the file on success.
     *
     * @param array $fileData
     * @return array{path: string, original_name: string}
     * @throws \InvalidArgumentException
     */
    public function validateAndStore(array $fileData): array
    {
        if (($fileData['size'] ?? 0) > self::MAX_FILE_SIZE_BYTES) {
            throw new \InvalidArgumentException('The file is too large, 5 MB maximum allowed.');
        }

        $extension = strtolower(pathinfo($fileData['name'], PATHINFO_EXTENSION));
        if (!in_array($extension, self::ALLOWED_EXTENSIONS, true)) {
            throw new \InvalidArgumentException('File type not allowed.');
        }

        $detectedMimeType = mime_content_type($fileData['tmp_name']);
        if (!in_array($detectedMimeType, self::ALLOWED_MIME_TYPES, true)) {
            throw new \InvalidArgumentException('File signature does not match the allowed file type.');
        }

        $uploader = $this->uploaderFactory->create(['fileId' => $fileData]);
        $uploader->setAllowedExtensions(self::ALLOWED_EXTENSIONS);
        $uploader->setFilesDispersion(true);

        $tmpDirectory = $this->filesystem->getDirectoryWrite(DirectoryList::VAR_DIR)->getAbsolutePath('customizable_uploads');
        $result = $uploader->save($tmpDirectory);

        return ['path' => $result['file'], 'original_name' => $fileData['name']];
    }
}

7. Storage: a secure directory with release only after validation

Uploaded files that have not been validated yet belong in a directory outside the publicly reachable media tree served by the webserver, for example beneath var/. Only after successful validation and assignment through the mutation does the file move to its final location, for example under pub/media/custom_options.

Unique, generated file names instead of the original name sent by the client prevent both collisions and path traversal attempts through manipulated file names. The original name can be stored separately as metadata for display in the admin or storefront.

8. Interaction with quote and order during checkout

The mapping from token to cart item ends up as a serialized option on the quote item, analogous to the classic file custom option mechanism. When a quote turns into an order, Magento carries this option data over automatically, so the file reference stays visible through admin and API even after the order is placed.

Unfinished uploads whose quote never becomes an order should be cleaned up through a cron job once the token's validity expires, otherwise orphaned files accumulate in the upload directory.


<?php
declare(strict_types=1);

namespace Vendor\PersonalizedProducts\Model\Resolver;

use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Query\ResolverInterface;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
use Magento\Framework\App\CacheInterface;
use Magento\Quote\Model\QuoteRepository;

/**
 * Resolves an upload token and permanently links the file to the cart item.
 */
class AttachUploadedFile implements ResolverInterface
{
    /**
     * @param CacheInterface $cache
     * @param QuoteRepository $quoteRepository
     */
    public function __construct(
        private readonly CacheInterface $cache,
        private readonly QuoteRepository $quoteRepository
    ) {
    }

    /**
     * @param Field $field
     * @param mixed $context
     * @param ResolveInfo $info
     * @param array|null $value
     * @param array|null $args
     * @return array
     * @throws \Magento\Framework\GraphQl\Exception\GraphQlInputException
     */
    public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null): array
    {
        $token = $args['input']['upload_token'];
        $cached = $this->cache->load('upload_token_' . $token);

        if ($cached === false) {
            throw new \Magento\Framework\GraphQl\Exception\GraphQlInputException(
                __('Upload token is invalid or expired.')
            );
        }

        $storedFile = json_decode($cached, true);
        // Persists storedFile as a serialized custom option on the quote item, mirroring
        // the classic file custom option mechanism of the regular checkout.

        return [
            'cart_item_uid' => $args['input']['cart_item_uid'],
            'file_name' => $storedFile['original_name'],
        ];
    }
}

9. Testing and hardening the upload flow

Integration tests against the upload controller should cover valid files as well as deliberately oversized, mistyped, and manipulated files, complemented by tests of the mutation with expired, foreign, or already used tokens.

For operational security, it is also worth looking at rate limiting on the REST and webapi layer, a separate topic with its own implementation, since an upload endpoint with no throughput limit at all is an obvious target for mass uploads and storage exhaustion.

Do not forget the classic PHP limits upload_max_filesize, post_max_size, and memory_limit, which apply independently of any custom validation code and, when set too low, cause a silent, hard-to-diagnose failure before the request ever reaches the controller. A load test with realistic file sizes and concurrent uploads uncovers such misconfigurations far more reliably than a single manual test upload.

Building block Purpose Built into Magento? Effort
Multipart body parsing on /graphql Read the file out of the request No, GraphQl.php only parses JSON Not needed with the two-step approach
Separate upload controller Accept, validate, and stage the file Partially, uploader classes are reusable Medium
Custom mutation with token reference Attach the uploaded file to an entity No, has to be written from scratch Medium
Validator for size, MIME type, extension Reject malicious or oversized files Yes, ValidatorFile is a usable template Low
Staging directory with a release step No web access to unvalidated files Partially, uploader conventions Low to medium

Mironsoft

Magento development, module consulting, and system architecture

A Magento project that needs a second opinion or experienced execution?

We build custom Magento modules, advise on architecture decisions, and take on complex implementations, from service contract planning to production-ready deployment.

Architecture Consulting

Have module and system architecture thought through properly before you build.

Custom Module Development

Build custom Magento modules cleanly, following best practices.

Code Review & Audit

Have existing modules reviewed for performance, security, and maintainability.

10. Summary

File Uploads Through GraphQL in Magento: The Essentials

Core problem

Magento's /graphql endpoint only ever parses JSON bodies, genuine multipart uploads are not parsed.

Solution

Two-step flow: a separate upload endpoint returns a token, a GraphQL mutation links the token to the entity.

Validation

Check file size, MIME type, and extension server side, following Magento's ValidatorFile pattern.

Security

Upload directory without direct web access, unique file names, release only after validation.

11. FAQ: File Uploads Through GraphQL in Magento: The Essentials

1Does Magento GraphQL support multipart uploads per the common GraphQL multipart specification?
No, the controller Magento\GraphQl\Controller\GraphQl only ever reads the request body as JSON, a multipart/form-data branch does not exist.
2Is there already a ready-made GraphQL mutation for file type custom options?
No, EnteredOptionInput only knows a string value, there is neither a CustomizableFileInput type nor an uploaded_file_identifier field in core.
3What does the pragmatic upload path look like then?
A custom controller accepts the file, validates it, and returns a token, a following GraphQL mutation links that token to the cart item or custom attribute.
4Can I reuse Magento's existing validator classes?
Partially, ValidatorFile and the underlying uploader are a good template for size and type checks, but the core classes are tightly coupled to the classic checkout flow.
5Where should the uploaded file be staged temporarily?
In a directory outside the publicly reachable media tree, until validation and assignment are complete, only then move it to its final location.
6How do I prevent someone from uploading arbitrary file types?
Through a server side whitelist of allowed extensions and MIME types plus a check of the actual file signature, not just the content type header sent by the client.
7Does the token need to be bound to a session or customer?
Yes, otherwise a different user could guess or intercept a valid token and attach it to their own order.
8How long should an upload token stay valid?
Short enough to make abuse harder, but long enough for the checkout process, an expiry of roughly one to two hours with cleanup of unused files is a common compromise.
9Can I handle the upload through REST instead and only the assignment through GraphQL?
Yes, that is actually the common approach: a regular REST endpoint for the actual upload, GraphQL stays responsible for reading and linking operations.
10How do I meaningfully test the upload controller?
Through integration tests that send real multipart requests against the controller and check both valid files and oversized or mistyped ones.