Architecture

The WemX domain model, the action pattern, payment handlers, provisioning jobs and the middleware pipeline.

Domain model

User ─┬─ Order ─┬─ Package ─┬─ Category
      │         │           ├─ PackagePrice (period_in_days, price, setup_fee, upgrade_fee)
      │         │           ├─ PackageConfigOption
      │         │           └─ ServerConnection ── Extension (type=server)
      │         ├─ OrderPrice (base + config-option lines, daily rates)
      │         ├─ OrderMember, OrderLog, OrderExceptionLog
      │         └─ OrderSubscription ── Subscription
      ├─ Payment ── GatewayConfig ── Extension (type=gateway)
      │     └─ payable (morph: Order, …), handler (class name)
      ├─ Cart ── CartItem ── cartable (morph: PackagePrice)
      ├─ ServerAccount (credentials created on a remote platform)
      ├─ BalanceTransaction, Address, UserBan, Email
      └─ RoleUser ── Role ── RolePermission

Key models:

ModelNotes
ExtensionOne row per discovered extension (identifier, namespace, type, status). Server and Gateway are subclasses with a global scope on type.
ServerConnectionA configured server. config is cast encrypted:array.
GatewayConfigA configured gateway. config is cast encrypted:array. webhook_id routes inbound webhooks.
Package / PackagePriceThe catalog. A period of 0 means one-time. data holds the package's server configuration.
OrderThe provisioned service. Stores cycle_price as a daily rate and period_in_days. $order->price returns the cycle total.
PaymentInvoice and payment in one. Holds handler (a class to call on completion), payable (morph), data and gateway_data.
SubscriptionA provider-side recurring agreement, with its own handler.
SettingKey/value application settings (see settings()).

Actions: the write API

Business operations live in action classes under app/Actions, reached through a static actions() method on the related model:

use App\Models\Order;
use App\Models\Payment;

Order::actions()->suspendOrderAsAdmin(['order_id' => $order->id]);
Order::actions()->extendOrderAsAdmin(['order_id' => $order->id, 'due_date' => '2026-12-31']);

$payment = Payment::actions()->renewalPaymentForClient([
    'order_id' => $order->id,
    'renewal_days' => 30,
]);

Each action validates its own input with Laravel's validator, enforces business rules (stock, license limits, ownership), performs the change, writes logs and fires events. Livewire components and controllers stay thin and call actions. Reuse actions from your extensions instead of writing to models directly, so validation, logging and side effects stay consistent.

Method names say who the action is for: …AsAdmin, …AsClient or …ForClient.

Handlers: what happens when money arrives

A payment doesn't know what it's paying for. Instead it stores the class name of a handler. When a gateway confirms the payment, Payment::completed() instantiates the handler and calls onPaymentCompleted($payment).

HandlerCreated byOn completion
CartCompletedHandlerCheckoutCreates an order per cart item (via PackagePurchasedHandler), then clears the cart
OrderRenewalHandlerClient renewalExtends the order's due date by data.renewal_days
BalanceTopupHandlerBalance top-upCredits data.amount to the user's balance

Subscriptions work the same way with onSubscriptionActivated, onSubscriptionDeactivated and onSubscriptionBillingDateUpdated (see OrderSubscriptionHandler). This is how you add your own billable flows. See Billing lifecycle.

Provisioning jobs

Provisioning never runs in the request. Order exposes four methods, each dispatching a queued job:

MethodJobCalls on the server extension
$order->createServer()OrderCreateServercreate($order, $connection)
$order->suspendServer()OrderSuspendServersuspend($order, $connection)
$order->unsuspendServer()OrderUnsuspendServerunsuspend($order, $connection)
$order->terminateServer()OrderTerminateServerterminate($order, $connection)

Pass false ($order->createServer(false)) to run synchronously. Each job updates the order status, fires the matching event (OrderActivated, OrderSuspended, …) and, if it fails, stores an OrderExceptionLog and fires an error event (OrderActivationFailed, …). A failed create sets the order to failed.

Request pipeline

Every web request passes through this middleware, in order:

MiddlewareResponsibility
SetUserLocaleApplies the user's language, or the session/default language for guests
InstallAppMiddlewareRedirects to the web installer until APP_INSTALLED=true
SyncRuntimeMiddlewareValidates the license, cached for 6 hours
CheckPendingOrSuspendedUserHolds pending/suspended accounts
CheckActiveUserBanEnforces user and IP bans
VerifyEmailMiddlewareRequires a verified email
RequireAddressMiddlewareRequires a complete address (if enabled)
RequireTFAMiddlewareRequires a 2FA check every 2 hours for users with 2FA
ImpersonateUserSwaps the authenticated user while staff impersonate
DefineCartMiddlewareResolves the current cart (guest or user) onto the request
AdminPathMiddlewareBlocks non-staff from any path containing admin

Admin routes (routes/admin.php, prefix /admin, name prefix admin.) also go through admin (staff only) and RequireAdminReauthentication, and most use permission:{name}:

Route::get('/orders', [OrdersController::class, 'index'])
    ->name('orders.index')
    ->middleware('permission:admin.orders');

In code and Blade, check permissions with auth()->user()->hasPermission('admin.orders.view').

CSRF-exempt endpoints

Inbound third-party calls are exempt from CSRF checks:

gateways/webhooks/*     gateways/callbacks/*
tickets/inbound-mail    marketplace/webhooks/*

Gateway extensions must therefore verify webhook authenticity themselves, for example with signature checks (see Gateway extensions).

Encryption

Server connection configs, gateway configs and ServerAccount passwords are encrypted with APP_KEY. Settings whose key contains encrypted are encrypted too. Changing APP_KEY makes all of them unreadable.

On this page