Overview
Products represent the offerings in your billing catalog. Each product can have multiple plans (pricing tiers), feature entitlements, and metadata. Products are organized by level to create upgrade/downgrade hierarchies.
use Eufaturo\Billing\Billing;
Quick Start
// Create a product
$product = Billing::products()->create(
name: 'Pro Plan',
description: 'Perfect for growing teams',
isVisible: true,
isPopular: true,
);
// Find a product
$product = Billing::products()->find('pro-plan');
// Get all visible products
$products = Billing::products()->visible();
Product Manager
Access the product manager through the Billing facade:
$manager = Billing::products();
Methods
create()
Create a new product.
public function create(
string $name,
?string $slug = null,
?string $description = null,
bool $isVisible = true,
bool $isPopular = true,
bool $isDefault = false,
array $metadata = [],
array $features = [],
): Product
Parameters:
$name (string): Product name (e.g., “Pro Plan”, “Enterprise”)
$slug (string|null): URL-friendly identifier (auto-generated from name if null)
$description (string|null): Product description
$isVisible (bool): Show in pricing page (default: true)
$isPopular (bool): Mark as “Most Popular” (default: true)
$isDefault (bool): Default selection in UI (default: false)
$metadata (array): Custom metadata as key-value pairs
$features (array): Feature assignments (see Features)
Returns: Product
Example:
use Eufaturo\Billing\Billing;
// Basic product
$basic = Billing::products()->create(
name: 'Basic',
description: 'For individuals just getting started',
isVisible: true,
isPopular: false,
);
// Pro product with metadata
$pro = Billing::products()->create(
name: 'Pro Plan',
slug: 'professional',
description: 'For growing teams',
isVisible: true,
isPopular: true,
isDefault: true,
metadata: [
'badge' => 'Most Popular',
'highlight_color' => '#3B82F6',
'support_level' => 'priority',
],
);
// Enterprise with custom slug
$enterprise = Billing::products()->create(
name: 'Enterprise',
slug: 'enterprise-2024',
description: 'Custom solutions for large organizations',
isVisible: true,
metadata: [
'custom_pricing' => true,
'dedicated_support' => true,
],
);
Product Levels: Products are automatically assigned a level (1, 2, 3…) based on creation order. This creates a natural upgrade/downgrade hierarchy.
update()
Update an existing product.
public function update(
Product $product,
string $name,
?string $slug = null,
?string $description = null,
bool $isVisible = true,
bool $isPopular = true,
bool $isDefault = false,
array $metadata = [],
array $features = [],
): Product
Parameters: Same as create(), plus:
$product (Product): The product instance to update
Returns: Product - Updated product instance
Example:
$product = Billing::products()->find('pro-plan');
$updated = Billing::products()->update(
product: $product,
name: 'Pro Plan (Updated)',
description: 'Now with even more features',
isPopular: true,
metadata: [
'badge' => 'Best Value',
'new_feature' => 'AI Assistant',
],
);
delete()
Soft delete a product.
public function delete(Product $product): Product
Parameters:
$product (Product): Product to delete
Returns: Product - Soft-deleted product instance
Example:
$product = Billing::products()->find('old-plan');
$deleted = Billing::products()->delete($product);
expect($deleted->trashed())->toBeTrue();
Deleting a product soft-deletes it (sets deleted_at). The product and its relationships are preserved in the database. Use restore() to undelete.
restore()
Restore a soft-deleted product.
public function restore(Product $product): Product
Parameters:
$product (Product): Soft-deleted product to restore
Returns: Product - Restored product instance
Example:
use Eufaturo\Billing\Products\Models\Product;
// Find including soft-deleted
$product = Product::withTrashed()->where('slug', 'old-plan')->first();
// Restore
$restored = Billing::products()->restore($product);
expect($restored->trashed())->toBeFalse();
find()
Find a product by slug.
public function find(string $slug): ?Product
Parameters:
$slug (string): Product slug
Returns: Product|null - Product instance or null if not found
Example:
// Find by slug
$product = Billing::products()->find('pro-plan');
if ($product) {
echo $product->name; // "Pro Plan"
}
// Handle not found
$product = Billing::products()->find('non-existent');
// $product is null
visible()
Get all visible products.
public function visible(): Collection
Returns: Collection<int, Product> - Collection of visible products
Example:
// Get products for pricing page
$products = Billing::products()->visible();
foreach ($products as $product) {
echo "{$product->name}: {$product->description}";
}
Only products with is_visible = true are returned. Perfect for displaying on your pricing page.
all()
Get all active (non-deleted) products.
public function all(): Collection
Returns: Collection<int, Product> - All active products
Example:
// Get all products (visible and hidden)
$allProducts = Billing::products()->all();
// Get count
$count = Billing::products()->all()->count();
withTrashed()
Get all products including soft-deleted ones.
public function withTrashed(): Collection
Returns: Collection<int, Product> - All products including soft-deleted
Example:
// Include deleted products
$allProducts = Billing::products()->withTrashed();
// Filter deleted products
$deletedProducts = $allProducts->filter(fn($p) => $p->trashed());
// Count active vs deleted
$active = $allProducts->filter(fn($p) => !$p->trashed())->count();
$deleted = $allProducts->filter(fn($p) => $p->trashed())->count();
Product Model
The Product model represents a subscription product.
Properties
/** @var int */
public int $id;
/** @var string */
public string $name;
/** @var string */
public string $slug;
/** @var string|null */
public ?string $description;
/** @var int - Hierarchy level (1, 2, 3...) */
public int $level;
/** @var bool - Show on pricing page */
public bool $is_visible;
/** @var bool - Mark as "Most Popular" */
public bool $is_popular;
/** @var bool - Default selection in UI */
public bool $is_default;
/** @var Collection - Custom metadata */
public Collection $metadata;
/** @var CarbonInterface */
public CarbonInterface $created_at;
/** @var CarbonInterface */
public CarbonInterface $updated_at;
/** @var CarbonInterface|null */
public ?CarbonInterface $deleted_at;
Relationships
// Get product plans
$product->plans; // Collection<ProductPlan>
// Get product features
$product->features; // Collection<Feature>
Methods
hasFeature()
Check if a feature is included in the product.
public function hasFeature(string $featureSlug): bool
Example:
if ($product->hasFeature('api-access')) {
// Product includes API access
}
getFeatureLimit()
Get the limit for a specific feature.
public function getFeatureLimit(string $featureSlug): ?int
Example:
$limit = $product->getFeatureLimit('team-members');
// Returns: 5 (for limit-based features)
// Returns: null (for unlimited or boolean features)
getIncludedFeatures()
Get all features that are included.
public function getIncludedFeatures(): Collection
Example:
$includedFeatures = $product->getIncludedFeatures();
foreach ($includedFeatures as $feature) {
echo "✓ {$feature->name}";
}
getExcludedFeatures()
Get all features that are excluded.
public function getExcludedFeatures(): Collection
Example:
$excludedFeatures = $product->getExcludedFeatures();
foreach ($excludedFeatures as $feature) {
echo "✗ {$feature->name}";
}
Product Features
Products can be linked to features with specific configurations:
use Eufaturo\Billing\Billing;
// Create product with features (via manager)
$product = Billing::products()->create(
name: 'Pro Plan',
features: [
[
'name' => 'api-access',
'included' => true,
],
[
'name' => 'team-members',
'included' => true,
'limit' => 10,
],
],
);
// Or attach features manually
$product->features()->attach($feature->id, [
'included' => true,
'limit' => 5,
'tier_value' => 'premium',
'sort' => 1,
]);
Pivot Fields:
included (bool): Whether the feature is included
limit (int|null): Numeric limit for the feature
tier_value (string|null): Tier value (e.g., “basic”, “premium”)
sort (int): Display order
Product Levels
Products are automatically assigned a level field that creates a natural hierarchy:
// First product created
$basic = Billing::products()->create(name: 'Basic');
// $basic->level = 1
// Second product created
$pro = Billing::products()->create(name: 'Pro');
// $pro->level = 2
// Third product created
$enterprise = Billing::products()->create(name: 'Enterprise');
// $enterprise->level = 3
Use Cases:
- ✅ Determine upgrade/downgrade direction
- ✅ Display products in correct order
- ✅ Calculate proration correctly
- ✅ Validate plan changes
Important Notes:
- Levels are auto-incremented sequentially
- Deleting a product doesn’t affect other levels
- New products continue from max level (even if gaps exist)
Complete Example
use Eufaturo\Billing\Billing;
// Create product hierarchy
$basic = Billing::products()->create(
name: 'Basic',
description: 'Perfect for individuals',
isVisible: true,
isPopular: false,
metadata: [
'support_level' => 'community',
],
);
$pro = Billing::products()->create(
name: 'Professional',
description: 'For growing teams',
isVisible: true,
isPopular: true,
isDefault: true,
metadata: [
'badge' => 'Most Popular',
'highlight_color' => '#3B82F6',
'support_level' => 'priority',
],
);
$enterprise = Billing::products()->create(
name: 'Enterprise',
description: 'Custom solutions',
isVisible: true,
isPopular: false,
metadata: [
'contact_sales' => true,
'custom_pricing' => true,
'support_level' => 'dedicated',
],
);
// Display in pricing table
$products = Billing::products()->visible();
foreach ($products as $product) {
echo "Level {$product->level}: {$product->name}\n";
echo $product->description . "\n";
if ($product->is_popular) {
echo "⭐ {$product->metadata['badge']}\n";
}
echo "---\n";
}
// Output:
// Level 1: Basic
// Perfect for individuals
// ---
// Level 2: Professional
// For growing teams
// ⭐ Most Popular
// ---
// Level 3: Enterprise
// Custom solutions
// ---
Products support flexible metadata for custom use cases:
$product = Billing::products()->create(
name: 'Pro Plan',
metadata: [
'badge' => 'Most Popular',
'highlight_color' => '#3B82F6',
'support_level' => 'priority',
'max_storage_gb' => 100,
'custom_domain' => true,
],
);
// Access metadata
$badge = $product->metadata['badge']; // "Most Popular"
$color = $product->metadata['highlight_color']; // "#3B82F6"
// Check existence
if (isset($product->metadata['custom_domain'])) {
// Enable custom domain feature
}
// Iterate metadata
foreach ($product->metadata as $key => $value) {
echo "{$key}: {$value}\n";
}
Common Metadata Use Cases:
- UI badges and highlights
- Feature flags not in the database
- Marketing copy variations
- Integration settings
- Display preferences
Working with Plans
Products can have multiple pricing plans:
$product = Billing::products()->find('pro-plan');
// Get all plans for this product
$plans = $product->plans;
// Get monthly plan
$monthlyPlan = $product->plans()
->where('interval', Interval::Month)
->where('interval_count', 1)
->first();
// Get yearly plan
$yearlyPlan = $product->plans()
->where('interval', Interval::Year)
->first();
See Product Plans documentation for more details.
Soft Deletes
Products use soft deletes for safe removal:
// Delete a product
$product = Billing::products()->find('old-plan');
Billing::products()->delete($product);
// Product is soft-deleted (deleted_at is set)
expect($product->trashed())->toBeTrue();
// Still accessible via withTrashed()
$allProducts = Billing::products()->withTrashed();
// Restore the product
Billing::products()->restore($product);
expect($product->trashed())->toBeFalse();
// Permanently delete (use with caution!)
$product->forceDelete();
Soft-deleted products remain in the database. Associated subscriptions and plans are preserved. Only use forceDelete() if you’re absolutely sure.
Best Practices
1. Product Hierarchy
Create products in the correct order (basic → pro → enterprise):
// ✅ Good: Natural progression
$basic = Billing::products()->create(name: 'Basic'); // level: 1
$pro = Billing::products()->create(name: 'Pro'); // level: 2
$enterprise = Billing::products()->create(name: 'Enterprise'); // level: 3
2. Slug Naming
Use consistent, URL-friendly slugs:
// ✅ Good: Clear, consistent slugs
$product = Billing::products()->create(
name: 'Professional Plan',
slug: 'professional', // or 'pro-plan'
);
// ❌ Avoid: Inconsistent or confusing slugs
$product = Billing::products()->create(
name: 'Professional Plan',
slug: 'prof-2024-v2', // Too specific
);
3. Visibility Control
Only show products you want customers to purchase:
// ✅ Active plan: visible
$current = Billing::products()->create(
name: 'Pro 2024',
isVisible: true,
);
// ✅ Legacy plan: hidden (existing customers keep it)
$legacy = Billing::products()->create(
name: 'Pro 2023',
isVisible: false,
);
Structure metadata consistently:
// ✅ Good: Organized, semantic metadata
$product = Billing::products()->create(
name: 'Enterprise',
metadata: [
'ui' => [
'badge' => 'Custom',
'color' => '#3B82F6',
],
'support' => [
'level' => 'dedicated',
'sla_hours' => 4,
],
'limits' => [
'storage_gb' => 1000,
'users' => 'unlimited',
],
],
);
Testing
use Eufaturo\Billing\Billing;
use Eufaturo\Billing\Database\Factories\ProductFactory;
test('can create a product via manager', function () {
$product = Billing::products()->create(
name: 'Test Product',
slug: 'test-product',
);
expect($product->name)->toBe('Test Product');
expect($product->slug)->toBe('test-product');
expect($product->level)->toBe(1);
});
test('product slug is auto-generated if not provided', function () {
$product = Billing::products()->create(
name: 'Test Product',
);
expect($product->slug)->toBe('test-product');
});
test('can use factory for testing', function () {
$product = ProductFactory::new()->create([
'name' => 'Factory Product',
]);
expect($product)->toBeInstanceOf(Product::class);
expect($product->name)->toBe('Factory Product');
});
See Also