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

Laravel Paystack Laravel Package

unicodeveloper/laravel-paystack

A Laravel package for integrating Paystack payments with minimal setup. Accept payments, verify transactions, and manage customers and subscriptions through a clean, expressive API. Supports recurring billing and webhooks.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and publish config

composer require unicodeveloper/laravel-paystack
php artisan vendor:publish --provider="Unicodeveloper\Paystack\PaystackServiceProvider"

Set the env vars the config file reads: PAYSTACK_PUBLIC_KEY, PAYSTACK_SECRET_KEY, PAYSTACK_PAYMENT_URL (https://api.paystack.co), MERCHANT_EMAIL. Nothing needs registering manually — the service provider is auto-discovered (on Laravel 11 that means it lives in the package's composer.json extra.laravel, so nothing to add to bootstrap/providers.php).

Where to look first

  • src/Payment.php — every API call the package wraps. This is the real documentation; the README lags behind. Read the method signatures before assuming an argument shape.
  • config/paystack.php — keys and base URL only. There is no "mode" switch: test vs live is purely which key pair you load.
  • The Paystack facade (Unicodeveloper\Paystack\Facades\Paystack) — thin proxy to Payment, so IDE autocompletion on the facade is weak. Inject Unicodeveloper\Paystack\Payment into your own service for discoverability.

First use case: accept a one-off card payment

use Unicodeveloper\Paystack\Facades\Paystack;

$response = Paystack::makePaymentRequest([
    'email'        => $order->email,
    'amount'       => (int) round($order->total * 100), // kobo
    'reference'    => $order->reference,                // pre-generated, unique
    'currency'     => 'NGN',
    'callback_url' => route('paystack.callback'),
    'metadata'     => ['order_id' => $order->id],
]);

return redirect($response['data']['authorization_url']);

Then verify server-side on the callback route (see below) before marking the order paid. Never trust the redirect alone — the query string is user-editable.

Implementation Patterns

A thin payment service, not raw facade calls in controllers

Wrap the facade in App\Services\PaystackGateway with methods like initialize(Order $order), verify(string $reference), refund(...). It gives you one place to normalise amount units, log requests, and swap the package out later.

Initialize + verify flow (the core loop)

  1. Generate reference yourself (Str::uuid() or an order-derived string) and persist it before calling Paystack. This is your idempotency key.
  2. makePaymentRequest() → store authorization_url, redirect.
  3. On the callback, Paystack::verifyTransaction($reference) and validate all of: $result['status'] === true, $result['data']['status'] === 'success', $result['data']['currency'] === $order->currency, and $result['data']['amount'] === (int) round($order->total * 100). The package does not enforce the amount check for you.
  4. Mark paid inside a transaction with a paid_at IS NULL guard (or a unique index on reference) so duplicate callbacks are harmless.

Channels (new in 1.2.0)

You can now steer the checkout UI:

'channels' => ['card', 'bank', 'ussd', 'bank_transfer'],

Only list channels actually enabled on your Paystack dashboard. Requesting mobile_money on an NGN account, or a channel you haven't activated, returns an initialization error rather than silently falling back — so keep the list configurable per merchant/currency instead of hardcoding.

Customers (changed in 1.2.0)

createCustomer() now takes a data array, so you can pass a full payload instead of whatever was previously hardcoded:

$customer = Paystack::createCustomer([
    'email'      => $user->email,
    'first_name' => $user->first_name,
    'last_name'  => $user->last_name,
    'phone'      => $user->phone,
]);

Persist $customer['data']['customer_code'] on your users/customers table. That code is what you reuse for plans, subscriptions, and transfer recipients — it saves you re-sending identity data and gives you a stable link back to the Paystack dashboard.

Banks and account resolution (new in 1.2.0)

The new bank-listing and account-confirmation calls remove the need to bundle your own bank JSON and to resolve NUBANs by hand. Typical payout-onboarding flow: list banks → user picks a bank → user enters account number → resolve account → store the returned account_name alongside the recipient_code. Note the exact method names in this release before wiring them up — check src/Payment.php or php artisan tinker against Paystack:: rather than trusting blog posts. Cache the bank list (Cache::remember(..., now()->addDay())) since it changes rarely and is called on every onboarding page.

Webhooks as the source of truth

Callbacks can be lost (user closes the tab). Register https://your-app/paystack/webhook, exclude it from CSRF, and treat it as the primary "paid" signal, with the callback as a fast UX path. Both routes should funnel into the same idempotent markPaid() method.

$signature = hash_hmac('sha512', $request->getContent(), config('paystack.secretKey'));

abort_unless(hash_equals($signature, $request->header('x-paystack-signature', '')), 401);

dispatch(new ProcessPaystackEvent($request->all())); // queue it
return response()->noContent();

Paystack retries on non-2xx, so return 200 immediately and do the work in a job.

Escape hatch for unwrapped endpoints

The package doesn't cover every Paystack resource (splits, disputes, terminals, etc. come and go between versions). Don't fight it — call the API directly with the same credentials and keep the response handling consistent:

Http::withToken(config('paystack.secretKey'))
    ->post(config('paystack.paymentUrl').'/split', $payload)
    ->throw()
    ->json();

Testing

Point the config's paymentUrl at a fake, or mock the HTTP client the package uses (it's Guzzle-based — use a MockHandler/HandlerStack in a test service provider, or Http::fake(['api.paystack.co/*' => ...]) if your version resolves through the Laravel client). Assert on your own Order state, not on the package's array shape, and write at least one test for the "verify says success but amount differs" path.

Gotchas and Tips

  • Everything is in the subunit. NGN amounts are kobo, GHS is pesewas. amount must be an integer; 1500.5 will be rejected. Store your order totals as integers and convert in exactly one place.
  • status: true is not "paid". The envelope's status means "the API call succeeded". The payment outcome is data.status, and 'success' is the only value you should accept. 'abandoned', 'failed', 'reversed', and 'ongoing' all need distinct handling.
  • Amount and currency must be re-checked server-side against your own stored order. A replayed verification for a ₦100 order won't help an attacker if you compare against the DB, but it will if you trust data.amount.
  • Config cache bites silently. After changing keys in .env, run php artisan config:clear (or config:cache in deploy). A stale sk_test_ key in production is the single most common "payments aren't settling" bug — fail fast at boot if app()->isProduction() && str_starts_with(config('paystack.secretKey'), 'sk_test_').
  • Laravel 11 CSRF exclusion moved. No app/Http/Middleware/VerifyCsrfToken.php anymore; in bootstrap/app.php do ->withMiddleware(fn (Middleware $middleware) => $middleware->validateCsrfTokens(except: ['paystack/webhook'])).
  • Verify the webhook signature against the raw body ($request->getContent()), not $request->all(). Any middleware that re-encodes JSON will break the HMAC.
  • Callback URLs must be publicly reachable over HTTPS for reliable settlement. localhost works for redirects but Paystack's dashboard/webhook delivery won't reach it — use a tunnel in dev.
  • Don't call verifyTransaction() on page render. Verify once, persist the result, and read from your DB afterwards. Repeated verification calls hit rate limits and, for some currencies, incur charges.
  • Idempotency is on you. Two callbacks, a retry, and a user refreshing the callback URL are all normal. Unique index on reference plus a guarded update is the cheapest insurance you'll ever buy.
  • channels errors are opaque. A "channel not supported" 400 from initialization almost always means the channel isn't enabled on the dashboard for that business/currency, not that your payload is malformed.
  • Error shape is inconsistent. Some methods return ['status' => false, 'message' => ...]; others will throw. Wrap calls in try/catch and check status, log message with the request context, and show users a generic failure — Paystack messages leak internal detail.
  • Extension points are thin. There's no plugin/hook system. For anything beyond wrapping, bind your own gateway class in the container and let it delegate to Payment; if you truly must change behaviour, extend Unicodeveloper\Paystack\Payment and rebind it, but be aware the facade resolves the concrete class and package updates can change protected method signatures.
  • Watch the version pin. 1.2.0 is the first release advertising Laravel 11 support; if you're on Laravel 12+, check composer.json constraints before assuming compatibility, and pin ^1.2 rather than *.
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.
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log
gregsphotography/site-builder-components
io-digital/clickatell
snowsoft/laravel-admin
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator
equipc/laravel-email-database-log
adedsec/nova-translated-labels
wazza/laravel-db-encryption
hokny/laravel-generator
vonsogt/backup
jengo/queues
jeremykenedy/laravel-observability
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums