Request form, GraphQL wiring, and status timeline for Adobe Commerce, plus a fallback for Open Source
Adobe Commerce ships Magento_Rma, a complete return workflow system including a GraphQL API, but the module is entirely absent from Magento Open Source. This article shows how to build a return request form as an Alpine component in a Hyva theme, wire up the requestReturn mutation and the customer.returns query, render a status timeline for customers, and sketch how an Open Source shop without native RMA can build its own simple return process.
Table of Contents
- 1. RMA in Magento: The Adobe Commerce Exclusivity and What Hyva Does Not Provide
- 2. Account Menu Entry and Controller Scaffold
- 3. Return Request Form as an Alpine Component
- 4. Wiring the Backend: requestReturn Mutation and Validation
- 5. Status Tracking: A Return Timeline for the Customer
- 6. File Upload for Return Slips and Photos
- 7. Multi Item Returns With a Different Reason per Line
- 8. Fallback Without Adobe Commerce: A Custom Returns Module Against a Custom Entity
- 9. How This Connects to Store Credit and Refunds After Approval
- 10. Summary
- 11. FAQ
1. RMA in Magento: The Adobe Commerce Exclusivity and What Hyva Does Not Provide
Before writing a single line of frontend code, the licensing question needs to be settled: the Magento_Rma module and its companion Magento_RmaGraphQl are exclusive to Adobe Commerce and Adobe Commerce Cloud. Magento Open Source ships without any Return Merchandise Authorization functionality at all, no return requests, no status tracking, none of the related GraphQL types. Running bin/magento module:status Magento_Rma on an Open Source install simply returns nothing, because the module is not part of the Composer package.
Hyva itself is a theme, not a commerce edition, so it does not carry any RMA logic of its own. The parent theme hyva-themes/magento2-default-theme-csp reimplements most Luma templates functionally, but only for modules that are actually present. If Magento_Rma is installed because the shop runs on Adobe Commerce, the standard Hyva theme still lacks matching frontend templates, since Hyva has historically focused on Open Source storefronts and deliberately left the Adobe Commerce only modules untouched.
For this article, that means two parallel paths: on Adobe Commerce, the native GraphQL Returns API is wired up directly, including the requestReturn mutation and the customer query with its returns field. On Magento Open Source, only a custom build against a custom entity remains, sketched out later in this article. Both paths share the same UI pattern, an Alpine form for the request and a timeline component for status tracking.
2. Account Menu Entry and Controller Scaffold
The entry point for customers is a new menu item on the customer account dashboard, added through the customer_account layout handle and the existing account.nav block, without overriding any core template. A custom view/frontend/layout/customer_account.xml references a block of type Magento\Framework\View\Element\Html\Link\Current pointing at the new route rma/returns/index.
The controller stays deliberately thin: it checks the customer session, does not load any RMA data server side, and only renders the page scaffold. The actual communication with Adobe Commerce happens entirely client side through GraphQL, matching Hyva's core principle of keeping server rendering minimal and handling interactivity with Alpine.js in the browser.
That separation also pays off for the full page cache, since the page itself stays statically cacheable while all customer specific return data is pulled in afterward via GraphQL, exactly like the order history already works in the customer account.
<?php
declare(strict_types=1);
namespace Mironsoft\Rma\Controller\Returns;
use Magento\Customer\Model\Session as CustomerSession;
use Magento\Framework\App\Action\Context;
use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\View\Result\Page;
use Magento\Framework\View\Result\PageFactory;
/**
* Renders the returns overview page scaffold in the customer account.
*/
class Index implements HttpGetActionInterface
{
/**
* @param Context $context
* @param PageFactory $resultPageFactory
* @param CustomerSession $customerSession
*/
public function __construct(
private readonly Context $context,
private readonly PageFactory $resultPageFactory,
private readonly CustomerSession $customerSession
) {
}
/**
* Returns the returns page, GraphQL data is loaded by the Alpine client.
*
* @return Page
*/
public function execute(): Page
{
$resultPage = $this->resultPageFactory->create();
$resultPage->getConfig()->getTitle()->set(__('My Returns'));
return $resultPage;
}
}
3. Return Request Form as an Alpine Component
The heart of the returns UI is a form pre filled from the customer's order history: line items of the selected order, each with quantity, reason, and desired resolution. A dedicated Alpine component works well here, using x-data to hold an array of order line items and a separate reactive object per row for quantity, reason, and resolution, so different line items can be filled in independently of each other.
The line item list itself comes from a server injected JSON structure, prepared by the block from an order repository lookup, or alternatively straight from a GraphQL query against customerOrders. The quantity per line item must be validated against the actually shipped quantity, both client side for instant feedback and server side before the mutation is sent, since a tampered payload must never reach the GraphQL API unchecked.
Resolution and reason are mapped to select fields with fixed enum values that match exactly the GraphQL enum ReturnItemRequestResolution and the configured return reasons, so the mutation in the next step needs no extra mapping layer.
<div x-data="returnRequestForm({
orderId: <?= (int) $block->getOrderId() ?>,
items: <?= /* @noEscape */ $block->getOrderItemsJson() ?>
})" class="mx-auto max-w-3xl px-4 py-8">
<template x-for="(item, index) in items" :key="item.orderItemId">
<div class="mb-4 rounded-lg border border-gray-200 p-4">
<p class="font-semibold" x-text="item.name"></p>
<div class="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-3">
<label class="text-sm">
Quantity
<input type="number" min="0" :max="item.qtyAvailable"
x-model.number="item.qtyToReturn"
class="mt-1 w-full rounded border-gray-300">
</label>
<label class="text-sm">
Reason
<select x-model="item.reason" class="mt-1 w-full rounded border-gray-300">
<template x-for="reason in reasons" :key="reason">
<option :value="reason" x-text="reason"></option>
</template>
</select>
</label>
<label class="text-sm">
Resolution
<select x-model="item.resolution" class="mt-1 w-full rounded border-gray-300">
<option value="REFUND">Refund</option>
<option value="EXCHANGE">Exchange</option>
<option value="STORE_CREDIT">Store credit</option>
</select>
</label>
</div>
</div>
</template>
<button @click="submitReturn()" class="rounded bg-gray-900 px-4 py-2 text-white">
Submit return
</button>
</div>
4. Wiring the Backend: requestReturn Mutation and Validation
Submitting the request runs through the requestReturn mutation of the GraphQL Returns API, provided by Adobe Commerce through Magento_RmaGraphQl. The Alpine component fires a fetch call against the GraphQL endpoint with the customer token in the Authorization header, since return requests are strictly tied to logged in customers and not available as a guest action.
The mutation expects a RequestReturnInput object with the order id, an items array containing order_item_id, quantity_to_return, request_quantity, reason, and resolution per entry, plus optional contact details. Adobe Commerce performs server side validation itself, for example checking whether a line item still falls within the configured return window, but the Alpine component should catch obvious errors like a zero quantity before submitting, to avoid unnecessary round trips.
On success, the response returns a MagentoReturn object including the newly assigned uid, which then serves as the reference for the status timeline covered next.
mutation RequestReturn($input: RequestReturnInput!) {
requestReturn(input: $input) {
return {
uid
number
status
items {
uid
quantity
request_quantity
reason
status
}
}
errors {
type
message
}
}
}
5. Status Tracking: A Return Timeline for the Customer
Once a return request exists, customers want to see at any time which stage the process has reached. Adobe Commerce models that through a fixed status enum whose key values are authorized, denied, received, approved, and closed. These values map cleanly onto a horizontal or vertical timeline component, since they suggest a clear linear order, even though denied is really an exit path rather than a step in the happy path.
The timeline is fetched through the customer.returns query and rendered client side in an Alpine component that shows a dot with label and, where available, timestamp for each status step. Reached steps are highlighted, the current step additionally gets a pulse animation via Tailwind utilities, open steps stay gray. The special case denied should be visually distinct from the success path, for example a red marker instead of green, so customers do not mistake it for a successful completion.
For support requests it also helps to show the return.number, the human readable return number, right above the timeline, since customers typically find that same number in confirmation emails and reference it when contacting customer service.
<div x-data="returnStatusTimeline({ steps: <?= /* @noEscape */ $block->getStatusStepsJson() ?> })">
<ol class="flex flex-col gap-4 sm:flex-row sm:items-center">
<template x-for="(step, index) in steps" :key="step.status">
<li class="flex items-center gap-2">
<span class="h-3 w-3 rounded-full"
:class="{
'bg-green-600': step.reached && step.status !== 'denied',
'bg-red-600': step.reached && step.status === 'denied',
'bg-gray-300': !step.reached
}"></span>
<span class="text-sm" x-text="step.label"></span>
<span class="text-xs text-gray-400" x-text="step.timestamp"></span>
</li>
</template>
</ol>
</div>
6. File Upload for Return Slips and Photos
Many merchants require a photo or a return slip as evidence for certain reasons, transport damage being the obvious example. The GraphQL Returns API itself does not ship a dedicated file upload mutation type, so in practice the upload runs through a classic, dedicated controller endpoint outside GraphQL that accepts the file and links it to the previously created return uid.
From a CSP perspective, the critical part is that the upload never targets an external domain through fetch, but consistently hits a first party route, so no additional connect-src allowance is needed in the Hyva CSP module. The Alpine form uses FormData together with the Magento form key from the hidden field, since file upload controllers must respect Magento's standard CSRF protection just like any other form, otherwise the request fails with a 403.
Server side, a strict whitelist of allowed MIME types such as image/jpeg, image/png, and application/pdf, plus a size limit, pays off before the file gets stored under a return specific subfolder inside pub/media, to avoid arbitrary uploads and the storage or security issues that come with them.
async function uploadReturnEvidence(file, returnUid) {
const formData = new FormData();
formData.append('evidence', file);
formData.append('return_uid', returnUid);
formData.append('form_key', window.hyva ? window.hyva.getFormKey() : formKeyValue);
const response = await fetch('/rma/returns/upload', {
method: 'POST',
body: formData,
credentials: 'same-origin'
});
if (!response.ok) {
throw new Error('Upload failed');
}
return response.json();
}
7. Multi Item Returns With a Different Reason per Line
Real world returns rarely touch a single line item only: a customer orders three items, wants to exchange one for the wrong size, wants a refund for a defective one, and keeps the third. The Alpine component therefore needs an independent reactive object per row, not one single global form object for the whole order, otherwise reason and resolution of different line items overwrite each other.
A solid data structure is an array of objects with orderItemId, qtyToReturn, reason, resolution, and a computed included flag that only becomes true once qtyToReturn is greater than zero. On submit, the component filters down to exactly those included line items and builds the items array for the requestReturn mutation from it, line items with a quantity of zero are consistently left out instead of being sent with a null value.
On the UX side, a bulk action like Select all followed by individual adjustments helps, because customers rarely want to click every line item separately on larger orders, yet still need a reason per line item in the end, so backend reporting can later segment cleanly by return reason.
8. Fallback Without Adobe Commerce: A Custom Returns Module Against a Custom Entity
Shops running Magento Open Source can still offer a usable returns experience, just without the native GraphQL Returns API. The pragmatic path is a small custom module with a custom entity, modeled through db_schema.xml, with fields for order id, customer id, line items as serialized JSON or a dedicated child table, status, reason, and resolution, essentially a lean copy of the core RMA concepts.
The Alpine form from the earlier section stays largely reusable as is, only the wiring changes: instead of a GraphQL mutation, the component calls a custom REST endpoint or a custom GraphQL mutation in the custom module, which validates the request and stores it as a new record in the custom table. Status follows a self defined but deliberately similar enum, for example requested, approved, rejected, received, and completed, so the timeline component can be reused almost unchanged.
The actual refund then has to be triggered manually in the admin, typically via a credit memo created by a support agent after inspecting the returned goods, since without Adobe Commerce the automated link between return status and refund process that ships with the commercial module is simply missing.
<?xml version="1.0"?>
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
<table name="mironsoft_rma_return" resource="default" engine="innodb"
comment="Custom returns table for the Open Source fallback">
<column xsi:type="int" name="entity_id" unsigned="true" nullable="false" identity="true"/>
<column xsi:type="int" name="order_id" unsigned="true" nullable="false"/>
<column xsi:type="int" name="customer_id" unsigned="true" nullable="false"/>
<column xsi:type="text" name="items_json" nullable="false"/>
<column xsi:type="varchar" name="status" length="32" nullable="false" default="requested"/>
<column xsi:type="timestamp" name="created_at" on_update="false" nullable="false" default="CURRENT_TIMESTAMP"/>
<constraint xsi:type="primary" referenceId="PRIMARY">
<column name="entity_id"/>
</constraint>
</table>
</schema>
9. How This Connects to Store Credit and Refunds After Approval
Once a return reaches approved and the merchandise has been marked received by the merchant, the question of the actual refund comes up. Adobe Commerce ties the resolution from the original request, refund, exchange, or store credit, to the matching follow up process, either a classic credit memo or a credit to the customer's store credit balance.
The depth of that topic, in particular the correct display of the store credit balance in the customer account, how it gets applied at the next checkout, and the related GraphQL access through customer.store_credit, belongs in a dedicated article about store credit and customer balances in the Hyva theme and is deliberately not expanded here.
For the returns UI itself, a short note in the status timeline is enough at this stage, indicating after closed which refund type applies, for example a small info line below the last timeline dot that links to the account balance page when store credit was chosen.
| Status | GraphQL Enum Value | Meaning for the Customer | Next Step | Timeline Color |
|---|---|---|---|---|
| Authorized | authorized | Request reviewed and cleared for return shipment | Package the item and send it back | Green |
| Denied | denied | Request was not approved, no return shipment needed | Contact support with questions | Red |
| Received | received | Merchandise has arrived at the merchant and is being inspected | Wait for the inspection result | Yellow |
| Approved | approved | Inspection complete, refund or exchange is being prepared | Wait for refund or replacement shipment | Green |
| Closed | closed | Return fully processed, refund or exchange completed | No further action needed | Gray |
Mironsoft
Hyvä theme development and Luma migration
Still running Luma, or a Hyvä theme that just doesn't feel right?
We build Hyvä themes for Magento from scratch or migrate existing Luma shops cleanly, with Tailwind CSS, Alpine.js, and none of the unnecessary JavaScript baggage.
Luma-to-Hyvä Migration
Move an existing shop to Hyvä in a structured way, without losing functionality.
Custom Theme Development
Build a custom Hyvä theme from scratch based on your design.
Performance Optimization
Improve Core Web Vitals and load times in the Hyvä frontend with purpose.
10. Summary
RMA Returns UI in Hyva: The Essentials at a Glance
Adobe Commerce Only
Magento_Rma and the GraphQL Returns API exist only in Adobe Commerce, Open Source has no native return functionality.
Alpine Request Form
Item selection from order history, quantity, reason, and resolution per line item inside one Alpine component.
Status Timeline
authorized, denied, received, approved, and closed rendered as a visual timeline via the customer.returns query.
Fallback Module
Without Adobe Commerce, a custom entity with its own status enum replaces the native RMA functionality.