ERP Architecture for Developers
The ERP is not a bolt-on — it is built on the same DDD layering as the rest of the plugin, in dedicated directories under wp-ecommerce-core/src/. This page is a reference map of how those layers connect and how the ERP stays in sync with commerce events. It is not a "build your own entity" tutorial; it exists so you can navigate the ERP code, call its services, and recognize the extension points.
For the generic DDD explanation (layer responsibilities, dependency direction) see Architecture → DDD Layers. This page is the ERP-specific instance of that pattern.
The four layers
Every ERP domain (invoicing, inventory, purchasing, accounting, CRM, HR) is implemented as the same vertical slice through four layers:
Presentation\Api\*ApiController → thin REST controller (routes + gate + shape)
│ resolves
▼
Application\Services\*Service → business logic, validation, orchestration
│ uses
▼
Infrastructure\Repositories\*Repository → SQL, persistence, extends AbstractErpRepository
│ reads / writes
▼
Domain\Entities\* → plain data objects (getters, constructed from arrays)
| Layer | Directory | Count (approx.) | Role |
|---|---|---|---|
| Domain entities | src/Domain/Entities/ | 48 | Plain PHP data objects (Invoice, Warehouse, JournalEntry, Employee, Lead, …). No base class, no persistence logic — constructed from an array, expose typed getters. |
| Repositories | src/Infrastructure/Repositories/ | 37 | Own all SQL for a table/aggregate. ERP repositories extend AbstractErpRepository (below). Return / accept entities. |
| Application services | src/Application/Services/ | ~28 | Business logic + validation + cross-repository orchestration (e.g. WarehouseService, AccountingService, PurchasingService, InvoiceService, MyDataService, FinancialReportService, HrService, CrmService). Pull dependencies lazily via the LazyResolvesServices trait. |
| API controllers | src/Presentation/Api/*ApiController.php | — | Thin: register routes, run the access gate as the permission_callback, resolve the service, and shape the response. No business logic. |
Support code that isn't a domain slice lives under src/Erp/: ErpDatabaseManager (DB routing), ErpActivationService / ErpMigrator / ErpDatabaseManager (schema + lifecycle), ErpUserService (roles/RBAC), ErpRouteHandler (the /erp/ SPA), ErpDataMigrationService, and Erp/Integration/ (the sync listeners below).
How the layers wire together (DI)
Services and repositories are resolved through the plugin Container. Controllers don't new a service — they resolve it, typically lazily so a controller can register its routes without instantiating its whole dependency graph:
// inside an ERP controller
private function getService(): WarehouseService
{
return $this->lazyResolve(WarehouseService::class, $this->warehouseService);
}
Anywhere outside a controller, the global helper wpec_resolve(SomeClass::class) returns a container-built instance. Bindings for ERP services/repositories are registered in the plugin Bootstrap. See Dependency Injection for the container itself.
Optional separate ERP database (F146)
By default the ERP tables live in the same database as the storefront (wp_ec_* prefix). A store can optionally route the ERP to a separate database on the same MySQL server — configured via the /erp/test-connection + /erp/save-credentials endpoints and migrated with /erp/migrate-data/*.
AbstractErpRepository is what makes this transparent to the rest of the code:
- It binds
$this->wpdbto the ERP DB connection whenErpDatabaseManager::isConfigured()is true, and to the mainglobal $wpdbotherwise. - Repositories that JOIN storefront tables use the base helpers
mainTable()/wpUsersTable(), which return fully-qualifieddb_name.tablereferences when ERP-routed so cross-database JOINs work. - When no separate DB is configured (the default), the base behaves identically to the pre-F146
global $wpdbpattern — no behaviour change, no migration needed.
Constraints to remember: the separate ERP DB must be on the same MySQL server as the main DB (cross-server JOINs don't work in standard MySQL), and AbstractErpRepository tracks transaction depth statically across all ERP repositories (it detects — and warns on — accidental nested transactions rather than silently breaking atomicity).
The practical takeaway for a developer: extend AbstractErpRepository for any ERP-side persistence and use its table helpers for cross-DB JOINs; you get the routing, connection selection, and transaction safety for free.
Commerce ↔ ERP integration listeners
The ERP must react to storefront events — a product is saved, a warehouse is created, a customer registers — and propagate the change to the ERP side (which may be a different database). That sync is done by a small set of integration listeners under src/Erp/Integration/, wired through a single bootstrap with a public extension filter.
The contract
interface IntegrationListenerInterface
{
// Called once during plugin init; the listener subscribes to its hook(s) here.
public function register(): void;
}
Concrete listeners extend AbstractIntegrationListener, which supplies the shared scaffold:
safely(string $context, callable $fn, string $level = 'error')— runs a side-effect and swallows + logs anyThrowableto theplugin.erp.integrationchannel, so an ERP-side write failure never fails the originating storefront request (the degraded-mode contract). Listeners wrap only the side-effect body insafely(); short-circuit guards (instanceof / routing / policy) stay inline above it. Handlers that must propagate an exception (e.g. a pre-transition validation gate) deliberately do not usesafely().logInfo(string $message)— an informational log on the same channel.- The
LazyResolvesServicestrait, so each listener gets lazy DI without re-declaring it.
The shipped listeners
| Listener | Reacts to | Keeps in sync |
|---|---|---|
ProductInventorySync | Product save / variant transitions | Product ↔ stock-level records; enforces the simple→variable pre-transition gate |
WarehouseInventorySync | Warehouse create/change | Warehouse ↔ stock-level rows |
CustomerContactSync | Customer register / update | Customer ↔ CRM contact |
ProductCostSync | Product cost changes | Product cost ↔ inventory valuation |
Registration + the extension point
IntegrationBootstrap::register() is the single entry point (called once on plugin init). It resolves each listener from the container, verifies it implements IntegrationListenerInterface, and calls register() — with per-listener error isolation so one broken listener can't cascade-kill the others.
The listener list is filterable, which is the public extension seam for adding or removing sync behaviour:
add_filter('wpec_erp_integration_listeners', function (array $classes): array {
$classes[] = \My\Erp\PriceListSync::class; // must implement IntegrationListenerInterface
return $classes;
});
Each class named by the filter must implement IntegrationListenerInterface, and its container binding must be registered so it can be resolved. A listener should no-op when ErpDatabaseManager::isConfigured() === false if its work only makes sense for separate-DB installs.
Recognizing the extension pattern
You are not expected to hand-build a 48-entity ERP. But when you need to extend it, the shape is consistent:
- Add ERP data / an operation → follow the vertical slice: a
Domain\Entities\*data object, anInfrastructure\Repositories\*RepositoryextendingAbstractErpRepository, anApplication\Services\*Servicefor the logic, and a thinPresentation\Api\*ApiControllerthat gates via theLicenseGatedErptrait (the ERP gate) and resolves the service. Register the bindings inBootstrap. - React to a commerce event from the ERP → write an
IntegrationListenerInterfaceimplementation (extendAbstractIntegrationListener), wrap side-effects insafely(), and add it via thewpec_erp_integration_listenersfilter. - Gate a new ERP route → reuse
LicenseGatedErp::erpModulePermissionCheck('erp_<module>', $request)as thepermission_callback(orrequireErpAdmin()for config/admin routes). See the ERP access gate.
The ERP internals (entities, repositories, services, the DDD bindings) are first-party: they evolve between releases and are documented here so you can build against your own store and understand the code. The two stable, intentionally-public seams are the REST API (gated as described) and the wpec_erp_integration_listeners filter. Pin to a store version for anything mission-critical.
Where to go next
- ERP API Reference — the domain-grouped endpoint index with the license + RBAC gate per group.
- DDD Layers · Dependency Injection — the generic architecture these ERP slices instantiate.
- Data Sovereignty — how the plugin owns its data (the ERP tables included).