raedev / laravel-payment-reconciliation
Reliable payment state management, idempotency, and provider reconciliation for Laravel applications.
Package info
github.com/RaedElNABOULSI/laravel-payment-reconciliation
pkg:composer/raedev/laravel-payment-reconciliation
Requires
- php: ^8.2
- illuminate/console: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.30
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.0|^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-21 09:54:52 UTC
README
Your payment integration already works. This package makes the payment lifecycle reliable.
This is not a payment SDK and not a provider-specific integration. It doesn't talk to Stripe or any gateway's charge API for you. What it does is own the part every payment integration eventually gets wrong by hand: tracking a payment through an explicit state machine, making retries and webhooks idempotent under concurrency, and detecting - safely, without guessing - when your local record and the provider's record disagree.
Table of contents
- Why payment integrations become inconsistent
- Why not just cron + poll the provider yourself?
- What this package does NOT do
- Requirements
- Installation
- Configuration
- Database setup
- Quickstart (zero config, no real provider needed)
- Creating payments
- State transitions
- The UNKNOWN state
- Webhook handling
- Observability - where to see results and errors
- Idempotency
- Reconciliation
- Provider adapter architecture
- Artisan commands
- Scheduler integration
- Events
- Testing
- Extension / custom providers
- Security considerations
- Roadmap
Why payment integrations become inconsistent
A "simple" payment integration is a distributed system: your app, the provider's API, and the provider's webhook delivery all have to agree on what happened, and any of them can fail independently.
- A charge request times out - did it succeed on the provider's side or not? You can't tell from the timeout alone.
- A webhook is delivered twice (providers guarantee at-least-once delivery, not exactly-once).
- Two webhook deliveries for the same event arrive concurrently and both pass a naive
if ($alreadyProcessed) return;check before either has written anything. - Your local row says
paid, a refund or dispute later makes the provider disagree, and nothing ever notices.
None of these are exotic edge cases - they are the normal operating conditions of any payment integration that runs long enough. This package gives you the primitives to handle them correctly once, instead of re-deriving them per project.
Why not just cron + poll the provider yourself?
This is the honest alternative every evaluator should compare against - a five-line scheduled job
that polls the provider and updates a status column looks like it does the same thing. What it
usually doesn't handle:
- Concurrency. Two webhook deliveries (or a webhook and a poll) landing at the same time will
both read
status = processing, both decide to update it, and one silently overwrites the other's work. This package closes that gap with unique database constraints pluslockForUpdate()transactions - not aSELECTfollowed by anUPDATE. - Idempotency. A retried webhook or a retried API call needs to be recognized as the same
operation, not reprocessed. That requires unique constraints on the right columns from the start,
not a
processed_idsarray you remember to check. - A real state machine. "Just update the status" tends to accept any transition, including the
ones that corrupt data - a
paidpayment reverting topending, or acancelledpayment somehow becomingpaid. This package makes invalid transitions raise, not "just happen." - The unknown state. A naive poller usually treats "I don't know" the same as "it failed," which means a network blip can cost you a paid order. This package treats "unknown" as a first-class, distinct state that safely resolves later.
If your integration is genuinely simple and none of the above applies yet, you may not need this package. It exists for the moment those things start to matter.
What this package does NOT do
- It does not process payments, charge cards, or talk to a bank/acquirer.
- It does not hold, move, or custody funds.
- It does not replace PCI-DSS-relevant handling of card data - it never sees a card number and has no opinion on how you collect one.
- It does not ship a working Stripe/Tap/Areeba integration in this version (see Roadmap) - it ships the contract and a fake provider so you can build one, and the currently-implemented gateways are limited to what's listed there.
- It does not provide a dashboard/UI in this version.
Requirements
- PHP ^8.2
- Laravel 12 or 13 (
illuminate/support/database/console^12.0 or ^13.0) - A configured database connection - any driver Laravel's schema builder supports (MySQL,
Postgres, SQLite, SQL Server). Migrations create two tables (
payments,webhook_events).
Laravel 10 and 11 are not supported: both are past their official security-fix window (https://laravel.com/docs/releases#support-policy), and Composer's advisory-blocking will refuse to install a project pinned to either regardless of this package. If your application is still on 10 or 11, upgrade Laravel first.
Installation
composer require raedev/laravel-payment-reconciliation
Laravel auto-discovers PaymentReconciliationServiceProvider. No manual registration needed.
Configuration
php artisan vendor:publish --tag=payment-reconciliation-config
This publishes config/payment-reconciliation.php:
return [ // The provider used when your code doesn't specify one explicitly. 'default_provider' => env('PAYMENT_RECONCILIATION_PROVIDER', 'fake'), // Maps a provider name (as stored on payments.provider) to its adapter class. 'providers' => [ 'fake' => \Raedev\LaravelPaymentReconciliation\Providers\FakePaymentProvider::class, ], // Override if these clash with existing tables in your app. 'table_names' => [ 'payments' => 'payments', 'webhook_events' => 'webhook_events', ], ];
Database setup
Migrations are auto-loaded, so php artisan migrate works immediately with no publish step. If you
need to customize the schema (e.g. via a different table name from config, applied before
migrating), publish them first:
php artisan vendor:publish --tag=payment-reconciliation-migrations php artisan migrate
This creates two tables:
payments- the package-owned payment record (UUID primary key,payablepolymorphic relation + a rawpayable_referencefallback, provider identifiers, integeramount,currency,status,provider_status,idempotency_key,metadataJSON column,last_reconciled_at).webhook_events- one row per successfully-claimed webhook delivery, the backbone of webhook idempotency.
Quickstart (zero config, no real provider needed)
The fake provider is bound by default, so you can exercise the entire lifecycle right after
migrate - no gateway account, no API keys:
use Raedev\LaravelPaymentReconciliation\Enums\PaymentStatus; use Raedev\LaravelPaymentReconciliation\Providers\FakePaymentProvider; use Raedev\LaravelPaymentReconciliation\Reconciliation\ReconciliationService; use Raedev\LaravelPaymentReconciliation\Services\PaymentService; $paymentService = app(PaymentService::class); $fakeProvider = app(FakePaymentProvider::class); // bound as a singleton // 1. Create a payment (defaults to `pending`). $payment = $paymentService->create([ 'provider' => 'fake', 'amount' => 4200, // cents - never a float 'currency' => 'USD', ]); // 2. Move it into processing once you've handed off to the provider. $payment = $paymentService->markProcessing($payment); $payment->update(['provider_transaction_id' => 'tx_demo_123']); // 3. The provider confirms the charge (e.g. a synchronous API response) - // amount/currency are checked before this is accepted. $payment = $paymentService->markPaid($payment, actualAmount: 4200, actualCurrency: 'USD'); echo $payment->status->value; // "paid" // 4. Some time later, a scheduled reconciliation run confirms both sides // still agree - this is the same call `payments:reconcile` makes. $fakeProvider->setProviderStatus('tx_demo_123', PaymentStatus::Paid, 4200, 'USD'); $result = app(ReconciliationService::class)->reconcile($payment, $fakeProvider); echo $result->type->value; // "matched"
Run it inside php artisan tinker right after installing to see the full
pending -> processing -> paid -> reconciled flow with nothing but this package and Laravel. See
Reconciliation for what happens instead when the two sides don't agree.
Creating payments
$payment = Payment::create([ 'provider' => 'custom', 'amount' => 10000, // integer minor units (cents) - floats are rejected 'currency' => 'USD', ]);
Amounts are stored and validated as integers via a dedicated Eloquent cast. Passing a float
(10000.50) throws InvalidAmountException immediately - it is never silently truncated or rounded.
Linking a payment to your own model
// Eloquent model on your side (default mechanism): $payment = Payment::create([ 'provider' => 'custom', 'amount' => 10000, 'currency' => 'USD', 'payable_type' => Order::class, 'payable_id' => $order->id, ]); $payment->payable; // returns the Order // No Eloquent model at all - use the raw reference fallback: $payment = Payment::create([ 'provider' => 'custom', 'amount' => 10000, 'currency' => 'USD', 'payable_reference' => 'external-order-999', ]);
State transitions
$payment->transitionTo(PaymentStatus::Processing);
or via the service (identical behavior - the model method is a thin, ergonomic wrapper around it):
app(PaymentService::class)->markProcessing($payment);
Allowed transitions:
pending -> processing, cancelled
processing -> paid, failed, unknown
unknown -> paid, failed
paid -> (terminal)
failed -> (terminal)
cancelled -> (terminal)
Anything else - paid -> pending, paid -> failed, cancelled -> paid, pending -> paid, ... -
throws InvalidStateTransitionException and changes nothing. Repeating a transition the payment is
already in (e.g. calling markProcessing() twice) is a safe no-op, not an error - this keeps
retried application calls and resent webhooks idempotent.
transitionTo()/Payment::transitionTo() refuse PaymentStatus::Paid and throw
InvalidArgumentException if you try - a bare status transition has no way to verify anything, and
paid is the one state where that matters. Use markPaid() (service or model) instead:
$payment = $paymentService->markPaid($payment, actualAmount: 4200, actualCurrency: 'USD'); // or, on the model: $payment = $payment->markPaid(actualAmount: 4200, actualCurrency: 'USD');
This is the only path that can reach paid, on the service, the model, webhook processing, and
automatic unknown -> paid reconciliation alike - there is no code path that mutates status to
paid without going through this check.
The UNKNOWN state
If your app can't determine whether a payment succeeded (a timeout while calling the provider, a
dropped connection), mark it unknown instead of guessing:
app(PaymentService::class)->markUnknown($payment);
An unknown payment is never auto-resolved to failed by this package. Reconciliation is the only
thing allowed to resolve it, and only in one direction based on the provider's own answer:
unknown -> paid (provider confirms success)
unknown -> failed (provider confirms failure)
unknown -> unknown (provider is still unsure, or unreachable - no change)
Webhook handling
use Raedev\LaravelPaymentReconciliation\Webhooks\WebhookProcessor; use Raedev\LaravelPaymentReconciliation\Exceptions\WebhookVerificationException; use Raedev\LaravelPaymentReconciliation\Exceptions\UnknownProviderTransactionException; Route::post('/webhooks/{provider}', function (Request $request, string $provider) { $providerAdapter = app(ProviderRegistry::class)->resolve($provider); try { $result = app(WebhookProcessor::class)->process( $request->all(), $request->headers->all(), $providerAdapter, $request->getContent(), // raw body - required by providers that sign raw bytes (Stripe, etc.) ); } catch (WebhookVerificationException $e) { return response()->json(['error' => 'invalid signature'], 400); } catch (UnknownProviderTransactionException $e) { return response()->json(['error' => 'unknown transaction'], 404); } return response()->json(['status' => $result->type->value]); });
WebhookProcessor::process():
- Verifies the payload against the provider's own signature check
(
PaymentProvider::verifyWebhook()) - forged payloads never touch the database. - Normalizes the payload via
PaymentProvider::parseWebhookPayload(). - Atomically claims
(provider, event_id)- see Idempotency. - Locks the matching payment row (
lockForUpdate()), validates amount/currency before acceptingpaid, and applies the transition through the same state machine used everywhere else.
The result is one of:
Processed- a new, authentic event that changed (or confirmed) state.Duplicate- the same(provider, event_id)was already processed; nothing happened.Rejected- a new, authentic event that described something unsafe (an amount/currency mismatch, or a transition the state machine disallows). Nothing was changed; aPaymentMismatchDetectedevent was fired so you can alert on it.
Observability - where to see results and errors
This package has no dashboard and does not log anything to a file or service by default. On a fresh install, the only place anything is visible is the database. Everything else is opt-in:
- The
paymentstable is the source of truth. Query it directly, or runphp artisan payments:status <uuid>for a one-payment snapshot in the terminal. - The
webhook_eventstable is an audit trail of every accepted webhook delivery, including its raw payload - useful for "did we actually receive this." - Events are fired but nothing listens to them until you do.
PaymentMismatchDetectedandPaymentBecameUnknownare the two worth alerting on - register listeners for them (in yourEventServiceProvider, orEvent::listen(...)) if you want a log line, a Slack message, or a Sentry breadcrumb when they happen. See Events for the full list. payments:reconcile's console output is not captured anywhere when run via the scheduler, unless you add it yourself:Schedule::command('payments:reconcile') ->hourly() ->appendOutputTo(storage_path('logs/payments-reconcile.log'));
- The one thing logged automatically:
ReconciliationServicecallsLog::warning()when a provider is unreachable, which goes to your app's default log channel (storage/logs/laravel.logunless configured otherwise). Every other outcome - matched, mismatch, resolved - only reaches a log if your event listener puts it there. - Exceptions from webhook processing (
WebhookVerificationException,UnknownProviderTransactionException,PaymentIntegrityException,InvalidStateTransitionException) bubble up to wherever you calledWebhookProcessor::process()- typically your controller, as shown above. The package doesn't log or swallow them for you.
Idempotency
Two independent mechanisms, both backed by database constraints rather than application-level checks:
- Payment creation: pass an
idempotency_key, andPaymentService::createIdempotent()returns the existing payment on a retried request instead of creating a duplicate. The safety net is a unique constraint onidempotency_key- the code attempts the insert and catches the unique violation, it never does a check-then-insert. - Webhook delivery:
webhook_eventshas a unique constraint on(provider, event_id). The insert is the concurrency check.
$payment = app(PaymentService::class)->createIdempotent([ 'provider' => 'custom', 'amount' => 10000, 'currency' => 'USD', 'idempotency_key' => (string) $request->header('Idempotency-Key'), ]);
Every state transition also runs inside DB::transaction() with lockForUpdate() on the payment
row, so two concurrent transition attempts serialize instead of racing.
Reconciliation
$result = app(ReconciliationService::class)->reconcile($payment, $providerAdapter);
Reconciliation never blindly copies the provider's state onto your local record. It produces a
ReconciliationResult describing what it found:
| Local status | Provider says | Result |
|---|---|---|
unknown |
paid/failed |
Resolved - the only case that auto-applies a transition |
unknown |
still unclear | StillUnknown - no change |
| anything else | matches | Matched - only last_reconciled_at updates |
| anything else | disagrees (status, amount, or currency) | Mismatch - nothing changes, PaymentMismatchDetected fires |
| any | provider unreachable | ProviderUnavailable - nothing changes, never treated as failure |
Provider adapter architecture
interface PaymentProvider { public function getName(): string; public function getPaymentStatus(Payment $payment): ProviderPaymentStatus; public function verifyWebhook(array $payload, array $headers = [], ?string $rawBody = null): bool; public function parseWebhookPayload(array $payload): ProviderWebhookPayload; }
PaymentProvider
|
+-- FakePaymentProvider (ships with this package - tests + quickstart)
+-- StripeProvider (planned - see Roadmap)
+-- YourCustomProvider
getPaymentStatus() must throw ProviderUnavailableException (not return a guess) when the
provider can't be reached - see The UNKNOWN state for why that distinction
matters.
verifyWebhook()'s $rawBody parameter exists because several major providers (Stripe,
Checkout.com, Square, Worldpay, Razorpay, PayTabs, ...) compute their webhook signature over the
exact raw bytes of the request body, not over specific fields. Re-serializing $payload with
json_encode() is not guaranteed to reproduce those bytes (key order, whitespace, and number
formatting can all differ), so those adapters need the original string. Always pass it through from
your controller ($request->getContent() in Laravel) even though it's optional - providers that
sign specific parsed fields instead (Adyen, Tap, Braintree) simply ignore it.
Artisan commands
php artisan payments:reconcile php artisan payments:reconcile --provider=stripe php artisan payments:reconcile --status=unknown php artisan payments:reconcile --payment=<uuid> php artisan payments:status <uuid>
With no filters, payments:reconcile only processes non-terminal payments (pending, processing,
unknown) - re-scanning every paid payment on every scheduled run would be an unbounded, pointless
table scan as your data grows. Pass --status=paid explicitly if you ever need to re-verify
terminal payments.
Scheduler integration
use Illuminate\Support\Facades\Schedule; Schedule::command('payments:reconcile --status=unknown')->everyFifteenMinutes(); Schedule::command('payments:reconcile')->hourly();
The package only provides the command - how often it runs is entirely up to your application.
Events
| Event | Fired when |
|---|---|
PaymentCreated |
A payment is created |
PaymentProcessing |
Transitioned to processing |
PaymentPaid |
Transitioned to paid |
PaymentFailed |
Transitioned to failed |
PaymentCancelled |
Transitioned to cancelled |
PaymentBecameUnknown |
Transitioned to unknown |
PaymentReconciled |
Any reconciliation run completes (check $event->result->type) |
PaymentMismatchDetected |
Local/provider state disagree, or a webhook described an unsafe change |
WebhookDuplicateDetected |
A (provider, event_id) was seen more than once |
Testing
composer test # PHPUnit composer analyse # PHPStan (Larastan) at level: max
composer test defaults to an in-memory SQLite database via Orchestra Testbench.
FakePaymentProvider is what the suite uses to simulate provider responses, timeouts, and signed
webhooks - see tests/Feature/*Test.php for the exact patterns (setProviderStatus(),
simulateUnavailable(), sign()).
SQLite has two real limitations for a payments package: lockForUpdate() is a no-op at the SQL
level there (Laravel's SQLite grammar emits no FOR UPDATE clause), and two connections to
:memory: are two unrelated empty databases, so genuine multi-connection lock contention can't be
exercised against it. Set DB_CONNECTION=mysql (plus DB_HOST/DB_PORT/DB_DATABASE/
DB_USERNAME/DB_PASSWORD as needed - see tests/TestCase.php) to run the same suite against a
real MySQL database instead; CI does this in a dedicated job on every push, and
ConcurrencyIntegrationTest (which proves a second connection is actually blocked by a held row
lock) only runs there.
Extension / custom providers
Implement PaymentProvider, register it in config, and use it exactly like the built-in one:
// config/payment-reconciliation.php 'providers' => [ 'fake' => \Raedev\LaravelPaymentReconciliation\Providers\FakePaymentProvider::class, 'my_gateway' => \App\Payments\MyGatewayProvider::class, ],
$payment = $paymentService->create(['provider' => 'my_gateway', 'amount' => 10000, 'currency' => 'USD']);
ProviderRegistry::resolve('my_gateway') (used internally by payments:reconcile and available to
your own code) will now return your adapter.
Security considerations
- Webhook payloads are only trusted after
PaymentProvider::verifyWebhook()passes - implement this with your provider's actual signing scheme (HMAC, etc.), not a shared-secret string in the payload. - A webhook reporting
paidis never sufficient on its own: amount and currency are checked against the local record before any transition topaidis attempted. - This package does not log full webhook payloads or payment metadata by default beyond what you
explicitly pass into
metadata- avoid putting card data or other PCI-scoped values there. - Table and column names are configurable; if you rename
payments/webhook_events, make sure any direct SQL or reporting elsewhere in your app is updated too.
Roadmap
- v0.2 (next, prioritized): a minimal, well-tested
StripeProvidercoveringgetPaymentStatus()andverifyWebhook()only - so a new user can go fromcomposer requireto a working real-provider integration in minutes, not by writing the adapter themselves first. - Additional provider adapters (Tap, others) once the Stripe adapter's shape has proven itself.
- An optional dashboard for browsing payments and reconciliation history - not planned before the core (including at least one real provider) is stable.
No provider beyond FakePaymentProvider is implemented as of this version - don't configure
stripe/tap/etc. as a provider name until their adapters ship.