binkode/laravel-paystack
A Laravel wrapper for the Paystack payment gateway with a clean, expressive API. Process transactions, manage customers, plans, and subscriptions, and handle webhooks through simple methods. Supports one-off and recurring payments, with publishable config for quick, convention-friendly setup.
Install and wire it up before touching any endpoint class:
composer require myckhel/laravel-paystack
php artisan vendor:publish --tag=paystack-config
Add PAYSTACK_PUBLIC_KEY, PAYSTACK_SECRET_KEY, and (if you use it) PAYSTACK_PAYMENT_URL to .env. The published config/paystack.php is the single source of truth for keys, base URL, and default currency — read it first, then read src/Paystack.php (the facade root) and the resource classes it dispatches to. The facade is the entry point for everything; the resource classes are one-per-Paystack-endpoint-group and are the best documentation of what is actually supported in the version you installed.
First use case — a redirect checkout, which is the 80% path:
use Myckhel\Paystack\Facades\Paystack;
$res = Paystack::transaction()->initialize([
'email' => $user->email,
'amount' => 50000, // kobo
'reference' => $reference = 'ord_'.Str::uuid(),
'callback_url' => route('paystack.callback'),
'metadata' => ['order_id' => $order->id, 'user_id' => $user->id],
]);
return redirect($res['data']['authorization_url']);
Then on the callback route, Paystack::transaction()->verify($reference) server-side before you mark anything paid. Do not trust the query string.
Note on v1.6.0: this release adds Laravel 13 / PHP 8.4 support (CI + dependency constraints) and exposes the Terminal, Virtual Terminal, and Order APIs. If you are on Laravel 11/12 today, the upgrade is non-breaking for existing calls; the new surface is purely additive.
Dynamic resource dispatch is the core idiom. Paystack::transaction(), Paystack::charge(), Paystack::transfer(), Paystack::customer(), and now Paystack::terminal(), Paystack::virtualTerminal(), Paystack::order() all return a resource object you call methods on. The returned value is a plain array shaped like Paystack's JSON. There is no DTO layer, so wrap it immediately:
final class PaystackGateway
{
public function verify(string $reference): PaymentResult
{
$res = Paystack::transaction()->verify($reference);
// normalize, assert $res['status'] === true, map data.status
}
}
One service class per domain concept keeps the array-shape fragility in one place and gives you something to fake in tests.
Escape hatch for anything not wrapped. The package exposes a generic route-based call for arbitrary endpoints. When the new Terminal/Order wrappers don't cover a specific Paystack endpoint (or when Paystack ships a new one before the package does), use that rather than dropping to raw Guzzle — you keep the package's base URL, auth headers, and error handling:
Paystack::route('order')->post($payload);
Paystack::route('virtual-terminal/'.$code)->get();
This is the most important pattern for day-to-day work: the wrapper will always lag the API, the route escape hatch won't.
Webhooks, not callbacks, are the source of truth. Register a single route, verify the x-paystack-signature header as HMAC-SHA512 of the raw body using your secret key, return 200 immediately, and dispatch the actual fulfilment to a queued job keyed on the event reference. Handle charge.success, transfer.success, transfer.failed, and — new with v1.6.0 — the terminal/order charge events. Terminal and Virtual Terminal charges are assisted and asynchronous, so webhook reconciliation is not optional there; a synchronous "did it work?" check will be wrong or racy.
Idempotency by reference. Store a unique index on reference (transactions) and on the webhook event identity. Paystack retries; your job will run twice. First-write-wins in the DB is cheaper than a distributed lock.
Amounts are integers in subunits. 50000 is ₦500.00, 50000 is also $500.00 if the currency is GHS/USD — the integer means nothing without currency. Persist both together and always cross-check the verified amount and currency against the order total before fulfilment.
Per-tenant / per-store keys. Multi-tenant apps need different keys per request. The pragmatic pattern is to resolve the tenant's keys and push them into config before the call (config()->set('paystack.secretKey', $tenant->paystack_secret)), scoped tightly inside a service method, or to construct a fresh client instance per tenant. Avoid setting this globally in a middleware — it leaks across queued jobs and long-running Octane workers.
Testing. Point at sandbox keys, and use Laravel's HTTP client fakes against api.paystack.co/* to assert the payload you build (amount, reference, metadata) rather than the response shape. Have one sandbox smoke test per flow you actually ship; the rest should be faked.
status is a boolean, not an HTTP code. Paystack frequently returns HTTP 200 with "status": false and a message. Always gate on $res['status'] === true and $res['data']['status'] === 'success' for transactions. A try/catch around the call will not catch a declined charge.$request->getContent(), not $request->all() — any re-serialization breaks the HMAC. Exclude the route from CSRF, and be aware that global middleware which mutates input can still make this fail confusingly. Log the signature mismatch, not the body.(int) ($total * 100) occasionally truncates. Use round() or, better, store prices in subunits and never multiply.array. Static analysis won't catch a renamed key; a Paystack-side field change becomes a silent null. Normalize at the boundary and assert with explicit checks.Paystack::route() escape hatch in mind — if a method is missing or misbehaves, calling the underlying endpoint directly through the package costs you nothing.composer update --with-all-dependencies and smoke-test your own flows — CI green on a matrix doesn't cover your webhook and queue interactions.metadata — it often contains customer PII.PaystackGateway service is where business logic belongs — don't let controllers call the facade directly, or you'll have no seam for faking or for swapping providers later.How can I help you explore Laravel packages today?