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.
Table of Contents
- 1. Why GraphQL has no native file upload and Magento does not close the gap
- 2. The schema gap illustrated by personalized products
- 3. Architecture decision: a two-step flow instead of a custom multipart endpoint
- 4. Step 1: a dedicated upload controller
- 5. Step 2: a custom mutation that references the token
- 6. Validation: size, MIME type, and file extension
- 7. Storage: a secure directory with release only after validation
- 8. Interaction with quote and order during checkout
- 9. Testing and hardening the upload flow
- 10. Summary
- 11. FAQ
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.