PHPackages                             particle-academy/laravel-catalog - 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. particle-academy/laravel-catalog

ActiveLibrary[Payment Processing](/categories/payments)

particle-academy/laravel-catalog
================================

Laravel package for managing Stripe catalog (Products, Prices) via a facade API

v0.11.0(1w ago)17731MITPHPPHP ^8.4CI passing

Since Jan 2Pushed 1w agoCompare

[ Source](https://github.com/Particle-Academy/laravel-catalog)[ Packagist](https://packagist.org/packages/particle-academy/laravel-catalog)[ RSS](/packages/particle-academy-laravel-catalog/feed)WikiDiscussions main Synced 1w ago

READMEChangelogDependencies (21)Versions (22)Used By (0)

[![Powered by Tynn](https://camo.githubusercontent.com/db5bfff78510a1a7471a9ad18c48da044e62a4bcd1661875963411643b84303e/68747470733a2f2f696d672e736869656c64732e696f2f656e64706f696e743f75726c3d687474707325334125324625324674796e6e2e61692532466f2532467061727469636c652d61636164656d792532466c61726176656c2d636174616c6f6725324662616467652e6a736f6e)](https://tynn.ai/o/particle-academy/laravel-catalog)

[![Fancy UI suite](art/fancy-ui.svg)](https://particle.academy)

Laravel Catalog Package
=======================

[](#laravel-catalog-package)

A Laravel package for managing Stripe catalog (Products and Prices) via a facade API. Built for apps that bring their own UI.

> **Important**: Every Product must have at least one Price before it can be synced to Stripe. Plans are Products with recurring Prices - there is no separate Plan model.

Table of Contents
-----------------

[](#table-of-contents)

- [Features](#features)
- [Requirements](#requirements)
- [Installation](#installation)
- [Configuration](#configuration)
- [Core Concepts](#core-concepts)
    - [Products, Plans, and Prices](#products-plans-and-prices)
    - [Price Requirements](#price-requirements)
- [Usage](#usage)
    - [Using the Catalog Facade](#using-the-catalog-facade)
    - [Creating Products](#creating-products)
    - [Creating Prices](#creating-prices)
    - [Working with Plans](#working-with-plans)
    - [Syncing to Stripe](#syncing-to-stripe)
    - [Creating Checkout Sessions](#creating-checkout-sessions)
    - [Fulfilling Payments](#fulfilling-payments)
- [Building an Admin UI](#building-an-admin-ui)
- [Integration with FMS](#integration-with-fms)
- [Testing](#testing)
- [Common Patterns](#common-patterns)

Features
--------

[](#features)

- **Product Management**: Create, edit, and manage Stripe products with full CRUD operations
- **Price Management**: Manage recurring (subscription) and one-time prices for products
- **Plans Support**: Plans are simply Products with recurring Prices - no separate model needed
- **Stripe Sync**: Automatic or manual synchronization with Stripe's catalog
- **Facade API**: Complete programmatic access via `Catalog` facade - bring your own UI
- **Product Features**: Support for product features and feature configurations via FMS integration
- **Checkout Integration**: Ready-to-use Stripe Checkout session creation for subscriptions and one-time payments
- **Queue Support**: Background sync jobs for better performance
- **Event Broadcasting**: Real-time sync status updates via Laravel Broadcasting
- **Soft Deletes**: Products and Prices use soft deletes to preserve financial history
- **Metadata Support**: Flexible metadata storage for custom product configurations
- **Storefront Configuration**: Built-in support for storefront plan visibility and recommendations

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

[](#requirements)

- Laravel 11+, 12+, or 13+
- PHP 8.2+
- Laravel Cashier ^15.0 or ^16.0
- Stripe PHP SDK ^13.0, ^16.0, or ^17.0

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

[](#installation)

### Step 1: Install via Composer

[](#step-1-install-via-composer)

```
composer require particle-academy/laravel-catalog
```

The package will auto-discover and register its service provider.

### Step 2: Publish Configuration (Optional)

[](#step-2-publish-configuration-optional)

```
php artisan vendor:publish --tag=catalog-config
```

This creates `config/catalog.php` where you can customize:

- Auto-sync to Stripe
- Queue connection for sync jobs
- Broadcasting channel
- **Table names** (see [Custom table names](#custom-table-names) below)

### Step 3: Run Migrations

[](#step-3-run-migrations)

The package auto-loads its own four migrations:

```
php artisan migrate
```

- **Catalog migrations** (auto-loaded):
    - `create_products_table` - Products table with Stripe sync fields
    - `create_prices_table` - Prices table for recurring and one-time pricing
    - `create_product_features_table` - Product features table
    - `create_product_feature_configs_table` - Product-feature pivot table
    - `add_lookup_key_to_catalog_tables` - `lookup_key` on products + prices

**Cashier migrations are NOT auto-loaded.** Catalog depends on Cashier, but auto-registering Cashier's `create_subscriptions_table` would be fatal for a host app that already owns a `subscriptions` table. You decide who owns those tables:

- **Greenfield Cashier app** — let Catalog load them: `CATALOG_LOAD_CASHIER_MIGRATIONS=true` (or set `catalog.load_cashier_migrations` in `config/catalog.php`).
- **App with existing subscription infra** — leave it off (the default) and manage Cashier yourself if needed: `php artisan vendor:publish --tag=cashier-migrations`.

Custom table names
------------------

[](#custom-table-names)

Catalog's four table names are config-driven, so you don't have to fork the package when your schema differs — e.g. your app already has its own `products` table and you need catalog's prefixed as `catalog_products`. Override any of them in `config/catalog.php`:

```
'tables' => [
    'products'                => 'catalog_products',
    'prices'                  => 'catalog_prices',
    'product_features'        => 'catalog_product_features',
    'product_feature_configs' => 'catalog_product_feature_configs',
],
```

Both the models (`Product` / `Price` / `ProductFeature`, including the `product_feature_configs` pivot relationship) and **every** migration the package ships read these values, so models, relationships, Stripe sync, and schema all stay in sync from a single change.

The migrations also **self-skip** (no error) when there is nothing to do — the target table already exists, a foreign-key target table is absent at apply time, or an added column is already there. So they can sit early in your chronological migration order and you can build the real tables later in your own migration if you prefer. (Same shape as `laravel-fms`v0.7.0's `fms.tables` block.)

A prefixed install is covered by the test suite: the migration tests run against `catalog_*` names, not the defaults.

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

[](#configuration)

### Environment Variables

[](#environment-variables)

Add these to your `.env` file:

```
# Stripe Configuration (via Laravel Cashier)
STRIPE_KEY=your_stripe_key
STRIPE_SECRET=your_stripe_secret

# Catalog Package Configuration
CATALOG_AUTO_SYNC_STRIPE=false
CATALOG_QUEUE_CONNECTION=default
```

### Configuration File

[](#configuration-file)

After publishing, edit `config/catalog.php`:

```
return [
    // Auto-sync products/prices to Stripe when created/updated
    'auto_sync_stripe' => env('CATALOG_AUTO_SYNC_STRIPE', false),

    // Queue connection for sync jobs
    'queue_connection' => env('CATALOG_QUEUE_CONNECTION', 'default'),

    // Broadcasting channel for sync events
    'broadcast_channel' => 'admin.products',
];
```

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

[](#core-concepts)

### Products, Plans, and Prices

[](#products-plans-and-prices)

- **Products**: Containers that hold pricing information. Can represent subscription plans, one-time purchases, or add-ons.
- **Plans**: Products with recurring Prices. There is no separate "Plan" model. A plan is a Product that:
    - Has at least one recurring Price (`type = 'recurring'`)
    - Is optionally marked for storefront display (`metadata->storefront->plan->show = true`)
- **Prices**: Define the actual pricing (amount, currency, interval). Every Product **must** have at least one Price.

### Price Requirements

[](#price-requirements)

**Critical**: Products cannot be synced to Stripe without at least one Price. Always create a Price when creating a Product:

```
// ❌ WRONG - Product without Price
$product = Product::create(['name' => 'My Product']);
Catalog::syncProduct($product); // Will fail or create incomplete Stripe product

// ✅ CORRECT - Product with Price
$product = Product::create(['name' => 'My Product']);
Price::create([
    'product_id' => $product->id,
    'unit_amount' => 2900,
    'currency' => 'USD',
    'type' => Price::TYPE_RECURRING,
    'recurring_interval' => 'month',
]);
Catalog::syncProductAndPrices($product); // Success!
```

Usage
-----

[](#usage)

### Using the Catalog Facade

[](#using-the-catalog-facade)

All catalog functionality is accessible via the `Catalog` facade, making it easy to use without the UI:

```
use LaravelCatalog\Facades\Catalog;
use LaravelCatalog\Models\Product;
use LaravelCatalog\Models\Price;

// Sync a product to Stripe (requires at least one Price)
$product = Product::with('prices')->find('product-id');
if ($product->prices->isEmpty()) {
    throw new \Exception('Product must have at least one Price before syncing.');
}
Catalog::syncProduct($product);

// Sync a price to Stripe
$price = Price::find('price-id');
Catalog::syncPrice($price);

// Sync product and all its prices (recommended)
Catalog::syncProductAndPrices($product);

// Test Stripe connection
$result = Catalog::testConnection();
// Returns: ['success' => true, 'message' => 'Connection successful']

// Create checkout session for subscription
$checkout = Catalog::subscriptionCheckout(
    owner: $user,
    price: $price, // Must be a recurring price
    successUrl: route('subscriptions.success'),
    cancelUrl: route('subscriptions.cancel'),
    metadata: ['source' => 'admin_panel'] // Optional
);

// Create checkout session for one-time payment
$checkout = Catalog::oneTimeCheckout(
    owner: $user,
    price: $price, // Must be a one-time price
    quantity: 1,
    successUrl: route('payments.success'),
    cancelUrl: route('payments.cancel'),
    metadata: [] // Optional
);

// Get checkout URLs directly (convenience methods)
$url = Catalog::getSubscriptionCheckoutUrl($user, $price, $successUrl, $cancelUrl);
$url = Catalog::getOneTimeCheckoutUrl($user, $price, 1, $successUrl, $cancelUrl);

// Access services directly if needed
Catalog::catalogService()->testConnection();
Catalog::checkoutService()->oneTimeCheckout(...);
```

### Important Notes

[](#important-notes)

- **Products Must Have Prices**: A Product cannot be synced to Stripe without at least one Price. Always create a Price when creating a Product.
- **Plans are Products**: There is no separate "Plan" model. Plans are Products with recurring Prices and storefront metadata.
- **Price Types**: Prices can be `recurring` (subscriptions) or `one_time` (one-time purchases).
- **Sync Before Checkout**: Always ensure Products/Prices are synced to Stripe before creating checkout sessions.

> For detailed explanations of Products, Plans, and Prices, see [Core Concepts](#core-concepts) above.

### Creating Products

[](#creating-products)

```
use LaravelCatalog\Models\Product;
use LaravelCatalog\Models\Price;

// Create a product (plan)
$product = Product::create([
    'name' => 'Pro Plan',
    'description' => 'Perfect for growing teams',
    'active' => true,
    'order' => 1,
    'metadata' => [
        'storefront' => [
            'plan' => [
                'show' => true,        // Show on storefront
                'recommended' => true,  // Mark as recommended
            ],
        ],
    ],
]);

// IMPORTANT: Create at least one Price for the Product
// Recurring monthly price (makes this a "plan")
$monthlyPrice = Price::create([
    'product_id' => $product->id,
    'unit_amount' => 2900, // $29.00 in cents
    'currency' => 'USD',
    'recurring_interval' => 'month',
    'recurring_interval_count' => 1,
    'type' => Price::TYPE_RECURRING,
    'active' => true,
]);

// You can add multiple prices to the same product
// Yearly price (same product, different billing interval)
$yearlyPrice = Price::create([
    'product_id' => $product->id,
    'unit_amount' => 29000, // $290.00 in cents (save $58/year)
    'currency' => 'USD',
    'recurring_interval' => 'year',
    'recurring_interval_count' => 1,
    'type' => Price::TYPE_RECURRING,
    'active' => true,
]);
```

### Creating Prices

[](#creating-prices)

```
use LaravelCatalog\Models\Price;

// Recurring monthly price (for subscription plans)
$monthlyPrice = Price::create([
    'product_id' => $product->id,
    'unit_amount' => 2900, // $29.00 in cents
    'currency' => 'USD',
    'recurring_interval' => 'month',
    'recurring_interval_count' => 1,
    'recurring_trial_period_days' => 14, // Optional trial period
    'type' => Price::TYPE_RECURRING,
    'active' => true,
]);

// Recurring yearly price
$yearlyPrice = Price::create([
    'product_id' => $product->id,
    'unit_amount' => 29000, // $290.00 in cents
    'currency' => 'USD',
    'recurring_interval' => 'year',
    'recurring_interval_count' => 1,
    'type' => Price::TYPE_RECURRING,
    'active' => true,
]);

// One-time price (for add-ons or one-time purchases)
$oneTimePrice = Price::create([
    'product_id' => $product->id,
    'unit_amount' => 9900, // $99.00 in cents
    'currency' => 'USD',
    'type' => Price::TYPE_ONE_TIME,
    'active' => true,
]);

// Using factory (recommended for tests)
$recurringPrice = Price::factory()
    ->for($product)
    ->create([
        'type' => Price::TYPE_RECURRING,
        'recurring_interval' => 'month',
    ]);

$oneTimePrice = Price::factory()
    ->for($product)
    ->oneTime()
    ->create([
        'unit_amount' => 9900,
    ]);
```

### Working with Plans

[](#working-with-plans)

Since plans are Products with recurring Prices, you can query them like this:

```
use LaravelCatalog\Models\Product;
use LaravelCatalog\Models\Price;

// Get all products that are plans (have recurring prices and are marked for storefront)
$plans = Product::whereHas('prices', function ($query) {
    $query->where('type', Price::TYPE_RECURRING);
})
->whereJsonContains('metadata->storefront->plan->show', true)
->with(['prices' => function ($query) {
    $query->where('type', Price::TYPE_RECURRING);
}])
->orderBy('order')
->get();

// Get the recommended plan
$recommendedPlan = Product::whereJsonContains('metadata->storefront->plan->recommended', true)
    ->with('prices')
    ->first();

// Check if a product is a plan
if ($product->isStorefrontPlan()) {
    // This is a plan shown on the storefront
}

// Get all recurring prices for a product (its plan options)
$planPrices = $product->prices()->where('type', Price::TYPE_RECURRING)->get();
```

### Syncing to Stripe

[](#syncing-to-stripe)

#### Manual Sync

[](#manual-sync)

```
use LaravelCatalog\Jobs\SyncProductToStripe;

// Dispatch sync job
SyncProductToStripe::dispatch($product->id);

// Or sync directly
use LaravelCatalog\Services\StripeCatalogService;

$catalogService = app(StripeCatalogService::class);
$catalogService->syncProduct($product);
```

#### Auto Sync

[](#auto-sync)

Enable auto-sync in `config/catalog.php`:

```
'auto_sync_stripe' => true,
```

Products and prices will automatically sync to Stripe when created or updated.

### Creating Checkout Sessions

[](#creating-checkout-sessions)

#### Subscription Checkout

[](#subscription-checkout)

```
use LaravelCatalog\Facades\Catalog;
use LaravelCatalog\Models\Price;
use App\Models\User;

$user = User::find(1);
$price = Price::find($priceId);

// Ensure price has been synced to Stripe first
if (!$price->stripePriceId()) {
    Catalog::syncProductAndPrices($price->product);
}

$checkout = Catalog::subscriptionCheckout(
    owner: $user,
    price: $price,
    successUrl: route('subscriptions.success'),
    cancelUrl: route('subscriptions.cancel'),
    metadata: ['source' => 'admin_panel']
);

// Redirect to Stripe Checkout
return redirect($checkout->asStripeCheckoutSession()->url);
```

#### One-Time Payment Checkout

[](#one-time-payment-checkout)

```
use LaravelCatalog\Facades\Catalog;

$checkout = Catalog::oneTimeCheckout(
    owner: $user,
    price: $oneTimePrice,
    quantity: 1,
    successUrl: route('payments.success'),
    cancelUrl: route('payments.cancel'),
);

return redirect($checkout->asStripeCheckoutSession()->url);
```

### Fulfilling Payments

[](#fulfilling-payments)

> **`successUrl` is not proof of payment.** A customer who pays and closes the tab never reaches it, and reaching it does not mean the payment cleared. Granting access on the redirect alone gives some customers nothing they paid for, and gives others access they did not pay for. Neither failure raises an error.

Fulfilment belongs on the webhook. You do not need to build one: this package depends on **Laravel Cashier**, which already registers a signed endpoint.

```
POST /stripe/webhook        (route name: cashier.webhook)

```

Cashier handles subscription lifecycle events itself, and dispatches `WebhookReceived` for **every** payload type before doing so. One-time payments are therefore a listener, not a controller, route and signature check of your own.

#### 1. Put your own identifier in the checkout metadata

[](#1-put-your-own-identifier-in-the-checkout-metadata)

This is what lets you reconcile a payment without depending on the browser coming back. Do it when you create the session:

```
$order = Order::create([...]);   // your own record, still unpaid

$checkout = Catalog::oneTimeCheckout(
    owner: $user,
    price: $oneTimePrice,
    quantity: 1,
    successUrl: route('payments.success'),
    cancelUrl: route('payments.cancel'),
    metadata: ['order_id' => (string) $order->id],
);
```

#### 2. Listen for the completed session

[](#2-listen-for-the-completed-session)

```
namespace App\Listeners;

use Laravel\Cashier\Events\WebhookReceived;

class FulfilOrder
{
    public function handle(WebhookReceived $event): void
    {
        if (($event->payload['type'] ?? null) !== 'checkout.session.completed') {
            return;
        }

        $session = $event->payload['data']['object'] ?? [];

        // Check payment_status, NOT $session['status'] === 'complete'.
        // A session can be complete and still unpaid — delayed payment
        // methods clear later, and some never clear at all.
        if (($session['payment_status'] ?? null) !== 'paid') {
            return;
        }

        $orderId = $session['metadata']['order_id'] ?? null;

        if ($orderId === null) {
            return;   // Another product sold through the same Stripe account.
        }

        $order = Order::find($orderId);

        if ($order === null || $order->isPaid()) {
            return;   // Unknown, or already handled — see idempotency below.
        }

        $order->markPaid($session['id'] ?? null);
    }
}
```

Laravel discovers listeners in `app/Listeners` automatically. Confirm it is wired with `php artisan event:list | grep WebhookReceived`.

#### 3. Configure Stripe

[](#3-configure-stripe)

```
STRIPE_KEY=pk_test_...
STRIPE_SECRET=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
```

**Set `STRIPE_WEBHOOK_SECRET`.** Without it Cashier does not verify signatures, and the endpoint will accept anything posted to it. Then point a Stripe webhook at `/stripe/webhook` and subscribe to `checkout.session.completed`.

Test locally with the Stripe CLI:

```
stripe listen --forward-to localhost:8000/stripe/webhook
stripe trigger checkout.session.completed
```

#### Fulfilment must be idempotent

[](#fulfilment-must-be-idempotent)

Stripe **retries** deliveries, so the same event arrives more than once. If you also fulfil on the `successUrl` redirect — reasonable, since it makes the happy path instant — then the two race each other, and in practice they collide often. Guard on your own record's state (the `$order->isPaid()` check above) rather than assuming one delivery.

Do not let a fulfilment failure bubble out of the listener either: a non-2xx response tells Stripe to redeliver, so a permanently failing order is retried indefinitely. Log it and return.

### Using Factories in Tests

[](#using-factories-in-tests)

The package includes factories that are automatically available:

```
use LaravelCatalog\Models\Product;
use LaravelCatalog\Models\Price;

// Create a product
$product = Product::factory()->create();

// Create a product with prices
$product = Product::factory()->create();
$price = Price::factory()->for($product)->create();

// Create a recurring price
$recurringPrice = Price::factory()
    ->for($product)
    ->create([
        'type' => Price::TYPE_RECURRING,
        'recurring_interval' => 'month',
    ]);

// Create a one-time price
$oneTimePrice = Price::factory()
    ->for($product)
    ->oneTime()
    ->create();
```

Building an Admin UI
--------------------

[](#building-an-admin-ui)

The package has no UI dependencies. Build your own admin interface using only the `Catalog` facade and Eloquent models.

### Key Principles

[](#key-principles)

1. **Use the Catalog Facade**: All Stripe operations go through `LaravelCatalog\Facades\Catalog`
2. **Products Must Have Prices**: Always create at least one Price when creating a Product
3. **Plans are Products**: Filter Products with recurring Prices to get plans
4. **Sync Before Checkout**: Ensure Products/Prices are synced to Stripe before creating checkout sessions

### Example: Custom Admin Controller

[](#example-custom-admin-controller)

```
