lacasera / collector-paystack
Subscription billing for Laravel with PayStack: a hosted-style billing portal, plan switching, webhooks and a Cashier-like API.
Requires
- php: ^8.2
- ext-intl: *
- guzzlehttp/guzzle: ^7.8
- illuminate/contracts: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- illuminate/validation: ^12.0|^13.0
- inertiajs/inertia-laravel: ^1.3.1|^2.0
- moneyphp/money: ^4.5
Requires (Dev)
- laravel/pint: ^1.17
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
- phpunit/phpunit: ^11.0|^12.0
README
A modern Laravel package for managing subscription billing with PayStack integration. Collector PayStack provides a complete billing portal with subscription management, payment processing, and customer billing features.
Features
- 🚀 Laravel 12 and 13 support
- 💳 Complete PayStack integration
- 📊 Subscription management (create, update, cancel)
- 🎨 Beautiful React-based billing portal
- 📅 Multiple billing intervals (monthly, yearly, daily, hourly)
- 🔄 Trial periods and grace periods
- 📧 Webhook handling for payment verification
- 🎯 Plan switching capabilities
- 📱 Responsive design with Tailwind CSS
Requirements
- PHP 8.2+ (8.3+ on Laravel 13)
- Laravel 12.0+ or 13.0+
- PayStack account and API keys
Installation
Install the package via composer:
composer require lacasera/collector-paystack
Then run the interactive installer, which publishes the config and assets and runs the migrations:
php artisan collector:install
Prefer to do it by hand? The installer is equivalent to:
php artisan vendor:publish --tag="collector-config" # config/collector.php php artisan vendor:publish --tag="collector-assets" # public/vendor/collector php artisan vendor:publish --tag="collector-views" # optional: resources/views/vendor/collector php artisan migrate
The migrations add billing columns to your
userstable (paystack_id,pm_type,trial_ends_at, …) and create asubscriptionstable.
Configuration
Add your PayStack credentials to your .env file:
PAYSTACK_SECRET_KEY=your_paystack_secret_key COLLECTOR_CURRENCY=NGN
Configure your subscription plans in config/collector.php:
'collectables' => [ 'user' => [ 'model' => App\Models\User::class, 'trial_days' => 14, 'default_interval' => 'monthly', 'plans' => [ [ 'name' => 'Basic', 'description' => 'Perfect for getting started', 'monthly_id' => 'PLN_your_monthly_plan_id', 'yearly_id' => 'PLN_your_yearly_plan_id', 'yearly_incentive' => 'Save 20%', 'features' => [ 'Up to 10 projects', '5GB storage', 'Email support', ], ], // More plans... ], ], ],
Usage
1. Prepare Your User Model
Add the Collectable trait to your User model:
<?php namespace App\Models; use Collector\Collectable; use Illuminate\Foundation\Auth\User as Authenticatable; class User extends Authenticatable { use Collectable; // Your existing model code... }
2. Access the Billing Portal
The billing portal is automatically available at /collector/billing. From the
portal users can:
- Browse the configured plans and toggle between monthly and yearly pricing
- Subscribe to a plan (or switch plans)
- Cancel their current subscription
Customising the portal URL
The portal URI is built from prefix + path, and both are yours to change in
config/collector.php:
'domain' => env('COLLECTOR_DOMAIN'), // null = your app's current domain 'prefix' => env('COLLECTOR_PREFIX', 'collector'), 'path' => 'billing',
| Config | Portal URL |
|---|---|
| defaults | /collector/billing |
'prefix' => 'account' |
/account/billing |
'prefix' => 'account', 'path' => 'subscription' |
/account/subscription |
'domain' => 'billing.example.com', 'prefix' => null |
https://billing.example.com/billing |
The subscribe and cancel endpoints move with the prefix, and the billing portal frontend receives them as shared Inertia props — so nothing needs rebuilding when you change these values.
Always link to the portal with the route name rather than a literal path, so your links follow the config:
route('collector.portal'); // or: $user->billingPortalUrl()
The webhook URL does not move with the prefix. It has its own
webhook_pathsetting, because PayStack learns that URL out-of-band from your dashboard — relocating the portal must not silently break an endpoint PayStack is already posting to. See Handle Webhooks.
3. The Subscription Management Portal
Subscribed customers can manage their billing at /collector/billing/manage
(route('collector.manage')), a tabbed page covering:
- Overview — current plan, amount, next billing date, trial and grace-period state, with actions to change plan or cancel
- Payment History — every PayStack transaction, paginated
- Payment Methods — stored cards, de-duplicated (PayStack records one authorization per transaction, so one card can appear many times)
- Subscriptions — the full subscription history, active and past
The portal reconciles with PayStack on load, so subscriptions started or cancelled outside your application still show up.
Linking to a section
The open section lives in the URL, so a reload keeps its place and you can send customers straight where they need to go:
route('collector.manage'); // overview route('collector.manage', ['section' => 'history']); // payment history route('collector.manage', ['section' => 'methods']); // payment methods route('collector.manage', ['section' => 'subscriptions']); // e.g. after a failed charge, drop them on their card: return redirect()->route('collector.manage', ['section' => 'methods']) ->with('error', 'Your card was declined.');
Valid sections are overview, history, methods and subscriptions; an
unrecognised value falls back to the overview rather than erroring, so a stale
bookmark still lands somewhere useful. Switching section in the browser updates
the URL without refetching, and the back button moves between sections.
How the two pages relate
/collector/billing -> forwards subscribers to the management page
/collector/billing?change=1 -> the plan grid, for switching plans
/collector/billing/manage -> the management page
A customer with an active subscription who visits the plans page is forwarded to
the management page — there is no point showing a plan grid to someone who
already has a plan. "Change plan" sends them back with ?change=1, which is the
flag that suppresses the forward; without it the button would bounce straight
back and appear to do nothing.
Cancelling lives only on the management page. A cancelled subscription stays visible there for the rest of its grace period, showing the date access ends, rather than disappearing the moment it is cancelled.
What PayStack can and cannot do
PayStack's API differs from Stripe's in two ways that shape this page:
| Action | How it works here |
|---|---|
| Change plan | PayStack has no endpoint to move a subscription to another plan. "Change plan" cancels the current subscription and starts a new checkout. |
| Update card | PayStack has no card-replacement API. The portal mints a short-lived link to PayStack's own hosted page, so card details never touch your application. |
Because plan changes are cancel-then-resubscribe, the package cancels any active subscription before starting a new checkout — reconciling with PayStack first, so a subscription missing from your local table cannot survive the switch and keep billing alongside its replacement.
4. Query Subscription State
The Collectable trait and the Subscription model expose the state you need:
$user = auth()->user(); // Is the user subscribed at all? $user->subscribed(); // Subscribed to a specific PayStack plan? (Cashier's subscribedToPrice) $user->subscribedToPlan('PLN_basic_monthly'); // Subscribed to any plan under a product (the plan-name group in config)? $user->subscribedToProduct('Basic'); // The user's active subscription (or null) $subscription = $user->subscription(); // Returns the matching Subscription (or null) $user->hasActivePlan('PLN_basic_monthly'); // The user's current active plan code, and all subscriptions (most recent first) $user->currentActivePlan()?->paystack_plan; $user->subscriptions; // Inspecting a subscription $subscription?->isActive(); $subscription?->onTrial(); $subscription?->onGracePeriod(); $subscription?->valid(); // active, on trial, or within grace period
Customers & Payment Methods
// Create / update the PayStack customer $user->createAsPayStackCustomer(['email' => $user->email]); $user->createOrGetPayStackCustomer(); $user->updatePayStackCustomer(['first_name' => 'Ada']); $user->hasPayStackId(); // Payment methods (from the PayStack customer's authorizations) $user->paymentMethods(); $user->defaultPaymentMethod(); // the stored default card $user->hasPaymentMethod(); // The in-app billing portal URL $url = $user->billingPortalUrl();
5. Start a Subscription
Subscriptions are created through PayStack's hosted checkout. The billing portal
does this for you when a user picks a plan, but you can also start the flow
yourself with a fluent, Cashier-style builder. checkout() returns a redirect to
PayStack:
return $request->user() ->newSubscription('default', 'PLN_basic_monthly') ->trialDays(5) ->checkout([ 'success_url' => route('billing.success'), ]);
success_urlmaps to PayStack'scallback_url; omit it to return to the built-in billing portal, which verifies and records the payment automatically.trialDays()records a trial period on the resulting subscription.- Starting a new subscription cancels the user's existing active subscription on PayStack (plan switching).
Need the raw URL instead of a redirect (e.g. for a JSON/API response)? Cast the result to a string:
$url = (string) $user->newSubscription('default', 'PLN_basic_monthly')->checkout();
6. Cancel a Subscription
$user->subscription()?->cancel('No longer needed');
Cancelling disables the subscription on PayStack and keeps it valid until the end of the current billing period (grace period).
7. Handle Webhooks
The package registers a webhook endpoint at POST /collector/webhooks. Add
this URL to your PayStack dashboard (Settings → API Keys & Webhooks). When
PAYSTACK_SECRET_KEY is set, every request is verified against PayStack's
x-paystack-signature header before it is processed.
This path is set by collector.webhook_path and is deliberately independent of
the portal's prefix, so moving the billing portal leaves the webhook where
your PayStack dashboard expects it. If you do change webhook_path, update the
URL in the dashboard at the same time or events will start 404ing. The endpoint
is named, so you can always resolve it with route('collector.webhook').
Handled events: subscription.create, subscription.not_renew,
charge.success, invoice.create, and invoice.payment_failed.
Events
Collector dispatches events throughout the billing lifecycle so you can hook in your own logic (send receipts, provision access, notify Slack, …):
| Event | Dispatched when |
|---|---|
Collector\Events\WebhookReceived |
Any signed PayStack webhook is received |
Collector\Events\PaymentReceived |
A charge.success / failed-invoice webhook arrives |
Collector\Events\InvoiceCreated |
An invoice.create webhook arrives |
Collector\Events\PaymentVerified |
A user returns from checkout with a reference |
Collector\Events\SubscriptionCanceled |
A subscription is cancelled |
Listen for them as usual:
use Collector\Events\SubscriptionCanceled; use Illuminate\Support\Facades\Event; Event::listen(SubscriptionCanceled::class, function (SubscriptionCanceled $event) { // $event->collectable, $event->subscription });
Authorization & Custom Resolution
By default the billing portal resolves the collectable from the authenticated user and allows access to anyone signed in. Override either behaviour from a service provider:
use App\Models\User; use Collector\Collector; use Illuminate\Http\Request; public function boot(): void { // Resolve which model the portal manages Collector::collectable(User::class)->resolve(fn (Request $request) => $request->user()); // Authorize who may view the portal Collector::collectable(User::class)->authorize( fn (User $collectable, Request $request) => $request->user()?->is($collectable) ); }
You can also change the portal path and its middleware in config/collector.php
(path and middleware).
Custom Models
If you extend the package's models, register them from a service provider (a
single useCustomerModel() call configures both collectable resolution and the
Subscription → owner relationship):
use Collector\Collector; Collector::useCustomerModel(\App\Models\User::class); Collector::useSubscriptionModel(\App\Models\Subscription::class);
Frontend Customization
The package ships a pre-built React portal (React 18 + TypeScript + Tailwind + Inertia.js); the compiled assets are inlined into the portal page, so no build step is required to use it.
To tweak the Blade shell, publish the views:
php artisan vendor:publish --tag="collector-views"
To modify the React source, edit resources/js and rebuild the bundle:
npm install
npm run build # emits public/js/app.js and public/css/collector.css
Testing
PHP (Pest / Testbench):
composer test # run the suite composer test-coverage # with a coverage report
Frontend and end-to-end:
npm run type-check # TypeScript npm run test # Vitest component tests npm run test:e2e # Playwright E2E against a served workbench app
Code Style
composer pint
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details.
Security Vulnerabilities
If you discover any security related issues, please email aboateng62@gmail.com instead of using the issue tracker.
Credits
License
The MIT License (MIT). Please see License File for more information.



