Files
core/docs/lunar.md
T

38 KiB

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
  2. Key Models and Relationships
  3. ModelManifest — Replacing Models
  4. Filament Panel Integration
  5. Extending Resources and Pages
  6. Activity Logging
  7. Pricing
  8. Tax
  9. Channels and Currencies
  10. Cart and Checkout
  11. Orders
  12. Customers and Addresses
  13. Discounts
  14. Products and Variants
  15. Collections and URLs
  16. Payments
  17. Shipping
  18. Search Indexing
  19. 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:

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

// 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:

\Lunar\Facades\ModelManifest::addDirectory(__DIR__.'/../Models');

Dynamic relationships (preferred for simple extensions)

For adding relationships without replacing models:

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

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:

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

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:

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

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

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:

public function extendForm(Form $form): Form
{
    $form->schema([
        ...$form->getComponents(true),
        TextInput::make('my_field'),
    ]);
    return $form;
}

Activity Logging

Lunar uses Spatie 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

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:

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

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:

$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.

$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

$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:

\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.

$product->scheduleChannel($channel);
$product->scheduleChannel($channel, now()->addDays(14), now()->addDays(28));
$products = Product::channel($channel)->get();

Currencies

$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:

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

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

$cart->add($variant, quantity: 2, meta: ['gift_wrap' => true]);

$cart->addLines([
    ['purchasable' => $variant, 'quantity' => 2],
]);

Updating and removing

$cart->updateLine($cartLineId, quantity: 3, meta: [...]);
$cart->remove($cartLineId);
$cart->clear();

Addresses

$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

$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:

class MyPipeline
{
    public function handle(Cart $cart, Closure $next): mixed
    {
        // modify cart...
        return $next($cart);
    }
}

Cart auth policy (login/logout behavior)

// 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

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

$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

$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:

'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

$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.

$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.

$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.

$cart->update(['coupon_code' => '20OFF']);
$cart->recalculate();

Discounts::validateCoupon('20OFF'); // bool
Discounts::resetDiscounts();        // clear cached discounts for current request

Custom discount types

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
$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

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

$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.

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

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

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:

'types' => [
    'card' => [
        'driver' => 'stripe',
        'released' => 'payment-received',
    ],
    'cash-in-hand' => [
        'driver' => 'offline',
        'authorized' => 'payment-offline',
    ],
],

Custom payment driver

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

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

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:

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:

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

// config/lunar/search.php
'indexers' => [
    Lunar\Models\Product::class => App\Search\CustomProductIndexer::class,
],

Custom indexer extends Lunar\Search\ScoutIndexer:

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

// In a migration
\Spatie\Permission\Models\Permission::create(['name' => 'manage-contracts', 'guard_name' => 'staff']);

Two-factor auth

LunarPanel::forceTwoFactorAuth()->register();   // enforce 2FA for all staff
LunarPanel::disableTwoFactorAuth()->register(); // disable 2FA entirely (this project uses OTP instead)

Authorization checks

// 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

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 ProductTypes 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.