Datei-Uploads über GraphQL Mutations in Magento umsetzen
AI generated
M2
di.xml
Magento 2 · GraphQL · File Upload
GraphQL File Upload Mutations
wie Datei-Uploads trotz fehlendem Multipart-Support sauber gelingen

GraphQL kennt keinen eingebauten Datei-Upload, und Magentos /graphql-Endpunkt verarbeitet ausschließlich JSON-Bodies, ein direkter Multipart-Upload scheitert also schon am Controller. Dieser Artikel zeigt am Beispiel individualisierter Produkte einen praxistauglichen Zwei-Schritt-Fluss aus dediziertem Upload-Controller und referenzierender Mutation, inklusive Validierung, sicherer Speicherung und Anbindung an Quote und Order.

15 Min. Lesezeit Zwei-Schritt-Upload-Muster Custom Attribute · individualisierte Produkte

1. Warum GraphQL kein natives File-Upload kennt und Magento das nicht schließt

Die GraphQL-Spezifikation definiert ausschließlich JSON-basierte Anfragen und kennt kein eingebautes Konzept für Binärdaten. Für Datei-Uploads hat sich in der Community die informelle Multipart-Request-Konvention etabliert, mit den Feldern operations und map plus den eigentlichen Dateiteilen, umgesetzt etwa durch Bibliotheken wie graphql-upload oder Apollo Upload.

Magentos eigener /graphql-Endpunkt setzt diese Konvention nicht um: Der Controller Magento\GraphQl\Controller\GraphQl liest den Request-Body ausschließlich als JSON aus und verarbeitet bei GET-Anfragen lediglich die Query-Parameter, ein Zweig für multipart/form-data fehlt vollständig. Ein direkter Multipart-POST an /graphql wird schlicht nicht als gültige GraphQL-Anfrage erkannt.

Bei Persisted Queries kommt eine zweite Einschränkung hinzu: Eine per GET gesendete, bereits registrierte Query trägt ohnehin nur einen Hash und die Variablen als Query-Parameter, für binäre Dateiinhalte ist in dieser Form schlicht kein Platz. Selbst ein hypothetischer, eigens gebauter Multipart-Zweig am /graphql-Endpunkt würde also nur für POST-Anfragen greifen und müsste mit Persisted Queries gesondert zusammenspielen, ein weiterer Grund, warum sich der Zwei-Schritt-Ansatz in der Praxis durchgesetzt hat.

2. Die Schema-Lücke am Beispiel individualisierter Produkte

Für Custom-Options vom Typ Datei modelliert Magento im Schema zwar die Lesesicht mit CustomizableFileOption und CustomizableFileValue, inklusive erlaubter Dateiendung und maximaler Bildgröße. Die Schreibsicht bleibt jedoch unvollständig: Die Mutation addProductsToCart erwartet entered_options als EnteredOptionInput mit einem reinen String-Wert.

Ein Blick in Magento\QuoteGraphQl\Model\Cart\BuyRequest\CustomizableOptionsDataProvider zeigt, dass dort ausschließlich value_string ausgewertet wird. Es existiert weder ein CustomizableFileInput-Typ noch ein uploaded_file_identifier-Feld, wer eine Datei an eine individualisierte Bestellposition hängen will, muss den kompletten Upload-Fluss selbst bauen.

Die im Schema vorhandenen Metadaten wie erlaubte Dateiendung oder maximale Bildgröße bleiben rein informativ, sie dienen ausschließlich dazu, dem Client vorab mitzuteilen, welche Datei überhaupt sinnvoll ist. Eine tatsächliche serverseitige Durchsetzung dieser Regeln findet an dieser Stelle nicht statt, weil es eben keinen Schreibpfad gibt, entsprechend muss die eigene Validierung im Upload-Controller diese Regeln eigenständig und vollständig nachbilden.

3. Architekturentscheidung: Zwei-Schritt-Fluss statt eigenem Multipart-Endpunkt

Zwei Wege sind grundsätzlich denkbar: einen eigenen, multipart-fähigen Controller direkt neben /graphql zu betreiben, oder einen klassischen Upload-Endpunkt zu bauen, der ein Token zurückgibt, das anschließend per regulärer GraphQL-Mutation referenziert wird.

Der Zwei-Schritt-Fluss ist in der Praxis deutlich robuster, weil er GraphQL vollständig JSON-basiert lässt, sich sauber mit bestehender Infrastruktur wie Persisted Queries verträgt und die Datei-Validierung in einem dedizierten, einfach zu testenden Controller bündelt, statt sie in die GraphQL-Ausführung zu verweben.

4. Schritt 1: Ein dedizierter Upload-Controller

Der Controller nimmt einen regulären multipart/form-data-POST entgegen, validiert die Datei und speichert sie temporär unter einem generierten, nicht erratbaren Dateinamen. Danach liefert er ein Token zurück, das Client-seitig für die anschließende Mutation gespeichert wird.

Wichtig ist, das Token nicht einfach den generierten Dateinamen sein zu lassen, sondern einen zusätzlichen, an Session oder Kunde gebundenen, kryptografisch zufälligen Wert zu verwenden, damit niemand ein fremdes Token erraten und in der eigenen Mutation missbrauchen kann.


<?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;

/**
 * Nimmt eine hochgeladene Datei für eine individualisierte Bestellposition entgegen
 * und liefert ein kurzlebiges Token, das anschließend per GraphQL-Mutation referenziert wird.
 */
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);
    }

    /**
     * Verarbeitet den Multipart-Upload und liefert ein Upload-Token als JSON zurück.
     *
     * @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. Schritt 2: Custom Mutation zur Zuordnung des Tokens

Die eigentliche GraphQL-Mutation bleibt vollständig JSON-basiert und bekommt lediglich das zuvor erzeugte Token als String-Argument. Der Resolver löst das Token serverseitig auf, prüft Gültigkeit und Besitzverhältnis und verknüpft die Datei mit der Bestellposition oder dem Custom-Attribut.

Dieser Schnitt hält die GraphQL-Seite einfach und typsicher, während sämtliche binäre Komplexität im Upload-Controller bleibt. Ein Client kann sogar mehrere Dateien nacheinander hochladen und erst am Ende eine einzige Mutation mit allen gesammelten Tokens absetzen.


# 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. Validierung: Größe, MIME-Type und Dateiendung

Die Validierung orientiert sich am Muster von Magento\Catalog\Model\Product\Option\Type\File\ValidatorFile, das die tatsächliche Content-Length gegen ein konfiguriertes Maximum prüft. Für eigene Uploads reicht ein vergleichbarer, expliziter Größencheck vor jeder weiteren Verarbeitung.

Genauso wichtig ist die Prüfung der tatsächlichen Dateisignatur statt allein des vom Client gesendeten Content-Type-Headers. Eine Whitelist erlaubter Endungen und MIME-Types verhindert, dass sich eine ausführbare Datei als harmloses Bild ausgibt und später über einen anderen Weg ausgeliefert wird.


<?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;

/**
 * Validiert eine hochgeladene Datei für individualisierte Produkte und
 * speichert sie in einem nicht öffentlich erreichbaren Zwischenverzeichnis.
 */
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
    ) {
    }

    /**
     * Prüft Größe, MIME-Type und Endung und speichert die Datei bei Erfolg zwischen.
     *
     * @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('Die Datei ist zu groß, maximal 5 MB erlaubt.');
        }

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

        $detectedMimeType = mime_content_type($fileData['tmp_name']);
        if (!in_array($detectedMimeType, self::ALLOWED_MIME_TYPES, true)) {
            throw new \InvalidArgumentException('Dateisignatur passt nicht zum erlaubten Dateityp.');
        }

        $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. Speicherung: Sicheres Verzeichnis und Freigabe erst nach Validierung

Hochgeladene, noch nicht validierte Dateien gehören in ein Verzeichnis außerhalb des öffentlich über den Webserver erreichbaren Media-Baums, etwa unterhalb von var/. Erst nach erfolgreicher Validierung und Zuordnung über die Mutation wandert die Datei in ihren endgültigen Ablageort, beispielsweise unter pub/media/custom_options.

Eindeutige, generierte Dateinamen statt des vom Client gesendeten Originalnamens verhindern sowohl Kollisionen als auch Path-Traversal-Versuche über manipulierte Dateinamen. Der ursprüngliche Name lässt sich separat als Metadatum für die Anzeige im Admin oder Storefront speichern.

8. Zusammenspiel mit Quote und Order beim Checkout

Die Zuordnung von Token zu Bestellposition landet analog zum klassischen File-Custom-Option-Mechanismus als serialisierte Option am Quote-Item. Beim Übergang von Quote zu Order übernimmt Magento diese Optionsdaten automatisch, sodass die Datei-Referenz auch nach Bestellabschluss über Admin und API sichtbar bleibt.

Nicht abgeschlossene Uploads, deren Quote nie zur Order wird, sollten über einen Cronjob nach Ablauf der Token-Gültigkeit wieder entfernt werden, sonst sammeln sich verwaiste Dateien im Upload-Verzeichnis an. Ein solcher Cronjob orientiert sich am selben Ablaufzeitpunkt wie das Upload-Token selbst und kann zusätzlich verwaiste Cache-Einträge bereinigen, damit Speicherplatz und Cache-Backend nicht schleichend mit nie referenzierten Dateiresten volllaufen.

Auch im Admin sollte die hochgeladene Datei sichtbar sein, etwa als Download-Link im Bestellpositions-Detail, damit ein Support-Mitarbeiter die individualisierte Datei ohne direkten Zugriff auf das Dateisystem prüfen kann. Diese Sichtbarkeit lässt sich über dasselbe serialisierte Optionsfeld realisieren, das ohnehin schon für die Zuordnung zwischen Bestellposition und Datei genutzt wird, ohne dass dafür ein zusätzlicher eigener Datenbank-Eintrag nötig wäre.


<?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;

/**
 * Löst ein Upload-Token auf und verknüpft die Datei dauerhaft mit der Bestellposition.
 */
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 ist ungültig oder abgelaufen.')
            );
        }

        $storedFile = json_decode($cached, true);
        // Persistiert storedFile als serialisierte Custom-Option am Quote-Item, analog
        // zum klassischen File-Custom-Option-Mechanismus des regulären Checkouts.

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

9. Testing und Absicherung des Upload-Flusses

Integrationstests gegen den Upload-Controller sollten sowohl gültige Dateien als auch bewusst zu große, falsch typisierte und manipulierte Dateien abdecken, ergänzt um Tests der Mutation mit abgelaufenen, fremden oder bereits verwendeten Tokens.

Für die Betriebssicherheit lohnt sich zusätzlich ein Blick auf die Rate-Begrenzung der REST- und Webapi-Schicht, ein eigenständiges Thema mit eigener Umsetzung, weil ein Upload-Endpunkt ohne jede Durchsatzbegrenzung ein naheliegendes Ziel für Massen-Uploads und Speicherplatz-Erschöpfung ist.

Nicht zu vergessen sind die klassischen PHP-Grenzwerte upload_max_filesize, post_max_size und memory_limit, die unabhängig vom eigenen Validierungscode gelten und bei einem zu niedrig eingestellten Wert schon vor dem Erreichen des Controllers zu einem stillen, schwer nachvollziehbaren Fehlschlag führen. Ein Lasttest mit realistischen Dateigrößen und gleichzeitigen Uploads deckt solche Fehlkonfigurationen zuverlässiger auf als ein einzelner manueller Testupload.

Baustein Zweck Magento-Bordmittel? Aufwand
Multipart-Body-Parsing am /graphql-Endpunkt Datei aus dem Request lesen Nein, GraphQl.php verarbeitet nur JSON Entfällt beim Zwei-Schritt-Ansatz
Separater Upload-Controller Datei entgegennehmen, validieren, temporär speichern Teilweise, Uploader-Klassen wiederverwendbar Mittel
Custom Mutation mit Token-Referenz Hochgeladene Datei einer Entität zuordnen Nein, muss selbst geschrieben werden Mittel
Validator für Größe, MIME und Endung Schadhafte oder zu große Dateien abweisen Ja, ValidatorFile als Vorbild nutzbar Niedrig
Persistenz-Verzeichnis mit Freigabe-Schritt Kein Web-Zugriff auf unvalidierte Dateien Teilweise, Uploader-Konventionen Niedrig bis mittel

Mironsoft

Magento-Entwicklung, Modul-Beratung und Systemarchitektur

Magento-Projekt, das eine zweite Meinung oder erfahrene Umsetzung braucht?

Wir entwickeln individuelle Magento-Module, beraten bei Architekturentscheidungen und übernehmen komplexe Umsetzungen, von der Service-Contract-Planung bis zum produktionsreifen Deployment.

Architektur-Beratung

Modul- und Systemarchitektur vor der Umsetzung fundiert durchdenken lassen.

Custom-Modul-Entwicklung

Individuelle Magento-Module nach Best Practices sauber umsetzen.

Code-Review & Audit

Bestehende Module auf Performance, Sicherheit und Wartbarkeit prüfen lassen.

10. Zusammenfassung

Datei-Uploads über GraphQL in Magento: Das Wichtigste auf einen Blick

Kernproblem

Magentos /graphql-Endpunkt verarbeitet ausschließlich JSON-Bodies, echte Multipart-Uploads werden nicht geparst.

Lösung

Zwei-Schritt-Fluss: separater Upload-Endpunkt liefert ein Token, eine GraphQL-Mutation verknüpft das Token mit der Entität.

Validierung

Dateigröße, MIME-Type und Endung serverseitig prüfen, angelehnt an Magentos ValidatorFile-Muster.

Sicherheit

Upload-Verzeichnis ohne direkten Web-Zugriff, eindeutige Dateinamen, Freigabe erst nach Validierung.

11. FAQ: Datei-Uploads über GraphQL in Magento: Das Wichtigste auf einen Blick

1Unterstützt Magento GraphQL Multipart-Uploads nach der verbreiteten GraphQL-Multipart-Spezifikation?
Nein, der Controller Magento\GraphQl\Controller\GraphQl liest den Request-Body ausschließlich als JSON aus, ein multipart/form-data-Zweig existiert nicht.
2Gibt es für Custom-Options vom Typ Datei bereits eine fertige GraphQL-Mutation?
Nein, EnteredOptionInput kennt nur einen String-Wert, es gibt weder einen CustomizableFileInput-Typ noch ein uploaded_file_identifier-Feld im Kern.
3Wie sieht der pragmatische Upload-Weg dann aus?
Ein eigener Controller nimmt die Datei entgegen, validiert sie und gibt ein Token zurück, eine anschließende GraphQL-Mutation verknüpft dieses Token mit der Bestellposition oder dem Custom-Attribut.
4Kann ich Magentos vorhandene Validator-Klassen wiederverwenden?
Teilweise, ValidatorFile und der darunterliegende Uploader sind ein gutes Vorbild für Größen- und Typprüfung, die Kern-Klassen sind aber eng an den klassischen Checkout-Fluss gekoppelt.
5Wo sollte die hochgeladene Datei zwischengespeichert werden?
In einem Verzeichnis außerhalb des öffentlich erreichbaren Media-Baums, bis Validierung und Zuordnung abgeschlossen sind, danach erst in den endgültigen Ablageort verschieben.
6Wie verhindere ich, dass jemand beliebige Dateitypen hochlädt?
Über eine serverseitige Whitelist erlaubter Endungen und MIME-Types plus eine Prüfung der tatsächlichen Dateisignatur, nicht nur des vom Client gesendeten Content-Type-Headers.
7Muss das Token an eine Session oder einen Kunden gebunden sein?
Ja, sonst kann ein fremder Nutzer ein gültiges Token erraten oder abfangen und es einer eigenen Bestellung zuordnen.
8Wie lange sollte ein Upload-Token gültig bleiben?
Kurz genug, um Missbrauch zu erschweren, aber lang genug für den Checkout-Prozess, ein Ablauf von etwa ein bis zwei Stunden mit Bereinigung nicht verwendeter Dateien ist ein üblicher Kompromiss.
9Kann ich den Upload stattdessen über REST abwickeln und nur die Zuordnung über GraphQL?
Ja, das ist sogar der verbreitete Ansatz: ein regulärer REST-Endpunkt für den eigentlichen Upload, GraphQL bleibt für Lese- und Verknüpfungsoperationen zuständig.
10Wie teste ich den Upload-Controller sinnvoll?
Über Integrationstests, die reale Multipart-Requests gegen den Controller schicken und sowohl gültige als auch überdimensionierte oder falsch typisierte Dateien prüfen.