amzad / laravel-purchasable
Simple, single-item purchase handling for Laravel Eloquent models (event bookings, subscription plans, and similar) - no cart, no line items.
Requires
- php: ^8.2
- illuminate/contracts: ^10.0||^11.0||^12.0
- spatie/laravel-model-states: ^2.12
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- carlos-meneses/laravel-mpdf: ^2.1
- filament/filament: ^4.0
- larastan/larastan: ^3.0
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^8.0||^9.0||^10.0
- pestphp/pest: ^3.0
- pestphp/pest-plugin-arch: ^3.0
- pestphp/pest-plugin-laravel: ^3.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- spatie/laravel-ray: ^1.35
Suggests
- carlos-meneses/laravel-mpdf: Required to use the "Print invoice" action on the Filament Order resource.
- filament/filament: Required to use the optional Filament v4 admin panel resources for Orders and Transactions (Amzad\LaravelPurchasable\Filament\PurchasablePlugin).
Provides
None
Conflicts
None
Replaces
None
README
Turns any Eloquent model into something purchasable — event bookings, subscription plans, course enrollments — without pulling in a full e-commerce cart system. One order = one purchasable item (with an optional quantity), full stop. No cart, no line items.
See plan.md for the full design (schema, state machine, events) and delivery phases. This README is filled in incrementally as each phase lands.
Installation
You can install the package via composer:
composer require amzad/laravel-purchasable
You can publish and run the migrations with:
php artisan vendor:publish --tag="purchasable-migrations"
php artisan migrate
Upgrading from v3
v4 adds guest checkout. Publish the migrations again to get add_guest_checkout_to_orders_table. It makes buyer_type/buyer_id nullable and adds guest_email, guest_name, guest_phone and access_token. It uses driver-specific SQL (MySQL/MariaDB, PostgreSQL, SQLite), so doctrine/dbal is not needed. On SQLite it rebuilds the orders table from its current definition, so columns your app added are kept.
The Buyer contract gained claimGuestOrders(). CreateOrder::execute() and CreateMultiItemOrder::execute() now accept a null buyer. If you implement the contract yourself instead of using the trait, add the new method.
You can publish the config file with:
php artisan vendor:publish --tag="purchasable-config"
Usage
Mark a model purchasable
use Amzad\LaravelPurchasable\Concerns\Purchasable; use Amzad\LaravelPurchasable\Contracts\Purchasable as PurchasableContract; class Event extends Model implements PurchasableContract { use Purchasable; public function getPurchasablePrice(): string|int|float { return $this->ticket_price; } // Optional — falls back to config('purchasable.default_currency') if omitted. public function getPurchasableCurrency(): string { return $this->currency; } // Optional — falls back to "Event #123" (the raw morph type + ID) in the // Filament admin and PDF invoice if omitted. public function getPurchasableDisplayName(): string { return $this->title; } }
Mark the buying model
use Amzad\LaravelPurchasable\Concerns\Buyer; use Amzad\LaravelPurchasable\Contracts\Buyer as BuyerContract; class User extends Authenticatable implements BuyerContract { use Buyer; // Optional — falls back to "User #123" (the raw morph type + ID) in the // Filament admin and PDF invoice if omitted. public function getBuyerDisplayName(): string { return $this->name; } }
Implementing the PurchasableContract/BuyerContract interfaces alongside the traits is what gives you IDE and static-analysis support when passing your models into the package's actions — it isn't required at runtime, but it's the supported pattern.
Create an order
$order = $event->purchase($user, ['quantity' => 2, 'meta' => ['seat_tier' => 'VIP']]); // or $order = $user->purchase($event, ['quantity' => 2]);
By default, purchase() throws AlreadyPurchasedException if the buyer already has a paid order for that purchasable, or already has a pending one. Two config flags relax this independently:
// config/purchasable.php 'allow_multiple_pending_orders' => false, // true: buyer can have >1 pending order for the same item at once 'allow_repurchase' => false, // true: buyer can buy the same item again after already paying for it
Record a transaction / payment
use Amzad\LaravelPurchasable\Actions\RecordTransaction; use Amzad\LaravelPurchasable\Enums\TransactionStatus; use Amzad\LaravelPurchasable\Enums\TransactionType; app(RecordTransaction::class)->execute( order: $order, type: TransactionType::Charge, gateway: 'manual', amount: $order->amount, status: TransactionStatus::Success, ); // order transitions Pending -> Paid, the OrderPaid event fires
A failed charge does not move the order out of Pending — retries are expected (failed, failed, success is a normal sequence). When a host app gives up retrying, it explicitly fails the order:
use Amzad\LaravelPurchasable\Actions\FailOrder; app(FailOrder::class)->execute($order); // Pending -> Failed, fires OrderFailed
A Failed order can also be put back into play instead of making the buyer start over:
use Amzad\LaravelPurchasable\Actions\RetryOrder; app(RetryOrder::class)->execute($order); // Failed -> Pending, fires OrderRetried
Guest checkout
Orders don't need a buyer model. Turn guest checkout on in the config:
// config/purchasable.php 'allow_guest_checkout' => true,
Then create the order from the purchasable side with the guest's contact details. email is required, and name and phone are optional:
$order = $event->purchaseAsGuest( ['email' => 'guest@example.com', 'name' => 'Sam Guest', 'phone' => '+96550000000'], ['quantity' => 2], ); $order->isGuest(); // true $order->buyerEmail(); // "guest@example.com" $order->access_token; // random 64-char token, generated for guest orders only
- The guest is identified by email. The email is trimmed and lowercased before it is stored and compared. The
allow_repurchaseandallow_multiple_pending_orderschecks apply per email, and a violation throwsAlreadyPurchasedException. - A missing or malformed email throws
GuestCheckoutException. So does any guest checkout whileallow_guest_checkoutisfalse. - Transactions, refunds, webhooks, state transitions and events work the same as for buyer orders.
A guest can't log in, so use the access_token to give them a link to their order, for example to pay for it, check its status or download the invoice. The token is hidden from toArray() and JSON output.
$order = Order::findByAccessToken($request->route('token')) ?? abort(404);
Query helpers:
Order::query()->guest()->get(); // orders without a buyer Order::query()->forGuest('guest@example.com')->get(); // orders placed with that email $event->isPurchasedByGuest('guest@example.com');
When a guest later registers, attach their guest orders to the new account. The default reads the buyer's email attribute, or you can pass an email explicitly:
$user->claimGuestOrders(); // returns the number of orders claimed $user->claimGuestOrders('guest@example.com');
Claimed orders move to the buyer, their access_token is revoked, and OrderClaimed fires for each one. guest_email stays on the order, so the guest can't get around the repurchase check by registering and checking out as a guest again.
Only claim once the email is verified (for example, after
email_verified_atis set). Otherwise anyone could register with someone else's address and take over their orders.
Multi-item orders work for guests too. Pass a null buyer and the guest details:
use Amzad\LaravelPurchasable\Actions\CreateMultiItemOrder; $order = app(CreateMultiItemOrder::class)->execute( [['purchasable' => $ticket, 'quantity' => 2], ['purchasable' => $merch]], null, ['guest' => ['email' => 'guest@example.com', 'name' => 'Sam Guest']], );
As with buyer orders, the repurchase and pending checks apply to multi-item guest orders only when multi_item.enforce_purchase_guards is on.
In the Filament admin and on the invoice, a guest order shows the guest's name (or their email when no name was given) as the buyer.
Check if a buyer has purchased something
if ($user->hasPurchased($event)) { /* ... */ } // or if ($event->isPurchasedBy($user)) { /* ... */ }
Issue a refund
Like charging, the host determines the outcome (however the actual refund gets processed) and passes it in — RefundOrder just defaults the amount to the order's remaining unrefunded balance and the gateway label to the one used on the original charge:
use Amzad\LaravelPurchasable\Actions\RefundOrder; use Amzad\LaravelPurchasable\Enums\TransactionStatus; app(RefundOrder::class)->execute($order, TransactionStatus::Success); // full refund -> Refunded app(RefundOrder::class)->execute($order, TransactionStatus::Success, amount: '10.500'); // partial -> PartiallyRefunded, fires OrderPartiallyRefunded
A second, later refund that brings the total up to the full paid amount moves the order from PartiallyRefunded to Refunded automatically. Pass gateway/gatewayTransactionId/responsePayload explicitly if you need to record something other than the defaults.
Cancel a pending order
use Amzad\LaravelPurchasable\Actions\CancelOrder; app(CancelOrder::class)->execute($order); // Pending|Failed -> Cancelled, fires OrderCancelled
Multi-item orders (cart-style checkout)
For ecommerce-style purchases — a basket with several, possibly different, purchasables — build the order directly from an array of items in one atomic call. There's no persistent cart entity; assemble the item list however you like (session, request payload, etc.) and hand it off:
$order = $user->purchaseMultiple([ ['purchasable' => $tShirt, 'quantity' => 2], ['purchasable' => $subscriptionPlan, 'quantity' => 1], ]);
Each entry becomes an OrderItem ($order->items), independently morphing to its own purchasable — an order can mix types freely. purchasable_type/purchasable_id/quantity stay null on a multi-item Order; those columns are only populated for the single-item flow above. unit_price defaults to getPurchasablePrice() per item, and order.amount is the summed total.
allow_repurchase/allow_multiple_pending_orders are not enforced on multi-item orders by default — a cart routinely contains repeat or concurrent purchases of the same item, unlike a one-time ticket/subscription. Set purchasable.multi_item.enforce_purchase_guards to true to re-apply the same per-item checks purchase() uses.
Refunds can target a single line item instead of the whole order — this also doubles as "cancel this item" (there's no pre-payment cart to remove it from, so cancelling a paid item is a full refund of just that item):
app(RefundOrder::class)->execute($order, TransactionStatus::Success, item: $itemToCancel);
This fires OrderItemRefunded/OrderItemPartiallyRefunded for the item, independently of the order's own aggregate status — refunding one item out of several only moves the order to PartiallyRefunded, not Refunded, while other items remain unrefunded.
Upgrading from a version without multi-item support: publish and run the new migrations (
create_order_items_table,make_orders_purchasable_columns_nullable_table,add_order_item_id_to_transactions_table). If you have existingOrderCreated/OrderPaidlisteners that assume$event->order->purchasableis non-null, guard them — a multi-item order leaves that relation null and stores its items on$order->itemsinstead.
Expire abandoned pending orders
Set pending_order_ttl (minutes) in config/purchasable.php, then schedule the package's Artisan command in your own app — it isn't scheduled automatically, since only the host knows its deployment's scheduler setup:
// routes/console.php (Laravel 11+) or app/Console/Kernel.php Schedule::command('purchasable:expire-orders')->everyFifteenMinutes();
Each run cancels every Pending order older than pending_order_ttl minutes via CancelOrder, so OrderCancelled fires normally.
Receive gateway webhooks
The package exposes a generic, gateway-agnostic endpoint — POST /purchasable/webhooks/{gateway} — that does no parsing or signature verification itself. You implement one WebhookHandlerContract per gateway and register it:
use Amzad\LaravelPurchasable\Contracts\WebhookHandlerContract; use Amzad\LaravelPurchasable\Actions\RecordTransaction; use Illuminate\Http\Request; class StripeWebhookHandler implements WebhookHandlerContract { public function handle(Request $request): void { // Verify the Stripe-Signature header yourself, then translate the // payload into a RecordTransaction call. app(RecordTransaction::class)->execute(/* ... */); } }
// config/purchasable.php 'webhook_handlers' => [ 'stripe' => \App\Webhooks\StripeWebhookHandler::class, ],
Reporting helpers
use Amzad\LaravelPurchasable\Models\Order; Order::revenueBetween(now()->startOfMonth(), now()); // '1250.000' Order::countsByStatus(); // ['pending' => 3, 'paid' => 12, ...] $user->purchaseHistory()->get(); // paid orders, newest first
Filament admin panel (optional)
The package ships optional, read-only Filament v4 resources for browsing Orders and Transactions. Filament isn't a dependency of this package — install it yourself, then register the plugin from your Panel provider:
composer require filament/filament:"^4.0"
// app/Providers/Filament/AdminPanelProvider.php use Amzad\LaravelPurchasable\Filament\PurchasablePlugin; public function panel(Panel $panel): Panel { return $panel // ... ->plugins([ PurchasablePlugin::make(), ]); }
Both resources (Orders, Transactions) are registered by default, list + view only — no create, edit, or delete, since orders and transactions are meant to be mutated through the package's actions (CreateOrder, RecordTransaction, RefundOrder, etc.), not hand-edited in the admin panel. Configure the plugin fluently:
PurchasablePlugin::make() ->orders(false) // don't register the Order resource ->transactions() // register the Transaction resource (default) ->navigationGroup('Sales'), // defaults to "Purchasable"; pass null to remove grouping
Each table ships filters for the columns admins actually narrow down by:
- Orders — Buyer (type + ID), Guest vs. buyer orders, Purchasable (type + ID), and a created-at date range, plus status and trashed. Guest orders show "Guest" as the buyer type, and the guest email column is searchable.
- Transactions — Order (searchable by order number), Gateway, Currency, and a created-at date range, plus type and status.
Export
Both tables ship a header Export action (all/filtered rows) and a bulk Export action (selected rows), producing CSV/XLSX via Filament's built-in export system (filament/actions, no extra dependency). Exports run as a queued job batch and notify the initiating user (via a database notification) when done, so three tables need to exist:
php artisan queue:batches-table # job_batches — exports are dispatched via Bus::batch() php artisan notifications:table # notifications — the "export ready" notification php artisan migrate # also picks up filament/actions' own `exports` table automatically
If your app doesn't already use queued job batching or database notifications, these two stubs won't exist yet — a fresh install commonly hits Base table or view not found: job_batches right after fixing the exports table error, so it's worth running all three up front rather than one at a time. You'll also need a working queue connection: QUEUE_CONNECTION=sync works for small exports in development; use a real queue driver in production.
Note: Filament's export system bypasses per-record authorization and exports every record matching the table's query — if
OrderResource/TransactionResourceneed to be scoped per-user or per-tenant, do it ingetEloquentQuery(), not by relying on policies. Gateway-supplied columns (gateway,gateway_transaction_id) are also defended against CSV/XLSX formula injection inTransactionExporter, since those values originate from webhook payloads outside the package's control.
Print invoice
The Order resource ships a Print invoice action (on the table rows and the view page) that downloads a PDF invoice — the order's details plus its full transaction history. This needs carlos-meneses/laravel-mpdf, a separate optional dependency (not installed by Filament itself):
composer require carlos-meneses/laravel-mpdf
The action stays hidden until that package is installed, so it's safe to enable the plugin without it if you don't need invoices. The invoice view is published under the purchasable view namespace (purchasable::orders.invoice) — publish and edit it if you want to change the branding or layout:
php artisan vendor:publish --tag="purchasable-views"
Testing
composer test
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details.
Security Vulnerabilities
Please review our security policy on how to report security vulnerabilities.
Credits
License
The MIT License (MIT). Please see License File for more information.