Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Flutterwave V3 Laravel Package

flutterwavedev/flutterwave-v3

Laravel package for Flutterwave v3 payments, offering a simple way to initiate and verify transactions, handle payment callbacks, and integrate Flutterwave’s payment APIs into your PHP or Laravel application.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and pin. The package is a thin, hand-maintained wrapper around Flutterwave's REST v3 API — no service provider, no facade, no publishable config, no exception hierarchy. Pin it explicitly so an upstream push can't break checkout:

composer require flutterwavedev/flutterwave-v3:"^1.0"

Check composer.json in the vendor directory first for the PHP constraint and whether it pulls Guzzle or uses raw cURL — that determines whether you can intercept HTTP in tests.

Where to look first. The repository is small, so read it in this order before writing a line of application code:

  1. src/ — one class per API resource (Payment, Transaction, Transfer, Beneficiary, etc.). Method names map roughly 1:1 to endpoints under https://api.flutterwave.com/v3.
  2. The constructor / key setter on whichever class you instantiate. This is where public key, secret key, and (for direct card charges) encryption key are accepted.
  3. README.md examples — they're the only documentation; treat them as the canonical calling convention.
  4. composer.json — PHP version, autoload namespace (Flutterwave\), dependencies.

Configure once, in config/. Don't scatter env() calls into services; config:cache will bite you.

// config/flutterwave.php
return [
    'public_key'     => env('FLW_PUBLIC_KEY'),
    'secret_key'     => env('FLW_SECRET_KEY'),       // FLWSECK-...
    'encryption_key' => env('FLW_ENCRYPTION_KEY'),   // only for direct card charges
    'secret_hash'    => env('FLW_SECRET_HASH'),      // webhook verif-hash value
    'currency'       => env('FLW_CURRENCY', 'NGN'),
    'redirect_url'   => env('FLW_REDIRECT_URL'),     // your callback route
];

First use case: hosted checkout, verify on return. This is the path with the least PCI exposure and the fewest library-specific surprises. You create a payment, redirect the customer to the returned link, and then independently verify the transaction server-side on the callback.

// app/Http/Controllers/CheckoutController.php
public function pay(Order $order, FlutterwaveGateway $gateway)
{
    $txRef = 'ord_'.$order->id.'_'.Str::ulid();      // unique, persisted before redirect

    $order->update(['flw_tx_ref' => $txRef, 'status' => 'pending']);

    $link = $gateway->initialize([
        'tx_ref'       => $txRef,
        'amount'       => $order->total,             // major units, e.g. 1500.00 NGN
        'currency'     => config('flutterwave.currency'),
        'redirect_url' => route('checkout.callback'),
        'customer'     => [
            'email' => $order->email,
            'name'  => $order->customer_name,
            'phonenumber' => $order->phone,
        ],
        'customizations' => [
            'title'       => config('app.name'),
            'description' => 'Order #'.$order->id,
        ],
    ]);

    return redirect()->away($link);
}

public function callback(Request $request, FlutterwaveGateway $gateway)
{
    // Never trust status/tx_ref in the query string — re-query Flutterwave.
    $txId = $request->string('transaction_id');
    $result = $gateway->verify($txId);

    // reconcile against $result['data']['tx_ref'], amount, currency
}

Wrap the library in your own gateway class (below) rather than calling Flutterwave\Payment from controllers — you will want to swap it, fake it, and log it.

Implementation Patterns

Wrap the library behind one application-owned class. This is the single highest-leverage decision. The package has no interface, no Laravel binding, and 25 stars; your wrapper is what gives you testability, timeouts, logging, and an escape hatch to replace the library later.

// app/Services/Flutterwave/FlutterwaveGateway.php
namespace App\Services\Flutterwave;

use Flutterwave\Payment;          // confirm the exact class in src/
use Illuminate\Support\Facades\Log;

final class FlutterwaveGateway implements PaymentGateway
{
    public function __construct(private array $config) {}

    public function initialize(array $payload): string
    {
        return $this->call('initialize', fn () =>
            $this->client()->initiatePayment($payload)['data']['link']
        );
    }

    public function verify(string $transactionId): array
    {
        return $this->call('verify', fn () =>
            $this->client()->verifyTransaction($transactionId)['data']
        );
    }

    private function client(): Payment
    {
        $client = new Payment();
        $client->setKeys($this->config['public_key'], $this->config['secret_key']);

        return $client;
    }

    private function call(string $op, \Closure $fn)
    {
        try {
            return $fn();
        } catch (\Throwable $e) {
            Log::error("flutterwave.{$op} failed", ['message' => $e->getMessage()]);
            throw new PaymentGatewayException("Flutterwave {$op} failed", 0, $e);
        }
    }
}

Bind it in AppServiceProvider:

$this->app->singleton(PaymentGateway::class, fn ($app) =>
    new FlutterwaveGateway($app['config']['flutterwave'])
);

Model the transaction as an explicit state machine, not a boolean. Flutterwave talks to you twice (browser redirect and webhook), and either can arrive first, be duplicated, or never arrive at all. Give yourself a payments table keyed by tx_ref with a unique index, and a paid_at timestamp guarded by a conditional update:

$updated = DB::table('payments')
    ->where('tx_ref', $data['tx_ref'])
    ->whereNull('paid_at')
    ->update(['paid_at' => now(), 'flw_transaction_id' => $data['id'], 'raw' => json_encode($data)]);

// $updated === 0 means already processed — safe to acknowledge and return.

Treat the redirect callback as UX and the webhook as truth. The callback exists so the user sees a success page. Credit the order only from the webhook (or from a verified callback with matching tx_ref, amount, and currency) — and route both through the same markPaid() method so the idempotency guard is shared.

Webhook route: no CSRF, no auth, fast 200.

// routes/api.php (excluded from CSRF by default; if in web.php, add to VerifyCsrfToken::$except)
Route::post('/webhooks/flutterwave', FlutterwaveWebhookController::class)
    ->withoutMiddleware(['auth']);

// controller
public function __invoke(Request $request)
{
    abort_unless(
        hash_equals(config('flutterwave.secret_hash'), (string) $request->header('verif-hash')),
        401
    );

    ProcessFlutterwaveEvent::dispatch($request->all());

    return response()->noContent();   // 200 within a second or two, work happens on the queue
}

verif-hash is a shared secret you set in the Flutterwave dashboard — a plain string comparison, not an HMAC signature over the body. hash_equals avoids timing leaks.

Use tx_ref as a business identifier, not a random string. ord_1042_a3f9... gives you a grep-able trace from a Flutterwave dashboard row back to an order without a lookup table, and the ULID suffix keeps it unique across retries. Store it before you call initialize so a crash mid-call doesn't orphan a payment.

Separate money-in from money-out. initialize/verify/refund live in one service; transfers, beneficiaries, and virtual accounts in another. Payouts need different retry semantics (a timed-out transfer may still have executed — never blind-retry, always reconcile by reference first) and are typically triggered from a queued job with an approval step.

Backfill with a scheduled reconciliation command. Flutterwave webhooks get missed. A command that re-verifies every payment still pending after 15 minutes, and every payment with no webhook after 24 hours, catches essentially all of them:

Payment::where('status', 'pending')
    ->where('created_at', '<', now()->subMinutes(15))
    ->each(fn ($p) => VerifyFlutterwavePayment::dispatch($p));

Testing. Because the library talks to the network directly, Http::fake() won't help. Fake at your boundary instead: bind a FakePaymentGateway implementing PaymentGateway in tests, and keep one integration test suite that hits the sandbox with test keys behind a separate test suite / env flag. For the webhook, just POST with the right verif-hash header — it's off the library's code path entirely.

Use Payment Links for anything invoice-shaped. If you're sending a link by email or WhatsApp and don't need custom UI, Flutterwave's payment-link endpoint removes an entire controller and callback route from your app.

Gotchas and Tips

Amounts are in major units, not minor. 1500.00, not 150000. This is opposite to Paystack and to most Laravel money libraries — if you store kobo/cents internally (you should), convert exactly once at the gateway boundary and assert on the way back out. Floating-point rounding on verify comparisons is a real source of false mismatches; compare with a small epsilon or store amounts as decimal strings.

tx_ref must be globally unique to your account. Reusing one returns a duplicate-reference error, and retried jobs are the classic offender. Generate a fresh ref per attempt only if the previous attempt definitively failed — otherwise you'll double-charge.

Two levels of status. Flutterwave responses carry an envelope status (success / error) and a nested data.status (successful, failed, pending). Checking only one is a common bug. And always compare data.amount and data.currency against your own order record — a verification that returns successful for a ₦100 payment on a ₦10,000 order is a real attack vector if you skip the comparison.

Webhooks are at-least-once and unordered. The same event will arrive multiple times, and a charge.completed can land after a refund. Idempotency on tx_ref + flw_transaction_id is not optional. Also: webhook delivery can precede the browser redirect, so your success page must tolerate an order that's already paid.

Reject webhooks that fail verif-hash — but don't rely on it alone for high-value flows. The secret hash is a static shared string with no replay protection. For anything large, re-verify the transaction via the API inside the job before crediting.

Direct card charges pull you into PCI scope and the library may not do the hard part. Charging a card without the hosted page requires a nonce, client_version, device_fingerprint, and 3DES encryption of card fields using a key derived from your encryption key. Some versions of this library expect you to produce those yourself. Unless you have a compliance reason, use the hosted checkout, Payment Links, or Flutterwave's JS/Inline to get a tx_ref back and verify it server-side.

No timeout configuration. A slow Flutterwave response will hang your PHP worker for as long as the default socket timeout allows. Keep your gateway call inside a job for anything non-interactive, and if the library exposes a cURL handle or Guzzle client, set an explicit connect/read timeout. At minimum, wrap the call in your own try/catch with a hard timeout at the process level.

No exception taxonomy. Errors are likely generic exceptions or — in the worst case — a returned error array that doesn't throw at all. Normalise both: check the envelope status, throw your own PaymentGatewayException, and log the raw response body. You will need that body the first time a customer says "it says failed but my card was debited."

Sandbox fidelity is uneven. Test keys give you test cards, but bank-transfer and USSD flows behave differently, currency support varies by account country, and some features require dashboard activation. Don't discover that in production — walk the full flow with test keys for each payment method you expose.

Small project, real risk. 25 stars, zero dependents, and a tiny surface means no community answers when something breaks and no guarantee of semver discipline. Concrete mitigations: pin the exact version, keep your FlutterwaveGateway wrapper thin enough that you can replace the internals in an afternoon, and consider that checkout + verify is roughly 50 lines against Laravel's Http client — which gets you Http::fake(), retries, ->timeout(), and proper exceptions for free. If you only need two endpoints, the library is optional.

Extend, don't fork. For endpoints the library doesn't cover (or covers badly), call them from your own wrapper using Http::withToken(config('flutterwave.secret_key'))->baseUrl('https://api.flutterwave.com/v3') — same credentials, same logging, same fakes as the rest of your app, and no reason to wait on an upstream PR.

Never log the secret key or raw card payloads. The library may echo request details on failure. Ensure APP_DEBUG=false in production, scrub Authorization headers from your exception handler's context, and keep FLW_SECRET_KEY out of any config:cache-dumped artifact you might share.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle
glavweb/extra-bundle
glavweb/api-bundle
glavweb/admin-bundle
adachsoft/api-version-bundle
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
cgv/localized-resource