Models & the owner trait
Purchase, PurchaseItem, Subscription, SubscriptionItem, PurchaseRefund, PurchaseNotification and BillingCustomer (under RoundlyConsulting\Purchases\Models) are standard Eloquent models with soft deletes and factories. BillingCustomer links an owner to its customer at a billing provider — written by Purchases::stripe()->customers()->sync(), read to bill the owner and to find the owner a webhook’s customer belongs to; its owner() is a morphTo.
One owner’s purchases
Purchases::for($owner) needs no trait on the model — it scopes to exactly that owner’s morph type and key:
use RoundlyConsulting\Purchases\Facades\Purchases;
Purchases::for($user)->subscribedTo('pro'); // bool
Purchases::for($user)->activeSubscription('pro'); // ?Subscription (latest active)
Purchases::for($user)->purchases()->latest()->get(); // Builder<Purchase>
Purchases::for($user)->subscriptions()->count(); // Builder<Subscription>The HasPurchases trait
Add the trait to your owner model — typically User — for the purchases and subscriptions relations through the package’s owner morph. Its activeSubscription() and subscribedTo() helpers are sugar over Purchases::for($this), so they behave exactly like the facade:
use RoundlyConsulting\Purchases\Concerns\HasPurchases;
class User extends Authenticatable
{
use HasPurchases;
}
$user->purchases; // MorphMany<Purchase>
$user->subscriptions; // MorphMany<Subscription>
$user->activeSubscription(); // ?Subscription — the latest active one
$user->activeSubscription('pro'); // ?Subscription — by plan name
$user->subscribedTo('pro'); // bool — both delegate to Purchases::for($this)Subscription scopes & helpers
use RoundlyConsulting\Purchases\Models\Subscription;
Subscription::active()->expiring(7)->get(); // active and ending within 7 days
Subscription::trialing()->get();
Subscription::canceled()->get();
$subscription->isActive(); // entitled and not past ends_at
$subscription->onTrial();
$subscription->daysUntilRenewal(); // ?int
$subscription->isExpiring(7);
$subscription->items; // HasMany<SubscriptionItem>- active() — status Completed or InGracePeriod, and ends_at empty or in the future.
- trialing() — trial_ends_at in the future.
- expiring($days = 7) — ends_at within the next $days days.
- canceled() — status Canceled.
- daysUntilRenewal() — whole days until ends_at (never negative), or null without an end date.
Provider scopes & relations
Purchase, Subscription and PurchaseRefund share forProvider(), byProviderId() and byTransaction(); PurchaseRefund adds chargebacks() and PurchaseNotification adds pending():
use RoundlyConsulting\Purchases\Models\Purchase;
use RoundlyConsulting\Purchases\Models\PurchaseNotification;
use RoundlyConsulting\Purchases\Models\PurchaseRefund;
Purchase::forProvider('stripe')->byProviderId('pi_123')->first();
Purchase::byTransaction('2000000000000001')->first();
PurchaseRefund::forProvider('google')->chargebacks()->get();
PurchaseNotification::forProvider('apple')->pending()->get();
$purchase->owner; // MorphTo — your User (or null)
$purchase->items; // HasMany<PurchaseItem>
$purchase->refunds; // HasMany<PurchaseRefund>
$purchase->meta; // Collection — the raw store payload
$refund->purchase; // BelongsTo<Purchase>Swapping models
Every model is swappable via config('purchases.models.*'). Your class must be the package model it replaces or extend it — any other class throws an InvalidConfigurationException naming the key instead of silently falling back to the packaged model:
// app/Models/Purchase.php
namespace App\Models;
use RoundlyConsulting\Purchases\Models\Purchase as BasePurchase;
class Purchase extends BasePurchase
{
// your relations, accessors, casts …
}
// config/purchases.php
'models' => [
'purchase' => \App\Models\Purchase::class,
// …
],Schema
| Table | Columns |
|---|---|
purchases | owner (nullable morph), provider, provider_id, transaction_id, status, price + price_currency, meta, last_event_at — unique (provider, provider_id) |
purchase_items | purchase_id (cascade delete), provider_id, name, price + price_currency, quantity |
subscriptions | owner (nullable morph), provider, provider_id, transaction_id, name, status, price + price_currency, active_from, trial_ends_at, ends_at, meta, last_event_at, cancel_at_period_end (bool, default false; cast to boolean) — unique (provider, provider_id) |
subscription_items | subscription_id (cascade delete), provider_id, name, price + price_currency, price_id (nullable, indexed — the Stripe price, what itemFor() matches), quantity (unsigned int, nullable — null for a metered item) |
purchase_refunds | purchase_id (nullable), provider, provider_id, transaction_id, reason, chargeback, price + price_currency, refunded_at, meta, last_event_at — unique (provider, provider_id) |
billing_customers | id, owner (morph per purchases.key_type), provider, provider_id (the customer id, cus_…) — unique (owner_type, owner_id, provider) and (provider, provider_id): one customer per owner and provider, never one customer for two owners |
purchase_notifications | provider, type, signature_verified, payload, processed_at, event_id, claimed_at — unique (provider, event_id); null ids never collide |
Every table has timestamps and soft deletes. Table names come from each model’s getTable(), so a swapped model with its own $table is migrated under that name — configure it before you run the migrations.
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.