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 ── RolePermissionKey models:
| Model | Notes |
|---|---|
Extension | One row per discovered extension (identifier, namespace, type, status). Server and Gateway are subclasses with a global scope on type. |
ServerConnection | A configured server. config is cast encrypted:array. |
GatewayConfig | A configured gateway. config is cast encrypted:array. webhook_id routes inbound webhooks. |
Package / PackagePrice | The catalog. A period of 0 means one-time. data holds the package's server configuration. |
Order | The provisioned service. Stores cycle_price as a daily rate and period_in_days. $order->price returns the cycle total. |
Payment | Invoice and payment in one. Holds handler (a class to call on completion), payable (morph), data and gateway_data. |
Subscription | A provider-side recurring agreement, with its own handler. |
Setting | Key/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).
| Handler | Created by | On completion |
|---|---|---|
CartCompletedHandler | Checkout | Creates an order per cart item (via PackagePurchasedHandler), then clears the cart |
OrderRenewalHandler | Client renewal | Extends the order's due date by data.renewal_days |
BalanceTopupHandler | Balance top-up | Credits 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:
| Method | Job | Calls on the server extension |
|---|---|---|
$order->createServer() | OrderCreateServer | create($order, $connection) |
$order->suspendServer() | OrderSuspendServer | suspend($order, $connection) |
$order->unsuspendServer() | OrderUnsuspendServer | unsuspend($order, $connection) |
$order->terminateServer() | OrderTerminateServer | terminate($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:
| Middleware | Responsibility |
|---|---|
SetUserLocale | Applies the user's language, or the session/default language for guests |
InstallAppMiddleware | Redirects to the web installer until APP_INSTALLED=true |
SyncRuntimeMiddleware | Validates the license, cached for 6 hours |
CheckPendingOrSuspendedUser | Holds pending/suspended accounts |
CheckActiveUserBan | Enforces user and IP bans |
VerifyEmailMiddleware | Requires a verified email |
RequireAddressMiddleware | Requires a complete address (if enabled) |
RequireTFAMiddleware | Requires a 2FA check every 2 hours for users with 2FA |
ImpersonateUser | Swaps the authenticated user while staff impersonate |
DefineCartMiddleware | Resolves the current cart (guest or user) onto the request |
AdminPathMiddleware | Blocks 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.