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)
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:
| 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:
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
publicfunctionextendForm(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`.
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 |
| `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
useLunar\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 |
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.
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.