NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Purchases for Laravel

Models & the owner trait

Purchase, PurchaseItem, Subscription, SubscriptionItem, PurchaseRefund and PurchaseNotification (under RoundlyConsulting\Purchases\Models) are standard Eloquent models with soft deletes and factories.

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

TableColumns
purchasesowner (nullable morph), provider, provider_id, transaction_id, status, price + price_currency, meta, last_event_at — unique (provider, provider_id)
purchase_itemspurchase_id (cascade delete), provider_id, name, price + price_currency, quantity
subscriptionsowner (nullable morph), provider, provider_id, transaction_id, name, status, price + price_currency, active_from, trial_ends_at, ends_at, meta, last_event_at — unique (provider, provider_id)
subscription_itemssubscription_id (cascade delete), provider_id, name, price + price_currency
purchase_refundspurchase_id (nullable), provider, provider_id, transaction_id, reason, chargeback, price + price_currency, refunded_at, meta, last_event_at — unique (provider, provider_id)
purchase_notificationsprovider, type, signature_verified, payload, processed_at

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 crypto

By 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.