youshido/graphql-files-bundle
A Symfony bundle that adds file upload support to the Youshido GraphQL library, mapping multipart requests to file arguments in your schema. A lightweight option for smaller projects that need upload handling without a heavier framework.
Install the bundle and its peer GraphQL library with Composer, then register it in your Symfony kernel/bundles config:
composer require youshido/graphql-files-bundle
// config/bundles.php (or AppKernel::registerBundles for older Symfony)
return [
// ...
Youshido\GraphQLFilesBundle\GraphQLFilesBundle::class => ['all' => true],
];
Where to look first: open the bundle's src/ (or lib/) and Resources/config to find (a) the GraphQL scalar/type it registers for file inputs and (b) the request listener that parses multipart uploads per the GraphQL multipart request spec. Those two pieces are the whole package — everything else is wiring.
First use case: a mutation that accepts an uploaded file and returns a URL. Expose the bundle's file scalar on an argument, and your resolver receives a Symfony\Component\HttpFoundation\File\UploadedFile:
use Youshido\GraphQL\Type\Scalar\AbstractScalarType; // plus the bundle's file type
$config->addField('uploadAvatar', [
'type' => Type::string(),
'args' => [
'file' => ['type' => Type::nonNull(new FileType())],
],
'resolve' => function ($value, array $args) {
/** @var \Symfony\Component\HttpFoundation\File\UploadedFile $file */
$file = $args['file'];
$name = bin2hex(random_bytes(8)) . '.' . $file->guessExtension();
$file->move($this->getParameter('uploads_dir'), $name);
return '/uploads/' . $name;
},
]);
Client-side, send a multipart request in the spec's operations / map / file-part shape (Apollo Upload Client or a hand-built FormData both work):
operations = {"query":"mutation($file: File!){ uploadAvatar(file:$file) }","variables":{"file":null}}
map = {"0":["variables.file"]}
0 = <binary file>
Treat file uploads as part of a mutation, not a query. Pair the file argument with the rest of the mutation's data so validation and persistence are one atomic resolver. Extract validation and storage into a small service (e.g. AvatarUploader) and inject it — don't stuff move() calls into type classes.
Normalize storage across backends. Have the resolver delegate to a service that returns a DTO/entity, so you can later swap local disk for S3 or Flysystem without touching the schema:
'resolve' => fn ($v, $args) => $this->container->get(Uploader::class)->store(
$args['file'], 'avatars'
)
Validate against the UploadedFile, not the GraphQL type. Use Symfony validators or File constraints on the underlying UploadedFile; the scalar only guarantees you received an upload object. Check isValid(), size, and MIME (getMimeType() / getClientMimeType() — trust the former).
Keep the scalar in your own schema builder. If you build types programmatically, centralize the FileType instantiation in a factory so you can swap in a custom scalar later without hunting through every mutation.
Testing: drive the integration with a real HTTP call, since the multipart parsing is the risky part. A curl or HttpClient test with the three-part body above validates the whole pipeline; unit-test the storage service separately. Add a smoke test that asserts the resolver actually receives an UploadedFile and not an array/string.
Auth/authorization: the resolver still runs under your normal security context, so apply $config->addField(..., ['resolve' => ..., 'args' => ...]) with the usual access checks. Don't rely on the bundle for CSRF or auth.
upload_max_filesize, post_max_size, and max_file_uploads; when post_max_size is exceeded PHP empties $_POST/$_FILES silently, so the GraphQL request looks empty rather than failing loudly.move()/copy during the resolver.map field is exact-match sensitive. A malformed multipart body (wrong field names, missing map, non-string keys) usually surfaces as "file argument is null" or a scalar coercion error. Enable GraphQL debug output and log the parsed variables first when debugging.Request::getContent() in a listener) can consume the request body before the bundle sees it. Keep such middleware from touching multipart POSTs.getClientOriginalName() in stored filenames or headers; generate your own name and derive extensions via guessExtension().composer.json constraints against your framework version before committing, and be ready to override the request-listener service in services.yaml if behavior needs adjusting.How can I help you explore Laravel packages today?