File Uploads Over GraphQL
File Uploads Over GraphQL
~8 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026
An obvious next step for the events project: a title image per event. GraphQL itself, however, has no native mechanism for classic multipart/form-data file uploads like an HTML form - every GraphQL request is structured as JSON. This chapter shows the approach Magento typically uses: base64-encoded image data as a string argument.
Why not a classic multipart upload?
The /graphql endpoint fundamentally expects a JSON body with query/variables - there's no dedicated content-type handling for multipart/form-data like the GraphQL multipart request community standard used by some other GraphQL servers. Magento's own solution for binary data (product images, customer avatars) is consistent: transfer the file content as a base64 string in the JSON payload, decode it server-side, and store it normally through the Filesystem abstraction.
The mutation in the schema
type Mutation {
uploadEventImage(
input: UploadEventImageInput!
): UploadEventImageOutput
@resolver(class: "Mironsoft\\Event\\Model\\Resolver\\UploadEventImage")
@doc(description: "Uploads a base64-encoded title image for an event")
}
input UploadEventImageInput @doc(description: "Input for uploadEventImage") {
event_id: Int!
file_name: String!
base64_encoded_data: String!
}
type UploadEventImageOutput @doc(description: "Result of uploadEventImage") {
image_url: String
}The resolver: validate, decode, store
<?php
declare(strict_types=1);
namespace Mironsoft\Event\Model\Resolver;
use Magento\Framework\Filesystem;
use Magento\Framework\App\Filesystem\DirectoryList;
use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Exception\GraphQlInputException;
use Magento\Framework\GraphQl\Query\ResolverInterface;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
/**
* Resolves the uploadEventImage mutation field.
*/
class UploadEventImage implements ResolverInterface
{
private const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp'];
private const MAX_BYTES = 2 * 1024 * 1024;
private const UPLOAD_SUBDIR = 'mironsoft/event';
/**
* @param Filesystem $filesystem Magento filesystem abstraction
*/
public function __construct(
private readonly Filesystem $filesystem,
) {
}
/**
* Decodes and stores a base64-encoded event image.
*
* @param Field $field Resolved GraphQL field configuration
* @param mixed $context Resolver context
* @param ResolveInfo $info GraphQL resolve tree info
* @param array|null $value Parent resolver's value, unused for a top-level field
* @param array|null $args Arguments passed to the uploadEventImage field
* @return array<string, mixed>
* @throws GraphQlInputException
*/
public function resolve(
Field $field,
$context,
ResolveInfo $info,
?array $value = null,
?array $args = null
): array {
$fileName = (string) ($args['input']['file_name'] ?? '');
$base64 = (string) ($args['input']['base64_encoded_data'] ?? '');
$extension = strtolower((string) pathinfo($fileName, PATHINFO_EXTENSION));
if (!in_array($extension, self::ALLOWED_EXTENSIONS, true)) {
throw new GraphQlInputException(
__('Only jpg, jpeg, png, and webp images are allowed.')
);
}
$binaryData = base64_decode($base64, true);
if ($binaryData === false || $binaryData === '') {
throw new GraphQlInputException(__('The uploaded data is not valid base64.'));
}
if (strlen($binaryData) > self::MAX_BYTES) {
throw new GraphQlInputException(__('The image must not exceed 2 MB.'));
}
$safeName = bin2hex(random_bytes(8)) . '.' . $extension;
$mediaDirectory = $this->filesystem->getDirectoryWrite(DirectoryList::MEDIA);
$relativePath = self::UPLOAD_SUBDIR . '/' . $safeName;
$mediaDirectory->writeFile($relativePath, $binaryData);
return [
'image_url' => '/media/' . $relativePath,
];
}
}Achtung: $fileName is only ever used to determine the file extension - never directly as the target file name. The name actually written to disk ($safeName) is freshly, randomly generated server-side. Without this step, a client could attempt a path traversal attack via a crafted file name like ../../etc/passwd.jpg - the ALLOWED_EXTENSIONS check alone doesn't protect against that; only fully re-generating the file name does, reliably.
Calling the mutation
{
"query": "mutation($eventId: Int!, $data: String!) { uploadEventImage(input: { event_id: $eventId, file_name: \"banner.png\", base64_encoded_data: $data }) { image_url } }",
"variables": {
"eventId": 3,
"data": "iVBORw0KGgoAAAANSUhEUgAA..."
}
}Tipp: Base64 encoding inflates the payload by roughly a third compared to the original file - a 1.5 MB image easily becomes 2 MB of JSON text. Both webapi/graphql/max_request_size in the PHP configuration (post_max_size, memory_limit) and your own MAX_BYTES check in the resolver should account for that overhead.
With image uploads for events covered, chapter 23 turns to the final building block of block 6: ACL checks for custom GraphQL endpoints, illustrated with an admin-only reporting field.