Skip to main content

Documentation 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.

OwnPay is engineered for performance: composite database indexes on every merchant_id column, stored generated columns for JSON field lookups, a Redis-backed cache and queue, OPcache with JIT, and idempotency middleware that prevents duplicate charges under retry load. This guide walks through each layer from cron configuration to horizontal scaling architecture.

Performance Targets (Single Server)

On a standard Docker Compose stack with Redis enabled, these are the baseline targets:
OperationTargetNotes
Payment creation (POST /api/v1/payments)< 150msIncludes fee rule resolution + ledger write
Checkout page load< 300msBrand theme + active gateway CSP build
Transaction list (100 items, paginated)< 200msUses idx_merchant_created composite index
Invoice/payment link lookup< 50msStored generated column + B-tree index
API response (any endpoint)< 200msRedis rate limiter adds < 5ms overhead
Webhook delivery< 1 secondSync delivery; async retry via WebhookRetryCron

Capacity Tiers

Daily VolumeAction Required
> 1,000 payments/daySwitch to CACHE_DRIVER=redis and QUEUE_DRIVER=redis
> 5,000 payments/dayTune PHP-FPM pool, increase InnoDB buffer pool
> 10,000 payments/dayAdd a dedicated database server
> 50,000 payments/dayRead replica + Redis Cluster
> 100,000 payments/dayHorizontal app scaling + load balancer

Cron Job Configuration

The OwnPay cron runner processes all nine scheduled jobs and must be called every minute from the system cron. Without it, webhooks are not retried, expired intents are not cleaned up, SMS verification never fires, and the queue backs up.
# Add to crontab (crontab -e)
* * * * * php /var/www/ownpay/public/index.php cron/run >> /dev/null 2>&1
The cron runner uses file-based flock locking per job to prevent concurrent execution. Jobs run on their own schedule:
JobSchedulePurpose
QueueWorkerJobEvery minuteProcess emails, webhook sends, async jobs
WebhookRetryCronEvery 5 minutesRetry up to 50 failed webhook deliveries
SmsVerificationJobEvery minuteMatch parsed SMS to pending transactions
DnsVerificationJobHourlyVerify custom domain DNS records
CurrencyUpdateJobEvery 6 hoursFetch live exchange rate updates
BalanceVerificationJobDailyCross-check ledger vs. transaction aggregates
RefundReconciliationJobDailySync refund status from gateway APIs
UpdateCheckJobDailyCheck for new OwnPay releases
SystemUpdateJobEvery 6 hoursApply updates during the 02:00–04:00 window
A missing cron entry is the most common cause of stuck transactions, stale exchange rates, and webhook delivery failures. Verify the cron is running: ls -la /var/www/ownpay/storage/cron/ — each job should have a .lock file with a recent timestamp.

Database Optimization

Composite Indexes

All tenant-scoped queries filter on merchant_id first. Every high-traffic table carries a (merchant_id, ...) composite index so brand-scoped WHERE clauses hit an index immediately:
TableIndexUsed By
op_transactionsidx_merchant_status (merchant_id, status)Dashboard payment list
op_transactionsidx_merchant_created (merchant_id, created_at)Date-range reports
op_system_settingsidx_merchant_group (merchant_id, group_name)SettingsRepository::getGroup()
op_customersidx_merchant_phone_hash (merchant_id, phone_hash)Customer lookup
op_webhook_eventsidx_webhook_status (webhook_id, status)Retry queue
op_audit_logidx_merchant_action (merchant_id, action)Filtered audit views

Stored Generated Columns

The op_transactions table uses MySQL Stored Generated Columns to eliminate runtime JSON extraction overhead:
-- Stored generated columns — indexed, no runtime JSON parsing
`invoice_id` BIGINT UNSIGNED GENERATED ALWAYS AS (
  CAST(JSON_UNQUOTE(JSON_EXTRACT(`metadata`, '$.invoice_id')) AS UNSIGNED)
) STORED,

KEY `idx_invoice_id` (`invoice_id`),
KEY `idx_payment_link_id` (`payment_link_id`)
Queries on invoice_id or payment_link_id use a normal B-tree index — sub-millisecond index seeks instead of full table scans.

InnoDB Buffer Pool

Set the buffer pool to ~70% of available database server RAM:
# .docker/mariadb.cnf (or your production my.cnf)
[mysqld]
innodb_buffer_pool_size     = 256M    # Docker default
innodb_buffer_pool_instances = 1

# For a dedicated 8 GB DB server:
# innodb_buffer_pool_size    = 5G
# innodb_buffer_pool_instances = 4

# Financial data integrity — never reduce these
innodb_flush_log_at_trx_commit = 1
innodb_flush_method            = O_DIRECT

# Slow query logging (queries > 2s)
slow_query_log      = 1
slow_query_log_file = /var/lib/mysql/slow-queries.log
long_query_time     = 2

Regular Maintenance

Run these queries on a schedule to keep the database performing well:
-- Weekly: update table statistics
ANALYZE TABLE op_transactions;
ANALYZE TABLE op_audit_log;
ANALYZE TABLE op_webhook_events;

-- Monthly: clean up expired temporary records
DELETE FROM op_idempotency_keys
WHERE created_at < DATE_SUB(NOW(), INTERVAL 30 DAY);

DELETE FROM op_password_resets
WHERE expires_at < NOW();

DELETE FROM op_login_attempts
WHERE created_at < DATE_SUB(NOW(), INTERVAL 30 DAY);

PHP-FPM Tuning

OwnPay’s production PHP-FPM pool configuration:
[ownpay]
listen = 127.0.0.1:9000

pm                   = dynamic
pm.max_children      = 20    # maximum simultaneous PHP workers
pm.start_servers     = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 8
pm.max_requests      = 500   # recycle worker after 500 requests (prevents memory leaks)

slowlog                 = storage/logs/php-fpm-slow.log
request_slowlog_timeout = 10s
Calculating the right pm.max_children:
# Check average worker memory usage under load
ps -o rss= -C php-fpm | awk '{sum+=$1} END {print sum/NR/1024 " MB average"}'

# Formula: available RAM ÷ average worker memory
# Example: 3000 MB available / 80 MB per worker = 37 → set pm.max_children = 35
PHP memory and execution limits:
memory_limit       = 256M   # per worker
max_execution_time = 120    # seconds; covers report generation
bcmath.scale       = 10     # financial precision

OPcache Configuration

OwnPay ships a production-tuned OPcache configuration:
opcache.enable                = 1
opcache.memory_consumption    = 128        # 128 MB bytecode cache
opcache.max_accelerated_files = 10000      # covers all OwnPay source files
opcache.revalidate_freq       = 0          # never check disk (production only)
opcache.validate_timestamps   = 0          # no filesystem stat() calls
opcache.jit                   = tracing    # PHP 8.3 JIT
opcache.jit_buffer_size       = 64M

realpath_cache_size = 4096K
realpath_cache_ttl  = 600
Set opcache.validate_timestamps = 0 in production only. After every deployment, reload PHP-FPM to clear the bytecode cache: systemctl reload php8.3-fpm.

Redis and Queue Setup

Switching from the default file-based cache and queue to Redis is the single highest-impact performance change for any deployment above 1,000 payments/day.
# .env
CACHE_DRIVER=redis
QUEUE_DRIVER=redis

REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0          # DB 0 for cache
REDIS_PREFIX=op:
The queue uses Redis DB 1 automatically (separate from the cache on DB 0). This allows safe co-location of cache and queue on the same Redis instance.
# docker-compose.yml Redis service
redis:
  image: redis:7-alpine
  command: >
    redis-server
    --maxmemory 256mb
    --maxmemory-policy allkeys-lru
    --save ""
    --appendonly no
Monitor Redis health:
# Real-time command stream
redis-cli monitor

# Hit rate (target: > 80%)
redis-cli info stats | grep -E 'keyspace_hits|keyspace_misses'

# Queue depth
redis-cli llen "op:queue:default"

# Failed jobs
redis-cli llen "op:queue:failed"

Webhook Delivery Performance

OwnPay’s WebhookRetryCron processes up to 50 pending events per execution (every 5 minutes) using an exponential backoff schedule:
AttemptRetry Delay
15 minutes
215 minutes
31 hour
46 hours
512 hours
624 hours
7+Dead-lettered
For high-volume webhook workloads, monitor the op_webhook_events table for growing pending counts — this indicates the cron isn’t keeping up or your endpoints are slow to respond.
-- Check webhook health
SELECT status, COUNT(*) as count, AVG(attempts) as avg_attempts
FROM op_webhook_events
GROUP BY status;

Horizontal Scaling

Architecture 2: Dedicated Database Server

When transaction volume outgrows the single-server stack:
┌──────────────────────┐        ┌──────────────────────┐
│   App Server         │        │   DB Server          │
│   PHP-FPM + Nginx    │──────→ │   MariaDB 10.11      │
│   Redis (local)      │        │   innodb_buffer: 5G+ │
│   CACHE_DRIVER=redis │        │   max_connections:300│
└──────────────────────┘        └──────────────────────┘
Update .env on the app server:
DB_HOST=192.168.1.10   # dedicated DB server IP
DB_CONNECT_RETRIES=5   # more retries over network

Architecture 3: Load-Balanced App Servers

For 50,000+ payments/day with high availability:
             Load Balancer (Nginx/HAProxy)
                   │               │
     ┌─────────────┘               └─────────────┐
     │                                            │
App Server 1 (PHP-FPM + Nginx)   App Server 2 (PHP-FPM + Nginx)
     │                                            │
     └──────────────────┬─────────────────────────┘

            ┌───────────┴───────────┐
            │                       │
      MariaDB Primary          MariaDB Read Replica
      (writes)                 (reports/analytics)

      Redis Cluster (3+ nodes)
Critical requirement for horizontal scaling: Sessions are stored in storage/sessions/ by default. When running multiple app servers, configure Redis-backed sessions:
# php-fpm pool config
php_value[session.save_handler] = redis
php_value[session.save_path]    = "tcp://redis:6379?prefix=op:sess:"
Nginx load balancer configuration:
upstream ownpay_app {
    least_conn;
    server app1.internal:80;
    server app2.internal:80;
    keepalive 32;
}

server {
    listen 443 ssl;
    server_name pay.example.com;

    location / {
        proxy_pass http://ownpay_app;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
Configure trusted proxies in .env:
TRUSTED_PROXIES=10.0.0.1,10.0.0.2   # load balancer IPs or CIDR ranges
OwnPay’s RateLimiterMiddleware reads X-Forwarded-For when TRUSTED_PROXIES is set, ensuring rate limits apply per end-user rather than per proxy.

CDN for Static Assets

Static assets (CSS, JS, images, gateway icons) are served with a 30-day Cache-Control: public, immutable header by Nginx. Point a CDN origin to your OwnPay server to offload static file delivery:
# Already in OwnPay's Nginx config
location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot)$ {
    expires 30d;
    add_header Cache-Control "public, immutable";
    access_log off;
}
Configure your CDN to cache responses with Cache-Control: immutable indefinitely and invalidate on each OwnPay update.

Monitoring and the Health Check Endpoint

Health Check Endpoint

GET /api/v1/health
Returns an HTTP 200 with a JSON body confirming the status of the database, cache, and queue subsystems. Use this as your load balancer health check probe and uptime monitor target. The Docker stack wires this up automatically:
healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost/api/v1/health"]
  interval: 30s
  timeout: 10s
  retries: 3

Key Metrics to Monitor

Application:
MetricTarget
PHP-FPM active workers< 80% of pm.max_children
PHP-FPM listen queue0
OPcache hit rate> 95%
Database:
MetricTarget
Slow queries (> 2s)0/min
InnoDB buffer pool hit rate> 95%
Active connections< 80% of max_connections
Queue:
MetricTarget
op:queue:default depth< 100
op_webhook_events with status='failed'0

Quick Health Check Script

#!/bin/bash
echo "=== OwnPay Health Check ==="

echo "--- PHP-FPM ---"
curl -s http://127.0.0.1/fpm-status 2>/dev/null | grep -E 'pool|processes|listen queue'

echo "--- Redis ---"
redis-cli info stats | grep -E 'keyspace_hits|keyspace_misses|evicted_keys'

echo "--- MariaDB ---"
mysql -u root -p"$DB_ROOT_PASS" -e "SHOW STATUS LIKE 'Threads_connected';" 2>/dev/null
mysql -u root -p"$DB_ROOT_PASS" -e "SHOW STATUS LIKE 'Slow_queries';" 2>/dev/null

echo "--- Disk ---"
df -h /var/www/ownpay/storage

echo "--- Cron Locks ---"
ls -la /var/www/ownpay/storage/cron/

echo "--- Queue Depth ---"
redis-cli llen "op:queue:default" 2>/dev/null

Scaling Decision Matrix

SignalAction
PHP-FPM listen queue > 0Increase pm.max_children (add RAM first)
Server RAM > 85% sustainedUpgrade RAM or add an app server
MariaDB Threads_connected > 80Enable connection pooling (ProxySQL) or add a read replica
OPcache hit rate < 90%Increase opcache.max_accelerated_files
Redis evicted_keys > 0/minIncrease Redis maxmemory
Replication lag > 1 secondTune innodb_log_file_size, check network
Webhook pending count growingIncrease WebhookRetryCron batch size or add a dedicated queue worker
Start with the Docker Compose single-server stack — it ships Redis and MariaDB pre-configured. Scale vertically first (more RAM = larger InnoDB buffer + more FPM workers). Only add horizontal complexity when vertical scaling is exhausted.

Build docs developers (and LLMs) love