The Pay package provides a unified, type-safe interface for accepting payments via multiple providers. It leverages Data Transfer Objects (DTOs) for consistency across gateways and the Money package for precise monetary calculations, ensuring your financial transactions are always accurate.
- Standardized DTOs: Consistent response objects regardless of the gateway.
- Precision Monetary Handling: Full integration with the
Moneypackage. - Multi-Driver Architecture: Support for 9+ popular global and local gateways.
- Atomic Transactions: Automatic logging of payment attempts and statuses.
- Idempotency: Prevents double-charging across all drivers by enforcing unique references.
- Failover & Fallbacks: Intelligent fallback to secondary drivers if primary ones fail.
- Webhook Management: Standardized job for processing asynchronous payment events.
- Reconciliation: CLI command to verify pending payments when webhooks fail.
- Analytics: Dashboard-ready reporting with revenue metrics, conversion rates, and charts.
Pay is a package that requires installation before use.
php dock package:install Pay --packagesThis will automatically:
- Publish the configuration to
App/Config/pay.php. - Run the migration for Pay tables.
- Register the
PayServiceProvider.
Ensure your database is configured in
.envbefore running the installation command.
Configuration is located at App/Config/pay.php. Most settings can be managed via your .env file.
return [
// Default driver to use (paystack, stripe, paypal, etc.)
'default' => env('PAY_DRIVER', 'paystack'),
// Default currency for transactions (NGN, USD, EUR, etc.)
'currency' => env('PAY_CURRENCY', 'NGN'),
// Logging settings for the automated PaymentService
'logging' => [
'enabled' => true,
'table' => 'pay_transaction',
'model' => \Pay\Models\PaymentTransaction::class,
],
// Webhook settings (requires driver-specific signature validation)
'webhooks' => [
'path' => '/pay/webhook',
'queue' => true,
'job' => \Pay\Jobs\ProcessWebhook::class,
],
// Gateway configurations
'drivers' => [
'paystack' => [
'public_key' => env('PAYSTACK_PUBLIC_KEY'),
'secret_key' => env('PAYSTACK_SECRET_KEY'),
],
// ... stripe, paypal, flutterwave, mollie, nowpayments, square, opay, monnify
],
];The Pay package uses the Money package to prevent floating-point errors. Drivers automatically handle the conversion between "major" units (e.g., Dollars) and "minor" units (e.g., Cents) required by APIs.
Instead of messy arrays, you interact with strictly-typed objects:
PaymentData: The request object containing amount, email, and reference.PaymentResponse: Returned after initialization (contains authorization URL).VerificationResponse: Returned after verification (contains status and final amount).
Use the Pay facade to create a payment session. You can use the fluent interface or pass an array (which will be automatically hydrated). By default, the amount is treated as major units (e.g., 50 means $50.00 or ₦50.00).
use Pay\Pay;
$response = Pay::amount(50)
->email('customer@example.com')
->reference('trx_' . uniqid())
->initialize();
// Redirect user to gateway
return $this->response->redirect($response->authorizationUrl);Verify the status of a transaction using its reference.
use Pay\Pay;
$response = Pay::verify($reference);
if ($response->isSuccessful()) {
// Payment confirmed!
$amountPaid = $response->amount; // \Money\Money object
$timestamp = $response->paidAt; // \DateTimeImmutable
}Returned by initialize().
| Property | Type | Description |
|---|---|---|
reference |
string |
Your unique transaction reference. |
authorizationUrl |
string |
The URL to redirect the user for payment. |
status |
Status |
Current status (usually PENDING). |
providerReference |
string |
The reference generated by the gateway. |
metadata |
array |
Raw provider response for debug/logging. |
Returned by verify().
| Property | Type | Description |
|---|---|---|
reference |
string |
Your unique transaction reference. |
status |
Status |
The verified status (SUCCESS, FAILED). |
amount |
Money |
The actual amount paid (Money object). |
paidAt |
DateTimeImmutable |
The timestamp of the successful payment. |
metadata |
array |
Raw verification data from the provider. |
| Method | Description |
|---|---|
amount(mixed $val) |
Sets the transaction amount (major units). |
email(string $email) |
Sets the customer email. |
reference(string $ref) |
Sets a custom unique reference. |
callbackUrl(string $url) |
Sets the redirect URL after payment. |
currency(string $code) |
Overrides the default currency (e.g., USD). |
metadata(array $data) |
Attaches custom metadata to the transaction. |
driver(string $driver) |
Sets the payment gateway driver. |
fallback(array $drivers) |
Sets fallback drivers if primary fails. |
initialize() |
Starts the payment flow (returns PaymentResponse). |
verify(string $reference) |
Verifies a transaction status via the gateway. |
analytics() |
Returns the PayAnalytics service for reporting. |
| Method | Description |
|---|---|
initialize(PaymentData $data, array $fallbackDrivers = []) |
Standardized flow with automated logging and fallbacks. |
verify(string $reference) |
Verifies and updates the internal transaction record. |
| Method | Description |
|---|---|
handle(string $driver, string $payload, string $signature) |
Processes and validates asynchronous gateway notifications. |
Fluent Filter Methods:
| Method | Description |
|---|---|
successful() |
Filter by successful payments. |
failed() |
Filter by failed payments. |
pending() |
Filter by pending payments. |
driver(string $name) |
Filter by payment gateway. |
between(string $from, string $to) |
Filter by date range. |
count() |
Get count with current filters applied. |
Analytics Methods:
| Method | Description |
|---|---|
getTotalRevenue(?string $from, ?string $to, string $currency) |
Total revenue as Money object. |
getTransactionCount(?string $status, ?string $driver, ?string $from, ?string $to) |
Count transactions with filters. |
getDailyVolume(string $from, string $to, ?string $currency) |
Daily payment stats for charts. |
getMonthlyVolume(string $from, string $to, ?string $currency) |
Monthly payment stats for charts. |
getRevenueByDriver(?string $from, ?string $to) |
Revenue breakdown by payment gateway. |
getTopCustomers(int $limit, ?string $from, ?string $to) |
Highest paying customers. |
getConversionRate(?string $from, ?string $to) |
Success vs failure ratio. |
getSummary(?string $from, ?string $to, ?string $currency) |
Comprehensive payment analytics. |
getAverageTransactionValue(?string $from, ?string $to) |
Average successful payment amount. |
Switch gateways and configure fallbacks fluently:
use Pay\Pay;
// Single driver
$response = Pay::amount(100)
->email('customer@example.com')
->driver('stripe')
->initialize();
// With fallback drivers (tries stripe first, then paypal if it fails)
$response = Pay::amount(100)
->email('customer@example.com')
->driver('stripe')
->fallback(['paypal', 'flutterwave'])
->initialize();The logging configuration controls automatic persistence of payment transactions to your database. When enabled, PaymentService automatically creates a database record for every payment attempt.
// In pay.php config
'logging' => [
'enabled' => true, // Enable/disable logging
'table' => 'pay_transaction', // Database table name
'model' => \Pay\Models\PaymentTransaction::class, // Model class to use
],When you call PaymentService::initialize():
- A new
PaymentTransactionrecord is created with statusPENDING - The transaction stores: reference, driver, amount (minor units), currency, email, metadata
- If the gateway returns a different reference, the record is updated
- On verification or webhook, the status is updated to
SUCCESSorFAILED
| Scenario | Use PaymentService | Use Pay Facade |
|---|---|---|
| E-commerce checkout | ✅ Automatic audit trail | ❌ Manual tracking |
| Subscription billing | ✅ Track all attempts | ❌ No history |
| One-time tip/donation | ✅ or ❌ Your choice | ✅ Lightweight |
| Testing/prototyping | ❌ Unnecessary overhead | ✅ Quick iteration |
use Pay\Pay;
class CheckoutController
{
public function pay(Request $request)
{
// Fluent approach with automatic logging
$response = Pay::amount($request->amount)
->email($request->email)
->reference('order_' . uniqid())
->metadata(['order_id' => $request->order_id])
->currency('USD')
->fallback(['stripe'])
->initialize();
return redirect($response->authorizationUrl, internal: false);
}
}use Pay\Models\PaymentTransaction;
// Find by reference (fluent scope)
$transaction = PaymentTransaction::byReference($reference)->first();
// Get all successful payments (fluent scope with enum)
$successful = PaymentTransaction::successful()->get();
// Get payments for a user (fluent scope)
$userPayments = PaymentTransaction::byEmail($user->email)->get();
// Chain scopes
$userSuccessful = PaymentTransaction::byEmail($user->email)->successful()->get();Standardized webhook processing is handled via the /pay/webhook route (configurable). When a gateway sends a notification:
- The request is routed to the
ProcessWebhookjob. - If
queueis enabled, the job is deferred for background processing. - The
WebhookServicevalidates the signature (where supported by the driver). - Relevant events are dispatched for your application to handle.
Create a controller to receive webhook notifications from payment gateways:
// App/Http/Controllers/PaymentWebhookController.php
namespace App\Http\Controllers;
use Pay\Jobs\ProcessWebhook;
use Queue\Queue;
class PaymentWebhookController extends Controller
{
public function handle(string $driver)
{
$payload = $this->request->body(); // Raw request body
$signature = $this->request->header('X-Webhook-Signature')
?? $this->request->header('X-Paystack-Signature')
?? $this->request->header('Stripe-Signature')
?? '';
// Dispatch to queue (recommended for reliability)
Queue::dispatch(ProcessWebhook::class, [
'driver' => $driver,
'payload' => $payload,
'signature' => $signature,
]);
// Return 200 OK immediately to acknowledge receipt
return $this->response->json(['status' => 'received']);
}
}With Anchor's convention-based routing, place your controller in the appropriate module:
App/
Pay/
Controllers/
WebhookController.php → /pay/webhook/{driver}
The webhook URL yourapp.com/pay/webhook/paystack automatically maps to App\Pay\Controllers\WebhookController::paystack().
Set your webhook URL in each payment gateway's dashboard:
| Gateway | Webhook URL |
|---|---|
| Paystack | https://yourapp.com/pay/webhook/paystack |
| Stripe | https://yourapp.com/pay/webhook/stripe |
| Flutterwave | https://yourapp.com/pay/webhook/flutterwave |
Use tools like webhook.site or ngrok during local development.
Pay dispatches the following events that you can listen to:
| Event | When Dispatched | Payload |
|---|---|---|
PaymentSuccessfulEvent |
Webhook confirms successful payment | PaymentTransaction, array $gatewayResponse |
PaymentFailedEvent |
Webhook confirms failed payment | PaymentTransaction, ?string $reason |
Register your listeners in your application's ServiceProvider (e.g., App/Providers/AppServiceProvider.php). See Service Providers for details on creating and registering providers. For more on events and listeners, see Events.
namespace App\Providers;
use Core\Event;
use Pay\Events\PaymentSuccessfulEvent;
use App\Listeners\OrderFulfillmentListener;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
// Option 1: Class-based listener (recommended)
Event::listen(PaymentSuccessfulEvent::class, OrderFulfillmentListener::class);
// Option 2: Closure listener
Event::listen(PaymentSuccessfulEvent::class, function ($event) {
$transaction = $event->transaction;
// Handle payment...
});
}
}// App/Listeners/OrderFulfillmentListener.php
namespace App\Listeners;
use Pay\Events\PaymentSuccessfulEvent;
class OrderFulfillmentListener
{
public function handle(PaymentSuccessfulEvent $event): void
{
$transaction = $event->transaction;
$orderId = $transaction->metadata['order_id'] ?? null;
// Fulfill the order
}
}Pay integrates bidirectionally with the Wallet package, supporting both wallet funding (crediting a wallet via external payment) and wallet payments (debiting from wallet balance).
Use the wallet driver to debit payments directly from a user's wallet balance. This is ideal for in-app purchases, subscriptions, or any scenario where users have pre-funded wallets.
use Pay\Pay;
// Pay from wallet balance
$response = Pay::amount(25)
->email($user->email)
->driver('wallet')
->metadata(['wallet_id' => $user->wallet->id])
->initialize();
if ($response->isSuccessful()) {
// Payment completed instantly - wallet was debited
$walletTransactionId = $response->metadata['wallet_transaction_id'];
}The
walletdriver requireswallet_idin metadata. Unlike external gateways, wallet payments are synchronous - the response indicates immediate success or failure (e.g., insufficient balance).
Fund a user's wallet using any external payment gateway. When the webhook confirms payment, the wallet is automatically credited:
// Initialize payment to fund a wallet
Pay::amount(100)
->email($user->email)
->metadata([
'wallet_id' => $user->wallet->id,
'intention' => 'fund' // Required for wallet funding
])
->initialize();When the webhook confirms payment, WalletFundingListener automatically:
- Checks for
wallet_idandintention: fundin metadata - Credits the wallet with the paid amount
- Logs the transaction with payment processor details
Use the wallet driver with external fallbacks for a seamless checkout experience:
// Try wallet first, fall back to Paystack if insufficient balance
$response = Pay::amount(50)
->email($user->email)
->driver('wallet')
->metadata(['wallet_id' => $user->wallet->id])
->fallback(['paystack', 'stripe'])
->initialize();// Step 1: Checkout Controller - Initialize Payment
class CheckoutController
{
public function checkout(CheckoutRequest $request)
{
$user = $this->auth->user();
$order = Order::create([
'user_id' => $user->id,
'total' => $request->total,
'status' => 'pending',
'refid' => Str::random('secure')
]);
$response = Pay::amount($order->total)
->email($user->email)
->reference('order_' . $order->refid)
->callbackUrl(url('orders/' . $order->id . '/confirm'))
->metadata(['order_id' => $order->id])
->initialize();
return redirect($response->authorizationUrl, internal: false);
}
// Step 2: Callback - User returns from gateway
public function confirm(string $reference)
{
$order = Order::findByReference($reference);
if (!$order) {
return redirect(route());
}
$result = Pay::verify($reference);
if ($result->isSuccessful()) {
// Don't fulfill here - wait for webhook for security
return $this->asView('orders.pending', ['order' => $order]);
}
return $this->asView('orders.failed', ['order' => $order]);
}
}
// Step 3: Event Listener - Handle webhook (recommended for fulfillment)
class OrderFulfillmentListener
{
public function handle(PaymentSuccessfulEvent $event)
{
$orderId = $event->transaction->metadata['order_id'] ?? null;
if ($orderId) {
$order = Order::find($orderId);
$order->update(['status' => 'paid']);
// Send confirmation email, dispatch shipping, etc.
Mail::send(new OrderConfirmation($order));
}
}
}| Driver | Region | Strengths |
|---|---|---|
paystack |
Africa | Best for Nigeria/Ghana/SA, easy setup. |
stripe |
Global | Market leader, best for Cards/Subscriptions. |
flutterwave |
Africa | Pan-African support, multi-currency. |
paypal |
Global | High consumer trust, easy checkout. |
mollie |
Europe | Popular in EU (iDEAL, Klarna). |
nowpayments |
Crypto | 100+ Cryptocurrencies, low fees. |
square |
Global | Integrated with POS, great for US/CA/UK. |
opay |
Africa | Strong mobile wallet support in Nigeria. |
monnify |
Africa | Best for virtual accounts/bank transfers. |
wallet |
In-App | Debit from user wallet balance, instant. |
-
Always store transaction references in your database before redirecting the user to the gateway. The
PaymentServicehandles this automatically if used. -
Never trust the
callbackUrlalone for critical fulfillment. Always usePay::verify()or configure Webhooks to confirm the payment status independently. -
Ensure your
secret_keyandpublic_keyare never committed to version control. Use.envvariables for all sensitive credentials.
Add the Payable trait to any model (User, Organization, Team, etc.) to enable convenient payment methods with robust polymorphic relationship linking:
use Pay\Traits\Payable;
class User extends BaseModel
{
use Payable;
// Must have an 'email' property for gateway communication
}This provides:
// Initiate a payment for this entity
// The entity is automatically linked via payable_id/payable_type
$response = $user->pay(100.00, ['order_id' => 123], 'USD');
// Get all payment transactions for this entity (polymorphic relationship)
$transactions = $user->transactions()->get();
// Chain with scopes
$successful = $user->transactions()->successful()->get();
$pending = $user->transactions()->pending()->get();When you call $user->pay():
- The trait injects
payable_id(user's ID) andpayable_type(class name) into metadata PaymentServiceextracts this info and saves it directly on the transaction record- The
transactions()relationship usesmorphMany()to query by these columns
Why not email? Email-based linking is fragile; if a user changes their email, the relationship breaks. Polymorphic relationships use immutable IDs, making them built for stability and scale.
use Pay\Models\PaymentTransaction;
// Get all transactions for a specific user
$userPayments = $user->transactions()->get();
// Or query directly via the model (fluent scope)
$tx = PaymentTransaction::byPayable($user)->successful()->get();
// Access the payable from a transaction
$transaction = PaymentTransaction::find(1);
$owner = $transaction->payable; // Returns User, Organization, etc.use Pay\Enums\Status;
Status::PENDING; // 'pending' - Payment initiated but not confirmed
Status::SUCCESS; // 'success' - Payment confirmed successful
Status::FAILED; // 'failed' - Payment failed or declined
Status::CANCELLED; // 'cancelled' - Payment cancelled by useruse Pay\Enums\Currency;
Currency::NGN; // Nigerian Naira
Currency::USD; // US Dollar
Currency::GBP; // British Pound
Currency::EUR; // Euro
Currency::KES; // Kenyan Shilling
Currency::GHS; // Ghanaian Cedi
Currency::ZAR; // South African Rand| Exception | When Thrown |
|---|---|
PaymentException |
General payment errors (gateway errors, etc.) |
InvalidDriverException |
Unknown or misconfigured driver requested |
PaymentVerificationFailedException |
Verification call failed or returned unexpected |
use Pay\Exceptions\PaymentException;
try {
$response = Pay::amount(100)->email($email)->initialize();
} catch (PaymentException $e) {
Log::error('Payment failed: ' . $e->getMessage());
}The PayAnalytics service provides methods for dashboards, reports, and chart data.
Access via Facade:
$analytics = Pay::analytics();Get Payment Summary:
$summary = Pay::analytics()->getSummary('2024-01-01', '2024-12-31');
// Returns:
// [
// 'total_revenue' => 5000000, // In minor units (cents)
// 'transaction_count' => 1500,
// 'successful_count' => 1200,
// 'failed_count' => 250,
// 'pending_count' => 50,
// 'conversion_rate' => 80.0, // Percentage
// 'revenue_by_driver' => [...],
// ]Revenue & Transaction Metrics:
// Total revenue as Money object
$revenue = Pay::analytics()->getTotalRevenue('2024-01-01', '2024-12-31', 'NGN');
echo $revenue->format(); // ₦5,000,000.00
// Fluent transaction counts
$successCount = Pay::analytics()->successful()->count();
$failedCount = Pay::analytics()->failed()->count();
$paystackCount = Pay::analytics()->driver('paystack')->count();
// Chain filters
$paystackSuccess = Pay::analytics()
->successful()
->driver('paystack')
->between('2024-01-01', '2024-12-31')
->count();
// Or use the direct method with parameters
$total = Pay::analytics()->getTransactionCount();Daily Volume for Charts:
$dailyData = Pay::analytics()->getDailyVolume('2024-12-01', '2024-12-31', 'NGN');
// Returns array with Money objects for revenue:
// [
// ['date' => '2024-12-01', 'successful' => 45, 'failed' => 5, 'revenue' => Money (₦250,000)],
// ['date' => '2024-12-02', 'successful' => 52, 'failed' => 3, 'revenue' => Money (₦310,000)],
// ]
foreach ($dailyData as $day) {
echo "{$day['date']}: {$day['revenue']->format()} ({$day['successful']} successful)";
}Revenue by Payment Gateway:
$byDriver = Pay::analytics()->getRevenueByDriver('2024-01-01', '2024-12-31', 'NGN');
// Returns array with Money objects:
// [
// ['driver' => 'paystack', 'revenue' => Money (₦3,500,000), 'count' => 800],
// ['driver' => 'stripe', 'revenue' => Money (₦1,500,000), 'count' => 400],
// ]
foreach ($byDriver as $item) {
echo "{$item['driver']}: {$item['revenue']->format()} ({$item['count']} transactions)";
}Top Customers:
$topCustomers = Pay::analytics()->getTopCustomers(10, '2024-01-01', '2024-12-31');
// Returns:
// [
// ['email' => 'vip@example.com', 'total_paid' => 500000, 'payment_count' => 25],
// ...
// ]Conversion Rate:
$conversion = Pay::analytics()->getConversionRate('2024-01-01', '2024-12-31');
// Returns:
// [
// 'total_attempts' => 1500,
// 'successful' => 1200,
// 'failed' => 250,
// 'pending' => 50,
// 'cancelled' => 0,
// 'conversion_rate' => 80.0, // Percentage
// ]The pay:verify-pending command acts as a reconciliation fallback for when webhooks fail to arrive. It queries pending payments, verifies their status with the respective gateways, and triggers events for successful payments.
# Verify all pending payments from the last 24 hours
php dock pay:verify-pending
# Verify last 48 hours, limit to 50 transactions
php dock pay:verify-pending --hours=48 --limit=50
# Verify only Paystack transactions
php dock pay:verify-pending --driver=paystack
# Preview what would be verified without making changes
php dock pay:verify-pending --dry-runOptions:
| Option | Description |
|---|---|
--hours=24 |
Only check payments created within the last N hours |
--driver=paystack |
Filter by specific payment driver |
--limit=100 |
Maximum number of transactions to process |
--dry-run |
Preview mode - no changes made |
The Pay package uses automated scheduling to reconcile pending payments that missed their webhooks. This is registered in the framework scheduler:
// packages/Pay/Schedules/PayVerifyPendingPaymentSchedule.php
namespace Pay\Schedules;
use Cron\Interfaces\Schedulable;
use Cron\Schedule;
class PayVerifyPendingPaymentSchedule implements Schedulable
{
public function schedule(Schedule $schedule): void
{
$schedule->task()
->signature('pay:verify-pending')
->hourly();
}
}Configure your .env file with the required credentials for each driver:
# Default Settings
PAY_DRIVER=paystack
PAY_CURRENCY=NGN
# Paystack
PAYSTACK_PUBLIC_KEY=pk_test_xxx
PAYSTACK_SECRET_KEY=sk_test_xxx
PAYSTACK_MERCHANT_EMAIL=merchant@example.com
# Stripe
STRIPE_PUBLIC_KEY=pk_test_xxx
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
# Flutterwave
FLW_PUBLIC_KEY=FLWPUBK_TEST-xxx
FLW_SECRET_KEY=FLWSECK_TEST-xxx
FLW_ENCRYPTION_KEY=xxx
# PayPal
PAYPAL_CLIENT_ID=xxx
PAYPAL_CLIENT_SECRET=xxx
PAYPAL_MODE=sandbox # sandbox or live
# Monnify
MONNIFY_API_KEY=xxx
MONNIFY_SECRET_KEY=xxx
MONNIFY_CONTRACT_CODE=xxx
MONNIFY_MODE=sandbox
# Square
SQUARE_ACCESS_TOKEN=xxx
SQUARE_LOCATION_ID=xxx
SQUARE_MODE=sandbox
# OPay
OPAY_PUBLIC_KEY=xxx
OPAY_SECRET_KEY=xxx
OPAY_MERCHANT_ID=xxx
OPAY_MODE=sandbox
# Mollie
MOLLIE_API_KEY=test_xxx
# NOWPayments
NOWPAYMENTS_API_KEY=xxx
NOWPAYMENTS_MODE=sandbox
# Webhooks
PAY_WEBHOOK_SECRET=your_webhook_secret