Magento 2 Experten — Hyvä Theme, Tailwind CSS & SEO aus einer Hand ›

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

app/code/Mironsoft/Event/etc/schema.graphqls
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

app/code/Mironsoft/Event/Model/Resolver/UploadEventImage.php
<?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.