OwnPay is a self-hosted, single-owner, multi-brand payment orchestrator written in PHP 8.3+. One administrator controls the entire installation and creates multiple isolated brands (stores) — each with its own domain, gateways, customers, and ledgers — all secured inside a single MySQL database using aDocumentation Index
Fetch the complete documentation index at: https://mintlify.com/own-pay/OwnPay-Documentation/llms.txt
Use this file to discover all available pages before exploring further.
merchant_id column on every scoped table. There is no public sign-up and no multi-tenant SaaS runtime: the sovereign owner model means you own every byte of data and every line of execution.
Tech Stack at a Glance
| Layer | Choice |
|---|---|
| Language | PHP 8.3+ (declare(strict_types=1) everywhere) |
| Persistence | MySQL 8 / MariaDB 10.4+ (PDO, prepared statements only) |
| Templating | Twig 3+ |
| Front controller | Single entry point public/index.php |
| DI container | Custom PSR-11 (src/Container.php) with reflection autowiring |
| Auth (mobile/API) | firebase/php-jwt |
| Frontend | Server-rendered Twig + vanilla CSS/JS |
| Static analysis | PHPStan level 9 |
| Tests | PHPUnit |
There is no framework runtime (no Laravel or Symfony). The kernel, router, container, and middleware pipeline are small, readable, first-party components under
src/.Request Lifecycle
Every HTTP request flows through a single front controller into theKernel:
Boot Sequence
Load
.env, build the DI container, set the timezone, boot plugins (before middleware so plugins can inject their own), load the middleware pipeline, fire system.boot, and register routes.Routing
Router::match() resolves the path to a controller and its middleware group. Unmatched paths return a 404; rejected middleware returns a redirect, 401, or 403.Controller Dispatch
The global and group middleware pipeline runs in order, then the controller is dispatched and its
Response is sent.If the application is not yet installed, all requests redirect to
/install. Error pages are rendered by a dependency-free ErrorPageRenderer so they work even when Twig or the database is unavailable.Directory Map
Core Subsystems
Dependency Injection
PSR-11 compliant container in
src/Container.php. Services are bound explicitly in config/services.php; unbound services are resolved by reflection (constructor autowiring). Supports shared singletons and transient bindings.Brand Isolation
Data access in
src/Repository/ uses the TenantScope trait to filter all queries by merchant_id at the SQL level. Use forTenant($brandId) for scoped access and forAllTenants() only for deliberate owner-level views.Double-Entry Ledger
Money movements are recorded as balanced debits and credits across
op_ledger_accounts, op_ledger_transactions, and op_ledger_entries. All monetary arithmetic uses bcmath strings, never floats.Plugin System
Plugins in
modules/ are discovered via manifest.json, scanned by PluginSandbox for dangerous calls (exec, eval, shell_exec, raw PDO), and loaded through PluginLoader. Hooks use WordPress-style doAction and applyFilter.White-Label Custom Domains
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 verified via a DNS TXT ownership record and A-record routing, then fully activated.
DomainMiddleware resolves HTTP_HOST against the op_domains table, injects the merchant_id, and blocks /admin/* on all custom domains. Always build customer-facing URLs through DomainUrlService — never hardcode a host:
Payment Gateway Architecture
Gateways are plugins implementingGatewayAdapterInterface:
CurrencyService using op_exchange_rates, and the conversion audit trail is stored in the transaction metadata. All inbound gateway callbacks are handled by a single UnifiedWebhookController endpoint (POST /webhook/{gateway}).
Repository Brand Isolation in Practice
Self-Update Engine
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.
API Surface
Three independent API layers, each with its own middleware group and authentication scheme:| Layer | Prefix | Auth |
|---|---|---|
| Merchant REST | /api/v1/* | Bearer API key (read/write scopes) |
| Mobile Companion | /api/mobile/v1/* | JWT (after device pairing) |
| Admin API | /api/admin/v1/* | Bearer API key (admin scope) |
Security Model
OwnPay treats all external input as adversarial. Key rules every contributor must respect:SQL Safety
Prepared statements only via the
Database wrapper. No string-interpolated SQL under any circumstances.Output Escaping
Twig autoescaping is always on. Never use
|raw on untrusted data.CSRF Protection
CsrfMiddleware on all non-API mutations. Tokens via SecurityHelpers::csrfToken().SSRF Guards
UrlValidator resolves and pins outbound webhook URLs to validated public IPs and never follows redirects.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 uses
forTenant()/*Scoped(). - Customer and gateway URLs always go through
DomainUrlService. - Run the full suite before submitting a PR:
composer test && composer analyse && composer lint.