lacasera/collector-paystack

Subscription billing for Laravel with PayStack: a hosted-style billing portal, plan switching, webhooks and a Cashier-like API.

Maintainers

Package info

github.com/lacasera/collector-paystack

pkg:composer/lacasera/collector-paystack

Transparency log

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.1 2026-07-21 15:45 UTC

This package is auto-updated.

Last update: 2026-07-21 15:47:14 UTC


README

Latest Version on Packagist Total Downloads

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.

The billing portal, listing the configured plans with a monthly/yearly toggle

The subscription management portal, showing the current plan, next charge and payment method

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 users table (paystack_id, pm_type, trial_ends_at, …) and create a subscriptions table.

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_path setting, 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.

The payment history section, listing PayStack transactions with amount, status and method

The payment methods section, with a button that hands off to PayStack's hosted card update page

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_url maps to PayStack's callback_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.