Magento RMA: Building Returns Management Without the Enterprise Feature
AI generated
M2
di.xml
Magento 2 · RMA · Returns · Service Contracts
Magento RMA: Returns Management Without an Enterprise License
A custom entity, state machine and repository built from scratch

Magento Open Source ships without a native RMA module, that only exists in Adobe Commerce. Anyone who still wants clean returns management builds a custom RMA module with declarative schema, Service Contracts, a real state machine and automatic creditmemo creation, instead of improvising returns management with custom attributes and emails.

18 min read db_schema.xml · Repository · State Machine · Plugin Magento 2.4.x · PHP 8.4 · Hyva

1. The RMA gap in Magento Open Source

Anyone searching for an RMA module in Magento Open Source (Community Edition) finds nothing. Return Merchandise Authorization, the full workflow from return request through approval to the creditmemo, is exclusive to Adobe Commerce. The Magento_Rma module folder exists in the enterprise core but not in Open Source, and downgrading the license removes this feature immediately. For many store operators this is the moment it becomes clear: returns management either runs entirely manually through email and a spreadsheet, or it gets built as a custom module.

The naive first instinct is often to attach a custom attribute to the order or the shipment that carries the return status as free text. That works for a prototype, but it breaks the moment multiple return line items belong to a single order, the moment partial returns need to be possible, or the moment a second support team works on the same return in parallel. A clean RMA system needs its own entity with its own lifecycle, not an attribute bolted onto someone else's object.

This article describes the complete blueprint for a standalone RMA module: a custom entity via db_schema.xml, Service Contracts following Magento convention, a real state machine for return requests, a form in the customer account, an admin management view, and the link to automatic creditmemo creation. All of it in PHP 8.4, using constructor property promotion and the repository pattern that Magento itself uses for its native entities.

2. Custom entity via declarative schema

The first building block for an RMA module is a dedicated database table for return requests. Instead of an InstallSchema script, Magento 2.4 consistently uses db_schema.xml, the declarative schema. The advantage over install scripts: Magento computes the necessary ALTER or CREATE statement itself from the diff between the declared and actual state, and whitelist files document every schema change with a version history.

The mironsoft_rma_request table needs at minimum a reference to the order, a reference to the shipment (for the later link to the actually shipped items), a status field, a field for the return reason and timestamps for every status change. A foreign key on sales_order_entity is important so that referential integrity is enforced at the database level, rather than only being checked in the application layer.

A second table, mironsoft_rma_request_item, models the line level, because a return request typically covers several product positions with different quantities. This split into header and item data follows the same pattern as sales_order and sales_order_item in Magento itself, and that is exactly what makes the module immediately understandable to other developers on the team, because they already know the pattern.


<?xml version="1.0"?>
<!-- File: app/code/Mironsoft/Rma/etc/db_schema.xml -->
<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_request" resource="default" engine="innodb"
           comment="RMA Return Request">
        <column xsi:type="int" name="entity_id" unsigned="true" nullable="false"
                identity="true" comment="Entity ID"/>
        <column xsi:type="int" name="order_id" unsigned="true" nullable="false"
                comment="Sales Order ID"/>
        <column xsi:type="int" name="shipment_id" unsigned="true" nullable="true"
                comment="Sales Shipment ID"/>
        <column xsi:type="varchar" name="status" nullable="false" length="32"
                default="requested" comment="RMA Status"/>
        <column xsi:type="text" name="reason" nullable="true" comment="Return Reason"/>
        <column xsi:type="int" name="creditmemo_id" unsigned="true" nullable="true"
                comment="Linked Creditmemo ID"/>
        <column xsi:type="timestamp" name="created_at" on_update="false" nullable="false"
                default="CURRENT_TIMESTAMP" comment="Created At"/>
        <column xsi:type="timestamp" name="updated_at" on_update="true" nullable="false"
                default="CURRENT_TIMESTAMP" comment="Updated At"/>
        <constraint xsi:type="primary" referenceId="PRIMARY">
            <column name="entity_id"/>
        </constraint>
        <constraint xsi:type="foreign" referenceId="MIRONSOFT_RMA_REQUEST_ORDER_ID_SALES_ORDER_ENTITY_ID"
                    table="mironsoft_rma_request" column="order_id"
                    referenceTable="sales_order" referenceColumn="entity_id"
                    onDelete="CASCADE"/>
        <index referenceId="MIRONSOFT_RMA_REQUEST_STATUS" indexType="btree">
            <column name="status"/>
        </index>
    </table>
</schema>

3. Service Contracts: Api/Data and Repository

An RMA module that works directly with collections or raw SQL queries is hard to test and hard to extend from the very start. Magento's standard approach is Service Contracts: an Api/Data/RmaRequestInterface for the data structure, an Api/RmaRequestRepositoryInterface for CRUD operations and search criteria, and concrete implementations in the Model namespace. Other modules, a future GraphQL resolver module or a REST endpoint can then program against the same stable interface without needing to know the internal data model.

The repository implementation uses PHP 8.4 constructor property promotion to avoid boilerplate assignments. For filtered reads, SearchCriteriaInterface is used instead of custom ad hoc methods like getByOrderIdAndStatus(), because otherwise the repository grows with every new filter criterion. A CollectionProcessorInterface translates the search criteria into the concrete resource collection query.

It is also important that the state transition itself does not live in the repository. The repository saves and loads data, it does not decide whether a transition from requested to approved is allowed. That responsibility belongs in a dedicated state machine object, described in the next section. This separation keeps every class small and individually testable.


<?php

declare(strict_types=1);

namespace Mironsoft\Rma\Model;

use Mironsoft\Rma\Api\Data\RmaRequestInterface;
use Mironsoft\Rma\Api\Data\RmaRequestInterfaceFactory;
use Mironsoft\Rma\Api\RmaRequestRepositoryInterface;
use Mironsoft\Rma\Model\ResourceModel\RmaRequest as RmaRequestResource;
use Mironsoft\Rma\Model\ResourceModel\RmaRequest\CollectionFactory;
use Magento\Framework\Api\SearchCriteriaInterface;
use Magento\Framework\Api\SearchResultsInterfaceFactory;
use Magento\Framework\Api\SearchResults\CollectionProcessorInterface;
use Magento\Framework\Exception\CouldNotSaveException;
use Magento\Framework\Exception\NoSuchEntityException;

/**
 * Repository implementation for RMA return requests.
 * Handles persistence only, state transitions live in RmaStateMachine.
 */
class RmaRequestRepository implements RmaRequestRepositoryInterface
{
    /**
     * @param RmaRequestResource $resource Resource model for direct persistence
     * @param RmaRequestInterfaceFactory $requestFactory Factory for new entity instances
     * @param CollectionFactory $collectionFactory Factory for search result collections
     * @param SearchResultsInterfaceFactory $searchResultsFactory Factory for wrapping search results
     * @param CollectionProcessorInterface $collectionProcessor Translates SearchCriteria into collection filters
     */
    public function __construct(
        private readonly RmaRequestResource $resource,
        private readonly RmaRequestInterfaceFactory $requestFactory,
        private readonly CollectionFactory $collectionFactory,
        private readonly SearchResultsInterfaceFactory $searchResultsFactory,
        private readonly CollectionProcessorInterface $collectionProcessor
    ) {
    }

    /**
     * Persists a return request entity.
     *
     * @param RmaRequestInterface $request Return request to persist
     * @return RmaRequestInterface Saved entity with entity_id populated
     * @throws CouldNotSaveException When persistence fails
     */
    public function save(RmaRequestInterface $request): RmaRequestInterface
    {
        try {
            $this->resource->save($request);
        } catch (\Exception $exception) {
            throw new CouldNotSaveException(__('Could not save the return request.'), $exception);
        }

        return $request;
    }

    /**
     * Loads a return request by its entity id.
     *
     * @param int $entityId Primary key of the return request
     * @return RmaRequestInterface Loaded entity
     * @throws NoSuchEntityException When no entity exists for the given id
     */
    public function getById(int $entityId): RmaRequestInterface
    {
        $request = $this->requestFactory->create();
        $this->resource->load($request, $entityId);

        if (!$request->getEntityId()) {
            throw new NoSuchEntityException(__('Return request with id "%1" does not exist.', $entityId));
        }

        return $request;
    }

    /**
     * Loads a filtered, paginated list of return requests.
     *
     * @param SearchCriteriaInterface $searchCriteria Filter, sort and pagination criteria
     * @return \Magento\Framework\Api\SearchResultsInterface Search result wrapper
     */
    public function getList(SearchCriteriaInterface $searchCriteria): \Magento\Framework\Api\SearchResultsInterface
    {
        $collection = $this->collectionFactory->create();
        $this->collectionProcessor->process($searchCriteria, $collection);

        $searchResults = $this->searchResultsFactory->create();
        $searchResults->setSearchCriteria($searchCriteria);
        $searchResults->setItems($collection->getItems());
        $searchResults->setTotalCount($collection->getSize());

        return $searchResults;
    }
}

4. The state machine for return requests

The core of any resilient RMA system is an explicit state machine with clearly defined states and allowed transitions: requested, approved, rejected, received and refunded. A return request always starts at requested as soon as the customer submits it. From there, the state machine only allows a transition to approved or rejected, never directly to received or refunded. This restriction may look pedantic at first glance, but it prevents exactly the inconsistencies that creep in with a simple free text status field.

Technically, the state machine is implemented as its own class holding a transition table as a constant, checking before every status change whether the target state is reachable from the current state. An invalid transition, for instance from rejected to refunded, throws a LocalizedException instead of silently overwriting the status. That is the decisive difference between a real state machine and a status setter that accepts any arbitrary string.

From approved, the request moves to received once the goods have physically arrived at the warehouse and been inspected. Only from received is the transition to refunded possible, and this exact transition triggers the creditmemo creation described in section 7 in the background. Every status change is additionally logged with a timestamp and an optional comment field, so the complete history of a return stays traceable afterwards, for example during support inquiries.

5. The return request form in the customer account

The customer must be able to initiate a return themselves, without emailing support. For this, a new section appears in the customer account, implemented as a Hyva section with Alpine.js for the interactive parts such as quantity selection per line item and a reason dropdown for the return. The list of returnable positions is determined server side from the customer's shipment, not from the entire order, because only items that were actually shipped can be sent back.

An alternative approach to the manual form is an observer on sales_order_shipment_save_after that automatically opens a return window in the customer account for certain product types or categories once a shipment has occurred. This makes sense when a right of return is bound to a shipping date, for example 14 days from delivery, because then the form can check server side whether this window is still open, instead of relying on client side JavaScript.

The form itself must validate server side that the requested return quantity per line item does not exceed the shipped quantity, and that the same order item does not already have multiple open return requests in parallel. This validation belongs in a dedicated ValidatorInterface, not in the controller, so the same check can later be reused by a REST or GraphQL endpoint without duplicating code.

6. Managing returns in the admin area

The support team needs an overview of all open return requests in the admin area. A UI component grid based on mironsoft_rma_request as the data source shows order number, customer, status and creation date, with filters by status and time range. Every row links to a detail view showing the individual return items, the return reason and the available action buttons for the next allowed status change.

The action buttons in the grid do not call the repository directly, but a dedicated controller that uses the state machine from section 4. This rules out an admin user forcing a state through the interface that is invalid from a business perspective, for example directly approving a return that was already rejected. The controller additionally checks the matching ACL resource, so only authorized roles can trigger status changes.

For stores leaning more heavily on Hyva than the classic Luma admin UI, the same overview can also be mirrored as a dedicated section in the customer account for B2B company administrators, using the same repository and state machine classes underneath. Reusing the same Service Contract layer for both the admin and the frontend view is exactly the advantage that separating interfaces from implementations delivers from day one.

7. From goods receipt inspection to the creditmemo

As soon as the warehouse team confirms the physical goods receipt and the return request is set to received, the next business step is creditmemo creation. A plugin on the state machine, specifically an after plugin on the method that executes the transition to refunded, calls Magento\Sales\Api\CreditmemoManagementInterface to create a creditmemo for the affected order items. A plugin is deliberately chosen over a preference here, because the core logic of the state machine stays unchanged while the creditmemo creation exists as an additional, swappable behavior alongside it.

It is important that the creditmemo quantity exactly matches the approved and actually received items, not the originally requested quantity. A customer might register five items but only actually ship back three, and the creditmemo may only be created for those three. The creditmemo_id reference in the mironsoft_rma_request table permanently links the return to the created creditmemo, so the entire process from request to refund stays traceable end to end.

For payment methods with external payment gateways, the plugin must additionally check whether an online refund is technically possible, or whether a manual offline creditmemo is required. This decision should not be hardcoded, but should run through the canRefund() check already provided by the respective payment module, so the RMA module does not need special handling for every payment method.

8. Notifications on every status change

A customer who submits a return and then hears nothing calls support, and that costs time and trust. For each of the five status changes, a dedicated email template is therefore registered through etc/email_templates.xml, and a matching observer listens for a module specific event, for example mironsoft_rma_status_changed, which the state machine dispatches after every successful transition.

The templates use Magento's template variable system, so the order number, customer name, return line items and the new status get inserted dynamically instead of assembling text in PHP code. That keeps the separation between sending logic and text content clean, and lets the marketing team adjust wording in the admin area under Marketing > Email Templates, without a deploy.

The observer itself stays deliberately thin: it loads the matching template based on the new status, gathers the template variables from the return request, and passes both to TransportBuilder. The actual decision of which template belongs to which status lives in a configuration class, not in a long switch block inside the observer, so a new status can be added later without touching the observer itself.


<?xml version="1.0"?>
<!-- File: app/code/Mironsoft/Rma/etc/email_templates.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Email:etc/email_templates.xsd">
    <template id="rma_status_requested"
              label="RMA: Return Request Received"
              file="rma_status_requested.html"
              type="html"
              module="Mironsoft_Rma"
              area="frontend"/>
    <template id="rma_status_approved"
              label="RMA: Return Request Approved"
              file="rma_status_approved.html"
              type="html"
              module="Mironsoft_Rma"
              area="frontend"/>
    <template id="rma_status_rejected"
              label="RMA: Return Request Rejected"
              file="rma_status_rejected.html"
              type="html"
              module="Mironsoft_Rma"
              area="frontend"/>
    <template id="rma_status_received"
              label="RMA: Goods Received"
              file="rma_status_received.html"
              type="html"
              module="Mironsoft_Rma"
              area="frontend"/>
    <template id="rma_status_refunded"
              label="RMA: Refund Issued"
              file="rma_status_refunded.html"
              type="html"
              module="Mironsoft_Rma"
              area="frontend"/>
</config>

A compact di.xml is enough to wire up the event and register the plugin, binding the observer to the event and hanging the creditmemo plugin off the state machine. Both wirings stay declarative and can be overridden in a second module if another team wants to add its own notification logic, without changing the core of the RMA module.


<?xml version="1.0"?>
<!-- File: app/code/Mironsoft/Rma/etc/di.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <preference for="Mironsoft\Rma\Api\Data\RmaRequestInterface"
                type="Mironsoft\Rma\Model\RmaRequest"/>
    <preference for="Mironsoft\Rma\Api\RmaRequestRepositoryInterface"
                type="Mironsoft\Rma\Model\RmaRequestRepository"/>

    <type name="Mironsoft\Rma\Model\RmaStateMachine">
        <plugin name="mironsoft_rma_create_creditmemo_on_refund"
                type="Mironsoft\Rma\Plugin\CreateCreditmemoOnRefundPlugin"
                sortOrder="10"/>
    </type>

    <type name="Magento\Framework\Event\Config\Data">
        <arguments>
            <argument name="events" xsi:type="array">
                <item name="mironsoft_rma_status_changed" xsi:type="array">
                    <item name="mironsoft_rma_send_status_email" xsi:type="array">
                        <item name="instance" xsi:type="string">Mironsoft\Rma\Observer\SendStatusChangeEmailObserver</item>
                    </item>
                </item>
            </argument>
        </arguments>
    </type>
</config>

9. RMA decisions compared

Every custom RMA module has decision points where the quick, naive approach and the pattern that stays maintainable long term diverge noticeably. The following overview summarizes the key decisions that should hold up over the entire lifetime of a returns management system.

Decision Naive approach Recommended pattern Benefit
Managing status Free text attribute on Order/Shipment Dedicated state machine with transition table Invalid transitions are prevented, not just logged
Data access Direct SQL queries or raw collections Repository with Service Contracts Testable, swappable, API ready
Triggering the creditmemo Manual in admin after a support email Plugin on the transition to refunded Consistent, no manual intermediate step
Customer notification Hardcoded email text in the observer email_templates.xml with template variables Editable in admin, without a deploy
Schema changes InstallSchema/UpgradeSchema scripts db_schema.xml, declarative Versioned diff, no manual migrations

The common thread behind all five rows is the same: returns management is not a feature you retrofit with an attribute and a cron job, it is a standalone business process with clear states, responsibilities and traceability. Building this structure in from the start saves later refactoring once partial returns, multiple line items or B2B special cases get added.

10. Summary

A custom RMA system for Magento Open Source is not a replacement hack for a missing enterprise feature, when built cleanly it can even fit a specific business process better than a generic Adobe Commerce solution. Declarative schema for the custom entity, Service Contracts for data access, an explicit state machine for the five states requested, approved, rejected, received and refunded, a plugin for automatic creditmemo creation, and template based emails for every status change together form an RMA module that is just as robust as native Magento functionality.

The decisive difference from improvised returns management with free text attributes lies in traceability: every status change is validated, logged and triggers exactly the right follow up actions, from the customer notification to the creditmemo. Building this structure in consistently from the first version of the module means it never needs a later refactor once return volume grows or new requirements like partial returns or B2B approval processes get added.

Magento RMA and returns management, the key takeaways

No native solution

Magento Open Source has no RMA module, that only exists in Adobe Commerce. A custom module is the only way without a license upgrade.

State machine instead of free text

Five defined states with validated transitions prevent inconsistent return data and invalid status changes.

Service Contracts & Repository

Api/Data interfaces and the repository pattern make the module testable and usable from REST, GraphQL and the admin UI at once.

Automated creditmemo

A plugin on the transition to refunded creates the creditmemo automatically, exactly for the items actually received.

11. FAQ: Magento RMA and returns management

1Does Magento Open Source have RMA?
No, RMA is exclusive to Adobe Commerce. Returns management in Open Source needs a custom module.
2Is a custom attribute enough for the status?
No, it does not validate transitions, does not support partial returns, and links poorly to a creditmemo. A dedicated entity with a state machine is required.
3Which states does a return go through?
requested, approved, rejected, received, refunded. Only specific transitions are allowed, for example never directly from requested to refunded.
4How is the entity created?
Through db_schema.xml, the declarative schema. Magento computes the required SQL statement automatically from the diff.
5Why Service Contracts instead of collection access?
They decouple data access from the internal model, make the module testable, and usable from REST, GraphQL and admin grid at once.
6How is the creditmemo triggered?
A plugin on the transition to refunded calls CreditmemoManagementInterface for the items actually received.
7Where does the customer submit the request?
In the customer account, as a Hyva section with Alpine.js. Returnable line items come server side from the shipment.
8How are customers notified?
Every status change dispatches an event, an observer sends the matching template from email_templates.xml with template variables.
9Where does the status transition belong?
In a dedicated state machine class with a transition table, not in the repository, which is only responsible for persistence.
10Can this be managed in the admin area?
Yes, through a UI component grid with ACL checked action buttons that use the state machine instead of overwriting status directly.

Mironsoft

Magento 2 development, Service Contracts and custom modules

Returns management that does not depend on Adobe Commerce?

We build you a dedicated RMA module for Magento Open Source, with declarative schema, clean Service Contracts, a real state machine and automatic credit memo creation, instead of managing returns through spreadsheets and email chains.

RMA module

Custom entity, repository and state machine following Magento conventions

Customer account integration

Return request form as a Hyva section with server-side validation

Credit memo automation

Plugin-based credit memo creation after goods-received inspection