veeqtoh/cashier-paystack
Laravel Cashier for Paystack: a fluent billing library that brings subscriptions, customers, payment methods, single charges, invoices, refunds, and webhook handling to your Laravel app with minimal configuration.
Day-one setup (about 15 minutes):
composer require veeqtoh/cashier-paystack
php artisan vendor:publish --provider="Veeqtoh\Cashier\Providers\CashierServiceProvider"
php artisan migrate
Add the credentials to .env — note that PAYSTACK_MODEL is a package-level config, not something you set per-model:
PAYSTACK_MODEL=App\Models\User
PAYSTACK_PUBLIC_KEY=pk_test_xxx
PAYSTACK_SECRET_KEY=sk_test_xxx
PAYSTACK_PAYMENT_URL=https://api.paystack.co
[email protected]
Then add the trait to your billable model:
use Veeqtoh\Cashier\Billable;
class User extends Authenticatable
{
use Billable;
}
Where to look first, in this order:
config/paystack.php — the actual source of truth for keys, model class, and payment URL. Confirm the published file matches what you put in .env (the package reads config, and stale cached config is the #1 false alarm).users (paystack_id, card metadata, trial_ends_at) versus the new subscriptions table, because you will be querying those columns directly for reporting.Veeqtoh\Cashier\Billable itself — the README has drifted from the code in a couple of places (see Gotchas), so confirm method signatures against the trait and the subscription model rather than trusting the docs verbatim.First use case to build: create a Paystack customer at registration, then convert to a subscription. That single flow exercises the customer API, the plan API, the local subscriptions state machine, and the webhook receiver — everything else is a variation on it.
// At registration
$user->createAsPaystackCustomer(); // see README "Customers > Creating Customers"
// At plan selection, after the Paystack JS popup returns an authorization code
$user->newSubscription('PLN_gx2wn530m0i3w3m', 'main')->create($authCode);
Every call needs both the Paystack plan code (PLN_…) and a local subscription name (main, pro, annual). The name is yours; the code belongs to Paystack. Centralise both so a staging/production plan swap is a config change, not a find-and-replace:
// config/plans.php
return [
'main' => ['code' => env('PAYSTACK_PLAN_MAIN'), 'amount' => 500000],
'annual' => ['code' => env('PAYSTACK_PLAN_ANNUAL'), 'amount' => 5000000],
];
Then $user->newSubscription(config('plans.main.code'), 'main').
authorization_code from the verified transaction, pass it to ->create($authToken). The card is stored, future renewals are automatic.->charge($amountInKobo) returns ['data' => ['authorization_url' => …]]; redirect the user and rely on the webhook to activate the subscription. Use this when you have no JS checkout or need a receipt-first flow.Don't mix them for the same plan — the state transitions differ and debugging "why is subscribed() false after payment" gets ugly.
create() with no token is a footgun for renewals->create() with no argument charges the customer's most recent authorization. That's convenient for a second subscription, but on a brand-new customer there is no prior authorization and you get an opaque API error. Gate it:
if ($user->hasPaystackAuthorization()) { // wrap the check, or inspect asPaystackCustomer()
$user->newSubscription($code, 'main')->create();
} else {
// send them through checkout
}
public function handle($request, Closure $next)
{
if ($request->user() && ! $request->user()->subscribed('main')) {
return redirect()->route('billing');
}
return $next($request);
}
Combine with onGracePeriod() in the UI so a cancelled user sees "your access ends on X" without losing their session mid-flow.
Local state only changes reliably if you receive charge.success, subscription.create, subscription.not_renew, subscription.disable, and invoice.payment_failed. Register the handler in config/paystack.php (or the Cashier:: webhook hook the package exposes), then:
bootstrap/app.php via ->withMiddleware(fn ($m) => $m->validateCsrfTokens(except: ['paystack/webhook'])); on Laravel 10 add it to VerifyCsrfToken::$except.x-paystack-signature HMAC-SHA512 against your secret key yourself if the package doesn't — never trust an unverified payload to mutate billing state.event.data.reference or the subscription code and upsert.// Sketch
public function handle(Request $request)
{
$payload = $request->all();
Log::channel('paystack')->info($payload['event'], $payload); // always log
return match ($payload['event']) {
'charge.success' => $this->activate($payload['data']),
'invoice.payment_failed' => $this->markPastDue($payload['data']),
'subscription.not_renew' => $this->flagCancelled($payload['data']),
default => response()->json(['status' => 'ignored']),
};
}
For one-off purchases (setup fees, add-ons) use the single-charge methods rather than creating a throwaway subscription — it keeps the subscriptions table semantically clean and your reporting sane. Reconcile the returned reference against your own orders table; don't rely on Cashier to be your ledger.
->trialDays(10)->create($authToken) — card collected up front, Paystack doesn't bill until the trial ends. The local trial_ends_at drives onTrial().trial_ends_at on the user row at registration, no card. onGenericTrial() distinguishes it from a real subscription trial.Only support generic trials if you're prepared to email users before expiry; you have no card on file and no automatic recovery.
The README documents cancel/resume, not swap. For plan changes, either call Paystack's subscription endpoint directly via the package's client, or cancelNow() + newSubscription(...) with a documented proration policy. Wrap this in your own service class so the mechanics stay in one place.
README drift. Several names in the docs don't match each other: the "checking subscription status" section mentions subscribedToPaystack() in prose while the code sample calls subscribedToPlan(). The plan_code/plan_name argument order is described in prose but swapped in one of the early examples. Read the trait and the Subscription model before coding against any method you haven't verified — a 5-minute grep saves an hour of BadMethodCallException.
Kobo, always. charge(10000) is ₦100.00. Every amount you pass must already be in the lowest denomination. Keep a Money value object or a single toKobo() helper and never inline * 100 in a controller.
Currency is global and boot-time. Cashier::useCurrency('ghs', 'GH₵') must be called in a service provider's boot(). It affects display formatting; it does not convert anything. If you're multi-currency, that's on you — Paystack plans are currency-bound.
Low bus factor. Six stars, zero known dependents, a single maintainer, and a "last release" date that looks like a typo in the packagist metadata. Treat this as a vendor dependency you own:
"veeqtoh/cashier-paystack": "1.2.3") rather than ^1.0, and review diffs before bumping.BillingService between your controllers and the package. If you ever need to fork, patch, or swap it, the blast radius is one class instead of forty call sites.Never hit the live API in CI. Use Http::fake() with recorded Paystack responses for unit tests, and reserve real test-key calls for a scheduled, non-blocking integration job. The package talks over HTTP to PAYSTACK_PAYMENT_URL, so faking works cleanly.
ends_at semantics are the confusing part. cancel() is a soft cancel: ends_at is set, subscribed() keeps returning true through the grace period, and onGracePeriod() is true until that date. cancelNow() kills access immediately. If your app has ever shown "cancelled" while still granting access, this is why — check cancelled() and onGracePeriod() explicitly in any UI that reports state.
Resume only works inside the grace period. resume() after ends_at has passed will not resurrect anything. If your product promises "reactivate anytime," that's a new subscription, not a resume.
Re-authorization on card expiry. Paystack will emit a failed-charge event when a card dies; the stored authorization becomes useless. Handle invoice.payment_failed with dunning (email + in-app banner) rather than letting subscribed() silently flip to false — users will not know why they lost access.
Config cache is a classic trap. php artisan config:cache after changing PAYSTACK_MODEL or MERCHANT_EMAIL, or the stale value survives deployments. Add config:clear to your local deploy script.
MERCHANT_EMAIL bites only at invoice time. Everything works until someone generates a PDF invoice and the merchant details are blank or wrong. Set it on day one, not at launch.
Migrations on an existing users table. The published migration adds columns rather than rebuilding; check for a naming collision with any card_brand / paystack_id / trial_ends_at you already have, and confirm the columns are nullable so existing rows don't fail the migration in production. Run it on a staging copy with production-shaped data first.
Log every webhook, forever. A paystack log channel with the full payload is the difference between a 5-minute diagnosis and a support-ticket archaeology session. Retain 30–90 days.
How can I help you explore Laravel packages today?