abraham-flutterwave/laravel-payment
Laravel package for Flutterwave for Business v3 APIs, simplifying payments integration in Laravel apps. Supports card, account, mobile money, bank transfers, USSD, Barter, and NQR collections with quick API calls.
1. Install and publish in one pass
composer require abraham-flutterwave/laravel-payment
php artisan vendor:publish --provider="Flutterwave\Payments\Providers\FlutterwaveServiceProvider"
The package ships a service provider with auto-discovery, so the Flutterwave facade and the /flutterwave/payment/* routes are live as soon as the config is published. Confirm what you actually got before writing code:
php artisan route:list --path=flutterwave
php artisan config:show flutterwave
2. Fill in the three keys and one flag
FLW_PUBLIC_KEY=FLWPUBK_TEST-xxxxxxxx
FLW_SECRET_KEY=FLWSECK_TEST-xxxxxxxx
FLW_ENCRYPTION_KEY=xxxxxxxx
FLW_ENVIRONMENT=development
FLW_ENVIRONMENT is the switch you will flip most often — keep it in your .env and never hardcode production in a committed file. While you are in config/flutterwave.php, set businessName, logo, title, description, currency and paymentType — this is the entire branding surface of the hosted/inline modal, and it is cheaper to change here than to fork the view.
3. Add the log channel now, not in production
config/logging.php needs an explicit channel; the package does not register one for you:
'flutterwave' => [
'driver' => 'single',
'path' => storage_path('logs/flutterwave.log'),
'level' => 'debug',
],
4. First use case: a one-off checkout
Put this in a controller behind your auth middleware, and persist the tx_ref before you render anything:
use Flutterwave\Payments\Facades\Flutterwave;
use Flutterwave\Payments\Data\Currency;
public function checkout(Order $order)
{
$txRef = Flutterwave::generateTransactionReference();
$order->update(['payment_reference' => $txRef, 'payment_status' => 'pending']);
$payload = [
'tx_ref' => $txRef,
'amount' => $order->total, // major units, e.g. 100 = ₦100
'currency' => Currency::NGN,
'customer' => ['email' => $order->user->email],
'meta' => ['order_id' => $order->id],
];
$payment_details = Flutterwave::render('inline', $payload);
return view('flutterwave::modal', compact('payment_details'));
}
That single call is the whole abstraction: render() builds the Flutterwave payload (using your config for branding and allowed payment types) and the package's Blade view drops the inline widget onto the page. Swap 'inline' for 'standard' and return redirect(Flutterwave::render('standard', $payload)) when you want Flutterwave's hosted page instead — useful when you do not want to touch your frontend at all.
Where to look first: config/flutterwave.php (branding + paymentType allowlist), then php artisan route:list --path=flutterwave (the built-in checkout/callback/success/failed/cancel/webhook routes), then the webhook sample in the README — that is the pattern you will actually ship against.
Pattern: webhook as the source of truth, callback as UX
The two are not interchangeable. The callback route (GET /flutterwave/payment/callback?tx_ref=...) exists so the browser lands somewhere sensible; the webhook is what your order state machine should trust, because it is signed and can arrive after the user has closed the tab.
Route::post('flutterwave/payment/webhook', function () {
$body = request()->getContent();
$webhook = Flutterwave::use('webhooks');
$transactions = Flutterwave::use('transactions');
$signature = request()->header($webhook::SECURE_HEADER);
if (! $webhook->verifySignature($body, $signature)) {
return response()->json(['status' => 'error'], 401);
}
['tx_ref' => $txRef, 'id' => $id] = $webhook->getHook();
['status' => $status, 'data' => $data] = $transactions->verifyTransactionReference($txRef);
if ($status !== 'success') {
return response()->json(['status' => 'ignored']);
}
return DB::transaction(function () use ($txRef, $id, $data) {
$order = Order::where('payment_reference', $txRef)->lockForUpdate()->firstOrFail();
if ($order->payment_status === 'paid') { // idempotency guard
return response()->json(['status' => 'duplicate']);
}
match ($data['status']) {
Status::SUCCESSFUL => $order->markPaid($id, $data),
Status::PENDING => $order->markPending(),
Status::FAILED => $order->markFailed($data),
};
return response()->json(['status' => 'success']);
});
})->name('flutterwave.webhook');
Note the double check: verify the signature, then call verifyTransactionReference() against the API rather than trusting the payload's status. The README does this deliberately and you should keep it.
Pattern: tx_ref as your idempotency key
generateTransactionReference() already prefixes with FLW_TRANSACTION_PREFIX (default LARAVEL-). Set a domain-specific prefix — FLW_TRANSACTION_PREFIX=ORDER- — and store the ref on the order row in the same transaction that creates the order. Every later step (callback, webhook, refund lookup, support ticket) is then a single indexed lookup instead of a metadata scrape. Pass order_id in meta as a backup path for reconciliation.
Pattern: wrap the facade in one service class
Do not sprinkle Flutterwave::use('transactions') across twenty controllers. use() returns a service object keyed by string, and that string is effectively an untyped public API. Collapse it:
final class PaymentGateway
{
public function verify(string $txRef): PaymentResult
{
['status' => $status, 'data' => $data] =
Flutterwave::use('transactions')->verifyTransactionReference($txRef);
return new PaymentResult($status, $data);
}
public function refund(string $transactionId, float $amount): array
{
return Flutterwave::use('transactions')->refundTransaction($transactionId, $amount);
}
}
One place to fix when the service names change, one place to fake in tests. See gotchas below on why this matters more than usual here.
Pattern: custom routes instead of the shipped ones (2.1.0+)
Version 2.1.0 lets you replace the checkout, callback, webhook and success/cancel routes. For anything beyond a demo, do this — the built-in routes live in /flutterwave/payment/*, which collides with your own /payments namespace and gives you no place to hang authorization or order linking. Register your own and keep the package routes disabled or simply unused.
Pattern: allowlist payment methods per market
paymentType in the config is the list of methods the modal offers. If you launch in Nigeria and Ghana only, prune it — a shorter list converts better and avoids showing USSD to users who cannot use it. FLW_DEFAULT_COUNTRY and FLW_DEFAULT_CURRENCY should match your primary market, since the currency in the payload must be consistent with the methods enabled.
Workflow: state machine on your side
Treat order state as pending → paid | failed | cancelled, and let only the webhook advance it to a terminal state. The callback and success/failed/cancel routes should just redirect with a flash message ("Payment received, confirming…") rather than declaring victory. Pending is a real, common state with mobile money and bank transfer — the README explicitly mentions polling for final status, so build a "we're confirming your payment" screen rather than a spinner that eventually 500s.
The committed vendor/ folder is a trap. The README says the vendor directory is committed so people without Composer can install. Do not treat that as a supported distribution path — your composer.lock should be the source of truth, and if you vendor this package you will get stale code and no autoload hygiene.
The webhook signature is over the raw body. request()->getContent() is correct; request()->all() re-serialized to JSON will not match and every webhook will 401. Equally, the webhook route must be excluded from CSRF (bootstrap/app.php → validateCsrfTokens(except: ['flutterwave/payment/webhook']) or the old VerifyCsrfToken::$except). A 419 here looks exactly like a broken signature, so check both when debugging.
abort(404) in the README's webhook is illustrative, not correct. Returning 404 to Flutterwave will trigger retries. Return 200 for anything you have durably processed (including duplicates and unknown tx_refs you choose to ignore), 401 only for a genuinely bad signature, and 5xx only when you want a retry.
Acknowledge fast, process later. Flutterwave will retry on non-2xx and time out on slow handlers. Verify the signature inline, push the tx_ref onto a queued job, return 200. Combined with the lock-and-check idempotency guard above, retries and out-of-order deliveries stop being your problem.
Amount units bite. The package passes amount straight through; Flutterwave expects major units (100 = ₦100). If you store kobo/cents internally, divide once at the boundary — not in the webhook and the checkout, or you will double-divide and 100× your refunds.
php artisan config:cache will silently freeze your keys. Any env change requires php artisan config:clear && php artisan config:cache. The classic symptom of this is switching FLW_ENVIRONMENT to production and still getting test-mode payments.
No built-in fakes or sandbox harness. The package's own tests hit the live API. Do not call Flutterwave::render() in a feature test — extract your service class and bind a fake in the container, or Http::fake() the underlying calls. Keep one manual smoke test against test keys in your release checklist.
Maturity: pin it, wrap it. Eight stars, zero dependents, and a public TODO list that still asks for transfers, subaccounts, plans and console commands. Pin the exact version in composer.lock, wrap the facade (see above), and do not assume the refund path, subaccount or plan APIs are complete just because the README mentions refunds in the 1.0.1 changelog. If you need payouts to marketplace vendors, that is a gap to plan for, not a detail.
Logging only works if you reference the channel. Adding flutterwave to config/logging.php does nothing on its own — set 'default'-adjacent usage or configure LOG_CHANNEL, and tail storage/logs/flutterwave.log when a payload looks malformed. The first place to look when a request fails is the Flutterwave error taxonomy (authorization, validation, server); wrong-key issues surface as validation, not as an obvious "key invalid".
Debug the modal by inspecting $payment_details. render() returns the config the Blade view consumes. dd($payment_details) when the widget shows the wrong amount, currency, or branding — the bug is almost always in config/flutterwave.php or a missing customer.email, which the API requires.
Extension points worth knowing: Flutterwave::use('<service>') for any service the package exposes, config/flutterwave.php for branding/allowed methods/prefix, and the published flutterwave::modal view for markup. Everything else — order modelling, retries, receipts — is yours to build, and that boundary is the right one to keep clean.
How can I help you explore Laravel packages today?