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.9.1(1mo ago)04271MITPHPPHP ^8.2CI passing

Since Jan 2Pushed 3w 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 today

READMEChangelogDependencies (14)Versions (14)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)
- [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

**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 the create migrations read these values, so models, relationships, Stripe sync, and schema all stay in sync from a single change.

The create migrations also **self-skip** (no error) when the target table already exists, or when a foreign-key target table is absent at apply time — 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.)

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);
```

### 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)

```
