Skip to content
  1. Extras
  2. MiniShop3
  3. Development
  4. Models and database schema

Models and database schema ​

MiniShop3 models are xPDO classes in the MiniShop3\Model namespace.

Core classes ​

php
use MiniShop3\Model\msProduct;
use MiniShop3\Model\msProductData;
use MiniShop3\Model\msOrder;
use MiniShop3\Model\msCustomer;

// Get product
$product = $modx->getObject(msProduct::class, $id);

// Get product data
$data = $product->getOne('Data');

// Get order with address
$order = $modx->getObject(msOrder::class, $id);
$address = $order->getOne('Address');

// Get customer
$customer = $modx->getObject(msCustomer::class, ['email' => $email]);

Model relations ​

getOne() — for 1:1 and N:1, getMany() — for 1:N.

ModelAliasTypeReturns
msProductData1:1product data (msProductData)
msProductOptions1:Nproduct option values
msProductDataFiles1:Ngallery files
msOrderAddress1:1order address (msOrderAddress)
msOrderProducts1:Norder line items
msOrdermsCustomerN:1the customer. That is the alias, not Customer
msCustomerTokens1:Nauth tokens
msCustomerAddressCustomerN:1the customer — the reverse relation from an address
msCustomerTokenCustomerN:1the customer — the reverse relation from a token

msCustomer has no Addresses relation: addresses are read with a direct query to msCustomerAddress by customer_id — see Working with a customer.

Schema file ​

core/components/minishop3/schema/minishop3.mysql.schema.xml
AttributeValue
packageMiniShop3\Model
baseClassxPDO\Om\xPDOObject
platformmysql
version3.0

Database tables ​

Products ​

ModelTableDescription
msProductsite_contentProduct (extends modResource)
msProductDatams3_productsProduct data (price, article, weight)
msProductFilems3_product_filesProduct gallery files
msProductOptionms3_product_optionsProduct option values
msLinkms3_linksProduct link types
msProductLinkms3_product_linksProduct-to-product links

msProductData — main fields ​

FieldTypeDescription
idintID (matches resource ID)
articlevarchar(50)Article/SKU
pricedecimal(12,2)Price
old_pricedecimal(12,2)Old price
stockdecimal(13,3)Stock quantity
weightdecimal(13,3)Weight
imagevarchar(255)Main image
thumbvarchar(255)Thumbnail
preview_file_idintGallery file ID used as the main preview (gallery order is not changed)
vendor_idintVendor ID
made_invarchar(100)Country of origin
newtinyint(1)"New" flag
populartinyint(1)"Popular" flag
favoritetinyint(1)"Favorite" flag
tagsjsonTags
colorjsonColors
sizejsonSizes

Categories ​

ModelTableDescription
msCategorysite_contentCategory (extends modResource)
msCategoryMemberms3_product_categoriesProduct–category relation
msCategoryOptionms3_category_optionsCategory options

Orders ​

ModelTableDescription
msOrderms3_ordersOrder
msOrderAddressms3_order_addressesOrder delivery address
msOrderProductms3_order_productsOrder line items
msOrderLogms3_order_logsOrder change history
msOrderStatusms3_order_statusesOrder statuses

msOrder — main fields ​

FieldTypeDescription
idintOrder ID
user_idintMODX user ID
customer_idintmsCustomer ID
tokenvarchar(128)Session token
uuidchar(36)Order UUID
numvarchar(20)Order number
costdecimal(12,2)Total cost
cart_costdecimal(12,2)Products cost
delivery_costdecimal(12,2)Delivery cost
weightdecimal(13,3)Total weight
status_idintStatus ID
delivery_idintDelivery method ID
payment_idintPayment method ID
contextvarchar(100)MODX context
order_commenttextOrder comment
idempotency_keyvarcharIdempotency key (programmatic orders)
createdondatetimeCreated at
updatedondatetimeUpdated at

Customers ​

ModelTableDescription
msCustomerms3_customersStore customer
msCustomerGroupms3_customer_groupsCustomer groups (segmentation, catalogue ACL)
msCustomerAddressms3_customer_addressesSaved customer addresses
msCustomerTokenms3_customer_tokensAuth tokens

msCustomerGroup — main fields ​

FieldTypeDescription
idintGroup ID
namevarchar(191)Group name
user_group_idintMODX user group ID (the principal for modAccessResourceGroup)
activetinyint(1)Active
created_atdatetimeCreation date
updated_atdatetimeUpdate date

msCustomer — main fields ​

FieldTypeDescription
idintCustomer ID
user_idintLinked modUser ID (optional)
first_namevarchar(191)First name
last_namevarchar(191)Last name
phonevarchar(50)Phone
emailvarchar(191)Email
passwordvarchar(255)Password hash
is_activetinyint(1)Active
is_blockedtinyint(1)Blocked
email_verified_atdatetimeEmail verification date
orders_countintOrder count
total_spentdecimal(12,2)Total order sum
last_order_atdatetimeLast order date
privacy_accepted_atdatetimePrivacy policy acceptance date

Difference from miniShop2

In miniShop2 customer = modUser. In MiniShop3 customer is a separate entity msCustomer, which can optionally be linked to modUser.

Delivery and payment ​

ModelTableDescription
msDeliveryms3_deliveriesDelivery methods
msPaymentms3_paymentsPayment methods
msDeliveryMemberms3_delivery_paymentsDelivery–payment link

Shipments ​

Tables without xPDO models: access goes through PdoShipmentStore and the ms3_shipment_lifecycle DI key (ShipmentLifecycleService). Phinx migrations: create_shipments, create_shipment_events.

TableDescription
ms3_shipmentsOne shipment per order (uniq_shipment_order)
ms3_shipment_eventsWebhook idempotency: provider_event_id per shipment

ms3_shipments — fields ​

FieldTypeDescription
idintShipment ID
order_idint unsignedOrder ID (unique)
delivery_idint unsignedDelivery method ID
statusvarchar(32)preparing, shipped, in_transit, delivered, cancelled, returned, failed
tracking_numbervarchar(191)Tracking number
external_idvarchar(191)ID on the provider side
providervarchar(191)Provider class or name
carriervarchar(191)Carrier
shipped_atint unsignedUnix time of dispatch
delivered_atint unsignedUnix time of delivery
last_event_idvarchar(191)Latest provider_event_id
metatextJSON metadata
createdon / updatedonint unsignedUnix time

With ms3_shipment_enabled=1, OrderStatusService carries the shipment status over to the order: shipped → ms3_status_sent, cancelled / failed → ms3_status_canceled. For delivered / in_transit see the ms3_shipment_on_*_status settings.

Vendors ​

ModelTableDescription
msVendorms3_vendorsProduct vendors

Options ​

ModelTableDescription
msOptionms3_optionsOption catalog
msOptionGroupms3_option_groupsOption groups (replaced the use of modCategory for grouping, since 1.11.0)

Field configuration ​

ModelTableDescription
msModelFieldms3_model_fieldsModel field settings
msModelFieldSectionms3_model_field_sectionsField sections
msGridFieldms3_grid_fieldsGrid column configuration (customers, orders, products)
msProductFieldms3_product_fieldsProduct fields (legacy)
msPageSectionms3_page_sectionsPage sections (legacy)
msExtraFieldms3_extra_fieldsExtra fields

Notifications ​

ModelTableDescription
msNotificationConfigms3_notification_configsNotification configuration

Working with models ​

Creating an order ​

This is not how to create an order

The example below only shows how order records are laid out in the database. An order assembled this way has passed no validation, got no cost or delivery calculation, fired no events and sent no notifications.

To create orders from code there is the ms3_programmatic_order service (ProgrammaticOrderService): it builds a draft and finalises it the usual way, and with idempotency_key a repeated call will not produce a duplicate.

php
$result = $modx->services->get('ms3_programmatic_order')->create([
    'idempotency_key' => 'crm-order-15', // required
    'products' => [
        ['id' => $productId, 'count' => 2],
    ],
    'delivery_id' => $deliveryId,
    'payment_id' => $paymentId,
]);
php
use MiniShop3\Model\msOrder;
use MiniShop3\Model\msOrderProduct;

// Create order
$order = $modx->newObject(msOrder::class);
$order->fromArray([
    'customer_id' => $customerId,
    'user_id' => $modx->user->get('id') ?: 0,
    'status_id' => $modx->getOption('ms3_status_new'),
    'delivery_id' => $deliveryId,
    'payment_id' => $paymentId,
    'context' => $modx->context->key,
]);
$order->save();

// Add product to order
$orderProduct = $modx->newObject(msOrderProduct::class);
$orderProduct->fromArray([
    'order_id' => $order->get('id'),
    'product_id' => $productId,
    'name' => $productName,
    'price' => $price,
    'count' => $count,
    'cost' => $price * $count,
    'options' => json_encode($options),
]);
$orderProduct->save();

Working with a customer ​

php
use MiniShop3\Model\msCustomer;
use MiniShop3\Model\msCustomerAddress;

// Find or create customer
$customer = $modx->getObject(msCustomer::class, ['email' => $email]);
if (!$customer) {
    $customer = $modx->newObject(msCustomer::class);
    $customer->fromArray([
        'email' => $email,
        'first_name' => $firstName,
        'phone' => $phone,
    ]);
    $customer->save();
}

// Add address
$address = $modx->newObject(msCustomerAddress::class);
$address->fromArray([
    'customer_id' => $customer->get('id'),
    'city' => $city,
    'street' => $street,
    'building' => $building,
]);
$address->save();

// Get all customer addresses with a direct query —
// msCustomer has no Addresses relation
$addresses = $modx->getIterator(msCustomerAddress::class, [
    'customer_id' => $customer->get('id'),
]);

Iterating records ​

getIterator() fetches rows one at a time and never holds the whole set in memory — on large result sets it beats getCollection():

php
use MiniShop3\Model\msOrder;

foreach ($modx->getIterator(msOrder::class, ['status_id' => 2]) as $order) {
    // Process order
}

getIterator() and classes without their own table

getCollection() and getObject() add a class_key filter to the criteria themselves — that is what addDerivativeCriteria() does. On top of that, msProduct and msCategory duplicate the guard in their own loadCollection() and load(). getIterator() goes through neither: it builds the iterator directly, and no filter is applied.

For msProduct and msCategory this matters: they have no table of their own and live in site_content along with every other MODX resource. Without the filter the query picks up foreign rows, and the first of them raises an error along the lines of "Instantiated a derived class msProduct that is not a subclass of the requested class msCategory".

So with getIterator() on such classes, set class_key by hand:

php
$criteria = [
    'class_key' => msProduct::class, // required
    'parent' => $categoryId,
];

foreach ($modx->getIterator(msProduct::class, $criteria) as $product) {
    // …
}

Models with a table of their own — msOrder, msCustomer, msProductData and the rest from the tables above — are unaffected.

Model architecture ​

A model owns its table and relations, business logic lives in services — see Service layer.

The models never became fully "thin": msProduct and msCategory extend modResource, and MODX keeps business logic in models. So a model may call a service:

php
// src/Model/msProductData.php
class msProductData extends xPDOSimpleObject
{
    public function getPrice(array $data = []): float
    {
        // Delegate to formatting service
        $ms3 = $this->xpdo->services->get('ms3');
        return $ms3->format->price($this->get('price'));
    }
}

Regenerating models ​

Models are maintained manually

In MiniShop3 models are NOT auto-generated from the XML schema via buildModel(). They are created and maintained manually in src/Model/.

This allows:

  • Custom methods in models
  • Full PHP 8 type hints
  • Full control over model code

Important: Do not run buildModel() — it will overwrite custom model methods.

Model file structure ​

src/Model/
├── msProduct.php           # Main model class
├── mysql/
│   └── msProduct.php       # MySQL-specific mapping

Example model ​

php
<?php
namespace MiniShop3\Model;

use MODX\Revolution\modResource;

class msProduct extends modResource
{
    // Custom model methods
    public function getPrice(): float
    {
        $data = $this->getOne('Data');
        return $data ? (float)$data->get('price') : 0.0;
    }
}

Example mapping ​

php
<?php
namespace MiniShop3\Model\mysql;

class msProduct extends \MiniShop3\Model\msProduct
{
    public static $metaMap = [
        'package' => 'MiniShop3\\Model\\',
        'version' => '3.0',
        'extends' => 'MODX\\Revolution\\modResource',
        'fields' => [
            'class_key' => 'MiniShop3\\Model\\msProduct',
        ],
        // ... other metadata
    ];
}

Phinx migrations ​

Phinx migrations change the database schema:

core/components/minishop3/migrations/
├── 20251020000000_initial_schema.php
├── 20251021000000_seed_page_sections.php
├── 20251023000000_seed_order_statuses.php
└── ...

Running migrations ​

You normally do not run them by hand: on install and upgrade the resolver_02_migrations resolver applies them, and ExtraFieldsService does when you work with extra fields. Both use the production environment, which is also the default in phinx.php.

For development, run them from the CLI:

bash
cd /path/to/modx
php core/components/minishop3/vendor/bin/phinx migrate -c core/components/minishop3/phinx.php

Example migration ​

php
<?php
use Phinx\Migration\AbstractMigration;

class CreateCustomersTable extends AbstractMigration
{
    public function change()
    {
        $table = $this->table('ms3_customers', [
            'engine' => 'InnoDB',
            'collation' => 'utf8mb4_unicode_ci',
        ]);

        $table
            ->addColumn('email', 'string', ['limit' => 191])
            ->addColumn('first_name', 'string', ['limit' => 191, 'null' => true])
            ->addColumn('phone', 'string', ['limit' => 50, 'null' => true])
            ->addIndex(['email'], ['unique' => true])
            ->create();
    }
}

Relation diagram ​

Product ​

Order ​

ms3_shipments / ms3_shipment_events are PDO tables, not MiniShop3\Model\* classes.

The msCustomer → msCustomerAddress line shows the relation in the database: addresses point at the customer through customer_id. The schema has no alias for walking from a customer to the addresses — only the other way round, $address->getOne('Customer').