feat(subscriptions): Cashier-backed subscription lifecycle with product link + events

Adds the package's missing subscription lifecycle so any host app gets
duration-aware, product-linked subscriptions without re-implementing billing:

- Models: Subscription (extends Laravel\Cashier\Subscription) + SubscriptionItem,
  in the package's UUID convention, resolved through shop.models/shop.tables.
  Subscription gains product()/resolveProduct(), callProductActions() (runs the
  product's ProductActions with the subscription + an access-expiry override),
  and recordStarted()/recordRenewed()/recordCanceled() lifecycle hooks.
- Events: SubscriptionStarted / SubscriptionRenewed / SubscriptionCanceled,
  carrying the Cashier subscription so host subclasses work too.
- Migration: UUID subscriptions / subscription_items tables (hasTable-guarded,
  config table names, nullable product_id + current_period_* columns).
- ShopServiceProvider points Cashier at these models by default; opt out via
  shop.subscriptions.register_cashier_models for apps that subclass Cashier.

Additive and backward-compatible (registration is config-gated, tables are
guarded). Adds SubscriptionLifecycleTest; full suite 1409 green. Docs + README.
This commit is contained in:
Fabian @ Blax Software 2026-06-02 11:40:15 +02:00
parent 96b9a19287
commit d4ae9339ac
12 changed files with 564 additions and 3 deletions

View File

@ -3,8 +3,8 @@
# Laravel Shop # Laravel Shop
[![Tests](https://github.com/blax-software/laravel-shop/actions/workflows/tests.yml/badge.svg)](https://github.com/blax-software/laravel-shop/actions/workflows/tests.yml) [![Tests](https://github.com/blax-software/laravel-shop/actions/workflows/tests.yml/badge.svg)](https://github.com/blax-software/laravel-shop/actions/workflows/tests.yml)
[![Tests Count](https://img.shields.io/badge/tests-1404%20passing-success?style=flat-square)](#testing) [![Tests Count](https://img.shields.io/badge/tests-1409%20passing-success?style=flat-square)](#testing)
[![Assertions](https://img.shields.io/badge/assertions-3759-blue?style=flat-square)](#testing) [![Assertions](https://img.shields.io/badge/assertions-3772-blue?style=flat-square)](#testing)
[![Latest Version](https://img.shields.io/packagist/v/blax-software/laravel-shop.svg?style=flat-square)](https://packagist.org/packages/blax-software/laravel-shop) [![Latest Version](https://img.shields.io/packagist/v/blax-software/laravel-shop.svg?style=flat-square)](https://packagist.org/packages/blax-software/laravel-shop)
[![License](https://img.shields.io/packagist/l/blax-software/laravel-shop.svg?style=flat-square)](https://packagist.org/packages/blax-software/laravel-shop) [![License](https://img.shields.io/packagist/l/blax-software/laravel-shop.svg?style=flat-square)](https://packagist.org/packages/blax-software/laravel-shop)
[![PHP Version](https://img.shields.io/packagist/php-v/blax-software/laravel-shop.svg?style=flat-square)](https://packagist.org/packages/blax-software/laravel-shop) [![PHP Version](https://img.shields.io/packagist/php-v/blax-software/laravel-shop.svg?style=flat-square)](https://packagist.org/packages/blax-software/laravel-shop)
@ -192,7 +192,7 @@ booking, Stripe sync and the event surface — so host applications can lean
on the behaviour with confidence. on the behaviour with confidence.
``` ```
Tests: 1404, Assertions: 3759 Tests: 1409, Assertions: 3772
``` ```
CI runs the full suite on every push (see the badge above). To run it CI runs the full suite on every push (see the badge above). To run it

View File

@ -38,6 +38,8 @@ return [
'product_price_tiers' => 'product_price_tiers', 'product_price_tiers' => 'product_price_tiers',
'products' => 'products', 'products' => 'products',
'cart_discounts' => 'cart_discounts', 'cart_discounts' => 'cart_discounts',
'subscriptions' => 'subscriptions',
'subscription_items' => 'subscription_items',
], ],
// Model classes (allow overriding in main instance) // Model classes (allow overriding in main instance)
@ -55,6 +57,23 @@ return [
'order_note' => \Blax\Shop\Models\OrderNote::class, 'order_note' => \Blax\Shop\Models\OrderNote::class,
'payment_provider_identity' => \Blax\Shop\Models\PaymentProviderIdentity::class, 'payment_provider_identity' => \Blax\Shop\Models\PaymentProviderIdentity::class,
'payment_method' => \Blax\Shop\Models\PaymentMethod::class, 'payment_method' => \Blax\Shop\Models\PaymentMethod::class,
'subscription' => \Blax\Shop\Models\Subscription::class,
'subscription_item' => \Blax\Shop\Models\SubscriptionItem::class,
],
/*
* Subscriptions are Cashier-backed. The package binds its own
* Cashier-extending Subscription / SubscriptionItem models (above) so it
* can link a subscription to a product and run product actions on the
* billing lifecycle. Set `subscriptions.register_cashier_models` to false
* if the host app wants to point Cashier at its own models instead.
*/
'subscriptions' => [
'register_cashier_models' => env('SHOP_REGISTER_CASHIER_MODELS', true),
// Stripe interval => the product-action event fired on a NEW subscription.
'started_event' => 'subscription.started',
'renewed_event' => 'subscription.renewed',
'canceled_event' => 'subscription.canceled',
], ],
// API Routes configuration // API Routes configuration

View File

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Cashier-backed subscription tables, in the package's UUID convention.
*
* Mirrors Laravel Cashier's `subscriptions` / `subscription_items` schema (so
* the package's Cashier-extending models work out of the box) but with UUID
* primary keys to match the rest of the package, plus a nullable `product_id`
* so a subscription can be linked to the {@see \Blax\Shop\Models\Product} it
* sells (used to run product actions on the billing lifecycle), and the
* `current_period_*` columns Cashier 15 syncs from Stripe.
*
* Guarded with `hasTable`, so an app that already owns a `subscriptions` table
* (e.g. one published from Cashier) is left untouched point
* `shop.tables.subscriptions` at a different name if you need both.
*/
return new class extends Migration
{
public function up(): void
{
$subscriptions = config('shop.tables.subscriptions', 'subscriptions');
$subscriptionItems = config('shop.tables.subscription_items', 'subscription_items');
if (! Schema::hasTable($subscriptions)) {
Schema::create($subscriptions, function (Blueprint $table) {
$table->uuid('id')->primary();
$table->uuid('user_id')->index();
$table->uuid('product_id')->nullable()->index();
$table->string('type');
$table->string('stripe_id')->unique();
$table->string('stripe_status');
$table->string('stripe_price')->nullable();
$table->integer('quantity')->nullable();
$table->timestamp('trial_ends_at')->nullable();
$table->timestamp('ends_at')->nullable();
$table->timestamp('current_period_start')->nullable();
$table->timestamp('current_period_end')->nullable();
$table->timestamps();
$table->index(['user_id', 'stripe_status']);
});
}
if (! Schema::hasTable($subscriptionItems)) {
Schema::create($subscriptionItems, function (Blueprint $table) use ($subscriptions) {
$table->uuid('id')->primary();
$table->uuid('subscription_id');
$table->string('stripe_id')->unique();
$table->string('stripe_product');
$table->string('stripe_price');
$table->integer('quantity')->nullable();
$table->timestamps();
$table->index(['subscription_id', 'stripe_price']);
$table->foreign('subscription_id')
->references('id')->on($subscriptions)
->cascadeOnDelete();
});
}
}
public function down(): void
{
Schema::dropIfExists(config('shop.tables.subscription_items', 'subscription_items'));
Schema::dropIfExists(config('shop.tables.subscriptions', 'subscriptions'));
}
};

View File

@ -770,3 +770,40 @@ In addition, any `ProductAction` configured on the product with the
`purchased` event still runs automatically on completion — the product is `purchased` event still runs automatically on completion — the product is
resolved via `config('shop.models.product')` / `...product_price`, so apps resolved via `config('shop.models.product')` / `...product_price`, so apps
overriding those models are covered too. overriding those models are covered too.
## Subscriptions
Subscriptions are **Cashier-backed**. The package ships
`Blax\Shop\Models\Subscription` (extends `Laravel\Cashier\Subscription`) which
adds the commerce link and lifecycle hooks Cashier doesn't have:
- `product()` / `resolveProduct()` — the `Product` this subscription sells
(linked via `product_id`, or resolved from the first item's `stripe_product`).
- `callProductActions($expiresAtOverride, $event)` — run the product's
`ProductAction`s for a lifecycle event, passing the subscription and an
optional access-expiry so grants can be scoped to the billing cycle.
- `recordStarted()` / `recordRenewed()` / `recordCanceled()` — fire the
`SubscriptionStarted` / `SubscriptionRenewed` / `SubscriptionCanceled` events
(and, for started/renewed, run the product actions).
The package points Cashier at these models automatically. If your app already
subclasses Cashier's `Subscription`, set
`shop.subscriptions.register_cashier_models = false` and register your own
models; everything in the package resolves through `shop.models.*` /
`shop.tables.*`, so your models slot in.
```php
use Blax\Shop\Events\SubscriptionRenewed;
class ExtendAccessOnRenewal
{
public function handle(SubscriptionRenewed $event): void
{
// $event->subscription->product, ->current_period_end, …
}
}
```
The subscription tables use the package's UUID convention; the migration is
`hasTable`-guarded, so point `shop.tables.subscriptions` elsewhere if your app
already owns a `subscriptions` table (e.g. a bigint Cashier one).

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Laravel\Cashier\Subscription;
/**
* Dispatched when a subscription is canceled. Access typically lapses at period end via Cashier's grace handling rather than being revoked immediately.
*
* Carries the Cashier subscription (the package's {@see \Blax\Shop\Models\Subscription}
* is a Cashier subscription, so this works for host subclasses too). Listen
* here to drive fulfillment without coupling to billing internals.
*/
class SubscriptionCanceled
{
use Dispatchable, SerializesModels;
public function __construct(public Subscription $subscription) {}
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Laravel\Cashier\Subscription;
/**
* Dispatched when a subscription renews into a new billing cycle (recurring invoice paid). Extend access to the new period here.
*
* Carries the Cashier subscription (the package's {@see \Blax\Shop\Models\Subscription}
* is a Cashier subscription, so this works for host subclasses too). Listen
* here to drive fulfillment without coupling to billing internals.
*/
class SubscriptionRenewed
{
use Dispatchable, SerializesModels;
public function __construct(public Subscription $subscription) {}
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Laravel\Cashier\Subscription;
/**
* Dispatched when a new subscription becomes active (initial checkout / first invoice). Grant access for the first billing cycle here.
*
* Carries the Cashier subscription (the package's {@see \Blax\Shop\Models\Subscription}
* is a Cashier subscription, so this works for host subclasses too). Listen
* here to drive fulfillment without coupling to billing internals.
*/
class SubscriptionStarted
{
use Dispatchable, SerializesModels;
public function __construct(public Subscription $subscription) {}
}

147
src/Models/Subscription.php Normal file
View File

@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Models;
use Blax\Shop\Events\SubscriptionCanceled;
use Blax\Shop\Events\SubscriptionRenewed;
use Blax\Shop\Events\SubscriptionStarted;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Laravel\Cashier\Subscription as CashierSubscription;
/**
* Cashier-backed subscription, in the package's UUID convention, linked to the
* {@see Product} it sells so the billing lifecycle can drive product actions
* and fulfillment events.
*
* This is the package's missing "subscription lifecycle": Cashier owns the
* billing mechanics (status, trials, grace, proration, Stripe sync); this
* subclass adds the commerce link `product()` and the lifecycle hooks
* ({@see recordStarted()}, {@see recordRenewed()}, {@see recordCanceled()})
* that fire package events and run the product's {@see ProductAction}s with a
* subscription + expiry context, so any host app gets duration-aware grants
* for free.
*
* Host apps that already subclass Cashier's Subscription can point
* `shop.models.subscription` at their model (and set
* `shop.subscriptions.register_cashier_models = false`) everything here is
* resolved through config, never hard-coded class references.
*
* @property string|null $product_id
* @property-read \Illuminate\Database\Eloquent\Collection<int, SubscriptionItem> $items
*/
class Subscription extends CashierSubscription
{
use HasUuids;
public function getTable()
{
return config('shop.tables.subscriptions', 'subscriptions');
}
/**
* The product this subscription sells (cached on `product_id`).
*
* @return BelongsTo<Model, $this>
*/
public function product(): BelongsTo
{
return $this->belongsTo(config('shop.models.product', Product::class), 'product_id');
}
/**
* Subscription line items uses the configured item model.
*
* @return HasMany<SubscriptionItem, $this>
*/
public function items(): HasMany
{
return $this->hasMany(
config('shop.models.subscription_item', SubscriptionItem::class)
);
}
/**
* Resolve (and cache) the product this subscription sells: the linked
* `product_id` first, else the first item's `stripe_product` mapped to a
* Product via `stripe_product_id`.
*/
public function resolveProduct(): ?Model
{
$productModel = config('shop.models.product', Product::class);
if ($this->product_id) {
$product = $productModel::find($this->product_id);
if ($product) {
return $product;
}
}
$stripeProduct = $this->items()->first()?->stripe_product;
if ($stripeProduct) {
$product = $productModel::where('stripe_product_id', $stripeProduct)->first();
if ($product && ! $this->product_id) {
$this->forceFill(['product_id' => $product->getKey()])->saveQuietly();
}
return $product;
}
return null;
}
/**
* Run the linked product's actions for a subscription lifecycle event,
* passing the subscription and an optional access-expiry override so
* grants can be scoped to the billing cycle.
*/
public function callProductActions(
?\Carbon\Carbon $expiresAtOverride = null,
?string $event = null
): void {
$product = $this->resolveProduct();
if (! $product || ! method_exists($product, 'callActions')) {
return;
}
$event ??= config('shop.subscriptions.started_event', 'subscription.started');
$product->callActions($event, null, [
'subscription' => $this,
'expiresAtOverride' => $expiresAtOverride,
]);
}
/**
* Mark a new subscription as started: fire {@see SubscriptionStarted} and
* run the product's actions for the configured "started" event.
*/
public function recordStarted(?\Carbon\Carbon $expiresAtOverride = null): void
{
$this->callProductActions($expiresAtOverride, config('shop.subscriptions.started_event', 'subscription.started'));
SubscriptionStarted::dispatch($this);
}
/**
* Mark a renewal (new billing cycle): fire {@see SubscriptionRenewed} and
* re-run the product's actions so grants extend to the new period.
*/
public function recordRenewed(?\Carbon\Carbon $expiresAtOverride = null): void
{
$this->callProductActions($expiresAtOverride, config('shop.subscriptions.renewed_event', 'subscription.renewed'));
SubscriptionRenewed::dispatch($this);
}
/**
* Mark a cancellation: fire {@see SubscriptionCanceled}. Access is left to
* lapse at period end (Cashier grace handling) rather than revoked here.
*/
public function recordCanceled(): void
{
SubscriptionCanceled::dispatch($this);
}
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Laravel\Cashier\SubscriptionItem as CashierSubscriptionItem;
/**
* Cashier-backed subscription line item in the package's UUID convention.
* Bound to the configured {@see Subscription} model.
*/
class SubscriptionItem extends CashierSubscriptionItem
{
use HasUuids;
public function getTable()
{
return config('shop.tables.subscription_items', 'subscription_items');
}
/**
* @return BelongsTo<Subscription, $this>
*/
public function subscription(): BelongsTo
{
return $this->belongsTo(
config('shop.models.subscription', Subscription::class)
);
}
}

View File

@ -34,6 +34,8 @@ class ShopServiceProvider extends ServiceProvider
$this->registerMigrations(); $this->registerMigrations();
$this->registerSubscriptionModels();
$this->registerRouteMacros(); $this->registerRouteMacros();
// Load routes if enabled (API only) // Load routes if enabled (API only)
@ -97,6 +99,32 @@ class ShopServiceProvider extends ServiceProvider
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); $this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
} }
/**
* Point Laravel Cashier at the package's product-linked Subscription /
* SubscriptionItem models so subscriptions created by Cashier carry the
* commerce link + lifecycle hooks. Host apps that subclass Cashier
* themselves disable this via `shop.subscriptions.register_cashier_models`
* and register their own models (which boot after this provider, so they
* win). No-op if Cashier isn't installed.
*/
protected function registerSubscriptionModels(): void
{
if (! config('shop.subscriptions.register_cashier_models', true)) {
return;
}
if (! class_exists(\Laravel\Cashier\Cashier::class)) {
return;
}
\Laravel\Cashier\Cashier::useSubscriptionModel(
config('shop.models.subscription', \Blax\Shop\Models\Subscription::class)
);
\Laravel\Cashier\Cashier::useSubscriptionItemModel(
config('shop.models.subscription_item', \Blax\Shop\Models\SubscriptionItem::class)
);
}
/** /**
* Register Route macros that hosts can use to wire shop endpoints * Register Route macros that hosts can use to wire shop endpoints
* concisely. Currently provides: * concisely. Currently provides:

View File

@ -0,0 +1,152 @@
<?php
namespace Blax\Shop\Tests\Feature\Subscriptions;
use Blax\Shop\Enums\ProductStatus;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Events\SubscriptionCanceled;
use Blax\Shop\Events\SubscriptionRenewed;
use Blax\Shop\Events\SubscriptionStarted;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductAction;
use Blax\Shop\Models\Subscription;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use PHPUnit\Framework\Attributes\Test;
use Workbench\App\Models\User;
class SubscriptionLifecycleTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
RecordingSubscriptionAction::$calls = [];
}
private function product(): Product
{
return Product::create([
'name' => 'Subscription Product',
'sku' => 'SUB-'.uniqid(),
'type' => ProductType::SIMPLE,
'status' => ProductStatus::PUBLISHED,
'manage_stock' => false,
]);
}
private function actionFor(Product $product, string $event = 'subscription.started'): void
{
ProductAction::create([
'product_id' => $product->id,
'events' => [$event],
'class' => RecordingSubscriptionAction::class,
'method' => 'handle',
'defer' => false,
'active' => true,
]);
}
private function subscriptionFor(Product $product, User $user): Subscription
{
return Subscription::create([
'user_id' => $user->id,
'product_id' => $product->id,
'type' => 'default',
'stripe_id' => 'sub_'.uniqid(),
'stripe_status' => 'active',
'stripe_price' => 'price_x',
'quantity' => 1,
]);
}
#[Test]
public function the_product_relation_and_resolver_link_subscription_to_product(): void
{
$user = User::factory()->create();
$product = $this->product();
$sub = $this->subscriptionFor($product, $user);
$this->assertTrue($sub->product->is($product));
$this->assertTrue($sub->resolveProduct()->is($product));
}
#[Test]
public function call_product_actions_runs_actions_with_subscription_context(): void
{
$user = User::factory()->create();
$product = $this->product();
$this->actionFor($product, 'subscription.started');
$sub = $this->subscriptionFor($product, $user);
$sub->callProductActions();
$this->assertCount(1, RecordingSubscriptionAction::$calls);
$args = RecordingSubscriptionAction::$calls[0];
$this->assertSame('subscription.started', $args['event']);
$this->assertInstanceOf(Subscription::class, $args['subscription']);
$this->assertTrue($args['subscription']->is($sub));
$this->assertArrayHasKey('expiresAtOverride', $args);
}
#[Test]
public function record_started_fires_event_and_runs_actions(): void
{
$user = User::factory()->create();
$product = $this->product();
$this->actionFor($product, 'subscription.started');
$sub = $this->subscriptionFor($product, $user);
Event::fake([SubscriptionStarted::class]);
$sub->recordStarted();
$this->assertCount(1, RecordingSubscriptionAction::$calls);
Event::assertDispatched(SubscriptionStarted::class, fn (SubscriptionStarted $e) => $e->subscription->is($sub));
}
#[Test]
public function record_renewed_fires_event_and_runs_renewal_actions(): void
{
$user = User::factory()->create();
$product = $this->product();
$this->actionFor($product, 'subscription.renewed');
$sub = $this->subscriptionFor($product, $user);
Event::fake([SubscriptionRenewed::class]);
$sub->recordRenewed();
$this->assertCount(1, RecordingSubscriptionAction::$calls);
$this->assertSame('subscription.renewed', RecordingSubscriptionAction::$calls[0]['event']);
Event::assertDispatched(SubscriptionRenewed::class, fn (SubscriptionRenewed $e) => $e->subscription->is($sub));
}
#[Test]
public function record_canceled_fires_event(): void
{
$user = User::factory()->create();
$product = $this->product();
$sub = $this->subscriptionFor($product, $user);
Event::fake([SubscriptionCanceled::class]);
$sub->recordCanceled();
Event::assertDispatched(SubscriptionCanceled::class, fn (SubscriptionCanceled $e) => $e->subscription->is($sub));
}
}
/**
* Test fulfillment handler records each invocation's (named) arguments so the
* test can assert the subscription context was passed through.
*/
class RecordingSubscriptionAction
{
/** @var array<int, array<string, mixed>> */
public static array $calls = [];
public static function handle(...$args): void
{
self::$calls[] = $args;
}
}

View File

@ -70,5 +70,8 @@ abstract class TestCase extends Orchestra
$migration = include __DIR__ . '/../database/migrations/2026_01_01_000002_add_max_per_cart_and_max_per_user_to_products.php'; $migration = include __DIR__ . '/../database/migrations/2026_01_01_000002_add_max_per_cart_and_max_per_user_to_products.php';
$migration->up(); $migration->up();
$migration = include __DIR__ . '/../database/migrations/2025_01_01_000004_create_blax_shop_subscriptions.php';
$migration->up();
} }
} }