Skip to content
  1. Extras
  2. MiniShop3
  3. Development
  4. Backend API
  5. Order API

Order API ​

Programmatic interface for working with MiniShop3 orders from PHP.

An order in MiniShop3 is made up of several models:

  • msOrder — main order model (cost, status, delivery, payment)
  • msOrderAddress — order address and contact data
  • msOrderProduct — order lines (products)
  • msOrderLog — order change log

Order lifecycle: draft → submit → status changes.

Order controller (facade) ​

The Order facade controller is the main way to work with orders from PHP: a single entry point backed by dedicated services.

The result comes in an envelope

Every controller method that returns an array returns the envelope ['success' => bool, 'message' => string, 'data' => array] — the same Utils::success() / Utils::error() shape the Web API uses. The values you need are in data; for get() they sit one level deeper, in data['order'].

Only these return a value directly: initialize(), initDraft(), remove() and hasPayment() — bool; getDraft() — an msOrder object or null; getUserId() — int.

php
$ms3 = $modx->services->get('ms3');

// Get current order data
$response = $ms3->order->get();
$order = $response['data']['order'];

// Add field
$result = $ms3->order->add('email', 'user@example.com');

// Set multiple fields
$result = $ms3->order->set([
    'email' => 'user@example.com',
    'phone' => '+79991234567',
    'first_name' => 'John',
    'delivery_id' => 1,
    'payment_id' => 1,
    'order_comment' => 'Call before delivery',
]);

// Submit order
$result = $ms3->order->submit();
// The payment response. With DefaultPayment data holds order_id, order_num, payment_link.

// Clear draft
$ms3->order->clean();

Initialization

Initialize the controller with the client token before the first call:

php
$ms3->order->initialize($token);
$ms3->order->initDraft();

In the REST API and in snippets this happens automatically.

Controller methods ​

MethodDescription
initialize($token, $config)Initialize with client token
initDraft()Load existing draft
get()Order data (fields + address)
add($key, $value)Add/update field
set($fields)Set multiple fields
remove($key)Remove field (set to null)
validate($key, $value)Validate field value
submit($data)Submit order
clean()Clear draft
getCost($onlyCost)Total cost (cart + delivery + payment)
getCartCost()Cart cost
getDeliveryCost()Delivery cost
getPaymentCost()Payment fee
setCustomerAddress($hash)Apply saved customer address
cleanCustomerAddress()Clear address fields
getDeliveryValidationRules($deliveryId)Delivery validation rules
getDeliveryRequiresFields($deliveryId)Required delivery fields
getDraft()Get the draft msOrder object
hasPayment($deliveryId, $paymentId)Check whether the delivery–payment pair is allowed
getUserId()MODX user ID for the current order; registers a user if there is none

Drafts (OrderDraftManager) ​

A draft is an msOrder in draft status. It appears on the first cart request and holds the data until submit.

php
$draftManager = $modx->services->get('ms3_order_draft_manager');

// Get or create draft
$draft = $draftManager->getOrCreateDraft($token, 'web');

// Get existing draft (no create)
$draft = $draftManager->getDraft($token, 'web');

// Get draft by customer ID (restore after login)
$draft = $draftManager->getDraftByCustomer($customerId, 'web');

// Draft data as array (order + address)
$data = $draftManager->toArray($draft);
// Address fields have prefix address_:
// ['email' => '...', 'address_city' => 'Moscow', 'address_street' => '...']

// Update one field
$draftManager->updateField($draft, 'order_comment', 'Call before delivery');
// Since 1.14.2-beta1 a key is written to one table only: the order wins over
// the address. For the shared columns (id, createdon, updatedon, properties)
// the address is no longer touched — set address properties directly:
// $draft->Address->set('properties', [...]); $draft->Address->save();

// Attach customer to draft
$draftManager->attachCustomer($draft, $customerId);

// Recalculate cost
$draftManager->recalculate($draft);

// Set delivery cost
$draftManager->setDeliveryCost($draft, 350.00);

// Check if cart is empty
if ($draftManager->isEmpty($draft)) {
    // No products
}

// Clear all fields
$draftManager->clean($draft);

// Delete draft (with products and address)
$draftManager->deleteDraft($draft);

Fields and validation (OrderFieldManager) ​

The service manages order fields and their validation, and fires events on every change.

add() will not write internal fields

Since 1.14.2-beta1 add() and remove() reject the internal draft columns — the same ones closed for the Web API (see checkout). The rule is static: it does not care whether the call arrived over HTTP or from an add-on, and it runs before the msOnBeforeAddToOrder event, so a plugin that used to intercept writes to status_id or customer_id never sees them.

From PHP the internal fields are written directly: OrderDraftManager::updateField(), attachCustomer(), setDeliveryCost() skip the filter.

php
$fieldManager = $modx->services->get('ms3_order_field_manager');

// Add field (with validation and events)
$result = $fieldManager->add($draft, $orderData, 'email', 'user@example.com');
// ['success' => true, 'data' => [...]]

// Remove field
$fieldManager->remove($draft, $orderData, 'email');

// Validate without saving
$result = $fieldManager->validate($orderData, 'phone', '+79991234567');
// ['success' => true] or ['success' => false, 'message' => 'Error']

Validation rules ​

By default delivery_id and payment_id are validated as required|numeric. The rest of the rules come from the delivery settings.

php
// Get validation rules for delivery
$rules = $fieldManager->getDeliveryValidationRules($deliveryId);
// ['city' => 'required|min:2', 'street' => 'required', ...]

// Get required fields list
$required = $fieldManager->getDeliveryRequiredFields($deliveryId);
// ['city', 'street', 'building', 'phone']

// Add custom rules
$fieldManager->setValidationRules([
    'company_name' => 'required|min:3',
]);

Cost calculation (OrderCostCalculator) ​

php
$calculator = $modx->services->get('ms3_order_cost_calculator');

// Cart cost
$result = $calculator->getCartCost($draft, $token);
// ['cost' => 5000.00]

// Delivery cost
$result = $calculator->getDeliveryCost($draft, $orderData, $token);
// ['cost' => 300.00]

// Payment fee
$result = $calculator->getPaymentCost($draft, $orderData, $token);
// ['cost' => 150.00]

// Total cost
$result = $calculator->getTotalCost($draft, $orderData, $token);
// ['cost' => 5450.00, 'cart_cost' => 5000.00, 'delivery_cost' => 300.00, 'payment_cost' => 150.00]

Each method fires a pair of msOnBefore... / msOn... events — that is where a plugin changes the cost.

Manager cost recalculation — POST /api/mgr/orders/{id}/recalculate-cost ​

Added in 1.11.0. The ManagerOrderCostRecalculator service recalculates cart_cost, weight, delivery_cost and the total cost from the saved order lines and the current delivery_id / payment_id. It touches no other order field.

Request body:

json
{
  "mode": "auto",
  "manual_delivery_cost": 500.0
}

Modes (mode):

ModeBehavior
auto (default)Recalculates only for DefaultDelivery / DefaultPayment (from price, weight_price, free_delivery_amount and the percentages). For custom handlers it returns the warning delivery_manual_required / payment_manual_required and keeps the previous delivery_cost and a fee of 0 — it calls no external API.
manualTakes the manual_delivery_cost you pass. The payment fee is still calculated automatically, from the field.
force_providerExplicitly calls loadController() → getCost() and loadHandler() → getCost() in try/catch. On failure — the warning delivery_provider_error / payment_provider_error, and the previous values stay.

The response repeats the order data from GET /api/mgr/orders/{id} and adds:

json
{
  "breakdown": {
    "cart_cost": 5000.0,
    "weight": 1.5,
    "delivery_cost": 300.0,
    "payment_cost": 150.0,
    "cost": 5450.0
  },
  "warnings": ["delivery_manual_required"]
}

The total passes through the shared clamp OrderService::clampComputedTotal(), so it never goes below zero. Delivery and payment discounts and markups (negative price, see 1.11.0) go through MiniShop3\Utils\PriceAdjustment.

Example call from JS (the order card in the manager):

javascript
const response = await fetch(`/api/mgr/orders/${orderId}/recalculate-cost`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'modAuth': MODx.modxConfig.auth, // or the current mgr-auth header
  },
  body: JSON.stringify({
    mode: 'manual',
    manual_delivery_cost: 500,
  }),
})
const json = await response.json()
// json.data.breakdown — cart_cost / delivery_cost / payment_cost / cost
// json.data.warnings — e.g. delivery_manual_required

Via HTTP (Manager API):

bash
curl -X POST 'https://example.com/api/mgr/orders/42/recalculate-cost' \
  -H 'Content-Type: application/json' \
  -H 'Cookie: …' \
  -d '{"mode":"auto"}'

See also API Router.

Order submit (OrderSubmitHandler) ​

OrderSubmitHandler runs the full submit flow: validation → create customer → calculate cost → generate number → change status → call payment.

php
$submitHandler = $modx->services->get('ms3_order_submit_handler');

$result = $submitHandler->submit($draft, $orderData, $token);
// On success the handler returns whatever $msPayment->send() returned,
// so the exact payload depends on the payment provider.
// DefaultPayment answers with message = ms3_payment_link_created and data:
// payment_link (success page URL, msorder = order number), order_id, order_num.
// When data holds no 'redirect' key, the handler appends one itself:
// the ms3_order_redirect_thanks_id page with msorder = order uuid.

Submit steps ​

  1. Event msOnSubmitOrder. Fields a plugin returned in data['data'] are written to the order through OrderFieldManager::add() — so protected columns are dropped here as well, see Fields the frontend may not change
  2. Check: cart not empty (ms3_order_err_empty)
  3. Both delivery_id and payment_id are set
  4. The payment method exists and is active (ms3_order_err_payment_not_found)
  5. The chosen delivery + payment pair is allowed (DeliveryService::getDeliveryPaymentPairError())
  6. Required fields of the chosen delivery are filled. A field counts as filled when the value itself arrives, the value arrives with an address_ prefix, or the key is already in properties['_validated']
  7. Find or create the customer (msCustomer) — an optional step: without an email and a phone no customer is created, and the order proceeds with the data in msOrderAddress only
  8. Optionally: create a MODX user (setting ms3_order_register_user_on_submit). Unlike the previous step, failure here aborts the submit with ms3_err_user_nf
  9. Calculate cost (cart + delivery + payment)
  10. Check stock with inventory()->assertOrderAvailable() before allocating a number
  11. OrderNumberGenerator::runWithNextNumber() writes the number and the totals — allocation and save share one lock
  12. The address goes to the customer's address book only if properties holds save_address and the order has a customer_id
  13. Events msOnBeforeCreateOrder / msOnCreateOrder
  14. Set status to ms3_status_new (default: 2)
  15. Call $msPayment->send()
  16. If the payment response holds no data.redirect, the handler appends the thank-you page URL with the order uuid

After step 11 the order already holds a number, and errors are handled differently from there on. A failure at steps 13–14 (or the repeated stock check before the status change) releases the number through revertAllocatedNumber() — but only when inventory tracking is on. At steps 15–16 the number stays with the order: the handler only drops the stock hold (abortInventoryHold()) and returns an error. A failed payment, in other words, does not free the number.

Order number generation ​

getNewOrderNum() is deprecated

The method is marked @deprecated and writes a warning to the MODX error log on every call. It reads the next number but does not reserve it: between the read and the save the same number can go to another order. Allocate numbers through OrderNumberGenerator::runWithNextNumber() — there the allocation and the save share one lock.

It stays for backward compatibility with older plugins: use it to show "the number the next order will get", not to assign one.

php
$num = $submitHandler->getNewOrderNum();
// "26/02-15" — format is configurable:
// ms3_order_format_num — date format (default 'ym')
// ms3_order_format_num_separator — separator (default '/')

Status management (OrderStatusService) ​

php
$statusService = $modx->services->get('ms3_order_status');

// Change order status
$result = $statusService->change($orderId, $newStatusId);
// true — success
// string — error message from lexicon

// Change status without notifications
$result = $statusService->change($orderId, $newStatusId, true);

Status constraints ​

Status propertyBehavior
final = trueCannot change to another status
fixed = trueCan only switch to status with higher position

On top of those two rules sits a list of allowed transitions — the ms3_order_status_transitions system setting. While it is empty, only final and fixed apply. Once the list is filled in, a transition has to be in it and must not break final or fixed.

The format is "from status : to status" pairs, comma-separated or as a JSON array:

text
2:3,3:4,2:5
json
[[2,3],[3,4]]

If the setting cannot be parsed, every status change is blocked with the ms3_err_status_transitions_invalid error. A typo in the list is not ignored: it halts order processing.

Notifications ​

On a status change, NotificationManager sends the notifications itself:

  • To customer — email, phone from msCustomer or modUserProfile
  • To managers — from settings ms3_email_manager, ms3_phone_manager, ms3_telegram_manager

Order products (msOrderProduct) ​

An order's products are stored in the msOrderProduct model (table ms3_order_products), one row per line.

msOrderProduct fields ​

FieldTypeDescription
product_idintegerProduct ID (msProduct)
order_idintegerOrder ID
product_keystringUnique line key (e.g. 123_a1b2c3d4)
namestringProduct name
countintegerQuantity
pricefloatUnit price
weightfloatUnit weight
costfloatLine cost (price × count)
optionsjsonSelected options
propertiesjsonExtra properties

Working with products in code ​

php
use MiniShop3\Model\msOrder;
use MiniShop3\Model\msOrderProduct;

$order = $modx->getObject(msOrder::class, $orderId);

// Get all order products
$products = $order->getMany('Products');
foreach ($products as $product) {
    echo $product->get('name') . ': ' . $product->get('count') . ' × ' . $product->get('price');
}

// Add product
$item = $modx->newObject(msOrderProduct::class);
$item->set('order_id', $orderId);
$item->set('product_id', $productId);
$item->set('product_key', $productId . '_' . md5(json_encode($options)));
$item->set('name', 'Product');
$item->set('count', 2);
$item->set('price', 1500.00);
$item->set('cost', 3000.00);
$item->set('weight', 0.5);
$item->set('options', $options);
$item->save();

// Recalculate order totals after changing products
$order->updateProducts();

Recalculating totals

After adding, removing or changing products call $order->updateProducts(). It recalculates cart_cost, weight and cost from all msOrderProduct of the order.

Order address (msOrderAddress) ​

Every order has exactly one related msOrderAddress (table ms3_order_addresses).

msOrderAddress fields ​

FieldTypeDescription
order_idintegerOrder ID
first_namestringFirst name
last_namestringLast name
phonestringPhone
emailstringEmail
countrystringCountry
indexstringPostal code
regionstringRegion
citystringCity
metrostringMetro station
streetstringStreet
buildingstringBuilding
entrancestringEntrance
floorstringFloor
roomstringApartment/office
commentstringAddress comment
text_addressstringFull address as single line
propertiesjsonExtra properties

Working with address in code ​

php
use MiniShop3\Model\msOrder;

$order = $modx->getObject(msOrder::class, $orderId);
$address = $order->getOne('Address');

// Read
echo $address->get('city');       // "Moscow"
echo $address->get('street');     // "Lenina"

// Update
$address->set('city', 'Saint Petersburg');
$address->save();

OrderAddressManager ​

The service works with the addresses of a draft:

php
$addressManager = $modx->services->get('ms3_order_address_manager');

// Apply saved customer address to draft
$result = $addressManager->setCustomerAddress($draft, $orderData, $addressHash);

// Clear all address fields
$result = $addressManager->cleanCustomerAddress($draft, $orderData);

// Save order address to customer addresses
$savedAddress = $addressManager->saveToCustomerAddresses($customerId, $orderData);

Order log (OrderLogService) ​

The log records order changes: status, fields, products.

php
$logService = $modx->services->get('ms3_order_log');

// Add log entry
$logService->addEntry($orderId, 'status', [
    'old' => 1,
    'new' => 2,
]);

// Add custom action entry
$logService->addEntry($orderId, 'field', [
    'key' => 'delivery_id',
    'old_value' => 1,
    'new_value' => 2,
], true);  // visible = true (shown to customer)

// Get log entries
$entries = $logService->getEntries($orderId);
// Only customer-visible
$entries = $logService->getEntries($orderId, true);
// With limit
$entries = $logService->getEntries($orderId, false, 50);

// Check if action is logged (setting ms3_order_log_actions)
if ($logService->shouldLog('status')) {
    // ...
}

Action types ​

ConstantValueDescription
msOrderLog::ACTION_STATUSstatusStatus change
msOrderLog::ACTION_PAYMENTpaymentPayment change
msOrderLog::ACTION_PRODUCTSproductsProducts change
msOrderLog::ACTION_ADDRESSaddressAddress change
msOrderLog::ACTION_FIELDfieldOrder field change

The ms3_order_log_actions setting lists the actions to log. The default is status,products,field,address; * logs everything.

Finalize from manager (OrderFinalizeService) ​

OrderFinalizeService finalizes orders created in the manager.

php
use MiniShop3\Services\Order\OrderOrigin;

$finalizeService = $modx->services->get('ms3_order_finalize');

$result = $finalizeService->finalize($orderId, [
    'skip_validation' => false,
    'skip_notifications' => false,
    'create_customer' => true,
    'force_create_customer' => false,
    'origin' => OrderOrigin::MANAGER, // default; for CRM use OrderOrigin::INTEGRATION
]);

Finalize accepts the skip_* keys and works with an existing draft. It never calls the payment service — unlike submit on the frontend.

The origin parameter (OrderOrigin): manager (default), storefront, integration. With manager, msOnBeforeMgrCreateOrder / msOnMgrCreateOrder also fire. The from_manager key in events is true only for origin=manager.

Programmatic order creation (ProgrammaticOrderService) ​

An API without an HTTP session — for extras, cron and integrations. This is not the Web API: routes/web.php has no dedicated endpoint.

Keyms3_programmatic_order
ClassMiniShop3\Services\Order\ProgrammaticOrderService
DraftOrderDraftManager::createSessionlessDraft() (no PHP session or cart token)
FinalizationOrderFinalizeService::finalize(..., origin=integration)
Idempotencycolumn ms3_orders.idempotency_key (unique, nullable)
php
use MiniShop3\Services\Order\OrderOrigin;

$orders = $modx->services->get('ms3_programmatic_order');

$result = $orders->create([
    'idempotency_key' => 'crm-invoice-10042', // required
    'products' => [
        ['product_id' => 15, 'count' => 2],
        // or a snapshot without a resource:
        // ['name' => 'Service', 'price' => 500, 'count' => 1, 'weight' => 0, 'options' => []],
    ],
    'customer_id' => 0,
    'delivery_id' => 1,
    'payment_id' => 1,
    'address' => [
        'first_name' => 'John',
        'email' => 'user@example.com',
        'phone' => '+79991234567',
    ],
    'order_comment' => 'From CRM',
    'context' => 'web',
    'origin' => OrderOrigin::INTEGRATION,
    // 'delivery_cost' => 300, // → cost_mode=manual on finalize
    // 'skip_notifications' => true,
    // 'skip_validation' => false,
    // 'properties' => ['source' => 'crm'],
]);

if (!$result['success']) {
    // Common messages:
    // ms3_order_err_idempotency_key_required
    // ms3_order_err_products_required
    // ms3_order_err_programmatic_create
    // or finalize / validation errors
    return $result;
}

// Success: data = { order_id, uuid, num, status_id }
// Same idempotency_key again:
//   already finalized order → success + ms3_order_programmatic_idempotent
//   draft with draft status → finalize again

Events: msOnBeforeCreateOrder / msOnCreateOrder with origin=integration and without from_manager. Events msOnBeforeMgrCreateOrder / msOnMgrCreateOrder are not fired.

Differs from POST /api/mgr/orders (a manager creates an empty or partial order in the UI) and from POST /api/v1/order/submit on the frontend (needs a token and a cart).

User resolution (OrderUserResolver) ​

The service finds or creates the MODX user from the order data.

php
$userResolver = $modx->services->get('ms3_order_user_resolver');

// Get or create MODX user_id
$userId = $userResolver->getUserId($orderData);

// Check if user exists
$user = $userResolver->checkUserExists([
    'email' => 'user@example.com',
    'phone' => '+79991234567',
]);

// Create user
$user = $userResolver->createUser([
    'email' => 'user@example.com',
    'first_name' => 'John',
    'last_name' => 'Doe',
    'phone' => '+79991234567',
]);

The ms3_order_user_groups setting defines the groups for new users. The format is group_id:role_id, comma-separated.

msOrder fields ​

FieldTypeDefaultDescription
user_idinteger0MODX user ID
customer_idinteger0Customer ID (msCustomer)
tokenstring—Client session token
uuidstring—Order UUID
createdondatetimenullCreated at
updatedondatetimenullUpdated at
numstring''Order number
costfloat0.0Total cost
cart_costfloat0.0Products cost
delivery_costfloat0.0Delivery cost
weightfloat0.0Total weight
status_idinteger0Current status ID
delivery_idinteger0Delivery method ID
payment_idinteger0Payment method ID
contextstring'web'MODX context
order_commentstringnullOrder comment
idempotency_keystringnullIdempotency key (programmatic orders, unique)
propertiesjsonnullExtra properties (incl. origin for integrations)

msOrder relations ​

RelationModelTypeDescription
AddressmsOrderAddresscomposite (one)Order address
ProductsmsOrderProductcomposite (many)Order products
LogmsOrderLogcomposite (many)Change log
msCustomermsCustomeraggregateCustomer. That is the actual alias — getOne('Customer') returns null
StatusmsOrderStatusaggregateStatus
DeliverymsDeliveryaggregateDelivery method
PaymentmsPaymentaggregatePayment method
UsermodUseraggregateMODX user

Composite vs Aggregate

Composite relations are deleted with the order (address, products, log). Aggregate relations are references only; related objects are not deleted.

Events ​

EventWhen fired
msOnBeforeSaveOrder / msOnSaveOrderOrder save
msOnBeforeRemoveOrder / msOnRemoveOrderOrder remove
msOnBeforeGetOrderCost / msOnGetOrderCostOrder total composition
msOnBeforeGetCartCost / msOnGetCartCostCart cost calculation
msOnBeforeGetDeliveryCost / msOnGetDeliveryCostDelivery cost calculation
msOnBeforeGetPaymentCost / msOnGetPaymentCostPayment cost calculation
msOnBeforeAddToOrder / msOnAddToOrderAdd/update field
msOnBeforeRemoveFromOrder / msOnRemoveFromOrderRemove field
msOnBeforeValidateOrderValue / msOnValidateOrderValueValue validation
msOnErrorValidateOrderValueValidation error
msOnSubmitOrderStart submit
msOnBeforeCreateOrder / msOnCreateOrderOrder creation
msOnBeforeChangeOrderStatus / msOnChangeOrderStatusStatus change
msOnBeforeGetOrderUser / msOnGetOrderUserFind/create user
msOnBeforeMgrCreateOrder / msOnMgrCreateOrderFinalize from manager (only origin=manager)

Manager REST API ​

Endpoints of the Vue orders UI. They run under an mgr session — see API Router.

MethodPathDescription
GET/api/mgr/ordersOrder list
GET/api/mgr/orders/filtersGrid filter values
GET/api/mgr/orders/statsAggregates for filters and the dashboard
GET/api/mgr/orders/{id}Order card
PUT/api/mgr/orders/{id}Update order
DELETE/api/mgr/orders/{id}Delete order
DELETE/api/mgr/orders/bulkBulk delete
POST/api/mgr/ordersCreate order from the manager
POST/api/mgr/orders/{id}/finalizeFinalize draft
POST/api/mgr/orders/{id}/recalculate-costRecalculate via ManagerOrderCostRecalculator
GET/api/mgr/orders/{id}/logsOrder log
GET/api/mgr/orders/{id}/productsOrder products
POST/api/mgr/orders/{id}/productsAdd a product
PUT/api/mgr/orders/{id}/products/{product_id}Update a product
DELETE/api/mgr/orders/{id}/products/{product_id}Delete a product
GET/api/mgr/orders/{id}/shipmentOrder shipment
PUT/api/mgr/orders/{id}/shipmentUpdate shipment

Create and finalize from the manager go through OrderFinalizeService and fire msOnBeforeMgrCreateOrder / msOnMgrCreateOrder.

Event parameters: Events.