ma-lara / payments
A Laravel payment gateway package that provides a unified API for integrating multiple payment providers, including Stripe and Paymob, with support for payments, payment verification, retries, refunds, transactions, and webhooks.
Requires
- php: >=8.1
- ext-curl: *
- ext-json: *
- laravel/framework: >=9.0 <14.0
- stripe/stripe-php: *
Requires (Dev)
- phpunit/phpunit: ^10.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
MA Lara Payment
A unified Laravel payment package for integrating multiple payment gateways through a consistent API.
Documentation • Testing • Contributing • License
Documentation
ma-lara/payments provides a unified API for integrating multiple payment gateways into Laravel applications.
The package exposes common operations such as:
pay()verify()retryPayment()refund()- Transaction queries
Each gateway implements the package's gateway contract while keeping provider-specific API calls, authentication, response mapping, and webhook handling isolated inside the gateway implementation.
Supported Gateways
| Gateway | Card | Wallet | Retry | Refund | Webhook / Callback |
|---|---|---|---|---|---|
| Stripe | ✅ | ❌ | ✅ | ✅ | ✅ Signed |
| Paymob | ✅ | ✅ | ✅ | ✅ | ✅ HMAC |
Important
The package backend is frontend-agnostic.
The package includes an optional Stripe Blade card component, but you can integrate the backend with:
- Blade
- React
- Vue
- Angular
- Vanilla JavaScript
- Mobile applications
- Any frontend capable of communicating with your backend API
Features
- Unified payment gateway contract.
- Runtime gateway selection through
driver(). - Stripe card payments using PaymentIntents.
- Paymob card payments using a hosted iframe.
- Paymob mobile-wallet payments.
- Payment retries.
- Full and partial refunds.
- Stripe signed webhook verification.
- Paymob HMAC callback verification.
- Local customer persistence.
- Local transaction persistence.
- Local refund persistence.
- Normalized
PaymentStatusenum. - Minor-unit monetary storage.
- Raw gateway responses stored in
meta_data. - Gateway-specific API services.
- Gateway-specific webhook handlers.
- Repository-based persistence.
- DTOs and value objects for important boundaries.
- Optional Stripe Blade card-payment component.
- Extensible architecture for adding additional gateways.
Requirements
The package requirements are defined by composer.json.
| Requirement | Version |
|---|---|
| PHP | >=8.1 |
| Laravel | >=9.0 |
| JSON | ext-json |
| cURL | ext-curl |
| Stripe | stripe/stripe-php |
The package targets the current development version documented by this README.
Installation
Install the package through Composer:
composer require ma-lara/payments
Laravel package discovery automatically registers the package service provider and facade.
Publish Configuration
php artisan vendor:publish --tag=ma-payment-config
Publish Views and Frontend Assets
php artisan vendor:publish --tag=ma-payment-views
Publish Migration Files
php artisan vendor:publish --tag=ma-payment-migrations
Run Migrations
php artisan migrate
The package provides three main tables:
| Table | Purpose |
|---|---|
payment_customers |
Maps application users to gateway customers |
payment_transactions |
Stores payment attempts and their gateway references |
refunded_payment_transactions |
Stores refund transactions |
Configuration
The package configuration is available at:
config/ma_payment_conf.php
The gateway registry is available at:
config/ma_payment_drivers.php
Environment Variables
Stripe
STRIPE_API_SECRET=sk_... STRIPE_API_KEY=pk_... STRIPE_BASE_URL=https://api.stripe.com STRIPE_CURRENCY=USD STRIPE_WEBHOOK_SECRET=whsec_...
| Variable | Purpose |
|---|---|
STRIPE_API_SECRET |
Stripe secret API key |
STRIPE_API_KEY |
Stripe publishable key |
STRIPE_BASE_URL |
Stripe API base URL |
STRIPE_CURRENCY |
Default Stripe currency |
STRIPE_WEBHOOK_SECRET |
Stripe webhook signing secret |
Paymob
PAYMOB_API_KEY=... PAYMOB_API_SECRET=... PAYMOB_INTEGRATION_ID=... PAYMOB_WALLET_INTEGRATION_ID=... PAYMOB_IFRAME_ID=... PAYMOB_HMAC=... PAYMOB_CURRENCY=EGP
| Variable | Purpose |
|---|---|
PAYMOB_API_KEY |
Paymob API key |
PAYMOB_API_SECRET |
Paymob API secret used where required |
PAYMOB_INTEGRATION_ID |
Card integration ID |
PAYMOB_WALLET_INTEGRATION_ID |
Wallet integration ID |
PAYMOB_IFRAME_ID |
Paymob card iframe ID |
PAYMOB_HMAC |
Callback HMAC secret |
PAYMOB_CURRENCY |
Default Paymob currency |
Gateway Registry
Gateways are registered through:
config/ma_payment_drivers.php
Example:
return [ 'stripe' => \Ma\Payment\Gateways\Stripe\StripeGateway::class, 'paymob' => \Ma\Payment\Gateways\Paymob\PaymobGateway::class, ];
The manager and factory use this registry to resolve the requested gateway.
Quick Start
Selecting a Gateway
Use Ma\Payment\Facades\MaPayment to select a gateway:
use Ma\Payment\Facades\MaPayment; $gateway = MaPayment::driver('stripe');
You can then call the common gateway operations:
$gateway->pay(...); $gateway->verify(...); $gateway->retryPayment(...); $gateway->refund(...);
The same architecture is used for Paymob:
$gateway = MaPayment::driver('paymob');
Payment Data
A payment request contains the payment amount, currency, customer information, and gateway-specific payment information.
Example:
$result = $gateway->pay([ 'amount' => 150.50, 'currency' => 'USD', 'customer' => [ 'id' => $user->id, 'first_name' => 'John', 'last_name' => 'Doe', 'email' => 'john@example.com', 'phone' => '+201000000000', ], 'source' => 'card', 'payment_method' => [ 'id' => 'pm_...', ], ]);
Amounts
Application-facing payment amounts use major units:
150.50
The package converts monetary values to minor units for gateway communication and persistence:
150.50 USD → 15050 cents
Transactions store amounts in minor units.
Payment Flow
The general payment architecture is:
Application
│
│ pay([...])
▼
MaPayment Facade
│
▼
PaymentGatewayManager
│
▼
PaymentGatewayFactory
│
▼
PaymentGatewayInterface
│
├───────────────┐
▼ ▼
Stripe Paymob
│ │
▼ ▼
Gateway API Gateway API
│ │
└───────┬───────┘
▼
PaymentTransaction
│
▼
Webhook / Callback
│
▼
PaymentStatus
The common payment workflow is implemented by BaseGateway.
Conceptually:
Payment Request
│
▼
Validate / Build DTO
│
▼
Get or create local customer
│
▼
Ensure gateway customer
│
▼
Call gateway API
│
▼
Build PaymentTransactionDTO
│
▼
Persist transaction
│
▼
Return payment result
Gateway implementations provide the provider-specific operations while the shared workflow remains in the base gateway.
Payment Status
The package normalizes gateway-specific statuses into:
Ma\Payment\Enums\PaymentStatus
Supported states include:
pending
processing
succeeded
failed
canceled
partially_refunded
fully_refunded
Gateway-specific status values are mapped into these common states.
For example:
Stripe: succeeded
Paymob: success
Gateway-specific: approved
↓
PaymentStatus::SUCCEEDED
This allows the application to work with a common status model regardless of the selected gateway.
Stripe
Capabilities
| Operation | Supported |
|---|---|
| Card payment | ✅ |
| PaymentIntent | ✅ |
| Gateway customer | ✅ |
| Retry payment | ✅ |
| Full refund | ✅ |
| Partial refund | ✅ |
| Signed webhook | ✅ |
| Wallet payment | ❌ |
| Capture | ❌ |
| Void | ❌ |
Stripe Card Payment
Stripe card payments use Stripe PaymentIntents.
The frontend creates a Stripe PaymentMethod and sends its ID to your backend.
$result = $gateway->pay([ 'amount' => 150.50, 'currency' => 'USD', 'customer' => [ 'id' => $user->id, 'first_name' => 'John', 'last_name' => 'Doe', 'email' => 'john@example.com', 'phone' => '+201000000000', ], 'source' => 'card', 'payment_method' => [ 'id' => 'pm_...', ], ]);
The backend then:
- Validates the payment request.
- Creates or updates the local customer.
- Creates the Stripe customer when required.
- Creates the PaymentIntent.
- Confirms the PaymentIntent.
- Persists the local transaction.
- Returns the payment result.
Stripe Blade Card Component
The package provides an optional Blade component for Stripe card payments.
It is not required to use the Stripe gateway.
View:
ma-payment::Stripe.card
The component uses Stripe Elements and Stripe.js to collect the customer's card details.
Publishing the Component
php artisan vendor:publish --tag=ma-payment-views
The published JavaScript asset is available under:
public/js/vendor/ma_payment/stripe/MaPaymentStripe.js
Component Example
<x-ma-payment::Stripe.card :amount="$amount" :currency="$currency" :customer="$customer" :source="$source" :success-url="$successUrl" :payment-url="$paymentUrl ?? $retryUrl" :publishable-key="$publishableKey" />
Component Properties
| Property | Required | Description |
|---|---|---|
publishableKey |
✅ | Stripe publishable key |
paymentUrl |
✅ | Backend payment endpoint |
retryUrl |
Optional | Backend retry endpoint |
successUrl |
✅ | Successful payment redirect |
amount |
✅ | Payment amount in major units |
currency |
✅ | Currency code |
customer |
✅ | Customer information |
source |
✅ | Payment source |
The component:
- Mounts Stripe Elements.
- Collects card information.
- Creates a Stripe PaymentMethod.
- Sends the PaymentMethod ID to your backend.
- Displays payment errors.
- Redirects after successful payment.
Stripe With Other Frontends
The Stripe Blade component is only a convenience feature.
You can use the same backend API with:
Blade
React
Vue
Angular
Vanilla JavaScript
React Native
Flutter
Other
React Example
import StripePayment from "../public/js/vendor/ma_payment/stripe/MaPaymentStripe.js"; const Checkout = (data) => { const payment = new StripePayment({ publishableKey: data.publishableKey, paymentUrl: data.paymentUrl successUrl: data.successUrl, amount: data.amount currency: data.currency, customer: data.customer, source: data.source, }); const submit = async() => { const { data, success, error } = await payment.pay(); } }
The frontend flow is:
Frontend
│
│ MaPaymentStripe.js
▼
Create PaymentMethod
│
▼
Your Backend
│
│ pay()
▼
ma-lara/payments
│
▼
Stripe API
For example, a React or Vue application can create a Stripe PaymentMethod and send its ID to a Laravel endpoint that calls:
$gateway->pay([ // ... 'payment_method' => [ 'id' => $paymentMethodId, ], ]);
The package does not require the frontend to use Blade.
Stripe Retry
Failed Stripe payments can be retried by re-confirming the existing PaymentIntent with a new PaymentMethod.
$gateway->retryPayment( $transactionId, $paymentMethodId );
The retry operation remains gateway-specific while being exposed through the common gateway contract.
Paymob
Capabilities
| Operation | Supported |
|---|---|
| Card payment | ✅ |
| Hosted iframe | ✅ |
| Mobile wallet | ✅ |
| Retry payment | ✅ |
| Full refund | ✅ |
| Partial refund | ✅ |
| HMAC callback verification | ✅ |
| Gateway transaction lookup | ✅ |
| Capture | ❌ |
| Void | ❌ |
Paymob Card Payment
Paymob card payments use a hosted checkout iframe.
$paylink = $gateway->pay([ 'amount' => 150.50, 'currency' => 'EGP', 'customer' => [ 'id' => $user->id, 'first_name' => 'John', 'last_name' => 'Doe', 'email' => 'john@example.com', 'phone' => '01010101010', ], 'source' => 'card', ]);
The payment flow is:
Application
│
▼
Paymob pay()
│
├── Authentication
├── Create order
├── Create payment key
└── Generate iframe URL
│
▼
Hosted iframe
│
▼
Customer payment
│
▼
Paymob callback
│
▼
verify()
A new local transaction is initially stored as:
pending
The callback later determines the final transaction status through paymob webhook handler.
Paymob Wallet Payment
Wallet payments are selected using:
'source' => 'wallet'
Example:
$paylink = $gateway->pay([ 'amount' => 200, 'currency' => 'EGP', 'customer' => [ 'id' => $user->id, 'first_name' => 'John', 'last_name' => 'Doe', 'email' => 'john@example.com', 'phone' => '01010101010', ], 'source' => 'wallet', ]);
The customer's phone number is used as the wallet identifier.
The Paymob wallet integration uses:
PAYMOB_WALLET_INTEGRATION_ID=...
The resulting payment response contains the provider redirect URL.
Your application can redirect the customer to that URL.
Paymob Callback Verification
Paymob callbacks must be verified before updating a transaction.
Example:
$transaction = $gateway->verify( $request->all() );
The verification flow is:
Paymob Callback
│
▼
Verify HMAC
│
├── Invalid → Reject
│
▼
Find Local Transaction
│
▼
Check Transaction State
│
▼
Update Transaction
│
▼
Return Verification Result
The callback signature is verified using the configured:
PAYMOB_HMAC=...
An invalid signature must prevent the transaction from being processed.
Applications should expose their own callback route and delegate the callback payload to the gateway.
Webhooks and Callbacks
The package does not register application routes or controllers automatically.
Your Laravel application owns the HTTP endpoint.
The application then delegates the payload to the appropriate gateway handler.
Stripe Webhook
Example:
use Illuminate\Http\Request; use Ma\Payment\Facades\MaPayment; Route::post('/stripe/webhook', function (Request $request,) { $gateway = MaPayment::driver('stripe'); return response()->json( $gateway->verify( $request->getContent(), $request->header('Stripe-Signature') ) ); });
The handler verifies the Stripe signature using:
STRIPE_WEBHOOK_SECRET=whsec_...
Handled events include:
payment_intent.succeeded
payment_intent.payment_failed
payment_intent.canceled
refund.created
charge.refunded
Unhandled events return an appropriate unhandled result rather than being processed as payment events.
Refund Webhook Race Condition and UpdateRefundTransactionJob
Stripe can deliver related webhook events independently. The package therefore cannot assume that the refund child transaction created from refund.created will always exist before the charge.refunded event attempts to update it:
refund.created
→ creates the refund transaction record
charge.refunded
→ updates the existing refund transaction
Because webhook processing can overlap, or events can arrive in an unexpected order, charge.refunded may attempt to update the refund transaction before refund.created has created (or committed) the corresponding record.
How the Package Solves It
When the charge.refunded branch of the Stripe webhook flow (StripeGateway::verify()) updates the parent transaction and a refund_id is present, the package dispatches Ma\Payment\Jobs\UpdateRefundTransactionJob to update the refund transaction record asynchronously:
UpdateRefundTransactionJob::dispatch($event['refund_id'], $event['status']->value);
The Job (src/Jobs/UpdateRefundTransactionJob.php) implements Illuminate\Contracts\Queue\ShouldQueue and works as follows:
- It attempts to find the refund transaction through
RefundTransactionRepository::getRefundTransaction()(which also locks the row withlockForUpdate()). - If the refund transaction is temporarily unavailable (for example, because
refund.createdhas not yet created it), the Job throwsMa\Payment\Exceptions\RefundTransactionNotFoundException. - Because the exception is thrown from a queued Job, Laravel retries the Job instead of permanently losing the update.
- The Job controls its retry behavior with the actual configuration from the code:
$tries = 5backoff(): [2, 4, 5, 6, 7](seconds between attempts)
The retry delay gives the refund.created webhook time to create the refund transaction before the update is attempted again. Once the refund transaction exists, the Job updates its refund_type attribute and completes.
Flow
Normal order:
Stripe webhook
→ webhook verification
→ refund event handling
→ dispatch UpdateRefundTransactionJob
→ Laravel queue
→ Job attempts update
→ refund exists → update succeeds
If the refund transaction does not yet exist:
Job
→ RefundTransactionNotFoundException
→ Laravel retry/backoff
→ retry
→ update succeeds once the refund exists
Queue Worker Requirement
Because UpdateRefundTransactionJob implements ShouldQueue, the consuming Laravel application must have a queue worker running for the Job to be processed:
php artisan queue:work
The package provides and dispatches the Job, but the Laravel application using the package is responsible for configuring its queue connection and running the queue worker.
Package Installation Context
UpdateRefundTransactionJob is included inside the package (under the Ma\Payment\Jobs namespace) and does not need to be published or copied into the consuming application's app/Jobs directory. The consuming application simply installs the package and runs its normal Laravel queue worker.
CSRF
If the webhook endpoint is registered under a CSRF-protected route group, configure the endpoint appropriately for your application.
For example:
->withoutMiddleware([VerifyCsrfToken::class])
Only disable CSRF protection for the webhook endpoint where appropriate.
Paymob Callback
Paymob callbacks are handled by your application's callback route.
Example:
use Ma\Payment\Facades\MaPayment; Route::post('/paymob/callback', function (Request $request) { $gateway = MaPayment::driver('paymob'); return response()->json( $gateway->verify($request->all()) ); });
The gateway verifies the HMAC before processing the transaction.
Transaction Processing
Transactions are stored in:
payment_transactions
A transaction contains information such as:
- Local transaction ID
- Customer ID
- Gateway
- Gateway reference
- Amount
- Remaining refundable amount
- Currency
- Status
- Gateway metadata
Gateway responses are stored in:
meta_data
This allows applications to retain the original gateway response for reconciliation and debugging.
Listing & Filtering Transactions
The package provides transaction listing and filtering through every gateway. Both StripeGateway and PaymobGateway implement these methods, which are declared on the Ma\Payment\Interfaces\PaymentGatewayInterface contract:
public function getTransactions(?string $status = null): Illuminate\Database\Eloquent\Collection; public function getCustomerTransactions(int $userId, ?string $status = null): Illuminate\Database\Eloquent\Collection;
Retrieving Transactions
Retrieve transactions through a resolved gateway driver:
use Ma\Payment\Facades\MaPayment; $gateway = MaPayment::driver('stripe'); // or 'paymob' // All transactions (no filter) $transactions = $gateway->getTransactions(); // Only succeeded transactions $transactions = $gateway->getTransactions('succeeded');
Filtering by Customer
getCustomerTransactions() returns the transactions belonging to a specific application user (matched through the package's local payment_customers mapping):
use Ma\Payment\Facades\MaPayment; $gateway = MaPayment::driver('paymob'); // All transactions of the user $transactions = $gateway->getCustomerTransactions($userId); // Only refunded transactions of the user $transactions = $gateway->getCustomerTransactions($userId, 'fully_refunded');
If the user has no gateway customer mapping, a Ma\Payment\Exceptions\CustomerNotFoundException is thrown.
Available Filters
The only filter parameter implemented is status (an optional ?string $status applied as an exact-match where('status', $status) condition on the payment_transactions table).
Supported status values are the values of the Ma\Payment\Enums\PaymentStatus enum:
pending
processing
succeeded
failed
canceled
fully_refunded
partially_refunded
Passing a status that does not exist in the database simply returns an empty collection; the package does not validate the status value against the enum.
Note: Filters by gateway, date range, order ID, or gateway reference are not exposed through
getTransactions()/getCustomerTransactions(). (Repository lookup helpers such asgetTransactionByRef(),getTransactionByOrderId(), andgetTransactionByGateway()exist for internal webhook/callback processing, but they are single-record lookups and are not part of the public listing API.)
Filtering by Multiple Criteria
Combining multiple filters is not supported by the listing methods — the only supported parameter is the single optional status filter. To filter by multiple criteria, retrieve the collection and narrow it in your application:
$transactions = $gateway ->getCustomerTransactions($userId, 'succeeded') ->filter(fn ($t) => $t->gateway === 'stripe');
Returned Structure
Both methods return an Illuminate\Database\Eloquent\Collection of Ma\Payment\Models\PaymentTransaction models. No pagination is implemented — the underlying query uses ->get(), so the full result set is loaded.
Each model exposes the following attributes (the model's $fillable fields):
| Field | Description |
|---|---|
gateway |
Gateway name (stripe, paymob) |
order_id |
Gateway order identifier (where applicable) |
customer_id |
Local package customer ID |
gateway_reference |
Gateway transaction reference |
minor_amount |
Original amount in minor units (e.g., cents) |
remain_minor_amount |
Remaining refundable amount in minor units |
currency |
Transaction currency |
status |
Payment status (see PaymentStatus values above) |
source |
Payment source |
source_subtype |
Payment source subtype (e.g., card brand) |
meta_data |
Raw gateway response |
The model also provides helpers and relations:
pounds(): float— convertsminor_amountto major units.customer()— belongs-to relation toMa\Payment\Models\PaymentCustomer.refundedPayments()— has-many relation toMa\Payment\Models\RefundedPaymentTransaction.
Example:
use Ma\Payment\Facades\MaPayment; $gateway = MaPayment::driver('stripe'); foreach ($gateway->getCustomerTransactions(auth()->id(), 'succeeded') as $transaction) { $transaction->gateway_reference; // Stripe PaymentIntent ID $transaction->minor_amount; // e.g. 15050 $transaction->pounds(); // 150.5 $transaction->status; // 'succeeded' $transaction->refundedPayments; // related refund records }
Refunds
Both Stripe and Paymob expose refunds through:
$gateway->refund($transactionId, $amount);
Example:
$gateway->refund($transactionId, 50);
The amount is provided in major units.
For example:
Transaction: $150.50
Refund: $50.00
Remaining: $100.50
The package prevents refunds that exceed the remaining refundable amount.
Refund Rules
The package validates:
- The transaction exists.
- The requested refund does not exceed the remaining amount.
- The gateway transaction reference matches the local transaction.
- The refund is persisted successfully.
- The remaining refundable amount is updated.
Refund records are stored in:
refunded_payment_transactions
Partial Refunds
Partial refunds can be performed multiple times until the remaining amount reaches zero.
Example:
Original transaction: $100
Refund 1: $30
Remaining: $70
Refund 2: $20
Remaining: $50
Refund 3: $50
Remaining: $0
The transaction status changes from:
succeeded
to:
partially_refunded
and finally:
fully_refunded
Capture
Capture is currently not supported.
There is no capture operation in:
PaymentGatewayInterface
BaseGateway
StripeGateway
PaymobGateway
Stripe PaymentIntents used by the package are confirmed using the configured payment flow rather than exposing a separate authorization/capture operation.
Void
Void is currently not supported.
The package does not expose an authorization-only → void lifecycle.
Paymob refunds are treated as refunds rather than as a separate void operation.
Retry Payments
Retry behavior is gateway-specific but exposed through the common API:
$gateway->retryPayment($transactionId);
Stripe
Stripe retries the PaymentIntent using a new PaymentMethod.
Paymob
Paymob creates a new payment attempt and returns a new payment link for the faild local transaction.
Retry operations should only be allowed for transaction states supported by the gateway implementation.
Gateway Support Matrix
| Capability | Stripe | Paymob |
|---|---|---|
| Card payment | ✅ | ✅ |
| Wallet payment | ❌ | ✅ |
| Retry payment | ✅ | ✅ |
| Full refund | ✅ | ✅ |
| Partial refund | ✅ | ✅ |
| Capture | ❌ | ❌ |
| Void | ❌ | ❌ |
| Webhook / callback | ✅ | ✅ |
| Signature verification | Stripe Signature | HMAC |
| Gateway transaction lookup | Limited | ✅ |
| Hosted card UI | Blade component | Paymob iframe |
Error Handling
Package exceptions are located under:
Ma\Payment\Exceptions
Common exceptions include:
| Exception | Purpose |
|---|---|
MissingPaymentInfoException |
Required payment information is missing |
CustomerNotFoundException |
Customer cannot be found |
TransactionNotFoundException |
Transaction cannot be found |
TransactionAlreadyProccessedException |
Transaction has already been processed |
TransactionCannotProcessException |
Transaction cannot be processed in its current state |
TransactionFailedException |
Payment failed |
GatewayTxnIdAndLocalTxnIdNotSameException |
Gateway/local reference mismatch |
GatewatTxnOrderIdAndLocalTxnOrderIdNotSameException |
Gateway/local order mismatch |
RefundAmountGreaterThanTransactionAmountException |
Refund exceeds the remaining amount |
InvalidWebhookSignatureException |
Webhook/callback signature is invalid |
Example:
use Ma\Payment\Exceptions\RefundAmountGreaterThanTransactionAmountException; try { $gateway->refund($transactionId, 999999); } catch (RefundAmountGreaterThanTransactionAmountException $e) { // Handle invalid refund amount. }
Stripe-specific SDK exceptions may also be exposed when the Stripe API rejects a request.
For example:
use Stripe\Exception\CardException;
Frontend Integration
The package does not require a specific frontend framework.
Stripe
Blade
React
Vue
Angular
Vanilla JS
Mobile
Other frontend
The frontend creates the Stripe PaymentMethod and sends the ID to your backend.
Frontend
│
│ MaPaymentStripe.js
▼
PaymentMethod
│
▼
Laravel Endpoint
│
▼
$gateway->pay()
│
▼
Stripe
Paymob
Paymob provides the payment interface through the provider:
Card
↓
Hosted iframe
Wallet
↓
Provider redirect URL
Your application only needs to redirect or embed the returned payment URL.
Architecture
The package uses a layered and extensible gateway architecture.
Application
│
▼
MaPayment Facade
│
▼
PaymentGatewayManager
│
▼
PaymentGatewayFactory
│
▼
PaymentGatewayInterface
│
├───────────────┐
▼ ▼
StripeGateway PaymobGateway
│ │
▼ ▼
StripeApiService PaymobApiService
│ │
▼ ▼
Stripe API Paymob API
The shared payment workflow is implemented by:
BaseGateway
Design Patterns
The package uses several established design patterns.
Facade
MaPayment
Provides a convenient entry point for the package.
Factory
PaymentGatewayFactory
Creates gateway implementations from the driver registry.
Strategy
PaymentGatewayInterface
Allows Stripe, Paymob, and future gateways to be interchangeable.
Template Method
BaseGateway::executePayment()
Defines the shared payment workflow while allowing gateways to implement gateway-specific operations.
Repository
Repositories isolate persistence logic from gateway logic.
Examples:
TransactionRepository
PaymentCustomerRepository
RefundTransactionRepository
DTO
DTOs define structured boundaries between application, gateway, and persistence layers.
Examples:
PaymentRequestDTO
PaymentTransactionDTO
Value Objects
The package uses value objects for validated primitives such as:
Money
UserEmail
Core Components
PaymentGatewayManager
Responsible for selecting a gateway driver.
$manager->driver('stripe'); $manager->driver('paymob');
PaymentGatewayFactory
Responsible for resolving a configured gateway implementation.
The factory reads:
config/ma-drivers.php
and resolves the gateway through Laravel's service container.
PaymentGatewayInterface
The gateway contract defines the common gateway API.
Typical operations include:
pay()
verify()
getTransactions()
getCustomerTransactions()
getGatewayTransactionByOrderId()
retryPayment()
refund()
Gateway implementations may support different capabilities while maintaining the common contract.
BaseGateway
BaseGateway contains the shared payment orchestration.
The main payment workflow is implemented by:
executePayment()
The method coordinates:
PaymentRequestDTO
↓
Customer handling
↓
Gateway customer handling
↓
Gateway API request
↓
PaymentTransactionDTO
↓
TransactionRepository
Gateway-specific classes should not duplicate this common workflow.
Data Transfer Objects
PaymentRequestDTO
Represents validated payment input.
It sits at the boundary between:
Application → Package
It handles concepts such as:
- Amount
- Currency
- Customer
- Source
- Payment method
PaymentTransactionDTO
Represents the data required to persist a payment transaction.
It sits at the boundary between:
Gateway → Persistence
Gateway implementations map provider responses into this DTO before passing the data to the repository.
Value Objects
Money
Represents monetary values and provides controlled conversion between major and minor units.
Example:
150.50
↓
15050 minor units
Financial amounts should be persisted using integer minor units rather than floating-point database values.
UserEmail
Represents a validated customer email address.
Repositories
The package uses repositories to isolate database persistence.
TransactionRepository
Handles payment transaction persistence and queries.
PaymentCustomerRepository
Handles gateway customer mappings.
RefundTransactionRepository
Handles refund transaction persistence.
Database operations involving concurrent transaction updates use appropriate row locking where required.
Database Relationships
The main relationships are:
users
│
│ user_id
▼
payment_customers
│
│ customer_id
▼
payment_transactions
│
│ gateway_reference
▼
refunded_payment_transactions
A customer can have multiple payment transactions.
A payment transaction can have multiple refund records.
Adding a New Gateway
Adding a new gateway should not require modifying the generic payment workflow.
For example, to add a Tap gateway:
src/
└── Gateways/
└── Tap/
├── TapGateway.php
└── Services/
├── TapApiService.php
└── TapWebhookHandler.php
1. Create the Gateway
class TapGateway extends BaseGateway implements PaymentGatewayInterface { // ... }
2. Inject the Gateway API Service
public function __construct( CustomerSerivce $customerService, TransactionRepositoryInterface $transactionRepository, private TapApiService $apiService, ) { parent::__construct( $customerService, $transactionRepository ); $this->gateway_name = 'tap'; }
3. Implement the Gateway API Call
protected function sendPaymentRequest( PaymentRequestDTO $dto ): array { return $this->apiService->charge($dto); }
4. Map the Gateway Response
Build a:
PaymentTransactionDTO
from the provider response.
Gateway-specific statuses should be normalized through the package status mapping.
5. Register the Driver
Add the gateway to:
config/ma-drivers.php
return [ 'stripe' => \Ma\Payment\Gateways\Stripe\StripeGateway::class, 'paymob' => \Ma\Payment\Gateways\Paymob\PaymobGateway::class, 'tap' => \Ma\Payment\Gateways\Tap\TapGateway::class, ];
The manager and factory do not need gateway-specific changes.
6. Add Configuration
Add provider credentials and gateway-specific configuration to:
config/ma-payment.php
Use environment variables for secrets and credentials.
7. Add Webhook Handling
If the gateway supports webhooks:
TapWebhookHandler
should be responsible for:
- Signature verification.
- Event parsing.
- Event identification.
- Transaction lookup.
- Status mapping.
- Safe transaction updates.
8. Add Tests
A new gateway should have tests covering:
- Successful payment.
- Failed payment.
- Invalid payment data.
- Verification.
- Invalid signature.
- Duplicate callback.
- Refund.
- Partial refund.
- Over-refund.
- Gateway-reference mismatch.
- Retry where supported.
9. Update Documentation
Add the gateway to:
- Supported Gateways.
- Gateway Support Matrix.
- Gateway-specific documentation.
- Configuration documentation.
Gateway Implementation Rules
When implementing a new gateway:
Do
- Extend
BaseGateway. - Implement
PaymentGatewayInterface. - Keep API communication inside a gateway-specific API service.
- Keep webhook parsing inside a gateway-specific webhook handler.
- Reuse the existing repositories.
- Reuse
PaymentRequestDTOwhere possible. - Build
PaymentTransactionDTOfor persistence. - Normalize gateway statuses.
- Store monetary values in minor units.
- Add automated tests.
Do Not
- Duplicate
executePayment(). - Put gateway API calls inside repositories.
- Put gateway-specific API logic in
BaseGateway. - Modify generic payment logic for every new gateway.
- Store secrets directly in source code.
- Silently pretend unsupported operations are supported.
Testing
The package is prepared for automated testing through PHPUnit and Composer. The complete testing infrastructure — the test runner, the Composer command, a local pre-push protection hook, and a GitHub Actions CI workflow — is already configured in this repository. However, the actual test cases have not been implemented yet, so no automated test coverage currently exists.
Automated Testing Infrastructure
1. PHPUnit
- PHPUnit is configured as a development dependency (
phpunit/phpunit: ^10.0inrequire-dev). composer testis the standard command for running the test suite (it invokesphpunit).phpunit.xmldefines two test suites:Unit(tests/Unit) andFeature(tests/Feature).- Test classes are autoloaded through the
Tests\PSR-4 mapping (autoload-devincomposer.json).
Note: These suites are currently empty. The presence of this configuration does not imply existing test coverage.
2. Local Pre-Push Protection
A Git pre-push hook is provided in the repository at:
.github/hooks/pre-push
The hook performs the following on every push:
- Runs
composer test. - If the test command fails (non-zero exit code), the push is rejected.
- If the test command succeeds, the push proceeds.
This is a preventive development workflow mechanism. It guarantees that pushes are only made after the test command passes locally. It does not represent existing test coverage — the hook simply executes whatever the test suite contains at the time.
3. GitHub Actions CI
A GitHub Actions workflow is configured at:
.github/workflows/test.yaml
It automatically executes the test command (composer test) on:
- Pushes to the
mainbranch. - Pull Requests targeting the
mainbranch.
The workflow runs on ubuntu-latest and uses a version matrix to validate package compatibility across the supported Laravel/PHP combinations:
| PHP | Laravel |
|---|---|
| 8.1 | 9.* |
| 8.2 | 10.* |
| 8.2 | 11.* |
| 8.2 | 12.* |
| 8.3 | 13.* |
Testing multiple Laravel versions ensures the package works across all Laravel versions it claims to support, rather than only the one used during development. The matrix is configured with fail-fast: false, so all combinations run even if one fails; however, a failed matrix job causes the overall CI workflow to fail, surfacing incompatibilities in the Checks tab.
Each matrix job installs the matrix-specific Laravel version, installs dependencies, and runs the test suite.
4. Pull Request / Branch Protection
On the GitHub side, Pull Requests targeting a protected branch can be combined with required status checks:
- The CI workflow runs automatically for every Pull Request.
- The branch is configured (in the repository settings) to require the CI check, a Pull Request cannot be merged while the CI workflow is failing.
- Failed required checks block the merge regardless of approvals.
This provides a second layer of protection that remains effective even if someone bypasses the local pre-push hook (for example, by pushing with --no-verify or pushing from a machine where the hook is not activated).
5. Protection Flow
Developer:
pre-push hook → test command → push allowed/rejected
GitHub:
Pull Request → GitHub Actions → required checks → merge allowed/rejected
6. Current Testing Status
| Component | Status |
|---|---|
| Testing infrastructure | Configured |
| PHPUnit | Configured |
Composer test command |
Configured |
Local pre-push hook |
Configured |
| GitHub Actions CI | Configured |
| Pull Request / branch protection | Configured |
| Test cases | Not implemented yet |
What Should Be Tested?
Payment
Test:
- Successful payment.
- Failed payment.
- Invalid payment data.
- Customer creation/update.
- Transaction persistence.
- Gateway response mapping.
- Payment status mapping.
Webhooks and Callbacks
Test:
- Valid signature.
- Invalid signature.
- Missing signature.
- Tampered payload.
- Unknown transaction.
- Already processed transaction.
- Supported events.
- Unsupported events.
For Stripe, test every event explicitly handled by the package.
For Paymob, test both valid and invalid HMAC callbacks.
Refunds
Test:
- Full refund.
- Partial refund.
- Multiple partial refunds.
- Over-refund rejection.
- Unknown transaction.
- Gateway/local reference mismatch.
- Correct remaining amount.
- Correct final transaction status.
Retry
Test:
- Valid retry.
- Retry of failed transaction.
- Retry of pending transaction where supported.
- Retry of an already completed transaction.
- Gateway API failure.
Contributing
Contributions are welcome.
When adding or modifying functionality:
- Follow the existing architecture.
- Keep gateway-specific code inside its gateway directory.
- Avoid changing the generic payment workflow unnecessarily.
- Add or update tests.
- Run the complete test suite.
- Update the documentation.
- Update the gateway support matrix when capabilities change.
- Preserve backward compatibility.
Project Structure
A simplified package structure:
lara_payments_ma/
├── LICENSE
├── README.md
├── composer.json
├── composer.lock
├── phpunit.xml # PHPUnit configuration (Unit / Feature suites)
│
├── .github/
│ ├── hooks/
│ │ └── pre-push # local pre-push hook: runs composer test before pushing
│ └── workflows/
│ └── test.yaml # CI workflow: PHP/Laravel version matrix test runner
│
├── config/
│ ├── ma_payment_drivers.php # gateway driver registry
│ └── ma_payment_conf.php # main package config
│
├── database/
│ └── migrations/
│ ├── 2026_08_21_154005_create_payment_customers_table.php
│ ├── 2026_08_22_165124_create_payment_transactions_table.php
│ └── 2026_08_24_160438_create_refunded_payment_transactions_table.php
│
├── resources/
│ ├── js/
│ │ └── stripe/
│ │ └── MaPaymentStripe.js # frontend Stripe integration JS
│ └── views/
│ ├── Stripe/
│ │ └── card.blade.php # Stripe card payment view
│
└── src/
├── MaPaymentServiceProvider.php # package service provider (bindings, migrations, views, lang, publishes)
├── PaymentGatewayManager.php # resolves the active gateway driver
│
├── DTOS/
│ ├── PaymentRequestDTO.php # incoming payment request data
│ └── PaymentTransactionDTO.php # transaction data transfer object
│
├── Enums/
│ └── PaymentStatus.php # payment status enum
│
├── Exceptions/
│ ├── CustomerNotFoundException.php
│ ├── GatewatTxnOrderIdAndLocalTxnOrderIdNotSameException.php # (typo: "Gatewat")
│ ├── GatewayTxnIdAndLocalTxnIdNotSameException.php
│ ├── MissingPaymentInfoException.php
│ ├── RefundAmountGreaterThanTransactionAmountException.php
│ ├── RefundTransactionNotFoundException.php
│ ├── TransactionAlreadyProccessedException.php # (typo: "Proccessed")
│ ├── TransactionCannotProcessException.php
│ ├── TransactionFailedException.php
│ └── TransactionNotFoundException.php
│
├── Facades/
│ └── MaPayment.php # facade: MaPayment::gateway(...)
│
├── Jobs/
│ └── UpdateRefundTransactionJob.php # queued Job: retries refund transaction updates (Stripe webhook race condition)
│
├── Factories/
│ └── PaymentGatewayFactory.php # builds gateway instances
│
├── Gateways/
│ ├── BaseGateway.php # shared gateway logic
│ ├── Paymob/
│ │ ├── PaymobGateway.php
│ │ └── Services/
│ │ ├── PaymobApiService.php # Paymob HTTP API calls
│ │ └── PaymobWebhookHandler.php # Paymob webhook verification/handling
│ └── Stripe/
│ ├── StripeGateway.php
│ └── Services/
│ ├── StripeApiService.php # Stripe API calls
│ └── StripeWebhookHandler.php # Stripe webhook handling
│
├── Interfaces/
│ ├── PaymentGatewayInterface.php # contract all gateways implement
│ ├── TransactionRepositoryInterface.php # transaction persistence contract
│ └── ViewablePaymentGatewayInterface.php # gateways that render their own view
│
├── Models/
│ ├── PaymentCustomer.php
│ ├── PaymentTransaction.php
│ └── RefundedPaymentTransaction.php
│
├── Repositories/
│ ├── PaymentCustomerRepository.php
│ ├── RefundTransactionRepository.php
│ └── TransactionRepository.php
│
├── Services/
│ ├── ClientApiService.php # outbound API client helper
│ ├── CustomerSerivce.php # (typo in filename: "Serivce")
│ └── PaymentTransaction.php # transaction orchestration service
│
└── ValueObjects/
├── Money.php
└── UserEmail.php
Architecture Principles
The package follows several important principles:
Single Responsibility
Different responsibilities are separated:
- Gateway
- API Service
- Webhook Handler
- Repository
- DTO
- Value Object
Open/Closed Principle
A new gateway can be added without changing the generic payment workflow.
Liskov Substitution
Gateway implementations follow the common gateway contract.
Dependency Inversion
Infrastructure dependencies are injected through abstractions where appropriate.
Separation of Concerns
Gateway-specific API behavior remains isolated from:
- Persistence.
- Generic payment orchestration.
- DTO definitions.
- Application integration.
Author
License
MIT © Mohamed Allam

