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

Paystack Php Laravel Package

yabacon/paystack-php

Unofficial PHP SDK for Paystack. Initialize and verify transactions, manage customers, plans, subscriptions, transfers and refunds. A lightweight, dependency-free wrapper with clean, chainable methods that drops easily into Laravel or any PHP project.

Deep Wiki
Context7

Getting Started

Install and configure

composer require yabacon/paystack-php

Requirements are just PHP + ext-curl. There is no config file to publish and no framework dependency — the secret key is the only thing you need.

Put the key in config (not inline), and never expose the secret key client-side:

// config/services.php
'paystack' => [
    'secret' => env('PAYSTACK_SECRET_KEY'),
    'public' => env('PAYSTACK_PUBLIC_KEY'), // only for inline/JS popup
],

First use case: redirect checkout

The 90% path is initialize → redirect → verify:

use Yabacon\Paystack;

$paystack = new Paystack(config('services.paystack.secret'));

$tranx = $paystack->transaction->initialize([
    'amount'      => $order->total_minor,   // kobo, integer
    'email'       => $request->user()->email,
    'reference'   => 'ORD-'.$order->id.'-'.Str::random(10),
    'callback_url'=> route('paystack.callback'),
    'metadata'    => ['order_id' => $order->id],
]);

if (! $tranx->status) {
    // $tranx->message is Paystack's human-readable reason
    return back()->withErrors($tranx->message);
}

return redirect($tranx->data->authorization_url);

Where to look first

The README is thin, so treat src/Paystack/Routes/*.php as the real API reference. Each route class (Transaction, Customer, Plan, Subscription, Transfer, Invoice) declares its methods and the exact payload keys Paystack expects — that's more reliable than any blog post. Then look at src/Paystack/Http/Response.php to understand the shape you'll be handling: { status: bool, message: string, data: mixed }.

Implementation Patterns

Bind it once, resolve it everywhere

The client is stateless per request, so a singleton binding keeps things tidy and testable:

// AppServiceProvider::register()
$this->app->singleton(Paystack::class, fn () => new Paystack(config('services.paystack.secret')));

In Laravel you can alternatively register the package's shipped service provider/facade (check the provider + facade class names in the package src/ — they differ slightly between versions) and get the static form Paystack::transaction()->initialize([...]). The instance API is the safer bet if you ever need two keys (multi-tenant, marketplace splits).

Persist the reference before redirecting

Create the order/payment row first, save the reference and the initialization result, then redirect. Your callback and webhook handlers both resolve the local record by reference — never by the amount in the response.

Verify pattern (controller)

public function callback(Request $request)
{
    $payment = Payment::where('reference', $request->query('reference'))
                      ->where('status', 'pending')
                      ->lockForUpdate()
                      ->firstOrFail();

    $res = app(Paystack::class)->transaction->verify(['reference' => $payment->reference]);

    if (! $res->status || $res->data->status !== 'success') {
        // mark failed / let the user retry with a fresh reference
    }

    abort_unless($res->data->amount === $payment->amount_minor, 422);
    abort_unless($res->data->currency === $payment->currency, 422);

    $payment->markPaid($res->data->id);           // idempotent
    return redirect()->route('orders.show', $payment->order_id);
}

Note the two statuses: $res->status means "the API call worked"; $res->data->status means "the money moved" (success, failed, abandoned, reversed). Checking only the first is the single most common bug in this integration.

Webhook as the source of truth

Callbacks can be dropped (user closes tab, network blip). Handle charge.success on your webhook endpoint and run the same verify/idempotent-mark-paid code:

Route::post('/webhooks/paystack', function (Request $request) {
    $expected = hash_hmac('sha512', $request->getContent(), config('services.paystack.secret'));
    abort_unless(hash_equals($expected, $request->header('x-paystack-signature')), 401);

    dispatch(new ProcessPaystackEvent($request->json()->all())); // respond 200 fast
    return response()->noContent();
});

Exclude the webhook route from CSRF and from auth middleware.

Direct charge / saved cards

For card-not-redirected flows, transaction->charge([...]) returns a data->status of send_pin, send_otp, success, etc., and you continue the flow by re-calling with the extra fields. For repeat customers, store data->authorization->authorization_code on verify and charge later with email, amount, authorization_code — no PAN ever touches your server.

Wrap the client behind your own seam

Because there is no clean HTTP mocking hook, define a small interface (PaymentGateway::initialize()/verify()/charge()) and let the Paystack client be one implementation. That gives you a fake for tests and a fallback to raw Guzzle for endpoints the package doesn't cover.

Gotchas and Tips

  • Amounts are minor units, integers. ₦50.00 → 5000. GHS uses pesewas, ZAR uses cents. Store the minor-unit integer in the DB and convert only at the view layer; passing a float or a naira value is the #2 support ticket after status checks.
  • References must be unique per integration. Re-initializing with an existing reference returns/errors on the old transaction. For retries, generate a new reference and link both to the same order.
  • metadata gets double-encoded if you pass a JSON string. Pass a PHP array.
  • ApiException on HTTP failure. Wrap calls in try { } catch (\Yabacon\Paystack\Exception\ApiException $e) { report($e); } and log the decoded body — $response->message is Paystack's own wording ("Invalid key", "Amount must be at least...") and is genuinely useful for support.
  • No built-in timeout or retry. The client fires cURL straight out. Don't call it inline in a request that also holds a DB transaction, and consider a retry() around transient failures for verify calls (verify is idempotent; initialize is not).
  • Old cURL / Windows: cURL error 60. The 2016-era client doesn't ship a CA bundle; point curl.cainfo at a current bundle in php.ini.
  • The package is effectively unmaintained (last Packagist tag is 2016) while Paystack keeps shipping endpoints. Before building on transfer/invoice/refund/payment request, confirm the installed version actually exposes the method — method_exists($paystack->transfer, 'initiate') — rather than trusting the README. Fallback for anything missing is trivial: POST https://api.paystack.co/... with Authorization: Bearer sk_... and Content-Type: application/json. You can also subclass a route class or drop in a custom one via the router if you want to stay inside the fluent API.
  • Don't call env() from app code if you register the Laravel provider — the key must come from config() so config:cache doesn't blank it.
  • Facade + multi-tenant don't mix. The facade binds one key globally; instantiate per tenant instead.
  • Test keys are separate sandboxes. Test card 4084084084084081, any future expiry, CVV 408, PIN 0000, OTP 123456. Failures you trigger in test mode still fire webhooks — keep a strict if (event.startsWith('charge.')) guard and don't let test events mark real orders paid (they carry data->domain === 'test').
  • Locking on callback: the callback is user-reachable and re-playable. lockForUpdate() plus a status = 'pending' guard on the payment row is what prevents double-crediting; a bare if (!$paid) is not.
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.
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core