定制 vnuswilliams/laravel-subscription 二次开发

按需修改功能、优化性能、对接业务系统,提供一站式技术支持

邮箱:yvsm@zunyunkeji.com | QQ:316430983 | 微信:yvsm316

vnuswilliams/laravel-subscription

Composer 安装命令:

composer require vnuswilliams/laravel-subscription

包简介

A robust, fluent and payment-agnostic subscription management package for Laravel. Handles plans, lifecycle (trial, grace period, cancellation) and consumable feature quotas.

README 文档

README

Latest Version on Packagist Total Downloads License PHP Laravel

A robust, fluent, and fully payment-agnostic Laravel package for managing subscription plans, lifecycle states (trial, grace period, cancellation), and consumable feature quotas.

This package does not handle any payments. It exclusively manages subscription business logic: who has access to what, for how long, and how much they have left. You plug in the payment provider of your choice (Stripe, Paystack, Flutterwave, PayPal…) around it.

This documentation is always release in french version.

Table of Contents

  1. Architecture
  2. Installation
  3. Configuring Plans in the Database
  4. Preparing the Subscriber Model
  5. Entry Points: Three Ways to Use the Package
  6. Managing Subscriptions
  7. Features & Quotas
  8. Lifecycle & Grace Period
  9. Route Protection Middleware
  10. Laravel Events
  11. Artisan Command
  12. Full Recipe: Application Service
  13. API Reference
  14. Tips & Best Practices

Architecture

The package is built on a strict separation of concerns:

SubscriptionManager          ← Public entry point (Facade or injection)
    │
    ├── SubscriptionService  ← Logic: subscribeTo, cancel, switchTo, renew…
    └── FeatureService       ← Logic: canConsume, consume, release, balance…

HasSubscriptions (Trait)     ← Ergonomic proxy on the Eloquent model

Golden rule: the HasSubscriptions trait contains no business logic. It delegates everything to the SubscriptionManager. This keeps the logic testable, injectable, and independent of the Eloquent model.

Installation

1. Install via Composer

composer require vnuswilliams/laravel-subscription

The ServiceProvider and Facade are auto-discovered by Laravel. No manual registration needed.

2. Publish the configuration and migrations

# Configuration
php artisan vendor:publish --tag=subscription-config

# Migrations
php artisan vendor:publish --tag=subscription-migrations

# Run migrations
php artisan migrate

3. (Optional) Publish the application service stub

php artisan vendor:publish --tag=subscription-stubs

This copies app/Services/SubscriptionService.php into your project — a pre-filled service tailored to your business domain (see the Full Recipe section).

Configuring Plans in the Database

The package does not create your plans automatically. You insert them via a seeder, a migration, or your application's admin interface.

Here is the expected structure for a monthly plan with features:

// database/seeders/PlanSeeder.php

use Vnuswilliams\Subscription\Models\Plan;
use Vnuswilliams\Subscription\Enums\FeatureType;
use Vnuswilliams\Subscription\Enums\PeriodicityType;

// Pro Plan — monthly, 7-day grace period
$pro = Plan::create([
    'name'             => 'Pro',
    'slug'             => 'pro',
    'description'      => 'For growing teams.',
    'periodicity_type' => PeriodicityType::Month->value,  // 'month'
    'periodicity'      => 1,
    'trial_days'       => 0,
    'grace_days'       => 7,
    'is_active'        => true,
]);

// Consumable feature: employee quota
$pro->features()->create([
    'slug'    => 'max-employees',
    'name'    => 'Number of employees',
    'type'    => FeatureType::Consumable->value,  // 'consumable'
    'charges' => 25,  // 25 available slots
]);

// Boolean feature: access to the employee portal
$pro->features()->create([
    'slug'    => 'employee-portal',
    'name'    => 'Employee Portal',
    'type'    => FeatureType::Boolean->value,  // 'boolean'
    'charges' => null,  // null = unlimited / no counter
]);

// Free Plan — permanent (no periodicity), 15-day trial
Plan::create([
    'name'             => 'Free',
    'slug'             => 'free',
    'periodicity_type' => null,   // null = permanent plan, never expires
    'periodicity'      => null,
    'trial_days'       => 15,
    'grace_days'       => 0,
    'is_active'        => true,
]);

Tip: centralise all your feature slugs in a FeatureEnum in your application. This prevents typos and gives you IDE autocompletion.

// app/Enums/FeatureEnum.php
enum FeatureEnum: string
{
    case MAX_EMPLOYEES    = 'max-employees';
    case EMPLOYEE_PORTAL  = 'employee-portal';
    case DOCUMENTS        = 'documents';
    case ADVANCED_REPORTS = 'advanced-reports';
    case PRIORITY_SUPPORT = 'priority-support';
}

Preparing the Subscriber Model

Add the HasSubscriptions trait to any Eloquent model that needs to subscribe to a plan: User, Company, Team, Organization

// app/Models/Company.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Vnuswilliams\Subscription\Traits\HasSubscriptions;

class Company extends Model
{
    use HasSubscriptions;
}

That's it. The trait automatically exposes the subscription() relationship and all of the package's fluent methods directly on your model.

Entry Points: Three Ways to Use the Package

The package exposes three interfaces depending on your context. Choose the one that fits your situation.

1. Via the Trait (on the model)

The most fluent syntax for one-off calls directly on the Eloquent instance:

$company->subscribeTo('pro');
$company->hasActiveSubscription();
$company->canConsume('max-employees', 1);
$company->balance('max-employees');

Ideal in Observers, Policies, or quick checks inside a Controller.

2. Via the Facade (anywhere in the app)

The Laravel-style static syntax, accessible everywhere without injection:

use Vnuswilliams\Subscription\Facades\Subscription;

Subscription::subscribeTo($company, 'pro');
Subscription::cancel($company);
Subscription::canConsume($company, 'max-employees', 1);
Subscription::balance($company, 'max-employees');

Ideal in Controllers, Actions, Jobs, or Listeners.

3. Via SubscriptionManager injection (in your services)

The recommended approach for complex business logic. Fully testable, with no static dependency:

use Vnuswilliams\Subscription\SubscriptionManager;

class SubscriptionService
{
    public function __construct(
        private readonly SubscriptionManager $subscription,
    ) {}

    public function canAddEmployee(Company $company): bool
    {
        return $this->subscription->hasActiveSubscription($company)
            && $this->subscription->canConsume($company, FeatureEnum::MAX_EMPLOYEES->value, 1);
    }
}

Tip: in a dedicated subscription service, always prefer direct injection. The Facade is handy for isolated calls, but makes unit testing harder.

Managing Subscriptions

Subscribing to a plan

Pass the plan slug (string) or a Plan instance directly:

// By slug
$company->subscribeTo('pro');

// By instance
$plan = Plan::where('slug', 'pro')->firstOrFail();
$company->subscribeTo($plan);

// Via the Facade
use Vnuswilliams\Subscription\Facades\Subscription;
Subscription::subscribeTo($company, 'pro');

If the plan has trial_days > 0, the status will automatically be set to on_trial and trial_ends_at will be calculated. No additional action required.

Subscribing with a custom expiration

Useful for free plans or fixed-duration promotional offers:

// Free plan with a 15-day trial
$company->subscribeTo('free', expiration: now()->addDays(15));

// Promotional offer: 3 months free
$company->subscribeTo('pro', expiration: now()->addMonths(3));

Switching plans (upgrade / downgrade)

// Immediate switch: the old subscription is removed, the new one starts
$company->switchTo('business');

// Deferred switch: the old subscription runs until its end date
$company->switchTo('starter', immediately: false);

// Via the Facade
Subscription::switchTo($company, 'business');

Cancelling a subscription

Cancellation does not cut access immediately. The user retains access until ends_at, then the grace period activates if configured. This is the expected behaviour for an end-of-period cancellation.

$company->subscription->cancel();

// Or via the Facade
Subscription::cancel($company);

To check whether a subscription is cancelled but still running:

if ($company->subscription->isCanceled()) {
    // The user has cancelled, but still has access until ends_at
    $expiresAt = $company->subscriptionExpiresAt();
}

Revoking access immediately

To cut access without waiting for the end of the period (suspension for non-payment, terms of service violation, etc.):

$company->subscription->suppress();

// Or via the Facade
Subscription::suppress($company);

Renewing a subscription

Starts a full new cycle from now. Useful after a successful payment:

$company->renewSubscription();

// Or via the Facade
Subscription::renew($company);

Checking subscription status

// Is the subscription valid? (active, on trial, or in grace period)
$company->hasActiveSubscription(); // bool

// What is the current plan?
$plan = $company->currentPlan(); // Plan|null
echo $plan->name;  // 'Pro'
echo $plan->slug;  // 'pro'

// When does it expire?
$date = $company->subscriptionExpiresAt(); // Carbon|null

// Direct access to the Subscription model
$sub = $company->subscription;
$sub->isActive();        // bool
$sub->isOnTrial();       // bool
$sub->isOnGracePeriod(); // bool
$sub->isCanceled();      // bool
$sub->isExpired();       // bool
$sub->hasAccess();       // bool — aggregates all valid states

Features & Quotas

Boolean features (yes/no access)

A boolean feature is simply attached to a plan or not. If it is not in the plan's feature list, access is denied.

// Does the company have access to the employee portal?
if ($company->canConsume('employee-portal')) {
    // access granted
}

// Via the Facade
if (Subscription::canConsume($company, 'employee-portal')) {
    // access granted
}

For a boolean feature, the $amount parameter is ignored. canConsume('feature', 0) and canConsume('feature', 1) return the same result.

Consumable features (quotas)

The standard flow for a quota feature: check → act → consume.

// ✅ Recommended pattern
if ($company->canConsume('max-employees', 1)) {

    // Business action first
    $employee = Employee::create([...]);

    // Consumption afterwards
    $company->consume('max-employees', 1);

} else {
    return back()->with('error', 'Employee quota reached. Please upgrade your plan.');
}

Important: always call canConsume() before consume(). The package does not throw an exception if you consume beyond the quota — that guard is your responsibility.

Releasing a slot (decrementing consumption)

When you delete a resource, release the corresponding slot:

// Deleting an employee → releases 1 slot
$employee->delete();
$company->release('max-employees', 1);

release() decrements used safely (never below 0). This is more reliable than deleting the last consumption record.

Inspecting quotas (for dashboards)

// Total slots allocated by the plan
$total = $company->totalCharges('max-employees');  // e.g. 25

// Slots consumed in the current period
$used = $company->usedCharges('max-employees');    // e.g. 17

// Remaining slots (PHP_INT_MAX if unlimited)
$remaining = $company->balance('max-employees');   // e.g. 8

Example usage in a Blade view for a progress bar:

@php
    $total     = $company->totalCharges('max-employees');
    $used      = $company->usedCharges('max-employees');
    $remaining = $company->balance('max-employees');
    $percent   = $total > 0 ? round(($used / $total) * 100) : 0;
@endphp

<div class="quota-bar">
    <div class="quota-bar__fill" style="width: {{ $percent }}%"></div>
</div>
<p>{{ $used }} / {{ $total }} employees — {{ $remaining }} slots remaining</p>

Lifecycle & Grace Period

The full lifecycle of a subscription:

[on_trial] ──(trial_ends_at passed)──> [active]
[active]   ──(ends_at passed)────────> [on_grace_period] ──(grace_ends_at passed)──> [expired]
[active]   ──(cancel())───────────────> [canceled] (hasAccess() = true until ends_at)
[active]   ──(suppress())─────────────> [expired]  (hasAccess() = false immediately)

The hasAccess() method is your single source of truth. It returns true for the active, on_trial, on_grace_period, and canceled (if ends_at is in the future) states. It returns false for expired and suppressed subscriptions.

Configuring the grace period per plan

The grace period is configured in the plan data (grace_days column). No global configuration is required. Each plan can have its own duration:

Plan::create([
    'slug'       => 'pro',
    'grace_days' => 7,   // 7-day grace period after expiration
    // ...
]);

Plan::create([
    'slug'       => 'free',
    'grace_days' => 0,   // No grace period on the free plan
    // ...
]);

Route Protection Middleware

The package automatically registers the subscribed middleware. Use it in your route files:

// routes/web.php

// Requires any valid subscription
Route::middleware(['auth', 'subscribed'])->group(function () {
    Route::get('/dashboard', [DashboardController::class, 'index']);
    Route::get('/employees', [EmployeeController::class, 'index']);
});

// Requires a specific plan (by slug)
Route::middleware(['auth', 'subscribed:business'])->group(function () {
    Route::get('/analytics', [AnalyticsController::class, 'index']);
    Route::get('/support', [SupportController::class, 'index']);
});

When access is denied, the middleware returns:

  • A JSON 403 if the request expects JSON (Accept: application/json)
  • A redirect to home with an error flash message otherwise

To customise this behaviour, extend CheckSubscription and rebind it in your AppServiceProvider.

Laravel Events

The package dispatches native Laravel events on every lifecycle transition. Register your listeners in EventServiceProvider or using Laravel 11+ #[AsEventListener] attributes.

Event Triggered when Typical use case
SubscriptionCreated A new subscription is created Welcome email, access activation
SubscriptionCanceled Subscription cancelled (end of period) Retention email, exit survey
SubscriptionEnteredGracePeriod Expiration reached, grace activated Urgent payment reminder email
SubscriptionExpired Grace period over, access cut Suspension, notification, archiving
FeatureQuotaReached A feature quota is exhausted Upsell notification, admin alert
// app/Listeners/SendWelcomeEmail.php

use Vnuswilliams\Subscription\Events\SubscriptionCreated;

class SendWelcomeEmail
{
    public function handle(SubscriptionCreated $event): void
    {
        $subscriber = $event->subscription->subscriber;
        // $subscriber is the Company, User, etc. instance

        Mail::to($subscriber->email)->send(new WelcomeMail($subscriber));
    }
}
// app/Listeners/NotifyQuotaExhausted.php

use Vnuswilliams\Subscription\Events\FeatureQuotaReached;

class NotifyQuotaExhausted
{
    public function handle(FeatureQuotaReached $event): void
    {
        $subscriber  = $event->subscription->subscriber;
        $featureSlug = $event->feature->slug;  // e.g. 'max-employees'

        // Send a notification suggesting an upgrade
        $subscriber->notify(new QuotaReachedNotification($featureSlug));
    }
}

Artisan Command

The subscription:check-lifecycle command iterates over all subscriptions in the database and performs any missing status transitions (active → on_grace_period → expired).

It is useful for users who do not log in often: their subscription will move to grace or expire even without an incoming request, and the relevant events will be dispatched correctly.

# Manual execution
php artisan subscription:check-lifecycle

Schedule it to run daily in routes/console.php (Laravel 11+):

// routes/console.php

use Illuminate\Support\Facades\Schedule;

Schedule::command('subscription:check-lifecycle')->daily();

Or in app/Console/Kernel.php (Laravel 10 and earlier):

protected function schedule(Schedule $schedule): void
{
    $schedule->command('subscription:check-lifecycle')->daily();
}

Full Recipe: Application Service

Publish the stub provided by the package, then adapt it to your domain:

php artisan vendor:publish --tag=subscription-stubs

This generates app/Services/SubscriptionService.php. Here is what it contains and how to use it:

// app/Services/SubscriptionService.php

use Vnuswilliams\Subscription\SubscriptionManager;

final class SubscriptionService
{
    public function __construct(
        private readonly SubscriptionManager $subscription,
    ) {}

    public function subscribeTo(Company $company, PlanEnum $planEnum): Subscription
    {
        $plan = $this->subscription->resolvePlan($planEnum->value);

        // App-specific business logic:
        // the FREE plan gets a manual 15-day trial
        if ($planEnum === PlanEnum::FREE) {
            return $this->subscription->subscribeTo($company, $plan, expiration: now()->addDays(15));
        }

        return $this->subscription->subscribeTo($company, $plan);
    }

    public function canAddEmployee(Company $company): bool
    {
        return $this->subscription->hasActiveSubscription($company)
            && $this->subscription->canConsume($company, FeatureEnum::MAX_EMPLOYEES->value, 1);
    }

    public function consumeEmployeeSlot(Company $company): void
    {
        $this->subscription->consume($company, FeatureEnum::MAX_EMPLOYEES->value, 1);
    }

    public function releaseEmployeeSlot(Company $company): void
    {
        $this->subscription->release($company, FeatureEnum::MAX_EMPLOYEES->value, 1);
    }

    public function remainingEmployeeSlots(Company $company): int
    {
        return $this->subscription->balance($company, FeatureEnum::MAX_EMPLOYEES->value);
    }

    public function currentPlan(Company $company): ?PlanEnum
    {
        $plan = $this->subscription->currentPlan($company);

        return $plan !== null ? PlanEnum::tryFrom($plan->slug) : null;
    }
}

Usage in a Controller:

// app/Http/Controllers/EmployeeController.php

class EmployeeController extends Controller
{
    public function __construct(
        private readonly SubscriptionService $subscriptionService,
    ) {}

    public function store(StoreEmployeeRequest $request): RedirectResponse
    {
        $company = $request->user()->company;

        if (! $this->subscriptionService->canAddEmployee($company)) {
            return back()->with('error', 'Employee quota reached.');
        }

        $employee = Employee::create($request->validated());

        $this->subscriptionService->consumeEmployeeSlot($company);

        return redirect()->route('employees.index')
            ->with('success', 'Employee added successfully.');
    }

    public function destroy(Employee $employee): RedirectResponse
    {
        $company = auth()->user()->company;

        $employee->delete();

        // Release the slot so it can be reused
        $this->subscriptionService->releaseEmployeeSlot($company);

        return redirect()->route('employees.index')
            ->with('success', 'Employee deleted.');
    }
}

API Reference

HasSubscriptions Trait

Method Return Description
subscription() MorphOne Eloquent relationship to the latest subscription
subscribeTo($plan, $expiration, $immediately) Subscription Subscribes or switches if an active subscription exists
switchTo($plan, $immediately) Subscription Switches plan
renewSubscription() Subscription Renews from now
hasActiveSubscription() bool Is the subscription valid? (active, trial, grace)
currentPlan() Plan|null Current plan
subscriptionExpiresAt() Carbon|null Expiration date
canConsume($slug, $amount) bool Quota or boolean access available?
consume($slug, $amount) SubscriptionUsage Consumes $amount units
release($slug, $amount) SubscriptionUsage Releases $amount units
balance($slug) int Remaining balance (PHP_INT_MAX if unlimited)
totalCharges($slug) int Total allocated by the plan
usedCharges($slug) int Amount consumed

Subscription Model

Method Return Description
isActive() bool Status is active AND ends_at is in the future
isOnTrial() bool trial_ends_at is in the future
isOnGracePeriod() bool Within the grace window
isCanceled() bool Cancelled (access may still be available)
isSuppressed() bool Immediately revoked
isExpired() bool No access remaining
hasAccess() bool Global source of truth
cancel() static End-of-period cancellation
suppress() static Immediate access revocation
renew() static Renewal from now

Available Enums

use Vnuswilliams\Subscription\Enums\SubscriptionStatus;
use Vnuswilliams\Subscription\Enums\FeatureType;
use Vnuswilliams\Subscription\Enums\PeriodicityType;

PeriodicityType::Day;    // 'day'
PeriodicityType::Week;   // 'week'
PeriodicityType::Month;  // 'month'
PeriodicityType::Year;   // 'year'

FeatureType::Boolean;    // 'boolean'
FeatureType::Consumable; // 'consumable'

SubscriptionStatus::Active;        // 'active'
SubscriptionStatus::OnTrial;       // 'on_trial'
SubscriptionStatus::OnGracePeriod; // 'on_grace_period'
SubscriptionStatus::Canceled;      // 'canceled'
SubscriptionStatus::Expired;       // 'expired'

Tips & Best Practices

Centralise your feature slugs in an enum. A typo like 'max-employes' instead of 'max-employees' silently returns false. FeatureEnum::MAX_EMPLOYEES->value never makes that mistake.

Always check before consuming. The package does not throw an exception if you call consume() when the quota is exhausted. The canConsume() guard is your responsibility.

Use release() when deleting resources. If a user deletes an employee, release the slot. Otherwise the counter stays inflated and the user loses capacity they should get back.

Do not confuse cancel() and suppress(). cancel() is a normal cancellation — access is maintained until the end of the paid period. suppress() is an administrative or punitive suspension — access is cut immediately.

Inject SubscriptionManager in your services, use the Facade in your controllers. Services need to be unit-testable — avoid the Facade in classes you test with pest or phpunit. In a Controller or a Livewire component, the Facade is perfectly appropriate.

Handle exceptions. The package throws typed exceptions for error cases:

use Vnuswilliams\Subscription\Exceptions\InvalidPlanException;
use Vnuswilliams\Subscription\Exceptions\SubscriptionNotFoundException;
use Vnuswilliams\Subscription\Exceptions\FeatureNotFoundException;

try {
    Subscription::subscribeTo($company, 'non-existent-plan');
} catch (InvalidPlanException $e) {
    // Plan not found or inactive
    Log::warning($e->getMessage());
}

Schedule the subscription:check-lifecycle command without fail. Without it, an inactive user who makes no requests will never see their subscription transition to expired in the database — and SubscriptionExpired events will never be dispatched.

License

This package is open-sourced software licensed under the MIT license.

统计信息

  • 总下载量: 0
  • 月度下载量: 0
  • 日度下载量: 0
  • 收藏数: 0
  • 点击次数: 0
  • 依赖项目数: 0
  • 推荐数: 0

GitHub 信息

  • Stars: 0
  • Watchers: 0
  • Forks: 0
  • 开发语言: PHP

其他信息

  • 授权协议: MIT
  • 更新时间: 2026-06-26

承接程序开发

PHP开发

VUE

Vue开发

前端开发

小程序开发

公众号开发

系统定制

数据库设计

云部署

网站建设

安全加固