Files

1209 lines
38 KiB
Markdown

# Lunar Reference
Lunar is a headless e-commerce package for Laravel (`lunarphp/core` + `lunarphp/lunar`). It handles products, carts, orders, payments, discounts, taxation, and more while leaving full control over the storefront UI. The admin panel is built on Filament.
---
## Table of Contents
1. [Core Concepts](#core-concepts)
2. [Key Models and Relationships](#key-models-and-relationships)
3. [ModelManifest — Replacing Models](#modelmanifest--replacing-models)
4. [Filament Panel Integration](#filament-panel-integration)
5. [Extending Resources and Pages](#extending-resources-and-pages)
6. [Activity Logging](#activity-logging)
7. [Pricing](#pricing)
8. [Tax](#tax)
9. [Channels and Currencies](#channels-and-currencies)
10. [Cart and Checkout](#cart-and-checkout)
11. [Orders](#orders)
12. [Customers and Addresses](#customers-and-addresses)
13. [Discounts](#discounts)
14. [Products and Variants](#products-and-variants)
15. [Collections and URLs](#collections-and-urls)
16. [Payments](#payments)
17. [Shipping](#shipping)
18. [Search Indexing](#search-indexing)
19. [Access Control](#access-control)
---
## Core Concepts
- **Headless**: Lunar is a Laravel package, not a standalone app. It lives inside your Laravel application.
- **No storefront UI prescribed**: The admin panel uses Filament/Livewire; the storefront can be Blade, Livewire, Inertia/Vue, or a pure API.
- **Table prefix**: All Lunar database tables use the `lunar_` prefix by default (configurable in `config/lunar/database.php`).
- **Prices stored as integers**: All monetary values are stored in the currency's smallest unit (e.g. cents). They are cast to `Lunar\DataTypes\Price` objects on retrieval.
- **Polymorphic morph map**: Lunar uses string aliases (e.g. `product`, `customer`) for polymorphic relations — not fully-qualified class names.
- **Config files**: Published to `config/lunar/` — key files are `database.php`, `cart.php`, `orders.php`, `taxes.php`, `payments.php`, `pricing.php`, `urls.php`, `media.php`.
### LunarUser trait
Add to your `User` model to gain relationships to carts, orders, and customers:
```php
use Lunar\Base\Traits\LunarUser;
use Lunar\Base\LunarUser as LunarUserInterface;
class User extends Authenticatable implements LunarUserInterface
{
use LunarUser;
}
```
This adds: `customers` (BelongsToMany), `carts` (HasMany), `orders` (HasMany), and `latestCustomer()`.
---
## Key Models and Relationships
### Quick map
| Model | Namespace | Notes |
|---|---|---|
| Product | `Lunar\Models\Product` | Has many variants; belongs to ProductType and optionally Brand |
| ProductVariant | `Lunar\Models\ProductVariant` | The actual purchasable item; has prices, stock, SKU |
| ProductType | `Lunar\Models\ProductType` | Defines which attributes a product has |
| ProductOption | `Lunar\Models\ProductOption` | e.g. "Size", "Color" |
| ProductOptionValue | `Lunar\Models\ProductOptionValue` | e.g. "Red", "Large" |
| Collection | `Lunar\Models\Collection` | Nested-set tree; belongs to CollectionGroup |
| CollectionGroup | `Lunar\Models\CollectionGroup` | Top-level grouping of collections |
| Cart | `Lunar\Models\Cart` | Has many CartLines; belongs to Currency and Channel |
| CartLine | `Lunar\Models\CartLine` | Polymorphic purchasable (usually ProductVariant) + quantity |
| CartAddress | `Lunar\Models\CartAddress` | Shipping or billing address on a cart |
| Order | `Lunar\Models\Order` | Created from a cart; has OrderLines and OrderAddresses |
| OrderLine | `Lunar\Models\OrderLine` | Snapshot of a purchased item |
| OrderAddress | `Lunar\Models\OrderAddress` | Immutable snapshot of shipping/billing address |
| Transaction | `Lunar\Models\Transaction` | Payment record (intent, capture, refund) |
| Customer | `Lunar\Models\Customer` | Buyer; linked to User via `customer_user` pivot |
| Address | `Lunar\Models\Address` | Saved address belonging to a Customer |
| Price | `Lunar\Models\Price` | Polymorphic; supports customer groups and quantity tiers |
| Currency | `Lunar\Models\Currency` | ISO 4217 code; exchange rate relative to default |
| Channel | `Lunar\Models\Channel` | Sales outlet; products are published per channel |
| Language | `Lunar\Models\Language` | ISO 639-1 code; controls translated attribute data |
| TaxClass | `Lunar\Models\TaxClass` | Assigned to variants; determines which rate applies |
| TaxZone | `Lunar\Models\TaxZone` | Geographic region; `tax_inclusive` or `tax_exclusive` |
| TaxRate | `Lunar\Models\TaxRate` | Belongs to TaxZone; has many TaxRateAmounts |
| Discount | `Lunar\Models\Discount` | Coupon/promo; AmountOff or BuyXGetY |
| Tag | `Lunar\Models\Tag` | Polymorphic tagging |
| Url | `Lunar\Models\Url` | SEO slug; polymorphic; one default per language |
| Brand | `Lunar\Models\Brand` | Optional brand for products |
### Product relationships
```
Product
├── productType → ProductType
├── brand → Brand (optional)
├── variants → ProductVariant[]
│ └── prices → Price[]
├── collections ↔ Collection[]
├── productOptions ↔ ProductOption[]
│ └── values → ProductOptionValue[]
├── associations → ProductAssociation[]
├── urls → Url[]
├── tags ↔ Tag[]
└── media (Spatie MediaLibrary)
```
### Cart relationships
```
Cart
├── lines → CartLine[]
│ └── purchasable → ProductVariant (polymorphic)
├── shippingAddress → CartAddress
├── billingAddress → CartAddress
├── currency → Currency
├── user → User
├── customer → Customer
└── orders → Order[]
```
### Order relationships
```
Order
├── lines → OrderLine[]
│ ├── productLines (excludes shipping type)
│ └── shippingLines (type = 'shipping')
├── shippingAddress → OrderAddress
├── billingAddress → OrderAddress
├── transactions → Transaction[]
│ ├── captures (type = 'capture')
│ ├── intents (type = 'intent')
│ └── refunds (type = 'refund')
├── customer → Customer
├── cart → Cart
└── currency → Currency (matched on currency_code)
```
### Customer relationships
```
Customer
├── users ↔ User[] (pivot: customer_user)
├── customerGroups ↔ CustomerGroup[]
├── addresses → Address[]
└── orders → Order[]
```
---
## ModelManifest — Replacing Models
`ModelManifest` is an interface-to-implementation registry. Registering a replacement does three things: binds the contract in the container, registers route model binding, and updates the morph map.
**Only models with contracts can be replaced.** Contracts live in `vendor/lunarphp/core/src/Models/Contracts/`. `Lunar\Admin\Models\Staff` has no contract and cannot be replaced this way.
### Replace a single model
```php
// In AppServiceProvider::boot()
\Lunar\Facades\ModelManifest::replace(
\Lunar\Models\Contracts\Customer::class,
\App\Models\Customer::class,
);
```
Your `App\Models\Customer` should extend `Lunar\Models\Customer`.
### Replace models from a directory
Scans the directory and replaces every model that extends a Lunar base model:
```php
\Lunar\Facades\ModelManifest::addDirectory(__DIR__.'/../Models');
```
### Dynamic relationships (preferred for simple extensions)
For adding relationships without replacing models:
```php
use Lunar\Models\Order;
Order::resolveRelationUsing('ticket', function ($order) {
return $order->belongsTo(Ticket::class, 'ticket_id');
});
```
Register in a service provider `boot()`.
### What is replaceable
Check `vendor/lunarphp/core/src/Models/Contracts/` for available contracts: `Customer`, `Order`, `Product`, `ProductVariant`, `ProductType`, `Cart`, `CartLine`, `Channel`, `Currency`, `Language`, `Collection`, `CollectionGroup`, `Price`, `Discount`, `Tag`, `Brand`, `TaxClass`, `TaxZone`, `TaxRate`, and others.
---
## Filament Panel Integration
### Registering the panel
```php
use Lunar\Admin\Support\Facades\LunarPanel;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
LunarPanel::panel(function ($panel) {
return $panel
->path('admin') // default: 'lunar'
->pages([SalesReport::class])
->resources([BannerResource::class])
->plugin(new ShippingPlugin())
->navigationGroups(['Catalog', 'Sales', 'Settings']);
})
->extensions([
CustomerResource::class => CustomerResourceExtension::class,
])
->disableTwoFactorAuth()
->register();
}
}
```
`LunarPanel::register()` must be called in `register()`, not `boot()`.
### Registering extensions (resources and pages)
Extensions are keyed by the Lunar resource/page class:
```php
LunarPanel::extensions([
\Lunar\Admin\Filament\Resources\ProductResource::class => MyProductExtension::class,
\Lunar\Admin\Filament\Resources\CustomerResource::class => MyCustomerExtension::class,
\Lunar\Admin\Filament\Resources\OrderResource\Pages\ManageOrder::class => MyOrderExtension::class,
]);
```
### All extendable resources
```php
use Lunar\Admin\Filament\Resources\{
ActivityResource, AttributeGroupResource, BrandResource, ChannelResource,
CollectionGroupResource, CollectionResource, CurrencyResource,
CustomerGroupResource, CustomerResource, DiscountResource, LanguageResource,
OrderResource, ProductOptionResource, ProductResource, ProductTypeResource,
ProductVariantResource, StaffResource, TagResource, TaxClassResource,
TaxRateResource, TaxZoneResource
};
```
### Staff model wiring
`Lunar\Admin\Models\Staff` is the Filament auth model. It does **not** go through ModelManifest. To extend it:
- Override it by extending the class and pointing Filament's auth config at your subclass.
- Listen on `Lunar\Admin\Models\Staff::created` (not your subclass) for events fired by Lunar internals.
- Use `LunarStaff::addActivitylogExcept([...])` on the base class to exclude fields from the activity log — not `getDefaultLogExcept()` on a subclass.
---
## Extending Resources and Pages
### ResourceExtension
Extend a Lunar resource's form, table, relations, pages, and sub-navigation:
```php
use Filament\Forms\Form;
use Filament\Tables\Table;
use Lunar\Admin\Support\Extending\ResourceExtension;
class MyProductResourceExtension extends ResourceExtension
{
public function extendForm(Form $form): Form
{
return $form->schema([
...$form->getComponents(withHidden: true), // always spread existing
TextInput::make('custom_field'),
]);
}
public function extendTable(Table $table): Table
{
return $table->columns([
...$table->getColumns(),
TextColumn::make('custom_field'),
]);
}
public function getRelations(array $managers): array
{
return [...$managers, MyRelationManager::class];
}
public function extendPages(array $pages): array
{
return [...$pages, 'my-page' => MyPage::route('/{record}/my-page')];
}
public function extendSubNavigation(array $nav): array
{
return [...$nav, MyPage::class];
}
public function headerActions(array $actions): array
{
return [...$actions, Actions\Action::make('custom')];
}
}
```
### RelationManagerExtension
```php
use Lunar\Admin\Support\Extending\RelationManagerExtension;
class MyExtension extends RelationManagerExtension
{
public function extendForm(Form $form): Form { /* ... */ }
public function extendTable(Table $table): Table { /* ... */ }
public function headerActions(array $actions): array { /* ... */ }
}
```
### Page extensions
| Class | Use case |
|---|---|
| `CreatePageExtension` | `heading()`, `beforeCreate()`, `afterCreation()`, `formActions()`, `headerActions()`, `headerWidgets()`, `footerWidgets()` |
| `EditPageExtension` | `heading()`, `beforeFill()`, `beforeSave()`, `beforeUpdate()`, `afterUpdate()` |
| `ListPageExtension` | `heading()`, `getTabs()`, `headerActions()`, `headerWidgets()`, `footerWidgets()` |
| `ViewPageExtension` | `heading()`, `headerActions()`, `headerWidgets()`, `footerWidgets()` |
| `RelationPageExtension` | `heading()`, `headerActions()`, `extendTable()` |
### Order management extension
```php
LunarPanel::extensions([
\Lunar\Admin\Filament\Resources\OrderResource\Pages\ManageOrder::class => MyOrderExtension::class,
]);
```
Overridable methods include `extendInfolistSchema()`, `extendOrderSummaryInfolist()`, `extendOrderTotalsInfolist()`, `extendTransactionsInfolist()`, and many targeted `extend*Entry()` methods.
### Extending in addons (defensive pattern)
When modifying forms in an addon, always spread existing components to avoid conflicts with other extensions:
```php
public function extendForm(Form $form): Form
{
$form->schema([
...$form->getComponents(true),
TextInput::make('my_field'),
]);
return $form;
}
```
---
## Activity Logging
Lunar uses [Spatie laravel-activitylog](https://spatie.be/docs/laravel-activitylog) with the trait `Lunar\Base\Traits\LogsActivity`.
**Models logged by default**: AttributeGroup, Brand, Cart, CartAddress, CartLine, Channel, CollectionGroup, Currency, Customer, CustomerGroup, Discount, Order, OrderAddress, OrderLine, Product, ProductOption, ProductType, ProductVariant, Tag, TaxClass, TaxRate, TaxZone, Transaction.
All entries use the `lunar` log name. Only dirty attributes are recorded; `updated_at` is excluded.
### Adding logging to custom models
```php
use Lunar\Base\Traits\LogsActivity;
class MyModel extends Model
{
use LogsActivity;
public static function getDefaultLogExcept(): array
{
return ['internal_notes'];
}
}
```
### Excluding fields on Lunar's own Staff model
Because Filament instantiates `Lunar\Admin\Models\Staff` directly (not a subclass), use this pattern in a service provider boot:
```php
use Lunar\Admin\Models\Staff as LunarStaff;
LunarStaff::addActivitylogExcept(['otp_code', 'otp_expires_at', 'password']);
```
---
## Pricing
Prices are stored as integers in the smallest currency unit (e.g. cents). The `Price` model is polymorphic — any model implementing `HasPrices` can have prices.
### Price model fields
| Field | Description |
|---|---|
| `price` | Integer; smallest currency unit |
| `compare_price` | Optional; used for showing crossed-out "was" prices |
| `min_quantity` | Minimum quantity for this tier (default 1) |
| `currency_id` | Currency FK |
| `customer_group_id` | Nullable; for group-specific pricing |
| `priceable_type/id` | Polymorphic parent (e.g. ProductVariant) |
### Pricing facade
```php
use Lunar\Facades\Pricing;
$response = Pricing::for($variant)
->currency($currency)
->customerGroups($customerGroups)
->qty(5)
->get();
$response->matched; // Lunar\Models\Price — best match
$response->base; // base price (qty=1, no group)
$response->priceBreaks; // Collection of tier prices
$response->customerGroupPrices; // Collection of group prices
```
### Price data type
Every price attribute returns `Lunar\DataTypes\Price`:
```php
$price->value; // raw integer
$price->decimal(); // float
$price->unitDecimal(); // float, divided by unit_quantity
$price->formatted(); // "£10.00" (uses PHP NumberFormatter)
$price->unitFormatted(); // formatted per unit
```
### Tax helpers on Price model
Config `lunar.pricing.stored_inclusive_of_tax` controls whether stored prices include tax.
```php
$priceModel->priceExTax(); // Lunar\DataTypes\Price
$priceModel->priceIncTax(); // Lunar\DataTypes\Price
$priceModel->comparePriceIncTax(); // Lunar\DataTypes\Price
// Override tax zone (useful before a cart exists)
$priceModel->priceIncTax($taxZone);
```
### Custom price formatter
Set `config/lunar/pricing.php` `formatter` to your class implementing `PriceFormatterInterface`.
---
## Tax
### Structure
```
TaxZone (geographic region)
└── TaxRate[] (e.g. "VAT", "State Tax")
└── TaxRateAmount[] (percentage per TaxClass)
ProductVariant → TaxClass
```
### TaxZone fields
| Field | Values | Notes |
|---|---|---|
| `zone_type` | `country`, `states`, `postcodes` | Determines scope |
| `price_display` | `tax_inclusive`, `tax_exclusive` | Controls how prices display |
| `default` | boolean | Fallback zone |
### TaxRate and TaxRateAmount
```php
$taxZone->taxRates()->create(['name' => 'VAT', 'priority' => 1]);
$taxRate->taxRateAmounts()->create([
'tax_class_id' => $defaultTaxClass->id,
'percentage' => 20.000,
]);
```
### Custom tax driver
Implement `Lunar\Base\TaxDriver` and register:
```php
\Lunar\Facades\Taxes::extend('taxjar', fn ($app) => $app->make(TaxJar::class));
```
Then set `config/lunar/taxes.php` `driver` to `'taxjar'`.
---
## Channels and Currencies
### Channels
Channels represent sales outlets (webstore, wholesale portal, etc.). Products and collections are published per channel with optional start/end dates.
```php
$product->scheduleChannel($channel);
$product->scheduleChannel($channel, now()->addDays(14), now()->addDays(28));
$products = Product::channel($channel)->get();
```
### Currencies
```php
$currency->code; // e.g. 'GBP'
$currency->exchange_rate; // relative to default currency
$currency->decimal_places; // e.g. 2
$currency->default; // boolean
```
Exchange rates do not auto-convert prices unless `sync_prices` is enabled on the non-default currency.
### Storefront session
Manages the active channel, currency, customer, and customer groups for the session:
```php
use Lunar\Facades\StorefrontSession;
$channel = StorefrontSession::getChannel();
$currency = StorefrontSession::getCurrency();
$customer = StorefrontSession::getCustomer();
$groups = StorefrontSession::getCustomerGroups();
StorefrontSession::setChannel($channel);
StorefrontSession::setCurrency($currency);
StorefrontSession::setCustomer($customer); // validates customer belongs to user
```
Customer resolution order: session → `$user->latestCustomer()`.
---
## Cart and Checkout
### CartSession facade
```php
use Lunar\Facades\CartSession;
$cart = CartSession::current(); // calculates totals; returns null if no cart
$cart->recalculate(); // force recalculation
CartSession::createOrder(); // creates order, removes cart from session
CartSession::createOrder(forget: false); // keeps cart in session
CartSession::forget(); // clear session (soft deletes cart by default)
CartSession::forget(delete: false); // clear session, keep cart in DB
```
### Adding items
```php
$cart->add($variant, quantity: 2, meta: ['gift_wrap' => true]);
$cart->addLines([
['purchasable' => $variant, 'quantity' => 2],
]);
```
### Updating and removing
```php
$cart->updateLine($cartLineId, quantity: 3, meta: [...]);
$cart->remove($cartLineId);
$cart->clear();
```
### Addresses
```php
$cart->setShippingAddress([
'country_id' => $id, 'first_name' => '...', 'line_one' => '...', 'city' => '...', 'postcode' => '...',
]);
$cart->setBillingAddress([/* same fields */]);
```
Accepts an array or any model implementing `Lunar\Base\Addressable` (e.g. `Lunar\Models\Address`).
### Cart total properties (after calculate/recalculate)
| Property | Description |
|---|---|
| `total` | Grand total including tax |
| `subTotal` | Sum of lines before tax and discounts |
| `subTotalDiscounted` | Subtotal after discounts |
| `taxTotal` | Total tax |
| `discountTotal` | Total discounts |
| `shippingSubTotal` | Shipping before tax |
| `shippingTaxTotal` | Tax on shipping |
| `shippingTotal` | Shipping including tax |
| `taxBreakdown` | TaxBreakdown object; iterate `.amounts` |
| `discountBreakdown` | Collection of discount breakdowns |
| `shippingBreakdown` | Collection of shipping line items |
### CartLine properties (after calculate)
`unitPrice`, `unitPriceInclTax`, `subTotal`, `subTotalDiscounted`, `discountTotal`, `taxAmount`, `total`
### Cart exceptions
| Exception | Cause |
|---|---|
| `Lunar\Exceptions\Carts\CartException` | Validation failure; access errors via `$e->errors()` |
| `InvalidCartLineQuantityException` | Quantity <= 0 |
| `NonPurchasableItemException` | Model does not implement `Purchasable` |
| `CartLineIdMismatchException` | Cart line does not belong to cart |
| `DisallowMultipleCartOrdersException` | Cart already has a completed order |
| `FingerprintMismatchException` | Cart contents changed since fingerprint was generated |
### Cart fingerprint
```php
$fingerprint = $cart->fingerprint();
// ... pass to frontend hidden input ...
$cart->checkFingerprint($request->fingerprint); // throws FingerprintMismatchException
```
### Cart pipelines
Configurable in `config/lunar/cart.php`:
```
CalculateLines → ApplyShipping → ApplyDiscounts → CalculateTax → Calculate
```
Cart line pipeline: `GetUnitPrice`
Custom pipeline class:
```php
class MyPipeline
{
public function handle(Cart $cart, Closure $next): mixed
{
// modify cart...
return $next($cart);
}
}
```
### Cart auth policy (login/logout behavior)
```php
// config/lunar/cart.php
'auth_policy' => 'merge', // or 'override'
```
`merge` — guest cart items combine with user's existing cart on login.
`override` — guest cart replaces user's cart.
### Shipping options
```php
use Lunar\Facades\ShippingManifest;
$options = ShippingManifest::getOptions($cart); // Collection of ShippingOption
$option = ShippingManifest::getOption($cart, $identifier);
$cart->setShippingOption($option); // triggers recalculate
```
`ShippingOption` properties: `name`, `description`, `identifier`, `price`, `collect` (bool).
---
## Orders
### Order creation from cart
```php
$order = $cart->createOrder();
// or via CartSession (also handles session cleanup):
$order = CartSession::createOrder();
```
Validation requirements before `createOrder()`:
- Billing address with `country_id`, `first_name`, `line_one`, `city`, `postcode`
- Shipping address (same fields) if cart contains shippable items
- A shipping option must be selected if cart is shippable
### Draft vs placed
```php
$order->isDraft(); // placed_at is null
$order->isPlaced(); // placed_at is not null
```
Only placed orders should be shown in the customer's order history.
### Order fields (key ones)
| Field | Type | Description |
|---|---|---|
| `status` | string | e.g. `payment-received`, `dispatched` |
| `reference` | string | Auto-generated; format configurable |
| `placed_at` | dateTime\|null | null = draft |
| `sub_total` | Price | Subtotal excluding tax |
| `tax_total` | Price | Total tax |
| `shipping_total` | Price | Shipping including tax |
| `discount_total` | Price | Total discounts |
| `total` | Price | Grand total |
| `currency_code` | string | ISO currency at time of order |
| `meta` | json | Custom metadata |
### Order line types
- `physical` — physical product
- `digital` — digital product
- `shipping` — shipping charge
Access via `$order->productLines` (physical + digital), `$order->shippingLines`.
### Order pipelines
Configurable in `config/lunar/orders.php`:
```
FillOrderFromCart → CreateOrderLines → CreateOrderAddresses →
CreateShippingLine → CleanUpOrderLines → MapDiscountBreakdown
```
### Order reference format
Default: zero-padded order ID (8 digits). Configurable in `config/lunar/orders.php`:
```php
'reference_format' => [
'prefix' => null,
'padding_direction' => STR_PAD_LEFT,
'padding_character' => '0',
'length' => 8,
],
```
### Transactions
| Field | Description |
|---|---|
| `type` | `intent`, `capture`, or `refund` |
| `success` | boolean |
| `amount` | integer (smallest unit) |
| `driver` | e.g. `stripe` |
| `reference` | provider reference |
| `card_type` | e.g. `visa` |
| `last_four` | last 4 digits |
| `parent_transaction_id` | links capture to intent, refund to capture |
---
## Customers and Addresses
### Customer fields
| Field | Description |
|---|---|
| `first_name`, `last_name` | Name |
| `company_name` | Optional |
| `tax_identifier` | VAT / tax ID |
| `account_ref` | External reference |
| `attribute_data` | Custom attributes |
| `meta` | Flexible JSON |
Accessor: `$customer->full_name``"Mr. Tony Stark"`
### Linking users to customers
```php
$customer = Customer::create([...]);
$customer->users()->attach($user);
// For B2B: multiple users per customer
$customer->users()->sync([$userA->id, $userB->id]);
```
### Customer groups
Customer groups (`retail`, `wholesale`, etc.) control pricing, product visibility, and discount eligibility. Default group: `retail`.
```php
$customer->customerGroups()->attach($group);
// Filter products/collections by group
Product::customerGroup($group)->get();
// Schedule product availability for a group
$product->scheduleCustomerGroup($group, starts: now()->addDays(7));
$product->unscheduleCustomerGroup($group);
```
### Saved addresses
`Address` belongs to `Customer`. Fields: `first_name`, `last_name`, `company_name`, `tax_identifier`, `line_one` through `line_three`, `city`, `state`, `postcode`, `country_id`, `contact_email`, `contact_phone`, `delivery_instructions`, `shipping_default`, `billing_default`, `meta`.
When `shipping_default` is set to true, Lunar automatically unsets the previous default via an observer.
```php
$customer->addresses()->create([...]);
$customer->addresses()->where('shipping_default', true)->first();
```
---
## Discounts
### Discount model fields
| Field | Description |
|---|---|
| `name` | Display name |
| `coupon` | Optional coupon code |
| `type` | Fully-qualified class name of the discount type |
| `starts_at` / `ends_at` | Active window |
| `max_uses` | Global usage limit |
| `max_uses_per_user` | Per-user limit |
| `priority` | Processing order |
| `stop` | Stop processing further discounts if applied |
| `data` | Type-specific config (JSON) |
### Built-in discount types
- `Lunar\DiscountTypes\AmountOff` — percentage or fixed amount off
- `Lunar\DiscountTypes\BuyXGetY` — buy X items, get Y free/discounted
### Discount status
`$discount->status` returns: `active`, `pending`, `expired`, or `scheduled`.
### Applying discounts
Discounts are applied automatically during cart calculation when `coupon_code` is set on the cart, or based on cart contents for automatic discounts.
```php
$cart->update(['coupon_code' => '20OFF']);
$cart->recalculate();
Discounts::validateCoupon('20OFF'); // bool
Discounts::resetDiscounts(); // clear cached discounts for current request
```
### Custom discount types
```php
use Lunar\Facades\Discounts;
Discounts::addType(MyCustomDiscountType::class);
```
Implement `Lunar\DiscountTypes\AbstractDiscountType`. For admin panel support, also implement `Lunar\Admin\Base\LunarPanelDiscountInterface` and provide `lunarPanelSchema()`, `lunarPanelOnFill()`, `lunarPanelOnSave()`.
---
## Products and Variants
### ProductVariant fields (key)
| Field | Description |
|---|---|
| `sku` | Stock keeping unit |
| `stock` | Stock quantity |
| `purchasable` | `always` or `in_stock` |
| `unit_quantity` | Units per price point (default 1) |
| `weight_value`, `weight_unit` | For shipping weight calculations |
| `tax_class_id` | Determines applicable tax rate |
### Purchasable values
- `always` — can always be purchased (backorder)
- `in_stock` — only when `stock > 0`
```php
$variant->canBeFulfilledAtQuantity(3); // bool
```
### Attributes
All custom product/collection/brand attributes are stored in `attribute_data` as JSON. Access via `$model->attr('name')` which resolves translations based on current locale.
Attribute field types: `Text`, `TranslatedText`, `Number`, `Toggle`, `Dropdown`, `ListField`, `File`, `YouTube`, `Vimeo`.
### Product associations
```php
use Lunar\Base\Enums\ProductAssociation;
$product->associate($target, ProductAssociation::CROSS_SELL);
$product->associate($target, ProductAssociation::UP_SELL);
$product->associate($target, ProductAssociation::ALTERNATE);
$product->associate([$a, $b], ProductAssociation::CROSS_SELL);
$product->associations()->crossSell()->with('target')->get();
$product->dissociate($target);
```
### Pricing facade for products
```php
$pricing = Pricing::for($variant)
->currency(StorefrontSession::getCurrency())
->customerGroups(StorefrontSession::getCustomerGroups())
->get();
$pricing->matched; // best price for this context
$pricing->priceBreaks; // quantity tiers
```
---
## Collections and URLs
### Collection hierarchy
Collections use a nested set (via `HasNestedSets`). Each collection belongs to a `CollectionGroup`.
```php
$rootCollections = Collection::where('collection_group_id', $group->id)
->whereIsRoot()
->defaultOrder()
->with(['defaultUrl', 'children' => fn ($q) => $q->defaultOrder(), 'children.defaultUrl'])
->get();
// Full tree
$tree = Collection::where('collection_group_id', $group->id)
->defaultOrder()
->with('defaultUrl')
->get()
->toTree();
```
### URLs (slugs)
```php
use Lunar\Models\Url;
// Resolve product from slug
$url = Url::where('slug', $slug)
->where('element_type', (new Product)->getMorphClass())
->firstOrFail();
$product = Product::find($url->element_id);
// Create a URL
$product->urls()->create([
'slug' => 'my-product',
'language_id' => $language->id,
'default' => true,
]);
// Access URLs
$product->defaultUrl; // default URL for current locale
$product->localeUrl; // URL for current app locale
$product->urls; // all URLs
```
Auto-generation from `attr('name')` is enabled by default via `Lunar\Generators\UrlGenerator`. Configure in `config/lunar/urls.php`.
Add URL support to custom models with the `HasUrls` trait.
---
## Payments
### Payments facade
```php
use Lunar\Facades\Payments;
// Authorize
$response = Payments::driver('card')
->cart($cart)
->withData(['payment_intent' => $intentId])
->authorize();
$response->success; // bool
$response->orderId; // int|null
$response->message; // string|null
$response->paymentType; // string|null
// Capture (manual capture policy)
Payments::driver('card')->order($order)->capture($transaction, $amount);
// Refund
Payments::driver('card')->order($order)->refund($transaction, $amount, 'notes');
```
### Payment types configuration
In `config/lunar/payments.php`:
```php
'types' => [
'card' => [
'driver' => 'stripe',
'released' => 'payment-received',
],
'cash-in-hand' => [
'driver' => 'offline',
'authorized' => 'payment-offline',
],
],
```
### Custom payment driver
```php
use Lunar\Facades\Payments;
Payments::extend('custom', fn ($app) => $app->make(CustomPayment::class));
```
Extend `Lunar\PaymentTypes\AbstractPayment`. Implement `authorize()`, `capture()`, `refund()`.
Transaction types: `intent` (payment reserved, not yet captured), `capture` (payment captured), `refund`.
### Stripe integration
```php
use Lunar\Stripe\Facades\Stripe;
$intent = Stripe::fetchOrCreateIntent($cart); // create/get PaymentIntent
Stripe::syncIntent($cart); // update amount after cart changes
Stripe::cancelIntent($cart, CancellationReason::ABANDONED);
$intentId = Stripe::getCartIntentId($cart); // get existing intent ID
```
Webhook endpoint auto-registered at `stripe/webhook` (configurable). Verify with `LUNAR_STRIPE_WEBHOOK_SECRET`.
---
## Shipping
### Adding a custom shipping modifier
```php
namespace App\Modifiers;
use Lunar\Base\ShippingModifier;
use Lunar\DataTypes\Price;
use Lunar\DataTypes\ShippingOption;
use Lunar\Facades\ShippingManifest;
class CustomShippingModifier extends ShippingModifier
{
public function handle(Cart $cart, \Closure $next)
{
ShippingManifest::addOption(
new ShippingOption(
name: 'Standard Delivery',
description: 'Delivered in 3-5 days',
identifier: 'STANDARD',
price: new Price(500, $cart->currency, 1),
taxClass: TaxClass::first(),
)
);
return $next($cart);
}
}
```
Register in a service provider:
```php
public function boot(\Lunar\Base\ShippingModifiers $shippingModifiers): void
{
$shippingModifiers->add(CustomShippingModifier::class);
}
```
### Table Rate Shipping add-on
Install `lunarphp/table-rate-shipping`. Register the Filament plugin:
```php
LunarPanel::panel(fn ($panel) => $panel->plugin(new ShippingPlugin()))->register();
```
Concepts: **ShippingZone** (geographic region), **ShippingMethod** (delivery type), **ShippingRate** (zone + method + prices), **ShippingExclusionList** (products blocked from a zone).
Drivers: `ship-by` (cart total or weight tiers), `flat-rate`, `free-shipping` (minimum spend), `collection` (in-store pickup).
---
## Search Indexing
Lunar uses Laravel Scout for indexing. Models with the `Searchable` trait use indexers configured in `config/lunar/search.php`.
### ProductIndexer fields
`id`, searchable attributes, `status`, `product_type`, `brand`, variant `skus`, `created_at`.
### Custom indexer
```php
// config/lunar/search.php
'indexers' => [
Lunar\Models\Product::class => App\Search\CustomProductIndexer::class,
],
```
Custom indexer extends `Lunar\Search\ScoutIndexer`:
```php
class CustomProductIndexer extends ScoutIndexer
{
public function toSearchableArray(Model $model): array
{
return array_merge([], $this->mapSearchableAttributes($model));
}
public function getSortableFields(): array { return ['created_at']; }
public function getFilterableFields(): array { return ['status']; }
}
```
`mapSearchableAttributes()` automatically includes all attributes flagged as searchable in the admin panel.
---
## Access Control
Lunar uses `spatie/laravel-permission` scoped to the `staff` guard.
Built-in roles: `admin`, `staff`.
### Adding permissions
Create via migration (not through the UI — authorization logic must accompany every permission):
```php
// In a migration
\Spatie\Permission\Models\Permission::create(['name' => 'manage-contracts', 'guard_name' => 'staff']);
```
### Two-factor auth
```php
LunarPanel::forceTwoFactorAuth()->register(); // enforce 2FA for all staff
LunarPanel::disableTwoFactorAuth()->register(); // disable 2FA entirely (this project uses OTP instead)
```
### Authorization checks
```php
// Route middleware
Route::get('/custom', Controller::class)->middleware('can:manage-contracts');
// In code
Auth::user()->can('manage-contracts');
```
---
## Useful Facades Summary
| Facade | Purpose |
|---|---|
| `Lunar\Facades\CartSession` | Manage the active cart session |
| `Lunar\Facades\Payments` | Route payment calls to drivers |
| `Lunar\Facades\Pricing` | Resolve the best price for a purchasable |
| `Lunar\Facades\ShippingManifest` | Get/set shipping options on a cart |
| `Lunar\Facades\Discounts` | Validate coupons, register custom types |
| `Lunar\Facades\Taxes` | Register custom tax drivers |
| `Lunar\Facades\ModelManifest` | Replace Lunar models with custom implementations |
| `Lunar\Facades\StorefrontSession` | Manage channel, currency, customer for session |
| `Lunar\Admin\Support\Facades\LunarPanel` | Configure and extend the Filament panel |
---
## Artisan Commands
```bash
php artisan lunar:install # first-time setup
php artisan vendor:publish --tag=lunar # publish all config
php artisan vendor:publish --tag=lunar.migrations # publish migrations
php artisan scout:import "Lunar\Models\Product" # index products
php artisan scout:flush "Lunar\Models\Order" # flush order index
```
---
## Gotchas
Real bugs/traps hit while building against Lunar in this package — not obvious from reading Lunar's source in isolation.
- **`Lunar\Base\BaseModel` prefixes table names at construction time.** Mass-assigning a `parent_id` on a model using `kalnoy/nestedset`'s `NodeTrait` (e.g. `Collection`) triggers a mutator that queries the database *before* the table prefix is applied, throwing "relation does not exist". Use `appendToNode()`/`saveAsRoot()` instead of setting `parent_id` directly. See `MigrateImport\Shopify\Resolvers\CollectionResolver` for the pattern.
- **Don't trust a schema read from memory — re-check the actual migration/model file.** `products.brand` was assumed to be a plain string column based on an earlier read; it's actually `brand_id`, a real FK to a `Brand` model. Verify column names against the live `Schema::getColumnListing()` or the actual migration file, not recollection.
- **Lunar's default `Language` may not be `en`.** Don't hardcode `app()->getLocale()` for `TranslatedText`/translatable fields — use `Lunar\Models\Language::getDefault()->code` (wrapped here as `MigrateImport\DefaultLocale::code()`). A mismatch means data saves under the wrong locale key and silently doesn't render in the panel.
- **`attribute_data` is not a free-form array.** Values must be `Lunar\Base\FieldType` instances (`Text`, `TranslatedText`, `Number`, etc.), and the handle must be mapped to the product's `ProductType` via `mappedAttributes()`/`productAttributes()` — otherwise the value is silently dropped or won't render in the panel.
- **New `ProductType`s start with zero mapped attributes.** Creating one via `ProductType::create()` alone means `name`/`description` won't work until you `attach()` the existing system attributes to it.
- **`ProductOption.handle` must be unique and non-null if a product has more than one option.** Lunar's Filament variant-switcher widget does `SelectFilter::make($option->handle)` per option — two options with a `null`/matching handle throws "Filter must have a unique name" as a 500 when opening that product's variant pricing page. Always derive a slug and check uniqueness.
- **`Attribute.position` is per-group, and the panel sorts by it.** Hardcoding `position => 1` for multiple new attributes in the same group makes their order undefined/collide with existing attributes at position 1. Compute `max('position') + 1` per group instead.
- **Currency `decimal_places` isn't always 2.** A seeded/demo currency can have the wrong value (seen: EUR seeded with `decimal_places = 1`), which silently corrupts every price display (`€16.50` renders as `165`). If prices look wrong by a factor of 10, check the currency row before assuming the price-writing code is broken.