Recurring billing models as a custom module
Magento 2 ships with no subscription engine out of the box, subscription products have to be developed as a standalone module. This article shows how a data model, Service Contracts, cron billing and Vault tokenization for recurring payments are implemented cleanly in Magento 2.4.8. The focus is on Service Contracts, declarative schema and GraphQL mutations for customer self service.
Table of Contents
- 1. Why Magento 2 has no native subscription engine
- 2. Data model for subscription products: a custom module with db_schema.xml
- 3. Modeling the subscription entity as a Service Contract
- 4. Recurring payments: Recurring Profile vs. a custom payment gateway integration
- 5. Implementing cron-based billing cycles
- 6. Customer self service: pause, cancel and change via GraphQL
- 7. Invoicing and tax compliance for recurring payments
- 8. Integrating payment providers: Vault and tokenization
- 9. Dunning management for failed payments
- 10. Summary
- 11. FAQ
1. Why Magento 2 has no native subscription engine
Magento 2 does not provide a product type for subscription products or an engine for recurring billing out of the box. The Recurring Profile mechanism inherited from Magento 1 still exists technically in the Sales module, but it has not been developed further in years and is practically unsuitable for modern payment providers. Anyone who wants to sell subscription products in a Magento 2 store, whether that is software licenses, consumables on a subscription, or memberships, has to build the entire logic themselves or use a marketplace extension.
This gap is not an accident, it reflects the architecture: Magento 2 is designed as a transactional commerce platform for individual orders, not as a billing system for recurring revenue. A custom module for subscription products therefore has to introduce concepts that do not exist in the core: a subscription entity, a billing cycle, a state for active, paused and canceled subscriptions, and a link between the recurring payment and regular order processing.
For agencies this means a deliberate architecture decision at the start of the project. Service Contracts, declarative schema and dependency injection via di.xml are not optional here, they are a prerequisite for integrating the subscription module cleanly into existing checkout, payment and fulfillment processes without modifying the Magento 2 core. This article shows exactly that setup: from the database table through the Service Contract to the cron job that bills subscription products automatically.
2. Data model for subscription products: a custom module with db_schema.xml
The first building block for subscription products is a dedicated data model declared via db_schema.xml instead of classic InstallSchema scripts. The central table mironsoft_subscription stores per subscription a subscription_id, the customer_id as a foreign key to customer_entity, the original_order_id as a foreign key to the original sales_order, the affected product_sku, and interval fields such as interval_unit and interval_count.
The table also needs a next_billing_date for the next billing date, a status (active, paused, past_due, canceled) and a reference to the stored Vault payment token, so the recurring payment can be executed without re-entering payment data. Foreign keys on sales_order and customer_entity ensure referential integrity and prevent orphaned subscription records when a customer or an order is deleted.
The advantage of db_schema.xml over InstallSchema lies in idempotency: schema changes are described declaratively and applied automatically on every setup:upgrade based on a diff, including whitelist generation. That reduces migration errors considerably and keeps the structure of the subscription module traceable across version states.
<?xml version="1.0"?>
<!-- app/code/Mironsoft/Subscription/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_subscription" resource="default" engine="innodb" comment="Subscription Entity Table">
<column xsi:type="int" name="subscription_id" padding="10" unsigned="true" nullable="false" identity="true" comment="Subscription ID"/>
<column xsi:type="int" name="customer_id" padding="10" unsigned="true" nullable="false" comment="Customer ID"/>
<column xsi:type="int" name="original_order_id" padding="10" unsigned="true" nullable="false" comment="Original Order ID"/>
<column xsi:type="varchar" name="product_sku" nullable="false" length="64" comment="Product SKU"/>
<column xsi:type="varchar" name="interval_unit" nullable="false" length="16" default="month" comment="Interval Unit"/>
<column xsi:type="smallint" name="interval_count" unsigned="true" nullable="false" default="1" comment="Interval Count"/>
<column xsi:type="date" name="next_billing_date" nullable="false" comment="Next Billing Date"/>
<column xsi:type="varchar" name="status" nullable="false" length="16" default="active" comment="Subscription Status"/>
<column xsi:type="int" name="payment_token_id" padding="10" unsigned="true" nullable="true" comment="Vault Payment Token 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="subscription_id"/>
</constraint>
<constraint xsi:type="foreign" referenceId="MIRONSOFT_SUBSCRIPTION_CUSTOMER_ID_CUSTOMER_ENTITY_ENTITY_ID"
table="mironsoft_subscription" column="customer_id"
referenceTable="customer_entity" referenceColumn="entity_id" onDelete="CASCADE"/>
<constraint xsi:type="foreign" referenceId="MIRONSOFT_SUBSCRIPTION_ORIGINAL_ORDER_ID_SALES_ORDER_ENTITY_ID"
table="mironsoft_subscription" column="original_order_id"
referenceTable="sales_order" referenceColumn="entity_id" onDelete="CASCADE"/>
<index referenceId="MIRONSOFT_SUBSCRIPTION_STATUS_NEXT_BILLING_DATE" indexType="btree">
<column name="status"/>
<column name="next_billing_date"/>
</index>
</table>
</schema>
3. Modeling the subscription entity as a Service Contract
The subscription entity is not modeled as a plain model, but as a Service Contract: a SubscriptionInterface in the Api/Data namespace defines the getters and setters, a SubscriptionRepositoryInterface in the Api namespace defines save, getById, getList and deleteById. This separation decouples the public API of the subscription module from the concrete ORM implementation and allows other modules or GraphQL resolvers to program exclusively against the interfaces.
The concrete repository implementation uses Constructor Property Promotion from PHP 8.4 to inject the ResourceModel, Factory and CollectionProcessor compactly and with type safety. For queries using SearchCriteriaInterface, a CollectionProcessorInterface is used that applies filters such as status or customer_id never with a raw integer, but always in the array form with the eq key.
This Service Contract structure pays off especially for subscription products, because subscription data is read from multiple channels: the admin grid, the cron job and the GraphQL layer for customer self service. A clean contract prevents the same query logic from being duplicated in three different places.
<?php
declare(strict_types=1);
namespace Mironsoft\Subscription\Model;
use Mironsoft\Subscription\Api\Data\SubscriptionInterface;
use Mironsoft\Subscription\Api\SubscriptionRepositoryInterface;
use Mironsoft\Subscription\Model\ResourceModel\Subscription as SubscriptionResource;
use Mironsoft\Subscription\Model\ResourceModel\Subscription\CollectionFactory;
use Magento\Framework\Api\SearchCriteriaInterface;
use Magento\Framework\Api\SearchResultsInterfaceFactory;
use Magento\Framework\Exception\CouldNotSaveException;
use Magento\Framework\Exception\NoSuchEntityException;
/**
* Repository implementation for Subscription entities.
*/
class SubscriptionRepository implements SubscriptionRepositoryInterface
{
/**
* @param SubscriptionResource $resource Subscription resource model
* @param SubscriptionFactory $subscriptionFactory Subscription model factory
* @param CollectionFactory $collectionFactory Subscription collection factory
* @param SearchResultsInterfaceFactory $searchResultsFactory Search results factory
*/
public function __construct(
private readonly SubscriptionResource $resource,
private readonly SubscriptionFactory $subscriptionFactory,
private readonly CollectionFactory $collectionFactory,
private readonly SearchResultsInterfaceFactory $searchResultsFactory,
) {
}
/**
* Persists a subscription entity.
*
* @param SubscriptionInterface $subscription Subscription entity to save
* @return SubscriptionInterface
* @throws CouldNotSaveException
*/
public function save(SubscriptionInterface $subscription): SubscriptionInterface
{
try {
$this->resource->save($subscription);
} catch (\Exception $exception) {
throw new CouldNotSaveException(__('Could not save subscription: %1', $exception->getMessage()));
}
return $subscription;
}
/**
* Loads a subscription by its ID.
*
* @param int $subscriptionId Subscription entity ID
* @return SubscriptionInterface
* @throws NoSuchEntityException
*/
public function getById(int $subscriptionId): SubscriptionInterface
{
$subscription = $this->subscriptionFactory->create();
$this->resource->load($subscription, $subscriptionId);
if (!$subscription->getId()) {
throw new NoSuchEntityException(__('Subscription with id "%1" does not exist.', $subscriptionId));
}
return $subscription;
}
/**
* Returns due subscriptions for a given billing date, filtered by active status.
*
* @param SearchCriteriaInterface $searchCriteria Search criteria with status and next_billing_date filters
* @return \Magento\Framework\Api\SearchResultsInterface
*/
public function getList(SearchCriteriaInterface $searchCriteria): \Magento\Framework\Api\SearchResultsInterface
{
$collection = $this->collectionFactory->create();
foreach ($searchCriteria->getFilterGroups() as $group) {
foreach ($group->getFilters() as $filter) {
$collection->addFieldToFilter($filter->getField(), [$filter->getConditionType() ?: 'eq' => $filter->getValue()]);
}
}
$searchResults = $this->searchResultsFactory->create();
$searchResults->setSearchCriteria($searchCriteria);
$searchResults->setItems($collection->getItems());
$searchResults->setTotalCount($collection->getSize());
return $searchResults;
}
}
4. Recurring payments: Recurring Profile vs. a custom payment gateway integration
Magento 2 knows the concept of the Recurring Profile from the Sales module, originally developed for Authorize.net payments. It allows a payment profile to be created at the payment gateway, which the provider then bills periodically on its own. For new subscription products this approach is barely relevant in practice anymore, because modern payment providers such as Adyen, Braintree or Stripe offer their own, considerably more flexible subscription APIs, or do not support server-side recurring profiles in the sense of the old Magento concept at all.
The practical approach for subscription products in Magento 2.4.8 is therefore reusing Vault payment tokens: on the first order the payment provider tokenizes the customer's payment method, and Magento only stores a reference ID via PaymentTokenRepositoryInterface. Every further billing in the subscription reuses this token, without Magento itself processing card data or the customer having to re-enter payment details.
This difference between a provider-managed Recurring Profile and a custom Vault token reuse determines significantly how much control the subscription logic retains over the billing timing, retry behavior and error handling.
| Billing Strategy | Control | PCI Scope | Complexity | Failure Handling |
|---|---|---|---|---|
| Vault token rebilling (custom cron job) | Fully in the shop | SAQ A, no card data handling | Medium to high, custom module | Freely controllable via a custom dunning state machine |
| Provider-managed subscription (e.g. Stripe Billing) | At the provider, Magento only syncs | Minimal, billing fully external | Low, provider API and webhooks | Predefined by the provider, little customizability |
| Manual Recurring Profile (Magento legacy) | Low, outdated API | Depends on the gateway, often higher SAQ scope | High, little documentation, barely maintained | Barely present, manual monitoring required |
5. Implementing cron-based billing cycles
The billing cycle for subscription products runs through a dedicated cron job registered in crontab.xml, running in its own cron group, not the default group, so it does not block other Magento jobs, typically running hourly. The job selects all subscription records with status active and next_billing_date less than or equal to the current time.
For every due subscription the cron job programmatically creates a new order. Instead of a manual cart checkout, an order is built directly from the stored product and customer data and persisted via OrderRepositoryInterface, after which the payment is triggered at the provider using the stored Vault token, and an invoice is created on success.
After successful billing the job updates next_billing_date by the configured interval and logs the result. If the payment fails, the dunning logic described in section 9 takes over instead of a silent failure.
<?xml version="1.0"?>
<!-- app/code/Mironsoft/Subscription/etc/crontab.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/crontab.xsd">
<group id="mironsoft_subscription">
<job name="mironsoft_subscription_process_due" instance="Mironsoft\Subscription\Cron\ProcessDueSubscriptions" method="execute">
<schedule>0 * * * *</schedule>
</job>
</group>
</config>
<?php
declare(strict_types=1);
namespace Mironsoft\Subscription\Cron;
use Mironsoft\Subscription\Api\SubscriptionRepositoryInterface;
use Mironsoft\Subscription\Api\Data\SubscriptionInterface;
use Mironsoft\Subscription\Model\OrderBuilder;
use Magento\Framework\Api\SearchCriteriaBuilderFactory;
use Magento\Sales\Api\OrderRepositoryInterface;
use Psr\Log\LoggerInterface;
/**
* Cron job that processes due subscriptions and creates recurring orders.
*/
class ProcessDueSubscriptions
{
/**
* @param SubscriptionRepositoryInterface $subscriptionRepository Subscription repository
* @param SearchCriteriaBuilderFactory $searchCriteriaBuilderFactory Search criteria builder factory
* @param OrderBuilder $orderBuilder Builds a recurring order from a subscription
* @param OrderRepositoryInterface $orderRepository Persists the newly created order
* @param LoggerInterface $logger Logs billing results and failures
*/
public function __construct(
private readonly SubscriptionRepositoryInterface $subscriptionRepository,
private readonly SearchCriteriaBuilderFactory $searchCriteriaBuilderFactory,
private readonly OrderBuilder $orderBuilder,
private readonly OrderRepositoryInterface $orderRepository,
private readonly LoggerInterface $logger,
) {
}
/**
* Selects due subscriptions and creates one order per subscription.
*
* @return void
*/
public function execute(): void
{
$searchCriteriaBuilder = $this->searchCriteriaBuilderFactory->create();
$searchCriteria = $searchCriteriaBuilder
->addFilter(SubscriptionInterface::STATUS, 'active')
->addFilter(SubscriptionInterface::NEXT_BILLING_DATE, date('Y-m-d'), 'lteq')
->create();
$dueSubscriptions = $this->subscriptionRepository->getList($searchCriteria)->getItems();
foreach ($dueSubscriptions as $subscription) {
try {
$order = $this->orderBuilder->buildFromSubscription($subscription);
$this->orderRepository->save($order);
$subscription->setNextBillingDate($this->calculateNextBillingDate($subscription));
$this->subscriptionRepository->save($subscription);
} catch (\Throwable $exception) {
$this->logger->error(sprintf(
'Subscription #%d billing failed: %s',
$subscription->getSubscriptionId(),
$exception->getMessage()
));
}
}
}
/**
* Calculates the next billing date based on the subscription interval.
*
* @param SubscriptionInterface $subscription Subscription entity
* @return string
*/
private function calculateNextBillingDate(SubscriptionInterface $subscription): string
{
$modifier = sprintf('+%d %s', $subscription->getIntervalCount(), $subscription->getIntervalUnit());
return (new \DateTime($subscription->getNextBillingDate()))->modify($modifier)->format('Y-m-d');
}
}
6. Customer self service: pause, cancel and change via GraphQL
Customers expect to be able to pause, cancel or change the quantity or interval of their subscription products themselves, without having to contact support. In a Hyvä or headless architecture, GraphQL is the natural path: a custom schema.graphql extends the Magento schema with mutations such as subscriptionPause, subscriptionCancel and subscriptionUpdate.
Each mutation is implemented via a resolver class that implements ResolverInterface and checks the authenticated customer through the customer context before accessing the SubscriptionRepositoryInterface. This ensures a customer can only modify their own subscription records.
The GraphQL mutation for pausing sets the subscription's status to paused and skips it in the next cron run without changing next_billing_date, so a later reactivation continues seamlessly at the same point in the billing cycle.
# app/code/Mironsoft/Subscription/etc/schema.graphqls
type Mutation {
subscriptionPause(subscription_id: Int! @doc(description: "Subscription entity ID")): SubscriptionPauseOutput @resolver(class: "Mironsoft\\Subscription\\Model\\Resolver\\SubscriptionPause")
}
type SubscriptionPauseOutput {
subscription_id: Int! @doc(description: "Paused subscription ID")
status: String! @doc(description: "New subscription status")
next_billing_date: String @doc(description: "Unchanged next billing date")
}
# Example mutation call
mutation {
subscriptionPause(subscription_id: 42) {
subscription_id
status
next_billing_date
}
}
7. Invoicing and tax compliance for recurring payments
Every billing of a subscription product creates a standalone order and therefore also its own invoice, created via Magento's regular InvoiceService. It is important that tax rates and, where applicable, product prices are recalculated on every billing cycle, because tax rates, the customer's address or price lists may have changed between two billing dates.
In Germany and the EU there are additional requirements for recurring payments: continuous invoice numbering under GoBD, correct VAT display per billing period, and a traceable history of all orders and invoices created per subscription. This traceability is also relevant for a right of withdrawal or a subsequent cancellation within a running billing period.
A clean subscription module therefore links every generated order and invoice back to the original subscription via subscription_id, so that the full billing history of a subscription product remains traceable in the admin grid and in the customer account.
8. Integrating payment providers: Vault and tokenization
Magento 2's Vault functionality is the foundation for PCI-compliant recurring payments for subscription products. Instead of storing card data in its own system, Vault only manages a PaymentTokenInterface with a provider-side token reference, read and stored via PaymentTokenManagementInterface.
Payment methods that support CanSaveInVault, such as Adyen, Braintree or Stripe, tokenize the payment method once at the first checkout. The subscription cron job then uses only the token ID together with the payment method code to authorize the order in the background, entirely without any further interaction from the customer.
The central advantage of this architecture lies in the reduced PCI DSS scope: since Magento itself never processes or stores plaintext card data, the shop stays within SAQ A, while the actual card data processing remains fully with the certified payment provider.
9. Dunning management for failed payments
Failed payments are the norm for subscription products, not the exception, for example due to expired cards or insufficient funds. A resilient subscription module therefore needs dunning management: a defined retry logic that automatically retries a failed billing after a configurable number of days, before the subscription moves into a critical state.
A state machine with the states active, past_due, suspended and canceled is well suited for this. After the first failure the subscription moves to past_due and the customer automatically receives a notification via an email template. If several retry attempts remain unsuccessful, the status moves to suspended, and only after a configured grace period to canceled.
This state logic belongs inside the cron job itself, not scattered across conditions in the checkout, so that the behavior for failed payments stays consistent across all subscription products in the shop and can be tracked transparently in the admin grid.
10. Summary
The most important steps for subscription products in Magento 2 always solve the same underlying problem: Magento 2 ships with no native subscription engine, so a custom module has to take over the data model, the billing cycle and the payment integration. db_schema.xml declares the subscription table with foreign keys on sales_order and customer_entity. Service Contracts with SubscriptionInterface and SubscriptionRepositoryInterface encapsulate access consistently for the admin grid, cron and GraphQL.
The cron job is the heart of every subscription module: it selects due subscription products, creates orders via OrderRepositoryInterface, and charges the customer using a stored Vault token, without card data ever landing in its own system. GraphQL mutations give customers self service for pausing, canceling and changing subscriptions, while a dunning state machine escalates failed payments in a controlled way instead of silently losing subscriptions.
Subscription products in Magento 2, the essentials at a glance
Data Model
db_schema.xml with a custom subscription table, foreign keys on sales_order and customer_entity, and an index on status and next_billing_date.
Service Contracts
SubscriptionInterface and SubscriptionRepositoryInterface decouple the admin grid, cron and GraphQL from the ORM.
Billing
A cron job via crontab.xml creates orders for due subscription products and charges customers using Vault payment tokens.
Self Service & Dunning
GraphQL mutations for pausing and canceling, state machine active/past_due/suspended/canceled for failed payments.
11. FAQ: Subscription Products in Magento 2
1Does Magento 2 have a native subscription feature?
2Recurring Profile vs. Vault token rebilling?
3What table does a subscription module need?
4Why Service Contracts instead of model access?
5How often should the cron job run?
6How do customers pause their subscription themselves?
7New invoice for every subscription billing cycle?
8Is Vault tokenization PCI DSS compliant?
9What happens on a failed payment?
10Marketplace extension instead of a custom module?
Mironsoft
Magento 2 custom modules, subscription commerce and payment integrations
Ready to build subscription products in your Magento 2 store the right way?
We develop subscription modules with Service Contracts, cron billing and Vault tokenization, tailored to your payment provider and your tax and invoicing requirements.
Architecture Review
Reviewing the data model, Service Contracts and cron strategy for subscription products
Subscription Module
Custom build with Vault integration, GraphQL self service and dunning management
Payment Integration
Connecting Adyen, Braintree or Stripe with PCI-compliant tokenization