sirmekus / ego
This package aims to simplify payment gateway processing for all mobile/web applications using a common interface. For this, contributors are highly welcomed to help create more payment gateways features that other users can use from a single library.
Requires
- php: ^8.0
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Ego ("money") is an all-in-one payment gateway library for the PHP (Laravel) community. It is designed to bring together all the possible payment gateways under one "umbrella" via a defined set of interfaces covering regular/day-to-day business/user goals.
With this library, you don't need to worry about switching between different payment gateways; just check it up here, and if it's available, via the guaranteed set of interfaces, use it straight away as it covers simple everyday use cases.
Note that the expected parameters required by your gateway of choice, when making a request via this package, should be sent as well.
For something more complex, you should consider using your preferred gateway's SDK (if available), extending our implementation, or creating a new implementation entirely for your use.
For this, I strongly encourage contributions, please. If you have ever worked with/on a particular payment gateway, please contribute by adding it here for other developers to use. Thank you.
Table of Contents
- Getting Started
- Available Interface Methods
- Available (Underlying) Gateway(s)
- Webhook Strategy
- Contributing
This documentation will constantly be updated as more interfaces/methods or payment gateways are added.
Getting Started
Install the package like so:
composer require sirmekus/ego
This library obscures the underlying payment gateway by providing, instead, a "factory" for you to interact with. This "factory" also contains the common methods (interface) all the available payment gateways (here) should have, so you can still interact with the payment gateway.
To publish the default config file to customize, run:
php artisan vendor:publish --provider="Emmy\Ego\Provider\EgoProvider"
Example usage:
$paymentFactory = new PaymentFactory(); $data = [ 'amount' => 1000, 'email' => 'Z0m0C@example.com', 'callback_url' => 'http://localhost/webhook', 'reference' => 'randomized', ]; $response = $paymentFactory->pay($data);
The PaymentFactory class accepts two optional parameters:
- A
PaymentGatewayinterface (or string indicating which payment gateway to use) - A "configuration" key which specifies how the underlying payment gateway shall be configured (for authentication) to hit the appropriate API.
If none is specified, the default — gotten from the config file — is used.
Available Interface Methods
// Sets the configuration/credentials for the underlying payment gateway public function setKey(string|array $key): void; // Builds the appropriate payload from an array of values the target gateway expects. // The underlying payment gateway class determines which fields it extracts. public function prepareForPayment(array $data): array; // Builds the appropriate transfer payload from an array of values. public function prepareForTransfer(array $data): array; // Initiates a payment or deposit public function pay(array $array): array; /** * Verifies a payment, deposit, or transfer. The return value is dependent on * the underlying payment gateway. * * $paymentType is optional and can be defined by your implementation. Enums are * intentionally avoided so each implementation can define its own. An error will * typically be thrown if the payment type is not supported. */ public function verifyPayment(array|string $array, ?string $paymentType = null): array; // Verifies an incoming webhook. If the webhook is valid, execution continues; // otherwise it fails with a 401/404 response. public function verifyWebhook(Request $request): void; // Fetches a list of banks supported by the underlying payment gateway public function getBanks(string $countryCode = ""): array; // Verifies an account number public function verifyAccountNumber(array $request): array; // Runs a transfer/withdrawal transaction public function transfer(array $data): array; // Returns the crafted payload if magic methods were used to build it public function getPayload(): array;
All of these methods are guaranteed to be accessible regardless of the payment gateway in use. However, you should know what payload parameters your chosen payment gateway expects and pass them when necessary.
Instead of manually crafting parameters, you can "build" them. For example, if your payment gateway expects: amount, currency, metadata, and reference, you can build them like so:
$paymentFactory = new PaymentFactory(); $paymentFactory->setAmount($amount); $paymentFactory->setCurrency($currency); $paymentFactory->setReference($reference); $paymentFactory->setMetadata($metadata); $response = $paymentFactory->pay();
Methods starting with the set keyword are 'magical' and represent a payload item. The parameter acts as the value. When pay() or transfer() is called without any parameter, it takes from the payload already built using the magic methods.
Alternatively, if you already have an array (say from submitted form data), instead of building the payload manually, you can dump it into the library via prepareForPayment() or prepareForTransfer() and the library will automatically build it for you. Even if the array is nested, it will fetch the first matching key/value pair required to create a request payload. This means a model (cast to an array) can be passed to it as well.
NB: How the payload is built is dependent on the underlying payment gateway. A gateway may require 5 parameters while the contributor of that particular gateway (in this package) may only cater for 2 in
prepareForPayment()orprepareForTransfer(). If the remaining parameters are important, it is recommended you set the payload manually instead.
Example:
$paymentFactory = new PaymentFactory(); // Assuming a validated Laravel request $paymentFactory->prepareForPayment($request->validated()); $response = $paymentFactory->pay();
The method will extract the minimal request parameters (or payload) needed to interact with the API endpoint of your preferred service provider or payment gateway.
Accessing Gateway-Specific Methods
If you need to call a method on the underlying gateway that is not part of the general interface, you can access the gateway instance directly:
$paymentFactory = new PaymentFactory(); $gateway = $paymentFactory->getGatewayInstance(); // Now you can use the actual payment gateway class $gateway->someGatewaySpecificMethod();
Swapping Gateway Implementations
If you have a gateway class with custom logic, you can swap our implementation with yours in the providers section of the ego.php config file. It must implement PaymentGatewayInterface. One way to do it is to extend our class and override the methods you need.
Config File Structure
The typical structure of the ego.php config file is shown below. After publishing it, add the relevant environment variables to your .env file.
return [ // Your app's default payment gateway. Must exist in the 'providers' section // below and have its credentials set up. 'default' => 'paystack', // Whether to verify webhook authenticity. Recommended to keep true. 'verify_webhook' => env('EGO_VERIFY_WEBHOOK', true), 'credentials' => [ 'paystack' => [ 'secret_key' => env('PAYSTACK_SECRET_KEY'), ], 'flutterwave' => [ 'secret_key' => env('FLUTTERWAVE_SECRET_KEY'), ], 'stripe' => [ 'secret_key' => env('STRIPE_SECRET_KEY'), 'signing_secret' => env('STRIPE_SIGNING_SECRET'), 'account_id' => env('STRIPE_ACCOUNT_ID'), 'client_id' => env('STRIPE_CLIENT_ID'), ], 'nomba' => [ 'client_id' => env('NOMBA_CLIENT_ID'), 'secret_key' => env('NOMBA_SECRET_KEY'), 'account_id' => env('NOMBA_ACCOUNT_ID'), 'signature_key' => env('NOMBA_SIGNATURE_KEY'), 'base_url' => env('NOMBA_BASE_URL'), ], 'budpay' => [ 'secret_key' => env('BUDPAY_SECRET_KEY'), 'public_key' => env('BUDPAY_PUBLIC_KEY'), ], 'paypal' => [ 'client_id' => env('PAYPAL_CLIENT_ID'), 'client_secret' => env('PAYPAL_CLIENT_SECRET'), 'url' => env('PAYPAL_URL', 'https://api-m.sandbox.paypal.com'), 'bn_code' => env('PAYPAL_BN_CODE'), 'paypal_webhook_id' => env('PAYPAL_WEBHOOK_ID'), ], ], 'providers' => [ 'paystack' => Emmy\Ego\Gateway\Paystack\Paystack::class, 'flutterwave' => Emmy\Ego\Gateway\Flutterwave\Flutterwave::class, 'stripe' => Emmy\Ego\Gateway\Stripe\Stripe::class, 'nomba' => Emmy\Ego\Gateway\Nomba\Nomba::class, 'budpay' => Emmy\Ego\Gateway\BudPay\BudPay::class, 'paypal' => Emmy\Ego\Gateway\Paypal\Paypal::class, ], ];
Available (Underlying) Payment Gateway(s)
Paystack
Once you know the typical request parameters expected by Paystack, you can plug them in directly into the appropriate method discussed above and use it straight away.
The following methods are available for Paystack in this package:
- All the methods defined in the interface
Paystack Special Cases
Case 1: Payment payload fields
When using prepareForPayment($array), the following will be extracted from the array passed in as a parameter:
emailamountcurrencychannelscallback_url(orcallbackUrl)bearermetadata(ormetaData)reference
When using prepareForTransfer($array), the following will be extracted:
recipient_typeaccount_nameaccount_numberbank_codereferenceamountdescription
Case 2: Authorization URL vs Authorization Code
On Paystack, you can charge customers by directing them to an authorization URL or by charging them directly via an authorization code. You don't need to worry about these details when using this package — the pay() method handles both.
To charge a customer via an authorization code, include a key named authorization_code in your payload/array. The default behaviour (when no authorization code is present) is to create a checkout link and redirect the user.
Case 3: Transfers
On Paystack, making a transfer/withdrawal to a bank account requires first creating a transfer recipient, which generates a unique code used to initiate the transfer.
This process is handled automatically when you use the transfer() method - you don't need to worry about it.
However, if you already have a transfer recipient code, simply include it in your payload as recipient_code and the package will skip the recipient creation step and initiate the transfer directly.
Flutterwave
Once you know the typical request parameters required by Flutterwave, you can plug them in directly into the appropriate method and use it straight away.
The following methods are available for Flutterwave in this package:
- All the methods defined in the interface
When using prepareForPayment($array), the following will be extracted:
emailamountcurrencytx_ref(orreference)redirect_url(orcallback_url/callbackUrl)metadata(ormetaData)
Stripe
Once you know the typical request parameters required by Stripe, you can plug them in directly into the appropriate method and use it straight away.
The following methods are available for Stripe in this package:
- All the methods defined in the interface
Stripe Special Cases
Case 1: Payment payload fields
When using prepareForPayment($array), the following will be extracted:
email(orcustomer_email)amountcurrency— defaults tousdif not providedmode— defaults topaymentif not provideddescription— used as the product name; defaults to"Account Funding"quantity— defaults to1tx_ref(orreference) — mapped toclient_reference_idredirect_url(orcallback_url/callbackUrl) — mapped tosuccess_url
Case 2: Transfers
Stripe transfers are sent to connected Stripe accounts (not directly to bank accounts). You must provide a destination (or destination_id) in your payload, which is the ID of the connected Stripe account to transfer funds to.
Case 3: Webhook verification
Stripe webhook verification requires a signing_secret configured in ego.credentials.stripe.signing_secret. Set the STRIPE_SIGNING_SECRET environment variable to the signing secret from your Stripe dashboard.
Nomba
Nomba is a Nigerian payment infrastructure provider. Once you know the typical request parameters it expects, you can plug them in directly and use it straight away.
The following methods are available for Nomba in this package:
- All the methods defined in the interface
Nomba Configuration
Add the following environment variables to your .env file:
NOMBA_CLIENT_ID=your_client_id NOMBA_SECRET_KEY=your_client_secret NOMBA_ACCOUNT_ID=your_account_id NOMBA_SIGNATURE_KEY=your_webhook_signature_key NOMBA_BASE_URL=https://api.nomba.com/v1/
All five values can be found in or generated from your Nomba Dashboard.
| Variable | Description |
|---|---|
NOMBA_CLIENT_ID |
Your OAuth2 client ID |
NOMBA_SECRET_KEY |
Your OAuth2 client secret |
NOMBA_ACCOUNT_ID |
Your Nomba parent account ID |
NOMBA_SIGNATURE_KEY |
The secret key used to verify incoming webhook payloads |
NOMBA_BASE_URL |
The Nomba API base URL (e.g. https://api.nomba.com/v1/) |
Nomba Authentication
Nomba uses OAuth2 client credentials for authentication. The package handles this automatically - you do not need to manage tokens yourself. When a request is made, the package:
- Checks Laravel's cache for a valid access token.
- If none exists (or it has expired), it requests a new one from Nomba using your
client_idandsecret_key. - Caches the new token for slightly less than its TTL to avoid edge-case expiry.
This means your app stays authenticated transparently across requests with zero manual token management.
Nomba Special Cases
Case 1: Payment payload fields
When using prepareForPayment($array), the following will be extracted:
| Key(s) in your array | Description |
|---|---|
email |
Customer's email address |
customerId / customer_id / email |
Customer identifier (falls back to email if absent) |
amount |
Amount to charge |
currency |
Currency code (e.g. NGN) |
reference |
Your unique order reference |
callback_url / callbackUrl |
URL to redirect to after payment |
token |
Tokenized card key (triggers direct card charge) |
tokenize |
Whether to tokenize the card — defaults to true |
Case 2: Authorization URL vs Tokenized Card Payment
Similar to Paystack, Nomba supports two payment flows:
- Authorization URL (default): A checkout link is created and the customer is redirected to it to complete payment. This is the default behaviour.
- Tokenized Card Payment: If you include a
tokenkey in your payload (a card token previously issued by Nomba), the customer is charged directly without a redirect.
The pay() method handles both flows automatically. The presence of a token in the payload is what triggers the direct charge.
// Default: redirect to checkout page $paymentFactory = new PaymentFactory('nomba'); $response = $paymentFactory->pay([ 'email' => 'customer@example.com', 'amount' => 5000, 'currency' => 'NGN', 'reference' => 'unique-ref-001', 'callback_url' => 'https://yourapp.com/payment/callback', ]); // $response['url'] contains the checkout link // Tokenized: charge card directly $response = $paymentFactory->pay([ 'email' => 'customer@example.com', 'amount' => 5000, 'currency' => 'NGN', 'reference' => 'unique-ref-002', 'token' => 'card-token-from-nomba', ]);
Case 3: Verifying Payments
verifyPayment() accepts an optional $paymentType parameter to distinguish between different transaction types:
$paymentType value |
Description |
|---|---|
transaction or deposit |
Verify a customer payment/deposit |
transfer or bank_transfer |
Verify an outbound bank transfer |
// Verify a customer payment $status = $paymentFactory->verifyPayment('unique-ref-001', 'transaction'); // Verify a bank transfer $status = $paymentFactory->verifyPayment('unique-ref-002', 'bank_transfer');
The returned array has the following structure:
[
'status' => 'success' | 'pending' | 'failed',
'message' => 'Transaction description or narration',
'data' => [...], // Full transaction data from Nomba
'reference' => 'order-reference-string',
]
Case 4: Transfers
When using prepareForTransfer($array), the following will be extracted:
| Key(s) in your array | Description |
|---|---|
accountNumber / account_number |
Recipient's bank account number |
amount |
Amount to transfer |
accountName / account_name |
Recipient's account name |
bankCode / bank_code |
Recipient's bank code |
narration / description |
Transfer narration |
senderName / sender_name |
Name of the sender |
merchantTxRef / reference |
Your unique transaction reference |
$paymentFactory = new PaymentFactory('nomba'); $response = $paymentFactory->transfer([ 'account_number' => '0123456789', 'account_name' => 'John Doe', 'bank_code' => '058', 'amount' => 10000, 'narration' => 'Payment for services', 'sender_name' => 'My Business', 'reference' => 'transfer-ref-001', ]);
Case 5: Account Number Lookup
Before making a transfer, you can verify a bank account number to confirm the account name:
$result = $paymentFactory->verifyAccountNumber([ 'accountNumber' => '0123456789', 'bankCode' => '058', ]); // Returns: // [ // 'success' => true, // 'accountNumber' => '0123456789', // 'bankCode' => '058', // 'accountName' => 'John Doe', // ]
Both accountNumber and bankCode are required. An exception is thrown if either is missing.
Case 6: Webhook Verification
Nomba signs its webhook payloads using HMAC-SHA256. The package verifies the signature automatically when you call verifyWebhook(). Ensure NOMBA_SIGNATURE_KEY is set in your .env file — this is the webhook signing secret from your Nomba dashboard.
The verification process:
- Extracts key fields from the webhook payload (
event_type,requestId, merchant and transaction details). - Concatenates them with the
nomba-timestampheader value. - Computes an HMAC-SHA256 hash using your signature key and compares it against the
nomba-signatureheader.
If the signatures do not match, an ApiException is thrown.
BudPay
BudPay lets you accept payments, verify transactions, validate bank accounts, and make single or bulk payouts. Once you know the typical request parameters required by BudPay, you can plug them into the appropriate method and use it straight away.
The following methods are available for BudPay in this package:
- All the methods defined in the interface
getBanksByCurrency()calculateTransferFee()verifyPayment()for both transaction and payout verification- bulk transfers via
prepareForTransfer()+transfer()
BudPay Configuration
Add the following environment variables to your .env file:
BUDPAY_SECRET_KEY=your_secret_key BUDPAY_PUBLIC_KEY=your_public_key
You can get these values from your BudPay dashboard API credentials section.
| Variable | Description |
|---|---|
BUDPAY_SECRET_KEY |
Secret key used for Bearer-token authentication against BudPay API requests |
BUDPAY_PUBLIC_KEY |
Public key used by this package to sign payout request payloads |
BudPay Authentication
BudPay authenticates API requests with your secret key as a Bearer token. The package handles this automatically through the configured BUDPAY_SECRET_KEY.
For payout endpoints, BudPay also requires an HMAC-SHA-512 value in the Encryption header. The package automatically sorts the payout payload, signs it with BUDPAY_PUBLIC_KEY, and adds the Encryption header when you call transfer().
BudPay Special Cases
Case 1: Payment payload fields
BudPay Standard creates a hosted checkout URL by initializing a transaction. When using prepareForPayment($array), the following will be extracted:
| Key(s) in your array | Description |
|---|---|
email |
Customer's email address |
amount |
Amount to charge |
currency |
Currency code, for example NGN |
first_name / firstName |
Customer's first name |
last_name / lastName |
Customer's last name |
reference |
Your unique transaction reference |
callback / callbackUrl / callback_url |
URL BudPay redirects to after payment |
$paymentFactory = new PaymentFactory('budpay'); $payload = $paymentFactory->prepareForPayment([ 'email' => 'customer@example.com', 'amount' => '5000', 'currency' => 'NGN', 'reference' => 'order-ref-001', 'callback_url' => 'https://yourapp.com/payment/callback', ]); $response = $paymentFactory->pay($payload);
Case 2: Verifying Payments
BudPay payment verification uses the transaction reference. verifyPayment() accepts a string reference or an array containing reference, data.reference, or transferDetails.paymentReference.
$status = $paymentFactory->verifyPayment('order-ref-001');
If you pass the optional $paymentType, it must be transaction or payout. null or leaving it empty defaults to transaction:
$status = $paymentFactory->verifyPayment('order-ref-001', 'transaction');
$status = $paymentFactory->verifyPayment('order-ref-001', 'payout');
Case 3: Banks and Account Number Lookup
You can fetch the full BudPay bank list with getBanks() or fetch banks for a specific currency with getBanksByCurrency(). BudPay currently documents bank-list support for currencies such as NGN, USD, GHS, and KES.
$paymentFactory = new PaymentFactory('budpay'); $banks = $paymentFactory->getBanks(); $nigerianBanks = $paymentFactory->getGatewayInstance()->getBanksByCurrency('NGN');
or
$paymentFactory = new PaymentFactory('budpay'); $banks = $paymentFactory->getBanks(); $nigerianBanks = $paymentFactory->getBanksByCurrency('NGN');
Before making a payout, you can verify the recipient's account name:
$result = $paymentFactory->verifyAccountNumber([ 'bank_code' => '000013', 'account_number' => '0050883605', ]);
Case 4: Transfer payload fields
BudPay supports single payouts to bank accounts. When using prepareForTransfer($array), the following will be extracted:
| Key(s) in your array | Description |
|---|---|
currency |
Transfer currency, for example NGN, KES, or GHS |
amount |
Transfer amount |
account_number |
Recipient's bank account number |
bank_code |
Recipient's bank code from BudPay's bank list |
bank_name |
Recipient's bank name |
narration |
Transfer narration or purpose |
meta_data |
Optional additional transfer metadata |
payment_mode |
Optional payment mode; useful for supported markets |
$paymentFactory = new PaymentFactory('budpay'); $payload = $paymentFactory->prepareForTransfer([ 'currency' => 'NGN', 'amount' => '10000', 'bank_code' => '000013', 'bank_name' => 'GUARANTY TRUST BANK', 'account_number' => '0050883605', 'narration' => 'Vendor payment', ]); $response = $paymentFactory->transfer($payload);
Case 5: Transfer Fees and Bulk Transfers
BudPay lets you calculate payout fees before sending money:
$fee = $paymentFactory->getGatewayInstance()->calculateTransferFee([ 'currency' => 'NGN', 'amount' => '10000', ]);
or
$fee = $paymentFactory->calculateTransferFee([ 'currency' => 'NGN', 'amount' => '10000', ]);
For bulk payouts, prepare a currency and an array of transfer items, then call prepareForTransfer() and transfer():
$paymentFactory = new PaymentFactory('budpay'); $payload = $paymentFactory->prepareForTransfer([ 'currency' => 'NGN', 'transfers' => [ [ 'amount' => '20000', 'bank_code' => '000013', 'bank_name' => 'GUARANTY TRUST BANK', 'account_number' => '0050883605', 'narration' => 'January salary', ], ], ]); $response = $paymentFactory->transfer($payload);
Case 6: Verifying Transfers
Single and bulk payouts can be verified with the BudPay payout reference returned by the transfer response:
$status = $paymentFactory->verifyPayment('trf_reference', 'payout');
Case 7: Webhook Verification
BudPay webhook verification in this package verifies the webhook by calling BudPay's API again and comparing the webhook reference and status with the verified response. The incoming request must be a POST, include a supported notify value (transaction, virtual_account, or payout), and contain a reference in data.reference, transferDetails.paymentReference, or reference.
For payout notifications, the package calls verifyPayment() with the payout route. For other supported notifications, it calls verifyPayment() for transaction verification. If the verified reference or status does not match the webhook payload, the request is rejected.
PayPal
PayPal payments in this package use the PayPal Orders v2 API. The buyer is sent to a PayPal-hosted page to approve the payment, and the package captures it when you verify.
The following methods are available for PayPal in this package:
prepareForPayment(),pay(),verifyPayment()/verifyTransaction(),verifyWebhook(),setKey()andgetPayload()generatePayPalAuthAssertion(), for when you act on behalf of another merchant
transfer(), getBanks() and verifyAccountNumber() are not supported. They throw Emmy\Ego\Exception\UnsupportedGatewayMethodExeption.
PayPal Configuration
Add the following environment variables to your .env file:
PAYPAL_CLIENT_ID=your_client_id PAYPAL_CLIENT_SECRET=your_client_secret PAYPAL_URL=https://api-m.sandbox.paypal.com PAYPAL_WEBHOOK_ID=your_webhook_id PAYPAL_BN_CODE=your_partner_bn_code
| Variable | Description |
|---|---|
PAYPAL_CLIENT_ID |
REST app client id from the PayPal Developer Dashboard |
PAYPAL_CLIENT_SECRET |
REST app secret |
PAYPAL_URL |
API host without a version path. Defaults to https://api-m.sandbox.paypal.com. Use https://api-m.paypal.com for live |
PAYPAL_WEBHOOK_ID |
ID of the webhook you registered in the dashboard. Required for verifyWebhook() |
PAYPAL_BN_CODE |
Optional. Partner attribution (BN) code, sent as PayPal-Partner-Attribution-Id |
Don't add
/v1or/v2toPAYPAL_URL. The package adds the right version to each request (/v1/oauth2/token,/v2/checkout/orders).
You can also pass credentials at runtime. Each key is optional and falls back to the config value:
$paymentFactory = new PaymentFactory('paypal', [ 'client_id' => 'another_client_id', 'client_secret' => 'another_secret', // `secret_key` is also accepted 'bn_code' => 'BN-CODE', // `bnCode` is also accepted 'url' => 'https://api-m.paypal.com', ]);
Passing a plain string as the key replaces only the client secret.
PayPal Authentication
PayPal uses OAuth 2.0 client credentials. The package exchanges your client id and secret for an access token and handles this automatically:
- The token is cached with your default Laravel cache store. It expires about a minute before PayPal's own expiry.
- The cache entry is tied to the client id, so switching credentials with
setKey()never reuses another account's token. - If authentication fails, an
ApiExceptionis thrown and nothing is cached. A401on a later request clears the cached token, so the next call fetches a new one.
PayPal Special Cases
Case 1: Payment payload fields
When using prepareForPayment($array), the following will be extracted:
| Key(s) in your array | Description |
|---|---|
amount |
Amount in major units (e.g. 10.50, not 1050). Sent to PayPal as a string |
currency |
ISO currency code, e.g. USD |
reference |
Your own reference. Sent as purchase_units[0].reference_id |
email |
The buyer's email. PayPal uses it to prefill its login page |
callback_url / callbackUrl |
Used as both the return URL and the cancel URL, unless you set them separately |
return_url / returnUrl |
Where PayPal sends the buyer after they approve the payment |
cancel_url / cancelUrl |
Where PayPal sends the buyer if they cancel |
merchant_id / merchantId |
Optional. PayPal merchant id of the seller you're collecting for (see Case 3) |
purchase_units |
Optional. Your own purchase_units array (see Case 2) |
payment_source |
Optional. Your own payment_source object (see Case 2). purchase_source is also accepted |
$paymentFactory = new PaymentFactory('paypal'); $payload = $paymentFactory->prepareForPayment([ 'amount' => '25.00', 'currency' => 'USD', 'reference' => 'order-ref-001', 'email' => 'buyer@example.com', 'return_url' => 'https://yourapp.com/paypal/return', 'cancel_url' => 'https://yourapp.com/paypal/cancel', ]); $response = $paymentFactory->pay($payload); return redirect($response['url']);
pay() returns:
| Key | Description |
|---|---|
status |
true when the order was created |
url |
The PayPal page to redirect the buyer to (the payer-action / approve link) |
id |
The PayPal order id. Save it, because you need it to verify the payment |
reference |
Your reference, as PayPal echoed it back |
api_message |
PayPal's full response |
Edge cases:
customer_id/customerIdare not read for PayPal. Other gateways use them for the buyer, but in PayPal a merchant id marks the account that receives the money. Usemerchant_idonly for a seller's PayPal merchant id.- A request that fails (e.g. an unsupported currency, or a
422from PayPal) throwsEmmy\Ego\Exception\ApiException. The message includes PayPal's error body.- If PayPal's response contains no approval link, an
ApiExceptionis thrown instead of returning an emptyurl.
Case 2: Building purchase_units / payment_source yourself (optional)
By default, the package builds the order body from the fields above:
[
'intent' => 'CAPTURE',
'purchase_units' => [[
'reference_id' => $reference,
'amount' => ['value' => (string) $amount, 'currency_code' => $currency],
// 'payee' => ['merchant_id' => $merchantId], // only when merchant_id is given
]],
'payment_source' => [
'paypal' => [
'email_address' => $email, // left out when empty
'experience_context' => [
'user_action' => 'PAY_NOW',
'return_url' => $returnUrl ?? $callbackUrl,
'cancel_url' => $cancelUrl ?? $callbackUrl,
],
],
],
]
For anything this doesn't cover, build either part yourself. Some examples are several items, shipping, brand_name, locale, shipping_preference, or another payment source such as card or venmo. Whatever you pass is sent to PayPal as-is, and the package's default for that part is skipped:
$paymentFactory->prepareForPayment([ 'amount' => '40.00', // still used by the default purchase_units if you don't pass your own 'currency' => 'USD', 'reference' => 'order-ref-002', 'payment_source' => [ 'paypal' => [ 'experience_context' => [ 'brand_name' => 'My Store', 'locale' => 'en-US', 'shipping_preference' => 'NO_SHIPPING', 'user_action' => 'PAY_NOW', 'return_url' => 'https://yourapp.com/paypal/return', 'cancel_url' => 'https://yourapp.com/paypal/cancel', ], ], ], ]); $response = $paymentFactory->pay();
$paymentFactory->pay([ 'purchase_units' => [[ 'reference_id' => 'order-ref-003', 'description' => 'Two T-shirts', 'amount' => [ 'currency_code' => 'USD', 'value' => '30.00', 'breakdown' => ['item_total' => ['currency_code' => 'USD', 'value' => '30.00']], ], 'items' => [ ['name' => 'T-shirt', 'quantity' => '2', 'unit_amount' => ['currency_code' => 'USD', 'value' => '15.00']], ], ]], 'return_url' => 'https://yourapp.com/paypal/return', 'cancel_url' => 'https://yourapp.com/paypal/cancel', ]);
Edge cases:
- These replace the defaults entirely. They are not merged. If you pass your own
payment_source, thencallback_url,return_urlandcancel_urlare ignored, so put the URLs insideexperience_contextyourself. If you pass your ownpurchase_units, thenamount,currency,referenceandmerchant_idare not put into the body. In that case, addpayeeyourself when collecting for a seller.- When you pass your own
purchase_units, thereferencereturned bypay()/verifyPayment()is the first unit'sreference_id. Set one if you rely on it.- The intent is always
CAPTURE.AUTHORIZEorders are not supported.
Case 3: Collecting on behalf of a merchant (platforms / marketplaces)
If you're a PayPal partner collecting for a seller who has onboarded with your platform, pass their PayPal merchant_id:
$paymentFactory->prepareForPayment([ 'amount' => '25.00', 'currency' => 'USD', 'reference' => 'order-ref-004', 'merchant_id' => 'SELLER_MERCHANT_ID', 'callback_url' => 'https://yourapp.com/paypal/return', ]); $paymentFactory->pay();
When you do, the package:
- sets
purchase_units[0].payee.merchant_id, so the seller receives the funds - sends a
PayPal-Auth-Assertionheader (an unsigned JWT that identifies your client id and the seller's payer id) on that request
You can also set it with $paymentFactory->setMerchantId('SELLER_MERCHANT_ID'). Calls such as verifyPayment() on that same instance also include the auth assertion. Use a separate PaymentFactory instance per seller.
Case 4: Verifying (and capturing) payments
After the buyer approves, PayPal redirects them to your return_url with the order id in the query string: ?token=5O190127TN364715T&PayerID=....
verifyPayment() accepts that order id as a string, or an array containing token, order_id, orderId, id or reference. That means you can pass the query string directly:
// In your return_url controller $status = $paymentFactory->verifyPayment($request->query()); // or $status = $paymentFactory->verifyPayment('5O190127TN364715T');
Important: In PayPal, an
APPROVEDorder has not taken any money yet. CallingverifyPayment()on anAPPROVEDorder captures it automatically, and the capture result is what you get back. The capture uses a fixedPayPal-Request-Idper order, so callingverifyPayment()again (from the return URL and a webhook, for example) won't charge the buyer twice.
The returned status is mapped as follows:
| PayPal order / capture state | status |
|---|---|
Order COMPLETED (capture COMPLETED) |
SUCCESS |
Order COMPLETED but capture PENDING (e.g. under review) |
PENDING |
Order CREATED, SAVED, PAYER_ACTION_REQUIRED |
PENDING |
Order VOIDED, or capture DECLINED / FAILED |
FAILED |
The response also has message (PayPal's raw order status), reference (your reference_id) and data (PayPal's full response).
Edge cases:
verifyPayment()needs the PayPal order id, not your ownreference. Save theidreturned bypay()if you want to verify later without the return URL.- An unknown order id, or any other API error, throws
ApiException.- The
$paymentTypeargument is ignored for PayPal.
Case 5: Webhook Verification
verifyWebhook($request) checks PayPal's signature on the request itself, without calling PayPal's verify API. It uses the PAYPAL-TRANSMISSION-ID, PAYPAL-TRANSMISSION-TIME, PAYPAL-TRANSMISSION-SIG, PAYPAL-AUTH-ALGO and PAYPAL-CERT-URL headers, your PAYPAL_WEBHOOK_ID, and a CRC32 checksum of the raw request body. If the check fails, the request is aborted with a 404.
Edge cases:
PAYPAL_WEBHOOK_IDmust be set. If it's missing, every webhook is rejected.- The certificate is only downloaded from an
httpsPayPal host (api.paypal.com,api-m.paypal.com,api.sandbox.paypal.com,api-m.sandbox.paypal.com). Any otherPAYPAL-CERT-URLis rejected without being fetched, so attackers can't sign events with a certificate they host themselves.- The certificate is cached for 24 hours.
- The signature is checked against the raw body. Don't change the request body before calling
verifyWebhook().- Supported algorithms:
SHA256withRSA,SHA1withRSA,SHA512withRSA.
A typical approach is to listen for CHECKOUT.ORDER.APPROVED and call verifyPayment($payload['resource']['id']), which captures the order. Then fulfil the order once status is SUCCESS, or on PAYMENT.CAPTURE.COMPLETED.
Webhook Strategy
Since this package implements a method that verifies webhook requests/payloads, one way of using the same route for all supported payment gateways is shown below:
Step 1: Create a dynamic route
// web.php Route::post('money/na/water/webhook/{gateway}', App\Http\Controllers\Dashboard\WebhookController::class);
The value assigned to the dynamic part of the URL above should match any of the supported payment gateways listed in the
providerssection of yourego.phpconfig file.
Step 2: Handle the payload
class WebhookController extends Controller { public function __invoke(Request $request, string $gateway) { $gateway = new PaymentFactory($gateway); $gateway->verifyWebhook($request); $payload = $request->json()->all(); // Handle the payload. Ideally, fire an event to avoid blocking // on long-running tasks. return response()->json(); } }
With this, you have a single route that can handle any webhook. If you move from Gateway A to B, you won't have to create a new route or implement new gateway-specific handling.
Contributing
Please check the 'contrib' directory for more information. I really appreciate you for doing this.