merchant_id column. It is not a multi-tenant SaaS - there is no public sign-up.
Looking to run it locally first? See LOCAL_SETUP.md.
1. Tech stack at a glance
There is no framework (no Laravel/Symfony runtime). The kernel, router, container, and middleware pipeline are small, readable, first-party code under
src/.
2. Request lifecycle
Every HTTP request flows through one front controller and theKernel:
Boot sequence (Kernel::boot())
- Load
.env(vlucas/phpdotenv). - Build the DI container from
config/services.php. - Set the timezone.
- Boot plugins (
PluginLoader::boot()) - before middleware, so a plugin can inject middleware via thesystem.middleware.pipelinefilter. - Load the middleware pipeline from
config/middleware.phpand apply the plugin filter. Security-critical admin middleware is then re-asserted and cannot be removed by a plugin. - Fire the
system.bootaction. - Load routes from
config/routes/web.php+config/routes/api.php(+ plugin routes). - Match the request, run its middleware group, dispatch the controller.
- Send the response.
- Fire
system.shutdown.
/install. Error and maintenance pages are rendered by a dependency-free ErrorPageRenderer (src/View/) so they still work when Twig or the database is down.
3. Directory map
4. Core subsystems
4.1 Dependency Injection (src/Container.php)
PSR-11 compliant. Services are bound explicitly in config/services.php; anything unbound is resolved by reflection (constructor autowiring). Supports shared singletons and transient bindings.
4.2 Repositories & brand isolation
Data access lives insrc/Repository/ on top of BaseRepository. Brand isolation is enforced at the query level by the TenantScope trait:
merchant_id column. Never run a scoped query without forTenant() (or a deliberate forAllTenants()).
4.3 Double-entry ledger
Money movements are recorded as balanced debits/credits acrossop_ledger_accounts, op_ledger_transactions, and op_ledger_entries. Balances follow standard accounting directionality (assets/expenses increase on debit; liabilities/equity/revenue increase on credit). All monetary math uses bcmath strings, never floats.
4.4 Events & plugins
EventManager provides WordPress-style actions (doAction) and filters (applyFilter). Plugins live in modules/ and register callbacks on these hooks. See the Hooks Reference and Plugin Developer Guide.
Plugins are discovered via manifest.json, run through a static code scanner (PluginSandbox) that denies dangerous calls (exec, shell_exec, passthru, eval, system, raw PDO/reflection), and only then loaded.
4.5 White-label custom domains (industry first)
OwnPay is the first self-hosted payment platform to support per-brand custom-domain checkout on a single installation. A brand maps its own domain (e.g.pay.yourbrand.com), which is DNS-verified (TXT ownership + A-record routing), then fully activated. From that point, every customer-facing URL uses the brand’s domain - customers see the brand’s logo, name, and colors; there is no trace of OwnPay.
DomainMiddleware resolves HTTP_HOST against the op_domains table, injects the merchant_id, and blocks /admin/* on custom domains. Always build customer-facing and gateway URLs through DomainUrlService (buildCheckoutUrl(), buildCallbackUrl()) - never hardcode a host.
4.6 Payment gateways
Gateways are plugins implementingGatewayAdapterInterface (src/Gateway/):
GatewayDefaults trait supplies sane no-op defaults so an adapter only implements what it supports. At checkout, the intent currency is auto-converted to a gateway-supported currency via CurrencyService using op_exchange_rates, and the conversion audit trail is persisted in the transaction metadata.
4.7 Configuration & settings
Runtime settings live inop_system_settings (group/key/value, with an optional merchant_id for per-brand overrides). Access them through EnvironmentService / SettingsRepository; EnvironmentService::get() falls back to OS environment variables when no stored value exists. There is no separate config store.
4.8 Self-update
src/Update/UpdateService.php performs an atomic, rollback-safe update: fetch manifest → back up → enter maintenance → download (GitHub release asset) → verify SHA-256 + RSA signature → extract (with zip-slip guards) → run migrations → health-check → exit maintenance. The same release zip also works as a fresh-install archive.
5. Security model
OwnPay treats all external input as adversarial. Highlights a contributor must respect:- SQL: prepared statements only (
Databasewrapper). No string-interpolated SQL. - Output: Twig autoescaping on; never
|rawuntrusted data. - CSRF:
CsrfMiddlewareon all non-API mutations; tokens viaSecurityHelpers::csrfToken(). - Passwords: Argon2id / bcrypt. API keys: 192-bit random, stored SHA-256-hashed, shown once.
- AuthN/Z tiers (see
config/middleware.php): web sessions for admin; Bearer API keys (read/write scopes) for the merchant API; an admin-scoped Bearer key for the admin API; JWT for the mobile companion API. - Rate limiting:
RateLimiterMiddleware; auth-sensitive endpoints (login, 2FA, device pairing) use a strict bucket. - SSRF: outbound webhooks resolve and pin a validated public IP (
UrlValidator::resolveSafeWebhookIp) and never follow redirects. - Headers: CSP (with per-request nonce), HSTS,
X-Frame-Options,X-Content-Type-OptionsviaSecurityHeadersMiddleware. - Secrets (
.env, signing keys) are never committed and never shipped in release zips.
6. API surface
Three independent API layers, each with its own middleware group and auth:
Formal API schema: docs.ownpay.org. Integration examples & guides: learn.ownpay.org.
7. Conventions for contributors
declare(strict_types=1);is the first line of every PHP file (no BOM).- Keep PHPStan at level 9 - run
composer analysebefore pushing. - Money is bcmath strings, never floats.
- Scoped DB access always goes through
forTenant()/*Scoped(). - Customer/gateway URLs always go through
DomainUrlService- never hardcode a host. - New gateway? Add a directory under
modules/gateways/<slug>/with amanifest.jsonand an adapter implementingGatewayAdapterInterface. See the Plugin Developer Guide. - Run the full check before a PR:
composer test && composer analyse && composer lint.
See also: Feature Reference → for the complete list of OwnPay capabilities · Local Setup → · learn.ownpay.org for integration guides.
❤️ Built by the Community, for the Community.