PHPackages                             artisan-build/till - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Payment Processing](/categories/payments)
4. /
5. artisan-build/till

ActiveLibrary[Payment Processing](/categories/payments)

artisan-build/till
==================

A companion to Laravel Cashier that provides Verbs-enabled webhook controllers and some utilities for setting up products and plans

00PHPCI failing

Since Oct 13Pushed 7mo ago2 watchersCompare

[ Source](https://github.com/artisan-build/till)[ Packagist](https://packagist.org/packages/artisan-build/till)[ RSS](/packages/artisan-build-till/feed)WikiDiscussions main Synced 1mo ago

READMEChangelogDependenciesVersions (1)Used By (0)

Till
====

[](#till)

A companion to Laravel Cashier that provides Verbs-enabled subscription management with powerful abilities, ledgers, and flexible payment processor integration.

Warning

This package is currently under active development, and we have not yet released a major version. Once a 0.\* version has been tagged, we strongly recommend locking your application to a specific working version because we might make breaking changes even in patch releases until we've tagged 1.0.

Overview
--------

[](#overview)

Till is a subscription management system built on top of Verbs (event sourcing) that provides:

- **Plan-based subscriptions** with individual and team modes
- **Ability system** for granular feature control
- **Ledger system** for usage-based billing (credits, quotas, etc.)
- **Event-sourced state** for reliable subscription tracking
- **Payment processor abstraction** (Stripe, Demo mode, extensible)
- **Built-in pricing UI components** with Livewire and Flux UI
- **Command-line scaffolding** for plans and abilities

Unlike traditional subscription packages that simply track billing, Till provides a complete feature management system where you define what users can do based on their subscription plan.

Requirements
------------

[](#requirements)

- PHP 8.3+
- Laravel 11.36+ or Laravel 12+
- Verbs package (event sourcing)
- Flux Themes package
- Sushi (for plan data modeling)

Installation
------------

[](#installation)

Install the package via Composer:

```
composer require artisan-build/till
```

### Running the Installer

[](#running-the-installer)

```
php artisan till:install
```

The installer will:

1. Publish the configuration file to `config/till.php`
2. Ask where you want to store your Plans (default: `app/Plans`)
3. Detect team mode (based on presence of `Team` model)
4. Configure user and team models
5. Create the Plans directory and Abilities subdirectory
6. Generate an `UnsubscribedPlan` as your default free tier

**Options:**

```
# Specify plans directory location
php artisan till:install Plans

# Undo installation and remove all published files
php artisan till:install --undo

# Force reinstall even if already installed
php artisan till:install --force
```

Configuration
-------------

[](#configuration)

The configuration file is published to `config/till.php`:

```
return [
    // Payment processor (Demo, Stripe)
    'payment_processor' => PaymentProcessors::Demo,

    // Enable team-based subscriptions vs individual subscriptions
    'team_mode' => true,

    // Model configuration
    'team_model' => \App\Models\Team::class,
    'user_model' => \App\Models\User::class,

    // Use live or test prices (null = auto-detect based on environment)
    'live_or_test' => env('TILL_LIVE_OR_TEST'),

    // Route URIs
    'subscribe_uri' => env('TILL_SUBSCRIBE_URI', 'subscribe'),
    'pricing_uri' => env('TILL_PRICING_URI', 'pricing'),
    'plans_uri' => env('TILL_PLANS_URI', 'plans'),

    // Default pricing display (week, month, year, life)
    'default_display' => env('TILL_DEFAULT_DISPLAY', 'year'),

    // Always show lifetime pricing option
    'always_show_lifetime' => true,

    // Path to your subscription plans
    'plan_path' => app_path('Plans'),

    // Livewire component for pricing display
    'pricing_section_template' => 'till::livewire.pricing_section',

    // Custom icons for active/inactive features
    'active_feature_icon' => null,
    'inactive_feature_icon' => null,

    // Ledger update frequencies
    'update_ledgers' => [
        'hourly' => true,
        'daily' => true,
        'weekly' => true,
        'monthly' => true,
        'yearly' => true,
    ],
];
```

Core Concepts
-------------

[](#core-concepts)

### Plans

[](#plans)

Plans define subscription tiers with pricing, features, and abilities. Each plan is a PHP class that extends `BasePlan`.

**Key Plan Properties:**

- `$prices` - Array of prices for different terms (week, month, year, life)
- `$features` - Array of features displayed on pricing pages
- `$can` - Array of abilities subscribers can perform
- `$wallet` - Array of ledger balances (for usage-based features)
- `$heading` - Display name for the plan
- `$subheading` - Tagline or description
- `$badge` - Badge configuration for plan display

### Abilities

[](#abilities)

Abilities are invokable classes that determine what a subscriber can do. Each ability returns a boolean indicating permission.

**Example Ability:**

```
namespace App\Plans\Abilities;

class SendEmails
{
    public function __invoke(?int $limit = null)
    {
        // Unlimited if no limit specified
        if ($limit === null) {
            return true;
        }

        $sent = auth()->user()->emails()->thisMonth()->count();

        return $sent < $limit;
    }
}
```

### Ledgers

[](#ledgers)

Ledgers track usage-based resources (credits, API calls, storage, etc.). They support:

- **Deposits** - Add credits to a ledger
- **Withdrawals** - Remove credits from a ledger
- **Spending** - Track usage with automatic balance checking
- **Period-based resets** - Hourly, daily, weekly, monthly, yearly

### Subscriber State

[](#subscriber-state)

The `SubscriberState` is a Verbs state that tracks:

- Current plan
- Renewal and expiration dates
- Wallet balances for ledgers
- Transaction history

Creating Plans
--------------

[](#creating-plans)

### Using the Command

[](#using-the-command)

```
php artisan till:create-plan
```

The command will prompt you for:

1. **Class name** - e.g., `StarterPlan`, `ProPlan`
2. **Heading** - Display name like "Pro"
3. **Subheading** - Tagline like "For growing teams"
4. **Prices** - Cost for week, month, year, and lifetime

This generates a plan class in your configured plans directory.

### Manual Plan Creation

[](#manual-plan-creation)

```
namespace App\Plans;

use ArtisanBuild\Till\Attributes\TeamPlan;
use ArtisanBuild\Till\Enums\PlanTerms;
use ArtisanBuild\Till\SubscriptionPlans\BasePlan;

#[TeamPlan]
class ProPlan extends BasePlan
{
    public array $prices = [
        PlanTerms::Month->value => 49.00,
        PlanTerms::Year->value => 490.00,
        PlanTerms::Life->value => 1490.00,
    ];

    public string $heading = 'Pro';
    public string $subheading = 'For growing teams';

    public array $features = [
        ['text' => 'Unlimited team members', 'icon' => 'users'],
        ['text' => '10,000 API calls per month', 'icon' => 'cloud'],
        ['text' => 'Priority support', 'icon' => 'chat'],
    ];

    public array $can = [
        ['AddUnlimitedSeats', []],
        ['SendEmails', ['limit' => 10000]],
        ['AccessAdvancedReports', []],
    ];
}
```

Using Abilities
---------------

[](#using-abilities)

### Adding the Tillable Trait

[](#adding-the-tillable-trait)

Add the `Tillable` trait to your User model:

```
use ArtisanBuild\Till\Traits\Tillable;

class User extends Authenticatable
{
    use Tillable;
}
```

This provides:

- `subscription()` - Get the subscriber state
- `abilities()` - Get array of all abilities
- `ableTo($ability)` - Check a specific ability

### Checking Abilities

[](#checking-abilities)

```
// In controllers
if (auth()->user()->ableTo('send-emails')) {
    // Send email
}

// In Blade templates
@if(auth()->user()->ableTo('access-advanced-reports'))
    Advanced Reports
@endif

// Using middleware (planned feature)
Route::middleware(['till:send-emails'])->group(function () {
    // Protected routes
});
```

Working with Ledgers
--------------------

[](#working-with-ledgers)

### Defining Ledgers

[](#defining-ledgers)

Create an enum for your ledgers:

```
namespace App\Plans;

enum Ledgers: string
{
    case ApiCalls = 'api-calls';
    case Storage = 'storage';
    case Emails = 'emails';
}
```

### Using Ledgers in Plans

[](#using-ledgers-in-plans)

```
use ArtisanBuild\Till\Enums\LedgerPeriods;

class ProPlan extends BasePlan
{
    public array $wallet = [
        Ledgers::ApiCalls->value => [
            'balance' => 10000,
            'period' => LedgerPeriods::Monthly,
        ],
        Ledgers::Storage->value => [
            'balance' => 100, // GB
            'period' => null, // Never resets
        ],
    ];
}
```

### Spending from Ledgers

[](#spending-from-ledgers)

```
use ArtisanBuild\Till\Events\LedgerDebited;

// Spend 1 credit from a ledger
LedgerDebited::fire(
    subscriber_id: auth()->user()->subscriberId(),
    ledger: Ledgers::ApiCalls,
    amount: 1
);

// Check balance before spending
$state = auth()->user()->subscription();
$balance = $state->wallet[Ledgers::ApiCalls->value] ?? 0;

if ($balance >= 10) {
    // Proceed with operation
}
```

### Depositing to Ledgers

[](#depositing-to-ledgers)

```
$state = auth()->user()->subscription();
$state->deposit(Ledgers::ApiCalls->value, 5000);
```

### Cost Attributes

[](#cost-attributes)

Mark actions with costs using the `#[Costs]` attribute:

```
use ArtisanBuild\Till\Attributes\Costs;

#[Costs(ledger: Ledgers::ApiCalls, amount: 1)]
class ProcessApiRequest
{
    public function __invoke($request)
    {
        // Processing logic
    }
}
```

Subscription Events
-------------------

[](#subscription-events)

Till uses event sourcing through Verbs. Key events:

### SubscriptionStarted

[](#subscriptionstarted)

```
use ArtisanBuild\Till\Events\SubscriptionStarted;

SubscriptionStarted::fire(
    subscriber_id: $user->id,
    plan_id: 'pro-plan',
    renews_at: now()->addMonth(),
    expires_at: null
);
```

### SubscriptionCacheUpdated

[](#subscriptioncacheupdated)

Automatically fired when abilities need recalculation:

```
use ArtisanBuild\Till\Events\SubscriptionCacheUpdated;

// Manually trigger cache refresh
SubscriptionCacheUpdated::commit(
    subscriber_id: $user->id
);
```

### NewSubscriberAddedToDefaultPlan

[](#newsubscriberaddedtodefaultplan)

Fired when a new user is created:

```
use ArtisanBuild\Till\Events\NewSubscriberAddedToDefaultPlan;

NewSubscriberAddedToDefaultPlan::fire(
    subscriber_id: $user->id
);
```

Plan Attributes
---------------

[](#plan-attributes)

### Plan Type Attributes

[](#plan-type-attributes)

```
use ArtisanBuild\Till\Attributes\IndividualPlan;
use ArtisanBuild\Till\Attributes\TeamPlan;
use ArtisanBuild\Till\Attributes\DefaultPlan;

// For individual subscriptions
#[IndividualPlan]
class PersonalPlan extends BasePlan {}

// For team subscriptions
#[TeamPlan]
class TeamPlan extends BasePlan {}

// Mark as the default plan for unsubscribed users
#[DefaultPlan]
class FreePlan extends BasePlan {}
```

### Plan Visibility

[](#plan-visibility)

```
use ArtisanBuild\Till\Attributes\ArchivedPlan;

// Hide from pricing page but keep existing subscriptions
#[ArchivedPlan]
class LegacyPlan extends BasePlan {}
```

UI Components
-------------

[](#ui-components)

### Pricing Section

[](#pricing-section)

Till includes a Livewire component for displaying pricing:

```

```

This automatically:

- Loads all visible plans
- Displays pricing toggle (month/year/lifetime)
- Shows current plan badge for authenticated users
- Renders subscribe buttons with proper URLs

### Customizing the Pricing Display

[](#customizing-the-pricing-display)

Override the template in config:

```
'pricing_section_template' => 'my-app::pricing.section',
```

Or publish and customize the default template:

```
php artisan vendor:publish --tag=till-views
```

Payment Processors
------------------

[](#payment-processors)

### Demo Mode

[](#demo-mode)

The default Demo processor allows testing without actual payments:

```
'payment_processor' => PaymentProcessors::Demo,
```

Demo mode:

- Doesn't process real payments
- Cannot be used in production
- Useful for local development and testing

### Stripe Integration

[](#stripe-integration)

Enable Stripe processor:

```
'payment_processor' => PaymentProcessors::Stripe,
```

Then sync your plans to Stripe:

```
php artisan till:sync-driver stripe
```

This creates Stripe products and prices based on your plan definitions.

Team Mode vs Individual Mode
----------------------------

[](#team-mode-vs-individual-mode)

### Team Mode (Default)

[](#team-mode-default)

When `team_mode` is `true`:

- Subscriptions belong to teams
- Team members share subscription benefits
- Use `current_team_id` to identify subscriber

### Individual Mode

[](#individual-mode)

When `team_mode` is `false`:

- Subscriptions belong to users
- Each user has their own subscription
- Use user `id` to identify subscriber

Usage Examples
--------------

[](#usage-examples)

### Complete Plan Example

[](#complete-plan-example)

```
namespace App\Plans;

use ArtisanBuild\Till\Attributes\TeamPlan;
use ArtisanBuild\Till\Enums\PlanTerms;
use ArtisanBuild\Till\SubscriptionPlans\BasePlan;
use App\Plans\Abilities\SendEmails;
use App\Plans\Abilities\AddTeamMembers;
use App\Plans\Abilities\AccessReports;

#[TeamPlan]
class BusinessPlan extends BasePlan
{
    public array $prices = [
        PlanTerms::Month->value => 99.00,
        PlanTerms::Year->value => 990.00,
        PlanTerms::Life->value => null, // Not offered
    ];

    public array $badge = [
        'text' => 'Most Popular',
        'color' => 'blue',
        'variant' => 'solid',
    ];

    public string $heading = 'Business';
    public string $subheading = 'Everything you need to scale';

    public array $features = [
        ['text' => 'Unlimited team members', 'icon' => 'users'],
        ['text' => '50,000 emails/month', 'icon' => 'envelope'],
        ['text' => 'Advanced analytics', 'icon' => 'chart-bar'],
        ['text' => 'Priority support', 'icon' => 'support'],
        ['text' => 'Custom integrations', 'icon' => 'puzzle-piece'],
    ];

    public array $can = [
        [AddTeamMembers::class, ['limit' => null]], // Unlimited
        [SendEmails::class, ['limit' => 50000]],
        [AccessReports::class, ['advanced' => true]],
    ];

    public array $wallet = [
        Ledgers::ApiCalls->value => [
            'balance' => 50000,
            'period' => LedgerPeriods::Monthly,
        ],
    ];
}
```

### Controller Example

[](#controller-example)

```
namespace App\Http\Controllers;

use ArtisanBuild\Till\Events\SubscriptionStarted;
use Illuminate\Http\Request;

class SubscriptionController extends Controller
{
    public function subscribe(Request $request, string $planId)
    {
        if (!auth()->user()->ableTo('change-plan')) {
            abort(403, 'You cannot change your plan');
        }

        // Process payment with your payment processor
        $payment = $this->processPayment($request, $planId);

        if ($payment->successful()) {
            SubscriptionStarted::fire(
                subscriber_id: auth()->user()->subscriberId(),
                plan_id: $planId,
                renews_at: now()->addMonth(),
            );

            return redirect()->route('dashboard')
                ->with('success', 'Subscription started successfully!');
        }

        return back()->with('error', 'Payment failed');
    }

    public function index()
    {
        $user = auth()->user();
        $subscription = $user->subscription();
        $abilities = $user->abilities();

        return view('subscriptions.index', [
            'currentPlan' => $subscription?->plan(),
            'abilities' => $abilities,
        ]);
    }
}
```

### Ability with Caching

[](#ability-with-caching)

```
namespace App\Plans\Abilities;

use ArtisanBuild\Till\Actions\CacheAbility;

class SendEmails
{
    public function __invoke(?int $limit = null)
    {
        if ($limit === null) {
            return true;
        }

        $key = 'emails-sent-' . auth()->user()->subscriberId();
        $sent = cache()->remember($key, 3600, function () {
            return auth()->user()->emails()->thisMonth()->count();
        });

        return app(CacheAbility::class)(
            'send-emails',
            $sent < $limit
        );
    }
}
```

### Using Ledgers in Actions

[](#using-ledgers-in-actions)

```
namespace App\Actions;

use ArtisanBuild\Till\Attributes\Costs;
use App\Plans\Ledgers;

#[Costs(ledger: Ledgers::ApiCalls, amount: 1)]
class ProcessApiRequest
{
    public function __invoke($endpoint, $data)
    {
        // Check if user has sufficient balance
        $state = auth()->user()->subscription();
        $balance = $state->wallet[Ledgers::ApiCalls->value] ?? 0;

        if ($balance < 1) {
            throw new \Exception('Insufficient API credits');
        }

        // Debit will happen automatically via #[Costs] attribute
        return $this->callApi($endpoint, $data);
    }

    protected function callApi($endpoint, $data)
    {
        // API call logic
    }
}
```

Development Commands
--------------------

[](#development-commands)

### Code Quality

[](#code-quality)

```
# Fix code style
composer lint

# Run static analysis
composer stan

# Run tests
composer test

# Run all quality checks
composer ready
```

### Testing

[](#testing)

```
# Run tests
composer test

# Run with coverage
composer test-coverage

# Clean up after testing
php artisan till:cleanup-after-testing
```

Advanced Features
-----------------

[](#advanced-features)

### Custom Subscriber IDs

[](#custom-subscriber-ids)

Override the `subscriberId()` method to use custom logic:

```
public function subscriberId(): int
{
    return config('till.team_mode')
        ? $this->current_team_id
        : $this->id;
}
```

### Custom Plan Loading

[](#custom-plan-loading)

Use the plan actions to load plans programmatically:

```
use ArtisanBuild\Till\Actions\GetPlans;
use ArtisanBuild\Till\Actions\GetActivePlans;
use ArtisanBuild\Till\Actions\GetVisiblePlans;
use ArtisanBuild\Till\Actions\GetPlanById;
use ArtisanBuild\Till\Actions\GetDefaultPlan;

// Get all plans
$allPlans = app(GetPlans::class)();

// Get only active plans (not archived)
$activePlans = app(GetActivePlans::class)();

// Get visible plans (for pricing page)
$visiblePlans = app(GetVisiblePlans::class)();

// Get specific plan
$plan = app(GetPlanById::class)('pro-plan');

// Get the default plan
$defaultPlan = app(GetDefaultPlan::class)();
```

### Middleware

[](#middleware)

Protect routes based on abilities:

```
// app/Http/Kernel.php
protected $middlewareAliases = [
    'till.abilities' => \ArtisanBuild\Till\Middleware\TillAbilities::class,
];

// routes/web.php
Route::middleware(['till.abilities:send-emails,access-reports'])
    ->group(function () {
        // Protected routes
    });
```

Troubleshooting
---------------

[](#troubleshooting)

### Abilities Not Updating

[](#abilities-not-updating)

Clear the abilities cache:

```
use ArtisanBuild\Till\Actions\ClearAbilitiesCache;

app(ClearAbilitiesCache::class)($subscriberId);
```

Or manually refresh:

```
use ArtisanBuild\Till\Events\SubscriptionCacheUpdated;

SubscriptionCacheUpdated::commit(subscriber_id: $userId);
```

### Plans Not Loading

[](#plans-not-loading)

Ensure your plans:

1. Extend `BasePlan`
2. Are in the configured `plan_path`
3. Have the appropriate attributes (`#[TeamPlan]` or `#[IndividualPlan]`)
4. Implement the `PlanInterface`

### State Not Syncing

[](#state-not-syncing)

Manually trigger state synchronization:

```
$processor = config('till.payment_processor');
$plan = auth()->user()->subscription()->plan();

if ($processor->sync($plan)) {
    // State was out of sync and has been corrected
}
```

Roadmap
-------

[](#roadmap)

- Complete Stripe integration
- Paddle payment processor
- Webhook handling for payment processors
- Proration support
- Trial periods
- Coupon/discount system
- Invoice generation
- Usage-based billing automation
- Complete `TillAbilities` middleware implementation
- Complete `CreateAbilityCommand` implementation

Testing
-------

[](#testing-1)

The package includes comprehensive tests using Pest:

```
composer test
```

Contributing
------------

[](#contributing)

Contributions are welcome! Please ensure all tests pass before submitting a pull request:

```
composer ready
```

License
-------

[](#license)

The MIT License (MIT). Please see the LICENSE file for more information.

Credits
-------

[](#credits)

- [Artisan Build](https://github.com/artisan-build)
- Built on [Verbs](https://verbs.thunk.dev) event sourcing
- UI powered by [Flux UI](https://fluxui.dev)
- [All Contributors](../../contributors)

###  Health Score

17

—

LowBetter than 6% of packages

Maintenance45

Moderate activity, may be stable

Popularity0

Limited adoption so far

Community10

Small or concentrated contributor base

Maturity13

Early-stage or recently created project

 Bus Factor1

Top contributor holds 98.3% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

### Community

Maintainers

![](https://www.gravatar.com/avatar/55eed7400c452edf7e7adfa4f1c6676b65b5ce1867fff6bddcb80b1bb45360af?d=identicon)[edgrosvenor](/maintainers/edgrosvenor)

---

Top Contributors

[![edgrosvenor](https://avatars.githubusercontent.com/u/1053395?v=4)](https://github.com/edgrosvenor "edgrosvenor (58 commits)")[![ProjektGopher](https://avatars.githubusercontent.com/u/1688608?v=4)](https://github.com/ProjektGopher "ProjektGopher (1 commits)")

### Embed Badge

![Health badge](/badges/artisan-build-till/health.svg)

```
[![Health](https://phpackages.com/badges/artisan-build-till/health.svg)](https://phpackages.com/packages/artisan-build-till)
```

###  Alternatives

[omnipay/paypal

PayPal gateway for Omnipay payment processing library

3156.8M53](/packages/omnipay-paypal)[eduardokum/laravel-boleto

Biblioteca com boletos para o laravel

626351.9k2](/packages/eduardokum-laravel-boleto)[tbbc/money-bundle

This is a Symfony bundle that integrates moneyphp/money library (Fowler pattern): https://github.com/moneyphp/money.

1961.9M](/packages/tbbc-money-bundle)[2checkout/2checkout-php

2Checkout PHP Library

83740.3k2](/packages/2checkout-2checkout-php)[smhg/sepa-qr-data

Generate QR code data for SEPA payments

61717.2k5](/packages/smhg-sepa-qr-data)[omnipay/dummy

Dummy driver for the Omnipay payment processing library

271.2M33](/packages/omnipay-dummy)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
