# Community Add-ons Source: https://docs.lunarphp.com/1.x/addons/community/overview The Lunar community has been creating add-on packages for Lunar. In Lunar v2 we intend to introduce a Lunar Marketplace where you can find community packages. For now, if you have an add-on you would like to share, please [open a PR](https://github.com/lunarphp/docs) to add it to this page. # Opayo Source: https://docs.lunarphp.com/1.x/addons/payments/opayo This addon enables Opayo payments on your Lunar storefront. This addon is currently in Alpha, whilst every step is taken to ensure this is working as intended, it will not be considered out of Alpha until more tests have been added and proved. ## Installation ### Require the composer package ```sh theme={null} composer require lunarphp/opayo ``` ### Configure the service Add the opayo config to the `config/services.php` file. ```php theme={null} // ... 'opayo' => [ 'vendor' => env('OPAYO_VENDOR'), 'env' => env('OPAYO_ENV', 'test'), 'key' => env('OPAYO_KEY'), 'password' => env('OPAYO_PASSWORD'), 'host' => env('OPAYO_HOST'), ], ``` ### Enable the driver Set the driver in `config/lunar/payments.php` ```php theme={null} [ 'card' => [ // ... 'driver' => 'opayo', ], ], ]; ``` ## Configuration Below is a list of the available configuration options this package uses in `config/lunar/opayo.php` | Key | Default | Description | | -------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `policy` | `automatic` | Determines the policy for taking payments and whether you wish to capture the payment manually later or take payment straight away. Available options `deferred` or `automatic` | *** ## Backend Usage ### Get a merchant key ```php theme={null} Lunar\Opayo\Facades\Opayo::getMerchantKey(); ``` ### Authorize a charge ```php theme={null} $response = \Lunar\Facades\Payments::driver('opayo')->cart( $cart = CartSession::current()->calculate() )->withData([ 'merchant_key' => $request->get('merchantSessionKey'), 'card_identifier' => $request->get('cardToken'), 'browserLanguage' => $request->get('browserLanguage'), 'challengeWindowSize' => $request->get('challengeWindowSize'), 'browserIP' => $request->ip(), 'browserAcceptHeader' => $request->header('accept'), 'browserUserAgent' => $request->get('browserUserAgent'), 'browserJavaEnabled' => $request->get('browserJavaEnabled', false), 'browserColorDepth' => $request->get('browserColorDepth'), 'browserScreenHeight' => $request->get('browserScreenHeight'), 'browserScreenWidth' => $request->get('browserScreenWidth'), 'browserTZ' => $request->get('browserTZ'), 'status' => 'payment-received', ])->authorize(); ``` When authorizing a charge, you may be required to submit extra authentication in the form of 3DSV2, you can handle this in your payment endpoint. ```php theme={null} if (is_a($response, \Lunar\Opayo\Responses\ThreeDSecureResponse::class)) { return response()->json([ 'requires_auth' => true, 'data' => $response, ]); } ``` `$response` will contain all the 3DSV2 information from Opayo. You can find more information about this using the following links: * [3-D Secure explained](https://www.elavon.co.uk/resource-center/help-with-your-solutions/opayo/fraud-prevention/3D-Secure.html) * [3D Secure Transactions](https://developer.elavon.com/products/opayo-direct/v1/3d-secure-transactions) * Stack overflow [SagePay 3D Secure V2 Flow](https://stackoverflow.com/questions/65329436/sagepay-3d-secure-v2-flow) Once you have handled the 3DSV2 response on your storefront, you can then authorize again. ```php theme={null} $response = Payments::driver('opayo')->cart( $cart = CartSession::current()->calculate() )->withData([ 'cres' => $request->get('cres'), 'pares' => $request->get('pares'), 'transaction_id' => $request->get('transaction_id'), ])->threedsecure(); if (! $response->success) { abort(401); } ``` ### Opayo card tokens When authenticated users make an order on your store, it can be good to offer the ability to save their card information for future use. Whilst we don't store the actual card details, we can use card tokens which represent the card the user has used before. > You must have saved payments enabled on your Opayo account because you can use these. To save a card, pass in the `saveCard` data key when authorizing a payment. ```php theme={null} $response = \Lunar\Facades\Payments::driver('opayo')->cart( $cart = CartSession::current()->calculate() )->withData([ // ... 'saveCard' => true ])->authorize(); ``` Assuming everything went well, there will be a new entry in the `opayo_tokens` table, associated to the authenticated user. You can then display these card representations at checkout for the user to select. The `token` is what replaces the `card_identifier` data key. ```php theme={null} $response = \Lunar\Facades\Payments::driver('opayo')->cart( $cart = CartSession::current()->calculate() )->withData([ // ... 'card_identifier' => $request->get('cardToken'), 'reusable' => true ])->authorize(); ``` Responses are then handled the same as any other transaction. # PayPal Source: https://docs.lunarphp.com/1.x/addons/payments/paypal This addon enables PayPal on your Lunar storefront. ## Installation ### Require the composer package ```sh theme={null} composer require lunarphp/paypal ``` ### Enable the driver Set the driver in `config/lunar/payments.php`. ```php theme={null} [ 'card' => [ // ... 'driver' => 'paypal', ], ], ]; ``` ### Add your PayPal credentials Add your PayPal credentials to `config/services.php` (or set the ENV vars below): ```php theme={null} // ... 'paypal' => [ 'env' => env('PAYPAL_ENV', 'sandbox'), 'client_id' => env('PAYPAL_CLIENT_ID'), 'secret' => env('PAYPAL_SECRET'), ], ``` > You can create REST API credentials and Webhooks in the PayPal Developer Dashboard: [https://developer.paypal.com/dashboard](https://developer.paypal.com/dashboard) ## Usage ```php theme={null} use Lunar\Facades\Payments; $response = Payments::driver('paypal')->cart( $cart )->withData([ 'paypal_order_id' => $request->get('orderID'), 'paypal_payment_id' => $request->get('paymentID'), 'status' => 'payment-received', ])->authorize(); if (! $response->success) { abort(401); } ``` # Stripe Source: https://docs.lunarphp.com/1.x/addons/payments/stripe This addon enables Stripe payments on your Lunar storefront. ## Installation ### Require the composer package ```sh theme={null} composer require lunarphp/stripe ``` ### Publish the configuration This will publish the configuration under `config/lunar/stripe.php`. ```sh theme={null} php artisan vendor:publish --tag=lunar.stripe.config ``` ### Publish the views (optional) Lunar Stripe comes with some helper components for you to use on your checkout, if you intend to edit the views they provide, you can publish them. ```sh theme={null} php artisan vendor:publish --tag=lunar.stripe.components ``` ### Enable the driver Set the driver in `config/lunar/payments.php` ```php theme={null} [ 'card' => [ // ... 'driver' => 'stripe', ], ], ]; ``` ### Add your Stripe credentials Make sure you have the Stripe credentials set in `config/services.php` ```php theme={null} 'stripe' => [ 'key' => env('STRIPE_SECRET'), 'public_key' => env('STRIPE_PK'), 'webhooks' => [ 'lunar' => env('LUNAR_STRIPE_WEBHOOK_SECRET'), ], ], ``` > Keys can be found in your Stripe account [https://dashboard.stripe.com/apikeys](https://dashboard.stripe.com/apikeys) ## Configuration Below is a list of the available configuration options this package uses in `config/lunar/stripe.php` | Key | Default | Description | | ---------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `policy` | `automatic` | Determines the policy for taking payments and whether you wish to capture the payment manually later or take payment straight away. Available options `manual` or `automatic` | | `sync_addresses` | `true` | When enabled, the Stripe addon will attempt to sync the billing and shipping addresses which have been stored against the payment intent on Stripe. | *** ## Backend Usage ### Create a PaymentIntent ```php theme={null} use \Lunar\Stripe\Facades\Stripe; Stripe::createIntent(\Lunar\Models\Cart $cart, $options = []); ``` This method will create a Stripe PaymentIntent from a Cart and add the resulting ID to the meta for retrieval later. If a PaymentIntent already exists for a cart this will fetch it from Stripe and return that instead to avoid duplicate PaymentIntents being created. You can pass any additional parameters you need, by default the following are sent: ```php theme={null} [ 'amount' => 1099, 'currency' => 'GBP', 'automatic_payment_methods' => ['enabled' => true], 'capture_method' => config('lunar.stripe.policy', 'automatic'), // If a shipping address exists on a cart // $shipping = $cart->shippingAddress 'shipping' => [ 'name' => "{$shipping->first_name} {$shipping->last_name}", 'phone' => $shipping->contact_phone, 'address' => [ 'city' => $shipping->city, 'country' => $shipping->country->iso2, 'line1' => $shipping->line_one, 'line2' => $shipping->line_two, 'postal_code' => $shipping->postcode, 'state' => $shipping->state, ], ] ] ``` ```php theme={null} $paymentIntentId = $cart->meta['payment_intent']; // The resulting ID from the method above. ``` ```php theme={null} $cart->meta->payment_intent; ``` ### Fetch an existing PaymentIntent ```php theme={null} use \Lunar\Stripe\Facades\Stripe; Stripe::fetchIntent($paymentIntentId); ``` ### Syncing an existing intent If a payment intent has been created and there are changes to the cart, you will want to update the intent so it has the correct totals. ```php theme={null} use \Lunar\Stripe\Facades\Stripe; Stripe::syncIntent(\Lunar\Models\Cart $cart); ``` ### Update an existing intent For when you want to update certain properties on the PaymentIntent, without needing to recalculate the cart. See [https://docs.stripe.com/api/payment\_intents/update](https://docs.stripe.com/api/payment_intents/update) ```php theme={null} use \Lunar\Stripe\Facades\Stripe; Stripe::updateIntent(\Lunar\Models\Cart $cart, [ 'shipping' => [/*..*/] ]); ``` ### Cancel an existing intent If you need to cancel a PaymentIntent, you can do so. You will need to provide a valid reason, those of which can be found in the Stripe docs: [https://docs.stripe.com/api/payment\_intents/cancel](https://docs.stripe.com/api/payment_intents/cancel). Lunar Stripe includes a PHP Enum to make this easier for you: ```php theme={null} use Lunar\Stripe\Enums\CancellationReason; CancellationReason::ABANDONED; CancellationReason::DUPLICATE; CancellationReason::REQUESTED_BY_CUSTOMER; CancellationReason::FRAUDULENT; ``` ```php theme={null} use Lunar\Stripe\Facades\Stripe; use Lunar\Stripe\Enums\CancellationReason; Stripe::cancelIntent(\Lunar\Models\Cart $cart, CancellationReason $reason); ``` ### Update the address on Stripe So you don't have to manually specify all the shipping address fields you can use the helper function to do it for you. ```php theme={null} use \Lunar\Stripe\Facades\Stripe; Stripe::updateShippingAddress(\Lunar\Models\Cart $cart); ``` ## Charges ### Retrieve a specific charge ```php theme={null} use \Lunar\Stripe\Facades\Stripe; Stripe::getCharge(string $chargeId); ``` ### Get all charges for a payment intent ```php theme={null} use \Lunar\Stripe\Facades\Stripe; Stripe::getCharges(string $paymentIntentId); ``` ## Webhooks The add-on provides an optional webhook you may add to Stripe. You can read the guide on how to do this on the Stripe website [https://stripe.com/docs/webhooks/quickstart](https://stripe.com/docs/webhooks/quickstart). The events you should listen to are `payment_intent.payment_failed`, `payment_intent.succeeded`. The path to the webhook will be `http:://yoursite.com/stripe/webhook`. You can customise the path for the webhook in `config/lunar/stripe.php`. You will also need to add the webhook signing secret to the `services.php` config file: ```php theme={null} [ // ... 'webhooks' => [ 'lunar' => '...' ], ], ]; ``` If you do not wish to use the webhook, or would like to manually process an order as well, you are able to do so. ```php theme={null} $cart = CartSession::current(); // With a draft order... $draftOrder = $cart->createOrder(); Payments::driver('stripe')->order($draftOrder)->withData([ 'payment_intent' => $draftOrder->meta['payment_intent'], ])->authorize(); // Using just the cart... Payments::driver('stripe')->cart($cart)->withData([ 'payment_intent' => $cart->meta['payment_intent'], ])->authorize(); ``` ## Storefront Examples First we need to set up the backend API call to fetch or create the intent, this isn't Vue specific but will likely be different if you're using Livewire. ```php theme={null} use \Lunar\Stripe\Facades\Stripe; Route::post('api/payment-intent', function () { $cart = CartSession::current(); $cartData = CartData::from($cart); if ($paymentIntent = $cartData->meta['payment_intent'] ?? false) { $intent = StripeFacade::fetchIntent($paymentIntent); } else { $intent = StripeFacade::createIntent($cart); } if ($intent->amount != $cart->total->value) { StripeFacade::syncIntent($cart); } return $intent; })->middleware('web'); ``` ### Vuejs This is just using Stripe's payment elements, for more information [check out the Stripe guides](https://stripe.com/docs/payments/elements) ### Payment component ```js theme={null} ``` ```html theme={null} ``` *** ## Extending ### Webhook event params In order to process the payment intent and link it to an order, we need the PaymentIntent ID and an optional Order ID. By default Lunar Stripe will look for the PaymentIntent ID via the Stripe Event and try and determine whether an existing order ID has been defined on the PaymentIntent meta. You can customise this behaviour by overriding the `ProcessesEventParameters` instance. ```php theme={null} // AppServiceProvider use Lunar\Stripe\Concerns\ProcessesEventParameters; use Lunar\Stripe\DataTransferObjects\EventParameters; public function boot() { $this->app->instance(ProcessesEventParameters::class, new class implements ProcessesEventParameters { public function handle(\Stripe\Event $event): EventParameters { $paymentIntentId = $event->data->object->id; // Setting $orderId to null will mean a new order is created. $orderId = null; return new EventParameters($paymentIntentId, $orderId); } }); ``` ## Events Below are the events which are dispatched under specific situations within the addon. ### CartMissingForIntent Dispatched when attempting to process a payment intent, but no matching Order or Cart model can be found. ```php theme={null} use Lunar\Stripe\Events\Webhook\CartMissingForIntent; public function handle(CartMissingForIntent $event) { echo $event->paymentIntentId; } ``` # Storefront Search Source: https://docs.lunarphp.com/1.x/addons/search ## Overview The Storefront Search add-on provides a unified API for performing search operations across multiple search engines. It wraps Laravel Scout and adds support for faceted search, filtering, sorting, and consistent response formatting using [Spatie Laravel Data](https://spatie-laravel-data.com). ### Supported Engines | Engine | Driver Name | Features | | ----------- | ------------- | ------------------------------------------------------------- | | Database | `database` | Basic search via Scout's database driver | | Meilisearch | `meilisearch` | Faceted search, filtering, sorting | | Typesense | `typesense` | Faceted search, filtering, sorting, highlights, vector search | ## Installation ### Require the composer package ```sh theme={null} composer require lunarphp/search ``` The package auto-discovers its service provider, so no additional registration is needed. ### Configuration Publish and customize the configuration by creating or editing `config/lunar/search.php`. The add-on configuration controls which facets are available for each model: ```php theme={null} // config/lunar/search.php return [ 'facets' => [ \Lunar\Models\Product::class => [ 'brand' => [ 'label' => 'Brand', ], 'colour' => [ 'label' => 'Colour', ], 'size' => [ 'label' => 'Size', ], ], ], ]; ``` Each facet key corresponds to a field in the searchable index. The `label` property is optional and defaults to the field name if not provided. The `engine_map` configuration, which controls which search driver is used for each model, is defined in the core Lunar search config. See the [Search reference](/1.x/reference/search) for details. ## Usage ### Basic Search Search models using the `Search` facade. By default, searches are performed against `Lunar\Models\Product`: ```php theme={null} use Lunar\Search\Facades\Search; $results = Search::query('Hoodies')->get(); ``` To search a different model, use the `model()` method: ```php theme={null} use Lunar\Search\Facades\Search; $results = Search::model(\Lunar\Models\Collection::class) ->query('Hoodies') ->get(); ``` Under the hood, the package detects which Scout driver is mapped for the given model via the `engine_map` configuration and performs the search using that driver. To improve performance, results are not hydrated from the database — instead, the raw indexed data is returned directly from the search provider. ### Specifying a Driver To explicitly use a specific search driver, call the `driver()` method: ```php theme={null} use Lunar\Search\Facades\Search; $results = Search::driver('meilisearch') ->query('Hoodies') ->get(); ``` ### Filtering Apply filters to narrow down search results. Filters are passed as key-value pairs where the key is the field name and the value is the filter value: ```php theme={null} use Lunar\Search\Facades\Search; $results = Search::query('Hoodies') ->filter([ 'status' => 'published', 'brand' => 'Acme', ]) ->get(); ``` ### Faceted Search Facets allow users to refine search results by selecting values within categories (e.g., brand, color, size). Set active facet selections using `setFacets()`: ```php theme={null} use Lunar\Search\Facades\Search; $results = Search::query('Hoodies') ->setFacets([ 'brand' => ['Nike', 'Adidas'], 'colour' => ['Red'], ]) ->get(); ``` The search response includes updated facet counts that reflect the current selections, allowing the storefront to show how many results match each facet value. To remove a specific facet or value: ```php theme={null} use Lunar\Search\Facades\Search; $search = Search::query('Hoodies') ->setFacets(['brand' => ['Nike', 'Adidas']]); // Remove a specific value from a facet $search->removeFacet('brand', 'Nike'); // Remove an entire facet $search->removeFacet('brand'); $results = $search->get(); ``` ### Sorting Sort results by a specific field: ```php theme={null} use Lunar\Search\Facades\Search; $results = Search::query('Hoodies') ->sort('created_at:desc') ->get(); ``` The sort format is `field:direction` where direction is either `asc` or `desc`. The field must be configured as sortable in the search engine. For Typesense, a raw sort expression can also be used: ```php theme={null} use Lunar\Search\Facades\Search; $results = Search::query('Hoodies') ->sortRaw('_text_match:desc,created_at:desc') ->get(); ``` ### Pagination Control the number of results per page using the `perPage()` method. The default is 50: ```php theme={null} use Lunar\Search\Facades\Search; $results = Search::query('Hoodies') ->perPage(24) ->get(); ``` ### Extending Queries For advanced use cases, extend the search query using `extendQuery()`: ```php theme={null} use Lunar\Search\Facades\Search; $results = Search::query('Hoodies') ->extendQuery(function ($engine, &$queries) { // Modify search queries before execution }) ->get(); ``` ## Response Format All search engines return a `Lunar\Search\Data\SearchResults` object with a consistent structure: | Property | Type | Description | | ------------ | --------------- | ----------------------------------------- | | `query` | `?string` | The search query that was executed | | `count` | `int` | Total number of matching results | | `page` | `int` | Current page number | | `perPage` | `int` | Number of results per page | | `totalPages` | `int` | Total number of pages | | `hits` | `SearchHit[]` | Array of search result hits | | `facets` | `SearchFacet[]` | Array of available facets with counts | | `links` | `View` | Pagination links (Laravel paginator view) | ### SearchHit Each hit contains the indexed document data and any highlights (Typesense only): | Property | Type | Description | | ------------ | ---------------------- | ----------------------------- | | `highlights` | `SearchHitHighlight[]` | Matched field highlights | | `document` | `array` | The raw indexed document data | ### SearchHitHighlight | Property | Type | Description | | --------- | ---------- | ---------------------------------- | | `field` | `string` | The field that matched | | `matches` | `string[]` | The matched tokens | | `snippet` | `?string` | A highlighted snippet of the match | ### SearchFacet | Property | Type | Description | | ----------- | -------------------- | ---------------------------------- | | `label` | `string` | Display label for the facet | | `field` | `string` | The index field name | | `values` | `SearchFacetValue[]` | Available values with counts | | `hierarchy` | `bool` | Whether this facet is hierarchical | ### SearchFacetValue | Property | Type | Description | | ---------- | -------------------- | ---------------------------------------- | | `label` | `string` | Display label for the value | | `value` | `string` | The actual value for filtering | | `count` | `int` | Number of results matching this value | | `active` | `bool` | Whether this value is currently selected | | `children` | `SearchFacetValue[]` | Child values for hierarchical facets | ## Handling the Response ### Displaying Results ```blade theme={null} @foreach($results->hits as $hit)
{{ $hit->document['name'] }}
@endforeach ``` ### Displaying Facets ```blade theme={null} @foreach($results->facets as $facet)
{{ $facet->label }} @foreach($facet->values as $facetValue) @endforeach
@endforeach ``` ### Pagination The `links` property contains a standard Laravel pagination view: ```blade theme={null} {{ $results->links }} ``` ### Accessing Pagination Metadata ```blade theme={null}

Showing page {{ $results->page }} of {{ $results->totalPages }} ({{ $results->count }} results)

``` ## TypeScript Integration If [Spatie TypeScript Transformer](https://spatie.be/docs/typescript-transformer) is being used, add the data path to the `typescript-transformer.php` config to generate TypeScript types for the search response classes: ```php theme={null} return [ // ... 'auto_discover_types' => [ // ... \Lunar\Search\data_path(), ], ]; ``` The generated types are available under the `Lunar.Search` namespace: ```ts theme={null} defineProps<{ results: Lunar.Search.SearchResults }>() ``` # Table Rate Shipping Source: https://docs.lunarphp.com/1.x/addons/table-rate-shipping Zone-based shipping rates with multiple driver types for Lunar storefronts. ## Overview The Table Rate Shipping add-on provides a flexible, zone-based shipping system for Lunar. It integrates with the core shipping modifier pipeline, allowing storefronts to offer multiple shipping methods with pricing that varies by zone, cart total, weight, or other criteria. Key concepts: * **Shipping Zones** define geographic regions (unrestricted, by country, state, or postcode). * **Shipping Methods** represent the types of delivery available (e.g., standard, express, collection). * **Shipping Rates** link a method to a zone with tiered pricing. * **Shipping Exclusion Lists** prevent specific products from being shipped to certain zones. ## Installation Install via Composer: ```bash theme={null} composer require lunarphp/table-rate-shipping ``` Then register the admin panel plugin in a service provider: ```php theme={null} use Filament\Panel; use Lunar\Admin\Support\Facades\LunarPanel; use Lunar\Shipping\ShippingPlugin; public function register(): void { LunarPanel::panel(function (Panel $panel) { return $panel->plugin(new ShippingPlugin()); })->register(); } ``` ## Configuration The configuration file is published at `config/lunar/shipping-tables.php`. ```php theme={null} return [ 'enabled' => env('LUNAR_SHIPPING_TABLES_ENABLED', true), // 'default' uses the system-wide default tax class. // 'highest' selects the highest tax rate from items in the cart. 'shipping_rate_tax_calculation' => 'default', ]; ``` | Option | Default | Description | | :------------------------------ | :---------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `enabled` | `true` | Enable or disable the shipping tables add-on. | | `shipping_rate_tax_calculation` | `'default'` | Tax class strategy for shipping rates. Use `'default'` for the system default tax class, or `'highest'` to use the highest tax rate found among cart items. | ## Models ### ShippingZone ```php theme={null} Lunar\Shipping\Models\ShippingZone ``` A shipping zone represents a geographic area that shipping rates apply to. Zones can be unrestricted (apply everywhere) or limited to specific countries, states, or postcodes. | Field | Type | Description | | :---------- | :-------------- | :---------------------------------------------------- | | id | `bigIncrements` | Primary key | | name | `string` | | | type | `string` | `unrestricted`, `countries`, `states`, or `postcodes` | | created\_at | `timestamp` | | | updated\_at | `timestamp` | | #### Relationships | Relationship | Type | Related Model | Description | | :----------------- | :------------ | :---------------------- | :------------------------------------------------ | | rates | HasMany | `ShippingRate` | Shipping rates for this zone | | shippingMethods | HasMany | `ShippingMethod` | Shipping methods available in this zone | | countries | BelongsToMany | `Lunar\Models\Country` | Countries included in this zone | | states | BelongsToMany | `Lunar\Models\State` | States included in this zone | | postcodes | HasMany | `ShippingZonePostcode` | Postcodes included in this zone | | orders | BelongsToMany | `Lunar\Models\Order` | Orders assigned to this zone | | shippingExclusions | BelongsToMany | `ShippingExclusionList` | Product exclusion lists associated with this zone | *** ### ShippingMethod ```php theme={null} Lunar\Shipping\Models\ShippingMethod ``` A shipping method represents a type of delivery, such as flat rate, weight-based, free shipping, or in-store collection. Each method uses a driver that determines how pricing is resolved. | Field | Type | Description | | :--------------- | :------------------ | :------------------------------------------------------------------------------ | | id | `bigIncrements` | Primary key | | name | `string` | | | description | `text` `nullable` | | | code | `string` `nullable` | Unique identifier used as the shipping option identifier | | enabled | `boolean` | Whether the method is active | | stock\_available | `boolean` | When enabled, the method is only available if all cart items are in stock | | cutoff | `time` `nullable` | Daily order cutoff time; orders placed after this time will not see this method | | data | `json` `nullable` | Driver-specific configuration (cast to `AsArrayObject`) | | driver | `string` | Driver identifier (e.g., `ship-by`, `flat-rate`, `free-shipping`, `collection`) | | created\_at | `timestamp` | | | updated\_at | `timestamp` | | #### Relationships | Relationship | Type | Related Model | Description | | :------------- | :------------ | :--------------------------- | :----------------------------------------------- | | shippingRates | HasMany | `ShippingRate` | Rates associated with this method | | customerGroups | BelongsToMany | `Lunar\Models\CustomerGroup` | Customer groups that can see and use this method | *** ### ShippingRate ```php theme={null} Lunar\Shipping\Models\ShippingRate ``` A shipping rate connects a shipping method to a shipping zone and holds the pricing. It implements the `Lunar\Models\Contracts\Purchasable` interface and uses the `HasPrices` trait, meaning prices are stored in Lunar's core `prices` table with support for tiered pricing and multiple currencies. | Field | Type | Description | | :------------------- | :-------------- | :----------------------------- | | id | `bigIncrements` | Primary key | | shipping\_method\_id | `foreignId` | References the shipping method | | shipping\_zone\_id | `foreignId` | References the shipping zone | | enabled | `boolean` | Whether the rate is active | | created\_at | `timestamp` | | | updated\_at | `timestamp` | | #### Relationships | Relationship | Type | Related Model | Description | | :------------- | :-------- | :------------------- | :--------------------------------------- | | shippingMethod | BelongsTo | `ShippingMethod` | The parent shipping method | | shippingZone | BelongsTo | `ShippingZone` | The parent shipping zone | | prices | MorphMany | `Lunar\Models\Price` | Tiered pricing via the `HasPrices` trait | *** ### ShippingZonePostcode ```php theme={null} Lunar\Shipping\Models\ShippingZonePostcode ``` | Field | Type | Description | | :----------------- | :-------------- | :------------------------------------------------- | | id | `bigIncrements` | Primary key | | shipping\_zone\_id | `foreignId` | References the shipping zone | | postcode | `string` | Indexed; spaces are automatically stripped on save | | created\_at | `timestamp` | | | updated\_at | `timestamp` | | #### Relationships | Relationship | Type | Related Model | Description | | :----------- | :-------- | :------------- | :----------------------- | | shippingZone | BelongsTo | `ShippingZone` | The parent shipping zone | *** ### ShippingExclusionList ```php theme={null} Lunar\Shipping\Models\ShippingExclusionList ``` An exclusion list groups products that should not be shipped to certain zones. If a cart contains any product on an exclusion list associated with a zone, shipping options for that zone are not available. | Field | Type | Description | | :---------- | :-------------- | :----------------------- | | id | `bigIncrements` | Primary key | | name | `string` | Unique name for the list | | created\_at | `timestamp` | | | updated\_at | `timestamp` | | #### Relationships | Relationship | Type | Related Model | Description | | :------------ | :------------ | :------------------ | :------------------------- | | exclusions | HasMany | `ShippingExclusion` | Products in this list | | shippingZones | BelongsToMany | `ShippingZone` | Zones this list applies to | *** ### ShippingExclusion ```php theme={null} Lunar\Shipping\Models\ShippingExclusion ``` | Field | Type | Description | | :---------------------------- | :------------------- | :---------------------------- | | id | `bigIncrements` | Primary key | | shipping\_exclusion\_list\_id | `foreignId` | References the exclusion list | | purchasable\_type | `string` | Polymorphic type | | purchasable\_id | `unsignedBigInteger` | Polymorphic ID | | created\_at | `timestamp` | | | updated\_at | `timestamp` | | #### Relationships | Relationship | Type | Related Model | Description | | :----------- | :-------- | :---------------------- | :------------------------------------------- | | list | BelongsTo | `ShippingExclusionList` | The parent exclusion list | | purchasable | MorphTo | — | The excluded purchasable (e.g., a `Product`) | ## Drivers Each shipping method uses a driver to determine how pricing is resolved. The add-on ships with four built-in drivers. ### Ship By **Driver identifier:** `ship-by` The most flexible driver. Pricing is based on either the cart total or the total weight of items in the cart. Prices are stored as tiered rates on the shipping rate, where each tier's `min_quantity` represents the threshold (cart subtotal in minor units, or total weight). **Data configuration:** | Key | Values | Description | | :---------- | :------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `charge_by` | `cart_total` (default), `weight` | Determines what value is used to match against price tiers. When set to `weight`, the total is calculated as `purchasable.weight_value * quantity` for each cart line. | ### Flat Rate **Driver identifier:** `flat-rate` Charges a flat rate per order, with optional tiered pricing based on the cart subtotal. Prices are stored on the shipping rate using Lunar's pricing system with quantity tiers representing cart subtotal thresholds. ### Free Shipping **Driver identifier:** `free-shipping` Offers free shipping when the cart meets a minimum spend requirement. **Data configuration:** | Key | Values | Description | | :-------------------- | :------------------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------- | | `minimum_spend` | `array` or `number` | Minimum cart subtotal required. Can be a single value or an array keyed by currency code (e.g., `{"GBP": 5000, "USD": 6000}`). Values are in minor units. | | `use_discount_amount` | `boolean` | When `true`, discount amounts are subtracted from the cart subtotal before checking the minimum spend. | ### Collection **Driver identifier:** `collection` Allows customers to collect their order in store. Returns a shipping option with a price of zero and sets `collect: true` on the shipping option, which can be used to identify collection orders in the storefront. ## Storefront Usage This add-on registers a `ShippingModifier` with Lunar's shipping modifier pipeline. Shipping options are automatically resolved and available through the `ShippingManifest`: ```php theme={null} use Lunar\Facades\ShippingManifest; $options = ShippingManifest::getOptions($cart); ``` The modifier resolves shipping zones based on the cart's shipping address, finds applicable rates, and converts them into `ShippingOption` instances that are added to the manifest. If an existing storefront already uses the `ShippingManifest` to retrieve shipping options, no changes are needed. The add-on integrates automatically. ## Advanced Usage ### Retrieving Supported Drivers ```php theme={null} use Lunar\Shipping\Facades\Shipping; $drivers = Shipping::getSupportedDrivers(); ``` ### Resolving a Driver Directly ```php theme={null} use Lunar\Shipping\Facades\Shipping; use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest; $option = Shipping::driver('ship-by') ->on($shippingRate) ->resolve( new ShippingOptionRequest( shippingRate: $shippingRate, cart: $cart, ) ); ``` ### Resolving Shipping Zones The zone resolver finds matching zones based on address criteria. Each method is optional; combining them makes the lookup more specific. ```php theme={null} use Lunar\Shipping\Facades\Shipping; use Lunar\Shipping\DataTransferObjects\PostcodeLookup; $zones = Shipping::zones() ->country($country) ->state($state) ->postcode( new PostcodeLookup( country: $country, postcode: 'NW1' ) ) ->get(); ``` The resolver always includes zones with type `unrestricted`, plus any zones matching the specified country, state, or postcode criteria. ### Resolving Shipping Rates The rate resolver finds applicable shipping rates for a cart, filtering by customer group, cutoff time, and stock availability. ```php theme={null} use Lunar\Shipping\Facades\Shipping; $rates = Shipping::shippingRates($cart)->get(); ``` ### Resolving Shipping Options The option resolver takes shipping rates and resolves them into priced `ShippingOption` instances using each rate's driver. ```php theme={null} use Lunar\Shipping\Facades\Shipping; use Lunar\Shipping\DataTransferObjects\ShippingOptionLookup; $rates = Shipping::shippingRates($cart)->get(); $options = Shipping::shippingOptions($cart)->get( new ShippingOptionLookup(shippingRates: $rates) ); ``` ## Events ### ShippingOptionResolvedEvent ```php theme={null} Lunar\Shipping\Events\ShippingOptionResolvedEvent ``` Dispatched each time a shipping option is successfully resolved from a shipping rate. | Property | Type | Description | | :--------------- | :------------------------------- | :---------------------------------- | | `cart` | `Lunar\Models\Contracts\Cart` | The cart being resolved | | `shippingRate` | `ShippingRate` | The shipping rate that was resolved | | `shippingOption` | `Lunar\DataTypes\ShippingOption` | The resulting shipping option | ## Order Observer When an order is created or updated, the add-on automatically resolves the shipping zone from the order's shipping address and attaches it to the order via the `order_shipping_zone` pivot table. The zone name is also stored in the order's meta for search indexing. # Extending Access Control Source: https://docs.lunarphp.com/1.x/admin/extending/access-control # Staff Members Your staff members are essentially users who can log in to the admin panel and have permissions assigned to them. Staff members are not to be confused with users in the `users` table, the Lunar uses a different table for authenticating users in the admin panel. This is a design choice to ensure that your customers can never accidentally be given access. # Roles and permissions In Lunar panel, we are utilizing roles and permission for authorization. This give you the ability to assign multiple permissions to a role and assign it to the staff without assigning permission one by one to the staff. ::: tip The admin panel is using `spatie/laravel-permission` package ::: ### Roles Out of the box Lunar provided `admin` and `staff` roles. You can create new role using our Access Control page in Staff menu. After installation, the panel will have one admin. You can assign more but non admins cannot assign other admins. ### Permissions Permissions can be assigned to roles or directly to staff and this dictates what they can do or see in the panel. If a user does not have a certain permission to view a page or perform an action they will get an Unauthorized HTTP error. They will also potentially see a reduced amount of menu items throughout the admin panel. To enable permissions on a staff member, simply edit them via the staff page and assign the permissions you want them to have. ##### Adding permissions While the panel provided a page to create role and assign permissions. It's deliberated that permission are not created from the panel as the authorization are required to be implemented in code. It might change in the future but the recommended way to create roles and permission would be Lunar migration state or Laravel's migration. So you can deploy it to other environment easily. ## Authorisation First party permission provided by Lunar are used to authorise repective section of the panel. You should still implement authorisation checking respectively for your new permissions and custom pages. Example: `middleware` 'can:permission-handle' `in-code` Auth::user()->can('permission-handle') ::: ### Two-Factor Authentication You can choose whether to enforce Two-Factor Authentication or disable it entirely. ```php theme={null} public function register() { \Lunar\Admin\Support\Facades\LunarPanel::enforceTwoFactorAuth()->register(); \Lunar\Admin\Support\Facades\LunarPanel::disableTwoFactorAuth()->register(); } ``` # Developing Addons Source: https://docs.lunarphp.com/1.x/admin/extending/addons When creating add-on packages for Lunar you may wish to add new screens and functionality to the Filament panel. To achieve this you will want to create a Filament plugin in your package. With Filament plugins you can add additional resources, pages and widgets. See [https://filamentphp.com/docs/3.x/panels/plugins](https://filamentphp.com/docs/3.x/panels/plugins) for more information. ## Registering Filament plugins Add-on packages should not try to register a Filament plugin automatically in the Lunar panel. Instead, installation instructions should be provided. Below is an example of how a plugin should be registered to the Lunar admin panel, typically in your Laravel app service provider. ```php theme={null} use Lunar\Admin\Support\Facades\LunarPanel; LunarPanel::panel(fn($panel) => $panel->plugin(new ReviewsPlugin())) ->register(); ``` # Extending Attributes Source: https://docs.lunarphp.com/1.x/admin/extending/attributes You can add your own attribute field types to Lunar and control how they are rendered in the panel. In order to render the form component from the attribute, it needs to be converted into a Filament form component and a suitable Livewire Synthesizer associated so it can be hydrated/dehydrated properly. ## Create the field ```php theme={null} use Lunar\FieldTypes\Text; class CustomField extends Text { // ... } ``` ## Create the field type ```php theme={null} use Lunar\Admin\Support\FieldTypes\BaseFieldType; class CustomFieldType extends BaseFieldType { protected static string $synthesizer = CustomFieldSynth::class; public static function getFilamentComponent(Attribute $attribute): Component { return TextInput::make($attribute->handle); } } ``` ## Adding settings There may be additional settings you want your field to have, for example the Number field has `min` and `max` settings. To add these fields, you will need to tell Filament how to render the inputs. ```php theme={null} class CustomFieldType extends BaseFieldType { // ... public static function getConfigurationFields(): array { return [ Grid::make(2)->schema([ \Filament\Forms\Components\TextInput::make('min_length'), \Filament\Forms\Components\TextInput::make('max_length'), ]), ]; } } ``` These will when be stored in the `configuration` JSON column for the attribute. Which you can then access when you render the field in the panel. ```php theme={null} use Lunar\Admin\Support\FieldTypes\BaseFieldType; class CustomFieldType extends BaseFieldType { // ... public static function getFilamentComponent(Attribute $attribute): Component { $min = (int) $attribute->configuration->get('min_length'); $max = (int) $attribute->configuration->get('max_length'); return TextInput::make($attribute->handle)->min($min)->max($max); } } ``` ## Create the Livewire Synthesizer So Livewire knows how to hydrate/dehydrate the values provided to the field type when editing, we need to add a Synthesizer. You can read more about Livewire Synthesizers and what they do here: [https://livewire.laravel.com/docs/synthesizers](https://livewire.laravel.com/docs/synthesizers) ```php theme={null} 'Cross Sell', self::UP_SELL => 'Up Sell', self::ALTERNATE => 'Alternate', self::BUNDLE => 'Bundle', self::ACCESSORY => 'Accessory', }; } } ``` Then update `config/lunar/products.php` to point to the custom enum: ```php theme={null} return [ 'association_types_enum' => \App\Enums\ProductAssociation::class, ]; ``` The admin panel reads from this configuration automatically. The custom types will appear in the association type dropdown when creating or viewing product associations, with no additional setup required. The enum must be a backed string enum, and each case must implement a `label()` method that returns the human-readable name. See the [Associations reference](/1.x/reference/associations#custom-types) for full details on using custom types in application code. # Extending Order Management Source: https://docs.lunarphp.com/1.x/admin/extending/order-management Although orders have access to the same customisation as [Pages](/1.x/admin/extending/pages) there are some additional methods available and an additional class to allow order lines to be customised. To register your extension: ```php theme={null} LunarPanel::extensions([ \Lunar\Admin\Filament\Resources\OrderResource\Pages\ManageOrder::class => MyManageOrderExtension::class, ]); ``` You then have access to these methods in your class to override area's of the order view screen. * `extendInfolistSchema(): array` * `extendInfolistAsideSchema(): array` * `extendCustomerEntry(): Infolists\Components\Component` * `extendTagsSection(): Infolists\Components\Component` * `extendAdditionalInfoSection(): Infolists\Components\Component` * `extendShippingAddressInfolist(): Infolists\Components\Component` * `extendBillingAddressInfolist(): Infolists\Components\Component` * `extendAddressEditSchema(): array` * `extendOrderSummaryInfolist(): Infolists\Components\Section` * `extendOrderSummarySchema(): array` * `extendOrderSummaryNewCustomerEntry(): Infolists\Components\Entry` * `extendOrderSummaryStatusEntry(): Infolists\Components\Entry` * `extendOrderSummaryReferenceEntry(): Infolists\Components\Entry` * `extendOrderSummaryCustomerReferenceEntry(): Infolists\Components\Entry` * `extendOrderSummaryChannelEntry(): Infolists\Components\Entry` * `extendOrderSummaryCreatedAtEntry(): Infolists\Components\Entry` * `extendOrderSummaryPlacedAtEntry(): Infolists\Components\Entry` * `extendTimelineInfolist(): Infolists\Components\Component` * `extendOrderTotalsAsideSchema(): array` * `extendDeliveryInstructionsEntry(): Infolists\Components\TextEntry` * `extendOrderNotesEntry(): Infolists\Components\TextEntry` * `extendOrderTotalsInfolist(): Infolists\Components\Section` * `extendOrderTotalsSchema(): array` * `extendSubTotalEntry(): Infolists\Components\TextEntry` * `extendDiscountTotalEntry(): Infolists\Components\TextEntry` * `extendShippingBreakdownGroup(): Infolists\Components\Group` * `extendTaxBreakdownGroup(): Infolists\Components\Group` * `extendTotalEntry(): Infolists\Components\TextEntry` * `extendPaidEntry(): Infolists\Components\TextEntry` * `extendRefundEntry(): Infolists\Components\TextEntry` * `extendShippingInfolist(): Infolists\Components\Section` * `extendTransactionsInfolist(): Infolists\Components\Component` * `extendTransactionsRepeatableEntry(): Infolists\Components\RepeatableEntry` ## Extending `OrderItemsTable` ```php theme={null} \Lunar\Facades\LunarPanel::extensions([ \Lunar\Admin\Filament\Resources\OrderResource\Pages\Components\OrderItemsTable::class => OrderItemsTableExtension::class ]); ``` ### Available Methods * `extendOrderLinesTableColumns(): array` * `extendTable(): Table` # Extending the Admin Panel Source: https://docs.lunarphp.com/1.x/admin/extending/overview The Lunar Panel is highly customizable, you can add and change the behaviour of existing Filament resources. This might be useful if you wish to add a button for additional custom functionality. ## Extending Pages To extend a page you need to create and register an extension. ### Extending edit resource For example, the code below will register a custom extension called `MyEditExtension` for the `EditProduct` Filament page. ```php theme={null} use Lunar\Admin\Support\Facades\LunarPanel; use Lunar\Panel\Filament\Resources\ProductResource\Pages\EditProduct; use App\Admin\Filament\Resources\Pages\MyEditExtension; LunarPanel::extensions([ EditProduct::class => MyEditExtension::class, ]); ``` ### Extending list resource For example, the code below will register a custom extension called `MyListExtension` for the `ListProduct` Filament page. ```php theme={null} use Lunar\Admin\Support\Facades\LunarPanel; use Lunar\Panel\Filament\Resources\ProductResource\Pages\ListProduct; use App\Admin\Filament\Resources\Pages\MyEditExtension; LunarPanel::extensions([ ListProduct::class => MyListExtension::class, ]); ``` ## Extending Resources Much like extending pages, to extend a resource you need to create and register an extension. For example, the code below will register a custom extension called `MyProductResourceExtension` for the `ProductResource` Filament resource. ```php theme={null} use Lunar\Admin\Support\Facades\LunarPanel; use Lunar\Panel\Filament\Resources\ProductResource; use App\Admin\Filament\Resources\MyProductResourceExtension; LunarPanel::extensions([ ProductResource::class => MyProductResourceExtension::class, ]); ``` ## Extendable resources All Lunar panel resources are extendable. This means you can now add your own functionality or change out existing behaviour. ```php theme={null} use Lunar\Panel\Filament\Resources\ActivityResource; use Lunar\Panel\Filament\Resources\AttributeGroupResource; use Lunar\Panel\Filament\Resources\BrandResource; use Lunar\Panel\Filament\Resources\ChannelResource; use Lunar\Panel\Filament\Resources\CollectionGroupResource; use Lunar\Panel\Filament\Resources\CollectionResource; use Lunar\Panel\Filament\Resources\CurrencyResource; use Lunar\Panel\Filament\Resources\CustomerGroupResource; use Lunar\Panel\Filament\Resources\CustomerResource; use Lunar\Panel\Filament\Resources\DiscountResource; use Lunar\Panel\Filament\Resources\LanguageReousrce; use Lunar\Panel\Filament\Resources\OrderResource; use Lunar\Panel\Filament\Resources\ProductOptionResource; use Lunar\Panel\Filament\Resources\ProductResource; use Lunar\Panel\Filament\Resources\ProductResource; use Lunar\Panel\Filament\Resources\ProductTypeResource; use Lunar\Panel\Filament\Resources\ProductVariantResource; use Lunar\Panel\Filament\Resources\StaffResource; use Lunar\Panel\Filament\Resources\TagResource; use Lunar\Panel\Filament\Resources\TaxClassResource; use Lunar\Panel\Filament\Resources\TaxZoneResource; ``` # Extending Pages Source: https://docs.lunarphp.com/1.x/admin/extending/pages ## Writing Extensions There are three extension types Lunar provides, these are for Create, Edit and Listing pages. You will want to place the extension class in your application. A sensible location might be `App\Lunar\MyCreateExtension`. Once created you will need to register the extension, typically in your app service provider. ## CreatePageExtension An example of extending a create page. ```php theme={null} use Filament\Actions; use Lunar\Admin\Support\Extending\CreatePageExtension; use Lunar\Admin\Filament\Widgets; class MyCreateExtension extends CreatePageExtension { public function heading($title): string { return $title . ' - Example'; } public function subheading($title): string { return $title . ' - Example'; } public function getTabs(array $tabs): array { return [ ...$tabs, 'review' => Tab::make('Review') ->modifyQueryUsing(fn (Builder $query) => $query->where('status', 'review')), ]; } public function headerWidgets(array $widgets): array { $widgets = [ ...$widgets, Widgets\Dashboard\Orders\OrderStatsOverview::make(), ]; return $widgets; } public function headerActions(array $actions): array { $actions = [ ...$actions, Actions\Action::make('Cancel'), ]; return $actions; } public function formActions(array $actions): array { $actions = [ ...$actions, Actions\Action::make('Create and Edit'), ]; return $actions; } public function footerWidgets(array $widgets): array { $widgets = [ ...$widgets, Widgets\Dashboard\Orders\LatestOrdersTable::make(), ]; return $widgets; } public function beforeCreate(array $data): array { $data['model_code'] .= 'ABC'; return $data; } public function beforeCreation(array $data): array { return $data; } public function afterCreation(Model $record, array $data): Model { return $record; } } // Typically placed in your AppServiceProvider file... LunarPanel::extensions([ \Lunar\Admin\Filament\Resources\CustomerGroupResource\Pages\CreateCustomerGroup::class => MyCreateExtension::class, ]); ``` ## EditPageExtension An example of extending an edit page. ```php theme={null} use Filament\Actions; use Lunar\Admin\Support\Extending\EditPageExtension; use Lunar\Admin\Filament\Widgets; class MyEditExtension extends EditPageExtension { public function heading($title): string { return $title . ' - Example'; } public function subheading($title): string { return $title . ' - Example'; } public function headerWidgets(array $widgets): array { $widgets = [ ...$widgets, Widgets\Dashboard\Orders\OrderStatsOverview::make(), ]; return $widgets; } public function headerActions(array $actions): array { $actions = [ ...$actions, Actions\ActionGroup::make([ Actions\Action::make('View on Storefront'), Actions\Action::make('Copy Link'), Actions\Action::make('Duplicate'), ]) ]; return $actions; } public function formActions(array $actions): array { $actions = [ ...$actions, Actions\Action::make('Update and Edit'), ]; return $actions; } public function footerWidgets(array $widgets): array { $widgets = [ ...$widgets, Widgets\Dashboard\Orders\LatestOrdersTable::make(), ]; return $widgets; } public function beforeFill(array $data): array { $data['model_code'] .= 'ABC'; return $data; } public function beforeSave(array $data): array { return $data; } public function beforeUpdate(array $data, Model $record): array { return $data; } public function afterUpdate(Model $record, array $data): Model { return $record; } public function relationManagers(array $managers): array { return $managers; } } // Typically placed in your AppServiceProvider file... LunarPanel::extensions([ \Lunar\Admin\Filament\Resources\ProductResource\Pages\EditProduct::class => MyEditExtension::class, ]); ``` ## ListPageExtension An example of extending a list page. ```php theme={null} use Filament\Actions; use Illuminate\Database\Eloquent\Builder; use Illuminate\Contracts\Pagination\Paginator; use Lunar\Admin\Support\Extending\ListPageExtension; use Lunar\Admin\Filament\Widgets; class MyListExtension extends ListPageExtension { public function heading($title): string { return $title . ' - Example'; } public function subheading($title): string { return $title . ' - Example'; } public function headerWidgets(array $widgets): array { $widgets = [ ...$widgets, Widgets\Dashboard\Orders\OrderStatsOverview::make(), ]; return $widgets; } public function headerActions(array $actions): array { $actions = [ ...$actions, Actions\ActionGroup::make([ Actions\Action::make('View on Storefront'), Actions\Action::make('Copy Link'), Actions\Action::make('Duplicate'), ]), ]; return $actions; } public function paginateTableQuery(Builder $query, int $perPage = 25): Paginator { return $query->paginate($perPage); } public function footerWidgets(array $widgets): array { $widgets = [ ...$widgets, Widgets\Dashboard\Orders\LatestOrdersTable::make(), ]; return $widgets; } } // Typically placed in your AppServiceProvider file... LunarPanel::extensions([ \Lunar\Admin\Filament\Resources\ProductResource\Pages\ListProducts::class => MyListExtension::class, ]); ``` ## ViewPageExtension An example of extending a view page. ```php theme={null} use Filament\Actions; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; use Filament\Infolists\Infolist; use Filament\Infolists\Components\TextEntry; use Lunar\Admin\Support\Extending\ViewPageExtension; use Lunar\Admin\Filament\Widgets; class MyViewExtension extends ViewPageExtension { public function headerWidgets(array $widgets): array { $widgets = [ ...$widgets, Widgets\Dashboard\Orders\OrderStatsOverview::make(), ]; return $widgets; } public function heading($title): string { return $title . ' - Example'; } public function subheading($title): string { return $title . ' - Example'; } public function headerActions(array $actions): array { $actions = [ ...$actions, Actions\ActionGroup::make([ Actions\Action::make('Download PDF') ]) ]; return $actions; } public function extendsInfolist(Infolist $infolist): Infolist { return $infolist->schema([ ...$infolist->getComponents(true), TextEntry::make('custom_title'), ]); } public function footerWidgets(array $widgets): array { $widgets = [ ...$widgets, Widgets\Dashboard\Orders\LatestOrdersTable::make(), ]; return $widgets; } } // Typically placed in your AppServiceProvider file... LunarPanel::extensions([ \Lunar\Admin\Filament\Resources\OrderResource\Pages\ManageOrder::class => MyViewExtension::class, ]); ``` ## RelationPageExtension An example of extending a relation page. ```php theme={null} use Filament\Actions; use Lunar\Admin\Support\Extending\RelationPageExtension; class MyRelationExtension extends RelationPageExtension { public function heading($title): string { return $title . ' - Example'; } public function subheading($title): string { return $title . ' - Example'; } public function headerActions(array $actions): array { $actions = [ ...$actions, Actions\ActionGroup::make([ Actions\Action::make('Download PDF') ]) ]; return $actions; } } // Typically placed in your AppServiceProvider file... LunarPanel::extensions([ \Lunar\Admin\Filament\Resources\ProductResource\Pages\ManageProductMedia::class => MyRelationExtension::class, ]); ``` ## Extending Pages In Addons If you are building an addon for Lunar, you may need to take a slightly different approach when modifying forms, etc. For example, you cannot assume the contents of a form, so you may need to take an approach such as this... ```php theme={null} public function extendForm(Form $form): Form { $form->schema([ ...$form->getComponents(true), // Gets the currently registered components TextInput::make('model_code'), ]); return $form; } ``` # Extending the Panel Source: https://docs.lunarphp.com/1.x/admin/extending/panel You may customise the Filament panel when registering it in your app service provider. We provide a handy function which gives you direct access to the panel to change its properties. For example, the following would change the panel's URL to `/admin` rather than the default `/lunar`. ```php theme={null} use Lunar\Admin\Support\Facades\LunarPanel; LunarPanel::panel(fn($panel) => $panel->path('admin')) ->extensions([ // ... ]) ->register(); ``` ## Adding to the panel The [Filament](https://filamentphp.com/) panel allows you to add further screens in the form of Pages or Resources, and indeed customise any available panel option. Below is an example of how you can use the panel object. ```php theme={null} LunarPanel::panel(function ($panel) { return $panel ->pages([ // Register standalone Filament Pages SalesReport::class, RevenueReport::class, ]) ->resources([ // Register new Filament Resources OpeningTimeResource::class, BannerResource::class, ]) ->livewireComponents([ // Register Livewire components OrdersSalesChart::class, ])->plugin( // Register a Filament plugin new ShippingPlugin(), ) ->navigationGroups([ // Set the navigation groups 'Catalog', 'Sales', 'CMS', 'Reports', 'Shipping', 'Settings', ]); })->register(); ``` For further information please consult the [Filament documentation](https://filamentphp.com/docs). # Extending Relation Managers Source: https://docs.lunarphp.com/1.x/admin/extending/relation-managers ## MyCustomerGroupPricingRelationManagerExtension An example of extending the CustomerGroupPricingRelationManager ```php theme={null} class MyCustomerGroupPricingRelationManagerExtension extends \Lunar\Admin\Support\Extending\RelationManagerExtension { public function extendForm(\Filament\Forms\Form $form): \Filament\Forms\Form { return $form->schema([ ...$form->getComponents(withHidden: true), \Filament\Forms\Components\TextInput::make('custom_column') ]); } public function extendTable(\Filament\Tables\Table $table): \Filament\Tables\Table { return $table->columns([ ...$table->getColumns(), \Filament\Tables\Columns\TextColumn::make('product_code') ]); } } // Typically placed in your AppServiceProvider file... LunarPanel::extensions([ \Lunar\Admin\Filament\Resources\ProductResource\RelationManagers\CustomerGroupPricingRelationManager::class => MyCustomerGroupPricingRelationManagerExtension::class, ]); ``` # Extending Resources Source: https://docs.lunarphp.com/1.x/admin/extending/resources ## MyProductResourceExtension An example of extending the ProductResource ```php theme={null} class MyProductResourceExtension extends \Lunar\Admin\Support\Extending\ResourceExtension { public function extendForm(\Filament\Forms\Form $form): \Filament\Forms\Form { return $form->schema([ ...$form->getComponents(withHidden: true), \Filament\Forms\Components\TextInput::make('custom_column') ]); } public function extendTable(\Filament\Tables\Table $table): \Filament\Tables\Table { return $table->columns([ ...$table->getColumns(), \Filament\Tables\Columns\TextColumn::make('product_code') ]); } public function getRelations(array $managers) : array { return [ ...$managers, // This is just a standard Filament relation manager. // see https://filamentphp.com/docs/3.x/panels/resources/relation-managers#creating-a-relation-manager MyCustomProductRelationManager::class, ]; } public function extendPages(array $pages) : array { return [ ...$pages, // This is just a standard Filament page // see https://filamentphp.com/docs/3.x/panels/pages#creating-a-page 'my-page-route-name' => MyPage::route('/{record}/my-page'), ]; } public function extendSubNavigation(array $nav) : array { return [ ...$nav, // This is just a standard Filament page // see https://filamentphp.com/docs/3.x/panels/pages#creating-a-page MyPage::class, ]; } } // Typically placed in your AppServiceProvider file... LunarPanel::extensions([ \Lunar\Admin\Filament\Resources\ProductResource::class => MyProductResourceExtension::class, ]); ``` # Admin Panel Source: https://docs.lunarphp.com/1.x/admin/introduction Lunar's admin panel is powered by **Filament v3**. It allows you to easily extend the admin panel to suit your project. With the panel you can administer your products, collections, orders, customers, discounts, settings and much more. ## Registering If you followed the core installation instructions or have installed a starter kit, you will likely already have this in place. ```php theme={null} use Lunar\Admin\Support\Facades\LunarPanel; class AppServiceProvider extends ServiceProvider { public function register(): void { LunarPanel::register(); } ``` # Extending Carts Source: https://docs.lunarphp.com/1.x/extending/carts The cart system can be extended with custom pipelines and modifiers to add business logic. ## Overview Carts are a central part of any e-commerce storefront. The cart system is designed to be easily extended, allowing any custom logic to be added throughout a cart's lifetime. ## Pipelines ### Adding a Cart Pipeline All pipelines are defined in `config/lunar/cart.php` ```php theme={null} 'pipelines' => [ /* * Run these pipelines when the cart is calculating. */ 'cart' => [ Lunar\Pipelines\Cart\CalculateLines::class, Lunar\Pipelines\Cart\ApplyShipping::class, Lunar\Pipelines\Cart\ApplyDiscounts::class, Lunar\Pipelines\Cart\CalculateTax::class, Lunar\Pipelines\Cart\Calculate::class, ], /* * Run these pipelines when the cart lines are being calculated. */ 'cart_lines' => [ Lunar\Pipelines\CartLine\GetUnitPrice::class, ], ], ``` You can add your own pipelines to the configuration, they might look something like: ```php theme={null} [ // ... App\Pipelines\Cart\CustomCartPipeline::class, ], ``` Pipelines will run from top to bottom ## Actions During the lifecycle of a cart, various actions are taken. While generally what Lunar provides will be fine for most storefronts, there are times where something slightly different may be needed. For this reason, all actions are configurable and can be swapped out as needed. Actions are defined in `config/lunar/cart.php` and if you need to replace an action, check the class of the action you want to change to see what it is expecting. ## Action validation You may wish to provide some validation against actions before they run. Your own validation may look something like: ```php theme={null} parameters['quantity'] ?? 0; // ... if (!$condition) { return $this->fail('cart', 'Something went wrong'); } return $this->pass(); } } ``` You can then register this class against the corresponding action in `config/lunar/cart.php`: ```php theme={null} 'validators' => [ 'add_to_cart' => [ // ... \App\Validation\CartLine\CartLineQuantity::class, ], // ... ], ``` If validation fails, a `Lunar\Exceptions\Carts\CartException` will be thrown. Errors can be accessed in the same way as Laravel's own validation responses. ```php theme={null} try { $cart->setShippingOption($option); } catch (\Lunar\Exceptions\Carts\CartException $e) { $e->errors()->all(); } ``` # Extending Discounts Source: https://docs.lunarphp.com/1.x/extending/discounts Custom discount types can be registered to add additional discount logic beyond the built-in types. ## Overview If you want to add additional functionality to Discounts, you can register your own custom discount types. ## Registering a discount type. ```php theme={null} use Lunar\Facades\Discounts; Discounts::addType(MyCustomDiscountType::class); ``` ```php theme={null} label('My label') ->required(), ]; } /** * Mutate the model data before displaying it in the admin form. */ public function lunarPanelOnFill(array $data): array { // optionally do something with $data return $data; } /** * Mutate the form data before saving it to the discount model. */ public function lunarPanelOnSave(array $data): array { // optionally do something with $data return $data; } } ``` # Extending Models Source: https://docs.lunarphp.com/1.x/extending/models Lunar's Eloquent models can be extended with custom relationships and, when needed, replaced entirely with custom implementations. ## Overview Lunar provides a number of Eloquent models and quite often in custom applications additional relationships and functionality are needed. Rather than modifying fields on Lunar's core models, it is recommended to create your own Eloquent models and relate them to Lunar's models. This keeps custom data separate and avoids conflicts during upgrades. Add-on packages must never replace core models, as this would conflict with other packages and the end user's application. Add-ons should use dynamic relationships and their own models exclusively. ## Dynamic Eloquent Relationships The recommended way to extend Lunar's models is by using Laravel's dynamic relationships. This allows custom relationships to be added to any Lunar model without needing to replace it. ```php theme={null} use Lunar\Models\Order; use App\Models\Ticket; Order::resolveRelationUsing('ticket', function ($orderModel) { return $orderModel->belongsTo(Ticket::class, 'ticket_id'); }); ``` This should be registered in the `boot` method of a service provider. See the [Laravel documentation on dynamic relationships](https://laravel.com/docs/eloquent-relationships#dynamic-relationships) for more information. ## Replaceable Models For cases where dynamic relationships are not sufficient, all Lunar models are replaceable. This means Lunar can be instructed to use a custom model throughout the ecosystem using dependency injection. Most use cases are better served by dynamic relationships above. Model replacement should only be used when deeper customization is required, such as overriding methods or adding scopes. ### Registration Custom models should be registered within the `boot` method of a service provider. When registering models, the Lunar model's contract should be set as the first argument and the custom model implementation as the second. ```php theme={null} /** * Bootstrap any application services. * * @return void */ public function boot() { \Lunar\Facades\ModelManifest::replace( \Lunar\Models\Contracts\Product::class, \App\Model\Product::class, ); } ``` #### Registering multiple Lunar models. If multiple models need to be replaced, instead of replacing them one by one, a directory can be specified for Lunar to scan for models. This assumes that each model extends its counterpart model, e.g. `App\Models\Product` extends `Lunar\Models\Product`. ```php theme={null} /** * Bootstrap any application services. * * @return void */ public function boot() { \Lunar\Facades\ModelManifest::addDirectory( __DIR__.'/../Models' ); } ``` ### Route binding Route binding is supported and simply requires the relevant contract class to be injected. ```php theme={null} Route::get('products/{id}', function (\Lunar\Models\Contracts\Product $product) { $product; // App\Models\Product }); ``` ### Relationship support If a model used in a relationship is replaced, the custom model will be returned via relationship methods. For example, assuming a custom `App\Models\ProductVariant` has been registered: ```php theme={null} // In a service provider. public function boot() { \Lunar\Facades\ModelManifest::replace( \Lunar\Models\Contracts\ProductVariant::class, \App\Model\ProductVariant::class, ); } // Somewhere else in your code... $product = \Lunar\Models\Product::first(); $product->variants->first(); // App\Models\ProductVariant ``` ### Static call forwarding If a custom model has additional methods, those functions can be called directly from the Lunar model instance. For example, to provide a new function on a product variant model: ```php theme={null} [ 'creation' => [ Lunar\Pipelines\Order\Creation\FillOrderFromCart::class, Lunar\Pipelines\Order\Creation\CreateOrderLines::class, Lunar\Pipelines\Order\Creation\CreateOrderAddresses::class, Lunar\Pipelines\Order\Creation\CreateShippingLine::class, Lunar\Pipelines\Order\Creation\CleanUpOrderLines::class, Lunar\Pipelines\Order\Creation\MapDiscountBreakdown::class, // ... ], ], ``` You can add your own pipelines to the configuration, they might look something like: ```php theme={null} [ 'creation' => [ // ... App\Pipelines\Orders\CustomOrderPipeline::class, ], ], ``` Pipelines will run from top to bottom # Extending Payments Source: https://docs.lunarphp.com/1.x/extending/payments Custom payment drivers can be added to support any payment provider. ## Overview Lunar provides an easy way for you to add your own payment drivers, by default, there is a basic `OfflinePayment` driver that ships with Lunar, additional providers should be added to your Storefront via addons. Below is a list of available payment drivers. ## Available drivers ### First party * [Stripe](https://github.com/lunarphp/stripe) ### Community > Made a custom driver that should be listed here? Get in touch on the Lunar discord channel to get it added. ## Building your own A payment driver should take into account 2 fundamentals: * Capturing a payment (whether straight away, or at a later date) * Refunding an existing payment ### Registering your driver ```php theme={null} use Lunar\Facades\Payments; Payments::extend('custom', function ($app) { return $app->make(CustomPayment::class); }); ``` ### The payment driver class The following is the complete class, which is then broken down below. ```php theme={null} order) { if (!$this->order = $this->cart->order) { $this->order = $this->cart->createOrder(); } } // ... $response = new PaymentAuthorize( success: true, message: 'The payment was successful', orderId: $this->order->id, paymentType: 'custom-type' ); PaymentAttemptEvent::dispatch($response); return $response; } /** * {@inheritDoc} */ public function refund(Transaction $transaction, int $amount = 0, $notes = null): PaymentRefund { // ... return new PaymentRefund(true); } /** * {@inheritDoc} */ public function capture(Transaction $transaction, $amount = 0): PaymentCapture { // ... return new PaymentCapture(true); } } ``` This is the most basic implementation of a driver, extending the `AbstractPayment` class provided by Lunar. This class contains useful helpers that can be used in custom drivers. [See available methods](#abstract-class-methods) #### Releasing payments ```php theme={null} public function authorize(); ``` This is where you'd check the payment details which have been passed in, create any transactions for the order and return the response. If payment is not being taken straight away, any transactions should be set to the type of `intent`. When the payment is later captured, it is recommended to create another transaction that is related to the intent via the `parent_transaction_id`. #### Capturing payments ```php theme={null} public function capture(Transaction $transaction, $amount = 0): PaymentCapture ``` When you have a transaction that has a type of `intent` the Staff member who is logged into the hub can then decide to capture it so the card used gets charged the amount that has been authorised. You can pass an optional amount, but be cautious as you generally cannot capture an amount that exceeds the original amount on the `intent` transaction. If you capture an amount less, services like Stripe will treat that as a partial refund and no further captures can take place on that order. Here you should create an additional transaction against the order to show how much has been captured. #### Refunding payments ```php theme={null} public function refund(Transaction $transaction, int $amount = 0, $notes = null): PaymentRefund ``` When refunding a transaction, you can only do so to one that's been captured. If you need to refund an order that hasn't been captured you should instead capture an amount less to what's been authorised. You should only refund transactions with the type `capture`. ## The AbstractPayment class ### Available methods * [`cart`](#cart) * [`order`](#order) * [`withData`](#withData) * [`setConfig`](#setconfig) #### `cart` ```php theme={null} public function cart(Cart $cart): self ``` Sets the `$cart` property on the payment driver. When using the `authorize` method, it is recommended to expect a `$cart` instance and check for the existence of an order. #### `order` ```php theme={null} public function order(Order $order): self ``` Sets the `$order` property on the payment driver. #### `withData` ```php theme={null} public function withData(array $data): self ``` This method allows you to add any additional data to the payment driver, this can be anything that the payment driver needs to function, for example. ```php theme={null} Payments::driver('stripe')->withData([ 'payment_intent' => $paymentIntentId ])->authorize(); ``` #### `setConfig` ```php theme={null} public function setConfig(array $config): self ``` Here you can set up any additional config for this payment driver. By default, this will be called when you register your payment driver and will take any values which are set in `config/lunar/payments.php` for that type. ## Creating transactions Depending on how your driver works, you're likely going to need to create some transactions depending on different scenarios. ### Database Schema ``` Lunar\Models\Transaction ``` | Field | Description | Example | | :---------------------- | :---------------------------------------------- | :------------------------------------------------------------------------------------------ | | id | | | | parent\_transaction\_id | The ID of the related transaction, nullable | | | order\_id | The ID of the order this transaction relates to | | | success | Whether or not the transaction was successful | 1 | | type | Whether `intent`,`capture` or `refund` | `intent` | | driver | The driver used i.e. `stripe` | `stripe` | | amount | The amount for the transaction in cents | `10000` | | reference | The reference for the driver to use | `STRIPE_123456` | | status | Usually populated from the payment provider | `success` | | notes | Any additional notes for the transaction | | | card\_type | The card type | `visa` | | last\_four | The last four digits of the card used | `1234` | | captured\_at | The DateTime the transaction was captured | | | meta | Any additional meta info for the transaction | `{"cvc_check": "pass", "address_line1_check": "pass", "address_postal_code_check": "pass"}` | | created\_at | | | | updated\_at | | | ### Best Practices #### Releasing When releasing a payment, if you're not charging the card straight away, you should create a transaction with type `intent`. This tells Lunar you intend to charge the card at a later date. ```php theme={null} Transaction::create([ //... 'type' => 'intent', ]); ``` If you are charging the card straight away, set the type to `capture`. ```php theme={null} Transaction::create([ //... 'type' => 'capture', ]); ``` #### Capturing If the card is already being charged, this step can be skipped as payment has already been received. When capturing a transaction, you should create an additional transaction with the amount that's been captured. Even if this is the same amount as the `intent` transaction. ```php theme={null} $intent = Transaction::whereType('intent')->first(); Transaction::create([ //... 'parent_transaction_id' => $intent->id, 'type' => 'capture', 'amount' => 2000, ]); ``` #### Refunding ```php theme={null} $capture = Transaction::whereType('capture')->first(); Transaction::create([ //... 'parent_transaction_id' => $capture->id, 'type' => 'refund', ]); ``` # Extending Search Source: https://docs.lunarphp.com/1.x/extending/search Search indexing can be extended to customize searchable, sortable, and filterable fields. ## Overview Good search is the backbone of any storefront, so Lunar aims to make this as extensible as possible. Custom fields can be indexed for the storefront without compromising what Lunar requires for the admin panel. There are three things to consider when you want to extend the search: * Searchable fields * Sortable fields * Filterable fields ## Default index values Eloquent models which use the `Lunar\Base\Traits\Searchable` trait will use an indexer class to tell Scout how each model should be indexed. If an indexer isn't mapped in the config, the default `ScoutIndexer` (provided by Lunar) will be used. This class will map a basic set of fields to the search index: * The ID of the model * Any `searchable` attributes. Some models require a bit more information to be indexed, such as SKU's, prices etc. For these scenarios, dedicated indexers have been created and are mapped in the config already. #### `Lunar\Search\ProductIndexer` Fields which are indexed: * The ID of the model * Any `searchable` attributes. * The product `status` * The product `product_type` * The `brand` (if applicable) * The ProductVariant `skus` related to the product. * The `created_at` timestamp ## Mapping custom indexers All indexers are mapped in `config/lunar/search.php` under `indexers`. If a model isn't mapped here, the default `ScoutIndexer` is used. To change how each model is indexed, map it like so: ```php theme={null} return [ // ... 'indexers' => [ Lunar\Models\Product::class => App\Search\CustomProductIndexer::class, ], ], ``` ## Creating a custom indexer To create your own indexer, simply create a custom class like so: ```php theme={null} with([ 'thumbnail', 'variants', 'productType', 'brand', ]); } // Scout method to get the ID used for indexing public function getScoutKey(Model $model): mixed { return $model->getKey(); } // Scout method to get the column used for the ID. public function getScoutKeyName(Model $model): mixed { return $model->getKeyName(); } // Simple array of any sortable fields. public function getSortableFields(): array { return [ 'created_at', 'updated_at', ]; } // Simple array of any filterable fields. public function getFilterableFields(): array { return [ '__soft_deleted', ]; } // Return an array representing what should be sent to the search service e.g. Algolia public function toSearchableArray(Model $model): array { return array_merge([], $this->mapSearchableAttributes($model)); } } ``` The `ScoutIndexer` class implements the `Lunar\Search\Interfaces\ScoutIndexerInterface` interface. If a custom indexer does not extend `ScoutIndexer`, it must implement this interface directly. There are some methods which are available on `ScoutIndexer` but not defined on the interface: #### mapSearchableAttributes ```php theme={null} mapSearchableAttributes(Model $model): array ``` This method will take all `searchable` attributes for the model attribute type and map them into the index, this means when you add searchable attributes in the hub they will automatically be added to the index. # Extending Shipping Source: https://docs.lunarphp.com/1.x/extending/shipping Custom shipping options can be added to the checkout using shipping modifiers. ## Overview On your checkout, if your customer has added an item that needs shipping, you're likely going to want to display some shipping options. Currently the best way to do this is to implement your own by adding a `ShippingModifier` and adding using that to determine what shipping options you want to make available and add them to the `ShippingManifest` class. ## Adding a Shipping Modifier Create your own custom shipping provider: ```php theme={null} namespace App\Modifiers; use Lunar\Base\ShippingModifier; use Lunar\DataTypes\Price; use Lunar\DataTypes\ShippingOption; use Lunar\Facades\ShippingManifest; use Lunar\Models\Cart; use Lunar\Models\Currency; use Lunar\Models\TaxClass; class CustomShippingModifier extends ShippingModifier { public function handle(Cart $cart, \Closure $next) { // Get the tax class $taxClass = TaxClass::first(); ShippingManifest::addOption( new ShippingOption( name: 'Basic Delivery', description: 'A basic delivery option', identifier: 'BASDEL', price: new Price(500, $cart->currency, 1), taxClass: $taxClass ) ); ShippingManifest::addOption( new ShippingOption( name: 'Pick up in store', description: 'Pick your order up in store', identifier: 'PICKUP', price: new Price(0, $cart->currency, 1), taxClass: $taxClass, // This is for your reference, so you can check if a collection option has been selected. collect: true ) ); // Or add multiple options, it's your responsibility to ensure the identifiers are unique ShippingManifest::addOptions(collect([ new ShippingOption( name: 'Basic Delivery', description: 'A basic delivery option', identifier: 'BASDEL', price: new Price(500, $cart->currency, 1), taxClass: $taxClass ), new ShippingOption( name: 'Express Delivery', description: 'Express delivery option', identifier: 'EXDEL', price: new Price(1000, $cart->currency, 1), taxClass: $taxClass ) ])); return $next($cart); } } ``` In your service provider: ```php theme={null} public function boot(\Lunar\Base\ShippingModifiers $shippingModifiers) { $shippingModifiers->add( CustomShippingModifier::class ); } ``` # Extending Taxation Source: https://docs.lunarphp.com/1.x/extending/taxation Taxation is driver-based, allowing custom tax calculation logic to replace or extend the default system. ## Overview Taxation is complex and sometimes what Lunar offers out of the box is not sufficient. This is why taxation is driver-based, allowing custom logic to be added as needed. By default, a `SystemTaxDriver` is used, which relies on Lunar's internal models and database as outlined in the [Taxation](/1.x/reference/taxation) reference. To use a custom implementation, the driver can be changed in the `config/lunar/taxes.php` config file. ```php theme={null} 'system', ]; ``` ## Writing a Custom Driver A custom driver must implement the `Lunar\Base\TaxDriver` interface: ```php theme={null} make(TaxJar::class); }); } ``` Then set the driver in the `config/lunar/taxes.php` config: ```php theme={null} 'taxjar', ]; ``` # AI Development Source: https://docs.lunarphp.com/1.x/getting-started/overview/ai-development Use Lunar documentation with AI tools like Claude, ChatGPT, Cursor, and VS Code Lunar's documentation is designed to work seamlessly with AI-powered development tools. Whether using an AI assistant to generate storefront code or querying the docs from an IDE, several built-in features make this possible. ## llms.txt The Lunar documentation site automatically provides an `llms.txt` file at the root of the docs domain. Similar to how `sitemap.xml` helps search engines index a website, `llms.txt` helps large language models discover and understand documentation content. Two files are available: | File | URL | Description | | --------------- | -------------------------------------------------------------------------- | ------------------------------------------------- | | `llms.txt` | [docs.lunarphp.com/llms.txt](https://docs.lunarphp.com/llms.txt) | Structured listing of all pages with descriptions | | `llms-full.txt` | [docs.lunarphp.com/llms-full.txt](https://docs.lunarphp.com/llms-full.txt) | Full documentation content in a single file | The `llms.txt` file lists every documentation page along with its description, making it useful for AI tools that need to locate specific topics. The `llms-full.txt` file consolidates all documentation into one file, which is ideal for AI tools that benefit from ingesting the entire knowledge base at once. When pasting documentation context into an AI chat, use `llms-full.txt` to give the model comprehensive knowledge of Lunar's API and features. ## MCP Server Lunar's documentation includes a built-in [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server. MCP allows AI applications to search and retrieve documentation directly, providing more accurate and up-to-date responses than relying on the model's training data alone. The MCP server is available at: ``` https://docs.lunarphp.com/mcp ``` ### Connecting from AI Tools The MCP server supports filtering by documentation version using a `version` parameter (e.g., `v1.x` or `v0.x`). #### Claude Code ```bash theme={null} claude mcp add lunar-docs --transport http https://docs.lunarphp.com/mcp ``` #### Cursor Open the Command Palette and select **MCP: Add Server**, then add the following to the MCP configuration: ```json theme={null} { "mcpServers": { "lunar-docs": { "url": "https://docs.lunarphp.com/mcp" } } } ``` #### VS Code Create or update `.vscode/mcp.json` in the project root: ```json theme={null} { "servers": { "lunar-docs": { "type": "http", "url": "https://docs.lunarphp.com/mcp" } } } ``` #### Claude (Web) Navigate to **Settings > Connectors > Add custom connector**, then enter a name and the MCP server URL. ## Markdown Export Any documentation page can be viewed as plain Markdown by appending `.md` to its URL. For example: ``` https://docs.lunarphp.com/1.x/reference/carts.md ``` This is useful for feeding specific pages into AI tools as context. Markdown is more token-efficient than HTML, resulting in faster processing and better response quality. ## Contextual Menu Each documentation page includes a contextual menu that provides quick access to AI-related actions: * **Copy as Markdown** for pasting into any AI tool * **Open in Claude**, **ChatGPT**, or **Perplexity** with the current page loaded as context * **Install MCP server** directly into Cursor or VS Code # Contributing Source: https://docs.lunarphp.com/1.x/getting-started/overview/contributing How to contribute code, report bugs, and improve the documentation. Lunar is an open source project and welcomes contributions of all kinds, from bug reports and documentation improvements to new features and code fixes. ## Monorepo Lunar uses a monorepo at [lunarphp/lunar](https://github.com/lunarphp/lunar) to manage its codebase. All packages, tests, and admin panel code live in a single repository. [Monorepos](https://en.wikipedia.org/wiki/Monorepo) are common in large projects and allow changes that span multiple packages to be developed and reviewed together. ## Contributing Code The basic process for contributing code to Lunar is: 1. Fork the [lunarphp/lunar](https://github.com/lunarphp/lunar) monorepo 2. Clone the fork locally 3. Create a new branch for the change 4. Make changes and write tests 5. Ensure the full test suite passes 6. Submit a pull request For a step-by-step walkthrough of setting up the monorepo inside a Laravel application, see the [Local Development](/1.x/getting-started/overview/local-development) guide. ### Coding Standards Lunar follows the [Laravel coding style](https://laravel.com/docs/contributions#coding-style) and uses [Laravel Pint](https://laravel.com/docs/pint) to enforce it. Before submitting a pull request, run Pint from the repository root: ```bash theme={null} vendor/bin/pint ``` ### Running Tests Lunar uses [Pest](https://pestphp.com/) for its test suite. Tests can be run from the repository root: ```bash theme={null} vendor/bin/pest ``` All existing tests must continue to pass, and new features or bug fixes should include corresponding test coverage. ## Reporting a Bug If a bug is discovered, please open a GitHub issue on the [lunarphp/lunar](https://github.com/lunarphp/lunar/issues) repository. The issue should include: **At minimum:** * A clear title and description of the issue * Steps to reproduce the behavior **Ideally:** * The version of Lunar, PHP, and Laravel in use * An accompanying pull request with a failing test that demonstrates the issue Providing as much detail as possible gives the issue the best chance of being resolved quickly. Creating an issue does not guarantee immediate activity; maintainers review issues as time allows. Security vulnerabilities should not be reported as public issues. See the [Security](/1.x/getting-started/overview/security) page for responsible disclosure instructions. ## Proposing a Feature Before investing time on a new feature, it is strongly recommended to start a [discussion](https://github.com/lunarphp/lunar/discussions) first. This allows the community and maintainers to provide feedback on whether the feature aligns with Lunar's direction before any code is written. Prototyping the feature alongside the discussion is welcome if it helps illustrate the proposal. ## Making a Pull Request When submitting a [pull request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request), the repository provides a template to guide the submission. A well-structured pull request should include: * A title that clearly summarizes the change * A description explaining what the change does and why it is valuable * References to any related issues (e.g., `Fixes #123`) * A label of `bug`, `enhancement`, or `feature` * Automated tests with adequate coverage * Any relevant documentation updates Pull requests that are missing key information or are unclear in their purpose may be delayed or closed. ## Documentation Lunar's documentation is maintained in the [lunarphp/docs](https://github.com/lunarphp/docs) repository. It is built with [Mintlify](https://mintlify.com/) and all content is written in MDX. To contribute documentation changes: 1. Fork the [lunarphp/docs](https://github.com/lunarphp/docs) repository 2. Install the Mintlify CLI and run the dev server locally 3. Make changes and verify they render correctly 4. Submit a pull request ```bash theme={null} npm i -g mint mint dev ``` Any new page must also be added to the `docs.json` navigation configuration, or it will not appear in the sidebar. # Introduction Source: https://docs.lunarphp.com/1.x/getting-started/overview/introduction Start building e-commerce with Laravel Lunar is a headless e-commerce package for [Laravel](https://laravel.com/) that provides everything needed to build a fully featured online store. ## Why Lunar? Unlike hosted platforms such as Shopify or WooCommerce, Lunar gives developers complete ownership of the codebase, the data, and the storefront. It handles the complex e-commerce logic — products, carts, orders, payments, discounts, taxation, and more — while leaving full creative control over the customer-facing experience. Because Lunar is a Laravel package (not a standalone application), it integrates directly into any Laravel project. There is no separate system to maintain, no external API to call, and no vendor lock-in. ## Tech Stack Lunar is made up of two primary Composer packages: * **`lunarphp/core`** — the e-commerce engine: models, pipelines, pricing, search, and all core functionality. * **`lunarphp/lunar`** — the admin panel, built on [Filament](https://filamentphp.com/), for managing products, orders, customers, and more. Although the admin panel uses Laravel Livewire, there is no requirement for the storefront to use Livewire. ## Key Features * **Product catalog** — products, variants, options, collections, associations, and flexible attribute system * **Cart and checkout** — cart management with built-in pipelines for validation, tax calculation, and shipping * **Order management** — full order lifecycle with statuses, transactions, and PDF support * **Discounts** — percentage, fixed amount, and "buy X get Y" discount types with conditions and limitations * **Payments** — first-party drivers for Stripe, PayPal, and Opayo, plus an extensible payment interface * **Search** — Algolia and Meilisearch support through Laravel Scout with pre-configured indexing * **Multi-channel and multi-currency** — sell across multiple channels with localized pricing * **Taxation** — configurable tax classes, zones, and rates * **Extensible admin panel** — customize pages, resources, and relation managers through Filament ## Getting Started Add Lunar to a Laravel application with Composer. Spin up a fully working storefront as a starting point. Step-by-step guides for building catalog, cart, and checkout flows. # Local Development Source: https://docs.lunarphp.com/1.x/getting-started/overview/local-development Setting up the Lunar monorepo locally for development and contribution. This guide walks through setting up the Lunar monorepo inside a Laravel application for local development and contribution. ## Prerequisites * PHP 8.2 or higher * A working [Laravel](https://laravel.com/docs/installation) application * [Composer](https://getcomposer.org/) * [Git](https://git-scm.com/) ## Setting Up the Monorepo ### 1. Create a packages directory In the root of the Laravel application, create a `packages` directory to hold the monorepo source code. ```bash theme={null} mkdir packages ``` Add `packages` to the `.gitignore` file so it is not committed to the application's repository. ``` /packages ``` ### 2. Clone the repository Fork the [lunarphp/lunar](https://github.com/lunarphp/lunar) repository on GitHub, then clone the fork into the `packages` directory. ```bash theme={null} cd packages git clone https://github.com/YOUR-USERNAME/lunar.git ``` This places the monorepo at `packages/lunar/`. ### 3. Configure Composer Back in the Laravel application's `composer.json`, add a path repository and require the monorepo package. ```json theme={null} { "repositories": [ { "type": "path", "url": "packages/*", "symlink": true } ], "minimum-stability": "dev", "require": { "lunarphp/lunarmono": "*" } } ``` The `lunarphp/lunarmono` package is the monorepo root and includes all Lunar packages: core, admin panel, payment drivers, and add-ons. The `symlink` option ensures that changes made in the `packages/lunar` directory are immediately reflected in the application without needing to run `composer update` after every edit. ### 4. Install dependencies Run Composer from the Laravel application's root directory to install the local packages. ```bash theme={null} composer update ``` Once complete, follow the standard [installation](/1.x/getting-started/setup/installation) steps to run migrations and publish assets. ## Running Tests Lunar uses [Pest](https://pestphp.com/) for testing. Tests can be run from the monorepo root at `packages/lunar/`. ```bash theme={null} cd packages/lunar vendor/bin/pest ``` To run tests for a specific package, use the `--filter` flag or point to a specific test directory. ```bash theme={null} vendor/bin/pest tests/core vendor/bin/pest tests/admin ``` All existing tests must continue to pass before submitting a pull request. ## Code Style Lunar follows the [Laravel coding style](https://laravel.com/docs/contributions#coding-style) and uses [Laravel Pint](https://laravel.com/docs/pint) for enforcement. Run Pint from the monorepo root before committing changes. ```bash theme={null} vendor/bin/pint ``` ## Monorepo Structure The monorepo contains the following packages under `packages/`: | Directory | Composer Package | Description | | --------------------- | ------------------------------ | --------------------------------------------- | | `core` | `lunarphp/core` | Core e-commerce functionality | | `admin` | `lunarphp/lunar` | Admin panel built with Filament | | `stripe` | `lunarphp/stripe` | Stripe payment driver | | `paypal` | `lunarphp/paypal` | PayPal payment driver | | `opayo` | `lunarphp/opayo` | Opayo payment driver | | `search` | `lunarphp/search` | Search with Typesense and Meilisearch support | | `meilisearch` | `lunarphp/meilisearch` | Meilisearch addon | | `table-rate-shipping` | `lunarphp/table-rate-shipping` | Table rate shipping addon | Tests for each package are located in the top-level `tests/` directory, organized by package name. ## Next Steps With local development set up, see the [Contributing](/1.x/getting-started/overview/contributing) guide for information on coding standards, submitting pull requests, and reporting bugs. # Security Source: https://docs.lunarphp.com/1.x/getting-started/overview/security Security features and best practices for Lunar-powered storefronts. Lunar is built on Laravel's security foundations and adds e-commerce-specific protections for payments, admin access, and customer data. ## Reporting Vulnerabilities If a security issue is discovered in Lunar, please reach out privately on [Discord](https://lunarphp.com/discord) or via email at [security@lunarphp.io](mailto:security@lunarphp.io) so the issue can be addressed and patched as soon as possible. Do not open public GitHub issues for security vulnerabilities. ## Securing Laravel Lunar inherits Laravel's built-in security features including CSRF protection, encryption, and hashed passwords. Before deploying to production, review these Laravel guides: * [Deployment](https://laravel.com/docs/deployment) * [Encryption](https://laravel.com/docs/encryption) * [Hashing](https://laravel.com/docs/hashing) ## Admin Panel Security The admin panel uses a dedicated `staff` authentication guard, separate from your storefront's user authentication. This means admin sessions are completely isolated from customer sessions. ### Two-Factor Authentication Lunar's admin panel supports two-factor authentication (2FA) out of the box. In production environments, 2FA can be enforced for all staff members: ```php theme={null} use Lunar\Admin\LunarPanelManager; LunarPanelManager::forceTwoFactorAuth(); ``` To disable 2FA entirely (not recommended for production): ```php theme={null} LunarPanelManager::disableTwoFactorAuth(); ``` When enabled, staff members are prompted to set up 2FA on their next login and are provided with recovery codes for account recovery. ### Role-Based Access Control Lunar uses [Spatie Laravel Permission](https://spatie.be/docs/laravel-permission) for granular access control. Permissions are scoped to the `staff` guard and support hierarchical grouping with dot notation (e.g., `settings:manage-staff`). For more details on configuring roles and permissions, see the [Access Control](/1.x/admin/extending/access-control) documentation. ### Session Protection The admin panel applies the following middleware to all routes: * **CSRF verification** prevents cross-site request forgery attacks * **Cookie encryption** ensures cookies cannot be tampered with * **Session authentication** regenerates the session ID on authentication changes, preventing session fixation attacks ## Payment Security ### PCI Compliance Lunar does not store sensitive card data. All payment processing is delegated to PCI-compliant providers such as Stripe, PayPal, and Opayo. Only non-sensitive reference data is stored locally: * Last four digits of the card number * Card type/brand * Transaction reference IDs Raw card numbers, CVVs, and full expiry dates are never stored in the database. ### Webhook Verification Payment add-ons such as Stripe automatically verify webhook signatures before processing events. This ensures that incoming webhook requests genuinely originate from the payment provider and have not been tampered with. ### 3D Secure & Strong Customer Authentication The Opayo add-on includes built-in support for 3D Secure and Strong Customer Authentication (SCA/PSD2). The Stripe add-on handles SCA through Stripe's Payment Intents API. These protocols add an additional layer of verification during checkout to reduce fraud. ## Cart & Session Security Lunar's cart session manager ties carts to the authenticated user when available, preventing cart hijacking. On logout, cart sessions are automatically cleared through the `CartSessionAuthListener`, ensuring abandoned sessions cannot be reused. These behaviors are configurable in `config/lunar/cart_session.php`: | Option | Description | | :----------------- | :------------------------------------------------------ | | `session_key` | The session key used to store the cart identifier | | `auto_create` | Whether to create a cart automatically when none exists | | `delete_on_forget` | Whether to delete cart data from the database on logout | ## Activity Logging Lunar uses [Spatie Activity Log](https://spatie.be/docs/laravel-activitylog) to maintain an audit trail of changes to core models including orders, transactions, and carts. Every change records what was modified, when, and by whom. Activity logs are viewable from the admin panel. Sensitive fields can be excluded from logging on a per-model basis to prevent unnecessary exposure. ## Securing Search Depending on the search driver in use, additional steps may be needed to protect indexed data in production. To provide a rich search experience in the admin panel, Lunar indexes several models, some of which may contain sensitive information. ### What Is Sensitive Information? Sensitive information includes any data that contains details about customers or orders, whether personally identifiable or not. This includes addresses, email addresses, names, and order details. ### Lunar's Indexes Index names are relative to the `SCOUT_PREFIX` environment variable. | Model | Index | Contains Sensitive Information | | :--------------------------- | :---------------- | :----------------------------- | | `Lunar\Models\Product` | `products` | No | | `Lunar\Models\Collection` | `collections` | No | | `Lunar\Models\ProductOption` | `product_options` | No | | `Lunar\Models\Customer` | `customers` | Yes | | `Lunar\Models\Order` | `orders` | Yes | ### Securing Meilisearch In a production environment, an API key must be set to control access to Meilisearch endpoints. It is recommended to use two API keys: one for admin tasks such as indexing documents (read/write) and one solely for reading. See: [Run Meilisearch in production](https://docs.meilisearch.com/learn/cookbooks/running_production.html) ### Securing Algolia Algolia provides many security features out of the box, along with additional steps to further lock down access. See: [Algolia Security Best Practices](https://www.algolia.com/doc/guides/security/security-best-practices/) ## Production Checklist Before deploying a Lunar-powered storefront to production, verify the following: * [ ] `APP_DEBUG` is set to `false` * [ ] `APP_ENV` is set to `production` * [ ] All payment provider API keys use production credentials, not test keys * [ ] Webhook secrets are configured for payment providers * [ ] Two-factor authentication is enabled (or enforced) for admin staff * [ ] Staff roles and permissions are configured, not all staff given admin access * [ ] Search API keys are restricted to the minimum required access * [ ] HTTPS is enforced for all traffic * [ ] The `APP_KEY` is set and kept secret # Upgrade Guide Source: https://docs.lunarphp.com/1.x/getting-started/overview/upgrade-guide Instructions for upgrading between Lunar versions, including breaking changes and migration steps. This page covers the steps required to upgrade between Lunar releases, along with any breaking changes or manual actions needed for each version. ## General Upgrade Instructions Before upgrading, back up the database and commit (or stash) any uncommitted changes. This makes it easy to roll back if something goes wrong. Update the Lunar package to the latest version: ```sh theme={null} composer update lunarphp/lunar ``` Run any new migrations: ```sh theme={null} php artisan migrate ``` After migrating, clear cached config and views to ensure the application picks up any changes: ```sh theme={null} php artisan optimize:clear ``` Pin Lunar to a specific minor version in `composer.json` (e.g., `"lunarphp/lunar": "^1.3"`) to avoid unexpected breaking changes when running `composer update`. Review the version-specific notes below for any additional steps required by the target release. *** ## 1.3 ### Low Impact The `stephenjude/filament-two-factor-authentication` package has been removed due to an issue with Fortify and no suitable release being tagged. Replace the package with the forked version provided by Lunar: ```diff theme={null} - "stephenjude/filament-two-factor-authentication": "^2.0", + "lunarphp/filament3-2fa": "^2.1.0", ``` *** ## 1.2 ### Low Impact #### Product association types ENUM Product association types have been moved to a dedicated ENUM. The model constants are now deprecated. The ENUM class can be swapped in config to allow for extending. #### Added meta fields to product options The `ProductOption` and `ProductOptionValue` models now include a `meta` field. If the application already defines custom `meta` fields on these models, a migration conflict may occur. *** ## 1.1 ### Medium Impact #### Renamed Order resource extension hook There was a typo in the extension hook. Rename `exendOrderSummaryInfolist` to `extendOrderSummaryInfolist` in any code that references it. *** ## 1.0.0 (stable release) This release introduces anonymous usage insights, sent via a deferred API call. The purpose of this addition is to provide insight into how Lunar is being used and at what capacity. No identifying information is sent or stored. This is completely optional; however, it is turned on by default. To opt out, add the following to a service provider: ```php theme={null} \Lunar\Facades\Telemetry::optOut(); ``` *** ## 1.0.0-beta.24 ### Medium Impact #### Customer `vat_no` field renamed The field on the `customers` table has been renamed to `tax_identifier`. This aligns with the new field of the same name on `addresses`, `cart_addresses`, and `order_addresses`. ### Low Impact #### Buy X Get Y discount conditions and rewards Buy X Get Y discounts can now use collections and variants as conditions, and variants as rewards. As part of this change, the `discount_purchasables` table has been renamed to `discountables` and has its own `Discountable` model. Any code referencing `discount_purchasables` directly, or the `purchasables` relation on the discount model, must be updated. *** ## 1.0.0-beta.22 ### High Impact This release removes Laravel 10 support. Projects must be upgraded to Laravel 11 or later before updating Lunar. [Laravel Shift](https://laravelshift.com/) can assist with this process. #### Lunar Panel discount interface The `LunarPanelDiscountInterface` now requires a `lunarPanelRelationManagers` method that returns an array of relation managers to show in the admin panel for the discount type. Update any custom discount types to include this method. *** ## 1.0.0-beta.21 ### High Impact #### Order reference generation changes The previous order reference generator used the format `YYYY-MM-{X}`, which had been in place since the early days of the project. This approach was not ideal for order references and could lead to anomalies when determining the next reference in the sequence. The new format uses the Order ID with leading zeros and an optional prefix: ``` // Old (assuming order #250 in April 2025) 2025-04-00250 // New (assuming order ID is 1965) 00001965 ``` The length of the reference and the prefix can be defined in the `lunar/orders.php` config file: ```php theme={null} 'reference_format' => [ /** * Optional prefix for the order reference */ 'prefix' => null, /** * STR_PAD_LEFT: 00001965 * STR_PAD_RIGHT: 19650000 * STR_PAD_BOTH: 00196500 */ 'padding_direction' => STR_PAD_LEFT, /** * 00001965 * AAAA1965 */ 'padding_character' => '0', /** * If the length specified below is smaller than the length * of the Order ID, then no padding will take place. */ 'length' => 8, ], ``` To keep using the previous reference generation logic, [copy the existing class](https://github.com/lunarphp/lunar/blob/1.0.0-beta20/packages/core/src/Base/OrderReferenceGenerator.php) into the application and update the `reference_generator` path in config. ### Medium Impact #### Two-factor authentication Staff members now have the ability to set up two-factor authentication (2FA). Currently this is opt-in; however, it can be enforced for all staff members: ```php theme={null} public function register() { \Lunar\Admin\Support\Facades\LunarPanel::enforceTwoFactorAuth()->register(); } ``` To disable 2FA entirely (the setup option will not appear): ```php theme={null} public function register() { \Lunar\Admin\Support\Facades\LunarPanel::disableTwoFactorAuth()->register(); } ``` *** ## 1.0.0-beta.1 ### High Impact #### Model extending Model extending has been completely rewritten and requires changes to any Laravel application that has previously extended Lunar models. Lunar models now implement a contract (interface) and support dependency injection across the storefront and the Lunar panel. Update how models are registered: ```php theme={null} // Old ModelManifest::register(collect([ Product::class => \App\Models\Product::class, // ... ])); // New ModelManifest::replace( \Lunar\Models\Contracts\Product::class, \App\Models\Product::class ); ``` If custom models do not extend their Lunar counterpart, they must implement the relevant contract in `Lunar\Models\Contracts`. See the [model extending](/1.x/extending/models) section for all available functionality. #### Polymorphic relationships To better support model extending, all polymorphic relationships now use an alias instead of the fully qualified class name. This allows relationships to resolve to custom models when interacting with Eloquent. There is an additional config setting in `config/lunar/database.php` where polymorph mappings can be prefixed: ```php theme={null} 'morph_prefix' => null, ``` By default this is set to `null`, so the mapping for a product would just be `product`. A migration handles this change for Lunar tables and some third-party tables; however, additional migrations may be needed for other tables or custom models. #### Shipping methods availability Shipping methods are now associated with customer groups. When using the shipping add-on, ensure that all shipping methods are associated with the correct customer groups. #### Stripe add-on The Stripe add-on now attempts to update an order's billing and shipping address based on what is stored against the Payment Intent. This is due to Stripe not always returning this information during express checkout flows. To disable this behavior, set the `lunar.stripe.sync_addresses` config value to `false`. ##### PaymentIntent storage and reference to carts/orders Previously, PaymentIntent information was stored in the Cart model's meta and then transferred to the order when created. This approach caused limitations and meant that if the cart's meta was updated elsewhere (or the intent information was removed), it could result in unrecoverable data loss. PaymentIntent data has been moved from the `payment_intent` key in meta to a dedicated `StripePaymentIntent` model. This allows more flexibility in how payment intents are handled. A `StripePaymentIntent` is associated with both a cart and an order. The stored information is now: * `intent_id` — the PaymentIntent ID provided by Stripe * `status` — the PaymentIntent status * `event_id` — if the order was placed via the webhook, this contains the event ID * `processing_at` — populated when a request to place the order is made * `processed_at` — populated with the current timestamp once the order is placed ##### Preventing overlap Previously, the job to place the order was dispatched to the queue with a 20-second delay. Now the payment type checks whether the order is already being processed and, if so, takes no further action. This prevents overlaps regardless of how they are triggered. *** ## 1.0.0-alpha.34 ### Medium Impact #### Stripe add-on The Stripe driver now checks whether an order already has a `placed_at` value. If so, no further processing takes place. Additionally, the webhook logic has been moved to the job queue with a 20-second dispatch delay. This allows storefronts to manually process a payment intent alongside the webhook without worrying about overlap. The Stripe webhook environment variable has been renamed: ```diff theme={null} - STRIPE_WEBHOOK_PAYMENT_INTENT=... + LUNAR_STRIPE_WEBHOOK_SECRET=... ``` The Stripe config that Lunar looks for in `config/services.php` has changed: ```php theme={null} 'stripe' => [ 'key' => env('STRIPE_SECRET'), 'public_key' => env('STRIPE_PK'), 'webhooks' => [ 'lunar' => env('LUNAR_STRIPE_WEBHOOK_SECRET'), ], ], ``` *** ## 1.0.0-alpha.32 ### High Impact A new `LunarUser` interface must be implemented on the application's `User` model. ```php theme={null} // ... class User extends Authenticatable implements \Lunar\Base\LunarUser { use \Lunar\Base\Traits\LunarUser; } ``` *** ## 1.0.0-alpha.31 ### High Impact Certain parts of `config/cart.php` that are specific to session-based cart interaction have been relocated to a new `config/cart_session.php` file. ```php theme={null} // Move to config/cart_session.php 'session_key' => 'lunar_cart', 'auto_create' => false, ``` Check this file for any new config values that may need to be added. *** ## 1.0.0-alpha.29 ### High Impact #### Cart calculate function no longer recalculates The `$cart->calculate()` method previously ran calculations every time it was called, regardless of whether the cart had already been calculated. Now `calculate()` only runs if cart totals do not exist. To force a recalculation, use `$cart->recalculate()`. #### Unique index for collection group handle The `handle` column on collection groups now has a unique index. If collection groups are created through the admin panel, no changes are required. ### Medium Impact #### Update custom shipping modifiers signature The `\Lunar\Base\ShippingModifier` `handle` method now correctly passes a closure as the second parameter. Update any custom shipping modifiers that extend this class: ```php theme={null} public function handle(\Lunar\Models\Cart $cart, \Closure $next) { // ... return $next($cart); } ``` *** ## 1.0.0-alpha.26 ### Medium Impact If custom classes implement the `Purchasable` interface, add the following methods: ```php theme={null} public function canBeFulfilledAtQuantity(int $quantity): bool; public function getTotalInventory(): int; ``` If checking the `ProductVariant` `purchasable` attribute, update the following check: ```php theme={null} // Old $variant->purchasable == 'backorder'; // New $variant->purchasable == 'in_stock_or_on_backorder'; ``` *** ## 1.0.0-alpha.22 ### Medium Impact Carts now use soft deletes. A cart is deleted when `CartSession::forget()` is called. To forget the session without deleting the cart, pass `delete: false`: ```php theme={null} \Lunar\Facades\CartSession::forget(delete: false); ``` *** ## 1.0.0-alpha.20 ### High Impact #### Stripe add-on facade change The Stripe add-on facade has been renamed: ```php theme={null} // Old \Lunar\Stripe\Facades\StripeFacade; // New \Lunar\Stripe\Facades\Stripe; ``` *** ## 1.0.0-alpha.x When upgrading to `1.x` from `0.x`, ensure the application is upgraded to `0.8` first. ### High Impact #### Change to Staff model namespace The Staff model has moved from `Lunar\Hub\Models\Staff` to `Lunar\Admin\Models\Staff`. Update all references in the codebase and any polymorphic relations. #### Spatie Media Library This package has been upgraded to version 11, which introduces some breaking changes. See the [Spatie Media Library upgrade guide](https://github.com/spatie/laravel-medialibrary/blob/main/UPGRADING.md) for more information. #### Media conversions The `lunar.media.conversions` configuration has been removed in favor of registering custom media definitions instead. Media definition classes allow registration of media collections, conversions, and more. See [Media Collections](/1.x/reference/media.html#media-collections) for further information. #### Product options The `position` field has been removed from the `product_options` table and is now found on the `product_product_option` pivot table. Position data is automatically adjusted when running migrations. #### Tiers renamed to price breaks The `tier` column on pricing has been renamed to `min_quantity`. Any references in code to `tiers` must be updated. ##### Price model ```php theme={null} // Old $priceModel->tier // New $priceModel->min_quantity // Old $priceModel->tiers // New $priceModel->priceBreaks ``` ##### Lunar\Base\DataTransferObjects\PricingResponse ```php theme={null} // Old public Collection $tiered, // New public Collection $priceBreaks, ``` ##### Lunar\Base\DataTransferObjects\PaymentAuthorize Two new properties have been added to the constructor for this DTO: ```php theme={null} public ?int $orderId = null, public ?string $paymentType = null ``` # Installation Source: https://docs.lunarphp.com/1.x/getting-started/setup/installation Add Lunar to a Laravel application with Composer. Lunar integrates into an existing Laravel application as a Composer package. This guide walks through the full setup process. ## Requirements * PHP >= 8.2 * Laravel 11, 12 * MySQL 8.0+ / PostgreSQL 9.4+ * `bcmath` PHP extension * `exif` PHP extension * `intl` PHP extension ## 1. Install the package ```sh theme={null} composer require lunarphp/lunar:"^1.0" -W ``` ## 2. Add the LunarUser trait Parts of the Lunar core rely on the `User` model having certain e-commerce relationships (carts, orders, customers). Add the bundled trait and interface to any model that represents users in the application. ```php theme={null} use Lunar\Base\Traits\LunarUser; use Lunar\Base\LunarUser as LunarUserInterface; // ... class User extends Authenticatable implements LunarUserInterface { use LunarUser; // ... } ``` ## 3. Register the admin panel Register the Lunar admin panel in the application's `AppServiceProvider`: ```php theme={null} use Lunar\Admin\Support\Facades\LunarPanel; class AppServiceProvider extends ServiceProvider { public function register(): void { LunarPanel::register(); } } ``` ## 4. Run the installer ```sh theme={null} php artisan lunar:install ``` The installer will guide through a series of prompts and perform the following: * Publish configuration files * Run database migrations * Create a default admin user * Seed initial data: a default channel, language, currency (USD), customer group, collection group, tax class, tax zone, and product/collection attributes * Publish Filament assets ## 5. Access the admin panel Once the installer completes, the admin panel is available at `https:///lunar`. ## Telemetry Lunar sends anonymous usage data once per day to help the maintainers understand how Lunar is used. The data does not identify a store in any way. To opt out, add the following to a service provider's `boot` method: ```php theme={null} \Lunar\Facades\Telemetry::optOut(); ``` ## Advanced installation options ### Publish configuration before installing The installer publishes configuration files automatically, but to customize settings before running `php artisan lunar:install`, publish them manually first: ```sh theme={null} php artisan vendor:publish --tag=lunar ``` The installer will detect the existing configuration and skip overwriting it. ### Table prefix Lunar prefixes all of its database tables to avoid conflicts. The default prefix is `lunar_` and can be changed in `config/lunar/database.php`: ```php theme={null} 'table_prefix' => 'lunar_', ``` ### User ID field type Lunar assumes the `User` model primary key is a `BIGINT`. If the application uses `INT` or `UUID` primary keys, update `config/lunar/database.php` before running migrations: ```php theme={null} // Supported values: 'bigint', 'int', 'uuid' 'users_id_type' => 'bigint', ``` ### Disable bundled migrations To take full control of Lunar's database migrations, disable the bundled migrations and publish them into the application: ```php theme={null} // config/lunar/database.php 'disable_migrations' => true, ``` ```sh theme={null} php artisan vendor:publish --tag=lunar.migrations ``` ## What's next? Get a head start with a pre-built Livewire or Inertia storefront. Learn how to build a storefront from scratch with step-by-step guides. Configure channels, languages, currencies, and more. # System Settings Source: https://docs.lunarphp.com/1.x/getting-started/setup/system-settings Configure languages, currencies, channels, customer groups, tax, and attributes to match regional and business requirements. After installation, Lunar creates a set of sensible defaults: English as the language, USD as the currency, a single "Webstore" channel, a "Retail" customer group, and a default tax zone. This page explains what each setting does and how to adjust the defaults to match a specific store's requirements. All of these settings can be managed through the admin panel under **Settings**, or programmatically using Eloquent models. The reference pages linked from each section below cover model fields, relationships, and code examples in detail. ## Configuration files Lunar ships with several configuration files. The installer publishes them automatically, but they can also be published manually: ```bash theme={null} php artisan vendor:publish --tag=lunar ``` The key configuration files are: | File | Purpose | | :-------------------------- | :-------------------------------------------- | | `config/lunar/database.php` | Table prefix, user ID type, migration control | | `config/lunar/taxes.php` | Tax driver selection | | `config/lunar/cart.php` | Cart behavior and pipelines | | `config/lunar/orders.php` | Order processing pipelines | | `config/lunar/media.php` | Media/image handling configuration | | `config/lunar/payments.php` | Payment driver configuration | | `config/lunar/urls.php` | URL generation settings | | `config/lunar/pricing.php` | Pricing pipeline configuration | ## Languages Languages enable translated content across Lunar models such as products, collections, and brands. Any model field stored as a `TranslatedText` attribute uses languages to determine which translations are available. The installer creates a single language: **English** (`en`). This can be changed or supplemented to support any number of languages. Each language has a `code` (a 2-character ISO 639-1 code like `en`, `fr`, or `de`) and a `name` for display purposes. Exactly one language must be marked as the default, which is used as the fallback when a translation is not available in the requested locale and determines the primary editing language in the admin panel. There should only ever be one default language. Setting more than one language as default will cause unexpected behavior. The admin panel handles this automatically when toggling the default. Translated fields are stored as JSON with language codes as keys (e.g. `{"en": "Leather boots", "fr": "Bottes en cuir"}`). Models provide helper methods like `attr()` to resolve the correct translation based on the application locale. For model fields, relationships, and code examples, see the [Languages reference](/1.x/reference/languages). ## Currencies Currencies define the monetary units available for product pricing. Each currency has an exchange rate relative to the default currency. The installer creates a single currency: **US Dollar** (`USD`) with an exchange rate of `1` and 2 decimal places. Key concepts: * **Code** should be a 3-character [ISO 4217](https://en.wikipedia.org/wiki/ISO_4217) code (e.g. `USD`, `GBP`, `EUR`). * **Exchange rate** is relative to the default currency. The default currency should always have an exchange rate of `1.0000`. For example, if USD is the default and 1 USD = 0.79 GBP, the GBP exchange rate would be `0.7900`. * **Decimal places** controls formatting precision (e.g. `2` for most currencies, `0` for JPY). * **Enabled** controls whether the currency is active. Disabled currencies can be pre-configured without being visible to customers. * **Sync prices**, when enabled on a non-default currency, allows Lunar to calculate prices automatically based on the default currency and the exchange rate. This is useful for stores that manage pricing in one currency and want approximate converted prices for others. Exchange rates do not automatically convert product prices unless price synchronization is enabled. Prices can be set independently per currency. For model fields, relationships, and code examples, see the [Currencies reference](/1.x/reference/currencies). ## Channels Channels represent different sales outlets or storefronts. They control where products, collections, and discounts are published and when they become available. The installer creates a single channel: **Webstore** (handle: `webstore`). Typical channel examples include a main webstore, a mobile app, a wholesale portal, or a marketplace integration. Each channel has a `name`, a URL-friendly `handle` (automatically slugified on save), and an optional `url`. Products and other models can be scheduled for availability on specific channels, optionally with start and end dates. This makes it possible to launch products on one channel before another, or run time-limited availability windows. Exactly one channel should be marked as the default. Channels support soft deletion, so removing a channel does not permanently destroy the record. For model fields, relationships, scheduling examples, and filtering queries, see the [Channels reference](/1.x/reference/channels). ## Customer groups Customer groups segment customers so that different groups can receive different pricing, product visibility, and discount rules. They are commonly used for B2B/B2C scenarios or loyalty tiers. The installer creates a single customer group: **Retail** (handle: `retail`). Typical customer group examples include Retail (standard consumers), Wholesale (B2B buyers with volume pricing), VIP (loyalty members), and Trade (industry-specific pricing). Customer groups can be associated with: * **Products** and **collections** to control visibility per group * **Discounts** to restrict promotions to specific groups * **Tax zones** to apply different tax rules per group * **Pricing** to define group-specific product prices Customer groups also support custom [attributes](/1.x/reference/attributes), allowing additional data to be stored against each group. ## Tax Lunar provides a flexible tax system built around three core concepts: **tax classes**, **tax zones**, and **tax rates**. These work together to calculate the correct tax for each order based on the product type and the customer's location. ### Tax classes Tax classes categorize products by their tax treatment. Different types of products may be taxed at different rates depending on the jurisdiction. The installer creates a single tax class: **Default Tax Class**. Common additional tax classes include: | Tax Class | Typical use | | :------------ | :----------------------------------------------------------------------- | | Standard | Most physical goods | | Reduced Rate | Items with a lower tax rate (e.g. children's clothing in some countries) | | Zero Rate | Items exempt from tax (e.g. certain food items) | | Digital Goods | Digital products, which may have different tax rules | Each product variant is assigned a tax class. When tax is calculated, the system looks up the rate that applies to that tax class within the relevant tax zone. ### Tax zones Tax zones define geographic regions where specific tax rates apply. A zone can be scoped by **countries**, **states**, or **postcodes**. The installer creates a single tax zone: **Default Tax Zone** (type: `country`, price display: `tax_exclusive`), with all countries assigned to it. | Zone type | Description | Typical use | | :---------- | :--------------------------------------------- | :------------------------------------- | | `country` | Matches entire countries | EU VAT, UK VAT | | `states` | Matches specific states or provinces | US state tax, Canadian provincial tax | | `postcodes` | Matches postcode patterns (supports wildcards) | City-level tax, special economic zones | Each tax zone also specifies a **price display** mode: * `tax_inclusive` — prices shown to customers include tax (common in the UK and EU) * `tax_exclusive` — prices shown to customers exclude tax (common in the US) ### Tax rates Each tax zone has one or more tax rates. A tax rate has a name, a priority (for ordering when multiple rates apply), and one or more **tax rate amounts** that define the percentage for each tax class. For example, a UK tax zone might have a single "VAT" rate with 20% for the standard tax class and 5% for a reduced-rate tax class. A US state zone might have separate "State Tax" and "City Tax" rates with different priorities. ### Tax driver The tax driver is configured in `config/lunar/taxes.php`. The default `system` driver uses the tax classes, zones, and rates described above. For complex scenarios (e.g. tax across all US states), a custom driver can integrate with external services like [TaxJar](https://www.taxjar.com/). See [Extending Taxation](/1.x/extending/taxation) for details. For model fields, relationships, and code examples, see the [Taxation reference](/1.x/reference/taxation). ## Attributes Attributes allow custom data fields to be defined for models like products, collections, brands, and customer groups. They are organized into **attribute groups** and support translatable values, validation rules, and multiple field types. The installer creates default attribute groups and system attributes for products and collections: | Attribute Group | Model | Attributes | | :-------------- | :--------- | :------------------------------------------------------------------- | | Details | Product | Name (required, translatable), Description (translatable, rich text) | | Details | Collection | Name (required, translatable), Description (translatable, rich text) | The admin panel provides a full interface for managing attribute groups and attributes under **Settings > Attributes**. From there, attribute groups can be created and reordered, and individual attributes can be configured with a specific field type, validation rules, and options like required, filterable, or searchable. Available field types include `Text`, `TranslatedText`, `Number`, `Toggle`, `Dropdown`, `ListField`, `File`, `YouTube`, and `Vimeo`. Custom field types can also be registered. For model fields, code examples, and details on adding attributes to custom models, see the [Attributes reference](/1.x/reference/attributes). ## Setting up for a specific region The examples below show how to adjust the installer defaults for common regional configurations. All of this can also be done through the admin panel. ### United Kingdom (GBP, tax-inclusive) ```php theme={null} use Lunar\Models\Country; use Lunar\Models\Currency; use Lunar\Models\TaxClass; use Lunar\Models\TaxZone; // Update the default currency to GBP $currency = Currency::default()->first(); $currency->update([ 'code' => 'GBP', 'name' => 'British Pound', 'exchange_rate' => 1.0000, 'decimal_places' => 2, ]); // Update the default tax zone for the UK (tax-inclusive) $taxZone = TaxZone::default()->first(); $taxZone->update([ 'name' => 'United Kingdom', 'zone_type' => 'country', 'price_display' => 'tax_inclusive', ]); // Clear existing countries and assign only the UK $taxZone->countries()->delete(); $uk = Country::where('iso3', 'GBR')->first(); $taxZone->countries()->create(['country_id' => $uk->id]); // Create the standard VAT rate $rate = $taxZone->taxRates()->create([ 'name' => 'VAT', 'priority' => 1, ]); $defaultTaxClass = TaxClass::default()->first(); $rate->taxRateAmounts()->create([ 'tax_class_id' => $defaultTaxClass->id, 'percentage' => 20.000, ]); ``` ### Europe (EUR, multiple countries) ```php theme={null} use Lunar\Models\Country; use Lunar\Models\Currency; use Lunar\Models\TaxClass; use Lunar\Models\TaxZone; // Update the default currency to EUR $currency = Currency::default()->first(); $currency->update([ 'code' => 'EUR', 'name' => 'Euro', 'exchange_rate' => 1.0000, 'decimal_places' => 2, ]); // Create a tax zone for Germany $deTaxZone = TaxZone::create([ 'name' => 'Germany', 'zone_type' => 'country', 'price_display' => 'tax_inclusive', 'active' => true, 'default' => true, ]); $germany = Country::where('iso3', 'DEU')->first(); $deTaxZone->countries()->create(['country_id' => $germany->id]); $rate = $deTaxZone->taxRates()->create([ 'name' => 'MwSt', 'priority' => 1, ]); $defaultTaxClass = TaxClass::default()->first(); $rate->taxRateAmounts()->create([ 'tax_class_id' => $defaultTaxClass->id, 'percentage' => 19.000, ]); ``` ### United States (USD, tax-exclusive, state-level) ```php theme={null} use Lunar\Models\TaxClass; use Lunar\Models\TaxZone; // Update the default tax zone to be tax-exclusive $taxZone = TaxZone::default()->first(); $taxZone->update([ 'name' => 'California', 'zone_type' => 'states', 'price_display' => 'tax_exclusive', ]); // Assign California $taxZone->countries()->delete(); $california = \Lunar\Models\State::where('code', 'CA')->first(); $taxZone->states()->create(['state_id' => $california->id]); // Create the state tax rate $rate = $taxZone->taxRates()->create([ 'name' => 'CA State Tax', 'priority' => 1, ]); $defaultTaxClass = TaxClass::default()->first(); $rate->taxRateAmounts()->create([ 'tax_class_id' => $defaultTaxClass->id, 'percentage' => 7.250, ]); ``` US tax is complex and varies by state, county, and city. For production stores selling across multiple US states, consider integrating with a dedicated tax service. See [Extending Taxation](/1.x/extending/taxation) for details on custom tax drivers. # Inertia + Vue Starter Kit Source: https://docs.lunarphp.com/1.x/getting-started/starter-kits/inertia-vue The Inertia + Vue Starter Kit is a production-ready storefront built on Laravel, [Inertia.js](https://inertiajs.com), and [Vue 3](https://vuejs.org). It is designed for developers who want the flexibility and interactivity of a single-page application while keeping the simplicity of Laravel’s server-side routing and controllers. This starter kit is currently in active development. Check back soon for installation instructions and release updates. Unlike the [Livewire Starter Kit](/1.x/getting-started/starter-kits/livewire), which serves as a basic reference implementation, the Inertia + Vue Starter Kit provides a fully realized front-end experience out of the box, ready to serve as the foundation for production stores. ## Features * Full product catalog with collection and search pages * Cart and multi-step checkout flows * Customer authentication, account area, and order history * Server-side rendering (SSR) for SEO and performance * Tailwind CSS styling with a clean, extensible component architecture * Example implementations for payments, shipping, and tax * Built to be customized, extended, and themed for client projects # Livewire Starter Kit Source: https://docs.lunarphp.com/1.x/getting-started/starter-kits/livewire The Livewire Starter Kit is a basic reference implementation for building a storefront with Lunar using [Laravel Livewire](https://livewire.laravel.com). It demonstrates how to load products and collections, manage a cart, and process a checkout. This starter kit is intended as a reference implementation only and is not production-ready. An [Inertia + Vue Starter Kit](/1.x/getting-started/starter-kits/inertia-vue) is currently in development and will serve as a production-ready foundation for new storefront projects. To install Lunar into an existing Laravel application instead, follow the [installation guide](/1.x/getting-started/setup/installation). ## Requirements * PHP >= 8.2 * MySQL 8.0+ / PostgreSQL 9.4+ * exif PHP extension (on most systems it will be installed by default) * intl PHP extension (on most systems it will be installed by default) * bcmath PHP extension (on most systems it will be installed by default) * GD PHP extension (used for image manipulation) ## Installation A suitable local development environment is needed to run Lunar. [Laravel Herd](https://herd.laravel.com) is recommended for this purpose. ### Create a new project ```bash theme={null} composer create-project --stability dev lunarphp/livewire-starter-kit my-store cd my-store ``` Or using the Laravel Installer: ```bash theme={null} laravel new my-store --using=lunarphp/livewire-starter-kit cd my-store ``` ### Configure the Laravel app Copy the `.env.example` file to `.env` and update the values to match the local environment. ```bash theme={null} cp .env.example .env ``` All relevant configuration files are included in the repository. ### Migrate and seed Install Lunar: ```bash theme={null} php artisan lunar:install ``` Seed the demo data: ```bash theme={null} php artisan db:seed ``` Link the storage directory: ```bash theme={null} php artisan storage:link ``` ## Access the storefront Once installation is complete: * Storefront: `http://` * Admin panel: `http:///lunar` ## Source code The full source is available on GitHub: [lunarphp/livewire-starter-kit](https://github.com/lunarphp/livewire-starter-kit). # Cart Source: https://docs.lunarphp.com/1.x/guides/cart Build a cart page with Lunar, covering line item display, quantity updates, coupon codes, and order totals. ## Overview The cart page displays the items a customer has added to their cart, lets them adjust quantities or remove items, apply coupon codes, and review totals before proceeding to checkout. This guide walks through building a cart page using Lunar's `Cart` model and `CartSession` facade. The examples below use standard Laravel controllers and Blade templates. The same concepts apply whether the storefront is built with Livewire, Inertia, or a headless API. ## Fetching the Current Cart The `CartSession` facade retrieves the cart for the current session. Calling `current()` automatically calculates totals (sub total, tax, discounts, etc.) before returning the cart. ```php theme={null} use Lunar\Facades\CartSession; $cart = CartSession::current(); ``` If no cart exists in the session, `current()` returns `null`. Handle this in the controller: ```php theme={null} use Lunar\Facades\CartSession; class CartController extends Controller { public function show() { $cart = CartSession::current(); if (! $cart || $cart->lines->isEmpty()) { return view('cart.empty'); } return view('cart.show', compact('cart')); } } ``` Cart prices are dynamically calculated and are not stored in the database. Calling `current()` triggers the calculation pipeline automatically. To force a recalculation after making changes, call `$cart->recalculate()`. ## Displaying Cart Lines Each cart line holds a reference to a `Purchasable` (typically a `ProductVariant`) along with the quantity and any custom metadata. After calculation, each line has computed properties for unit price, tax, discounts, and total. ```blade theme={null} @foreach($cart->lines as $line) @php $variant = $line->purchasable; $product = $variant->product; @endphp @endforeach
Product Price Quantity Total
{{ $product->attr('name') }}

{{ $product->attr('name') }}

@if($variant->getOptions()->isNotEmpty())

{{ $variant->getOption() }}

@endif

{{ $variant->sku }}

{{ $line->unitPrice->formatted() }}
@csrf @method('PATCH')
{{ $line->subTotal->formatted() }}
@csrf @method('DELETE')
``` ### Computed Properties on Cart Lines After `calculate()` runs, each `CartLine` has the following properties: | Property | Type | Description | | :------------------- | :---------------------- | :---------------------------------------------------------- | | `unitPrice` | `Lunar\DataTypes\Price` | Price for a single unit, excluding tax | | `unitPriceInclTax` | `Lunar\DataTypes\Price` | Price for a single unit, including tax | | `subTotal` | `Lunar\DataTypes\Price` | Line total before discounts and tax (unit price x quantity) | | `subTotalDiscounted` | `Lunar\DataTypes\Price` | Line total after discounts, before tax | | `discountTotal` | `Lunar\DataTypes\Price` | Total discount applied to this line | | `taxAmount` | `Lunar\DataTypes\Price` | Tax amount for this line | | `total` | `Lunar\DataTypes\Price` | Final line total including tax and discounts | All values are `Lunar\DataTypes\Price` objects with access to `value` (integer), `decimal()` (float), and `formatted()` (currency string). ### Accessing the Product from a Cart Line The `purchasable` relationship on a cart line is polymorphic. For standard product variants: ```php theme={null} $variant = $line->purchasable; // Lunar\Models\ProductVariant $product = $line->purchasable->product; // Lunar\Models\Product $product->attr('name'); // Product name $variant->getOption(); // e.g. "Blue, Large" $variant->sku; // e.g. "TSHIRT-BLU-L" ``` The cart's default eager loading (configured in `config/lunar/cart.php`) already loads `lines.purchasable.product`, `lines.purchasable.values`, and `lines.purchasable.product.thumbnail`, so these relationships are available without additional queries. ## Updating Quantities Use the `updateLine()` method on the cart to change a line's quantity. This triggers validation (e.g., stock checks) and recalculates the cart. ```php theme={null} class CartController extends Controller { public function updateLine(Request $request) { $request->validate([ 'line_id' => 'required|integer', 'quantity' => 'required|integer|min:1', ]); $cart = CartSession::current(); $cart->updateLine( cartLineId: $request->line_id, quantity: $request->quantity, ); return redirect()->route('cart.show'); } } ``` The full method signature accepts an optional `meta` parameter for updating custom metadata on the line: ```php theme={null} $cart->updateLine( cartLineId: $lineId, quantity: 3, meta: ['gift_wrap' => true], ); ``` ### Validation Errors When updating a line, Lunar runs validators defined in `config/lunar/cart.php`. If validation fails (for example, the requested quantity exceeds available stock), a `Lunar\Exceptions\Carts\CartException` is thrown. ```php theme={null} use Lunar\Exceptions\Carts\CartException; try { $cart->updateLine(cartLineId: $lineId, quantity: 100); } catch (CartException $e) { return redirect()->back()->withErrors(['quantity' => $e->getMessage()]); } ``` ## Removing Lines Use the `remove()` method to delete a line from the cart. ```php theme={null} class CartController extends Controller { public function removeLine(Request $request) { $request->validate([ 'line_id' => 'required|integer', ]); $cart = CartSession::current(); $cart->remove(cartLineId: $request->line_id); return redirect()->route('cart.show'); } } ``` To remove all lines at once, use `clear()`: ```php theme={null} $cart->clear(); ``` ## Coupon Codes Coupon codes are stored on the cart's `coupon_code` field. When the cart recalculates, the discount pipeline checks whether the code matches any active discount and applies it. ### Applying a Coupon ```php theme={null} class CartController extends Controller { public function applyCoupon(Request $request) { $request->validate([ 'coupon_code' => 'required|string', ]); $cart = CartSession::current(); $cart->update([ 'coupon_code' => $request->coupon_code, ]); $cart->recalculate(); if ($cart->discountTotal?->value === 0) { return redirect()->route('cart.show') ->withErrors(['coupon_code' => 'This coupon code is not valid.']); } return redirect()->route('cart.show') ->with('message', 'Coupon applied.'); } } ``` ### Removing a Coupon ```php theme={null} $cart->update(['coupon_code' => null]); $cart->recalculate(); ``` ### Displaying Applied Discounts After calculation, the cart provides a breakdown of all applied discounts: ```blade theme={null} @if($cart->discountTotal?->value > 0)

Discount: -{{ $cart->discountTotal->formatted() }}

@foreach($cart->discountBreakdown as $breakdown)

{{ $breakdown->discount->name }}: -{{ $breakdown->price->formatted() }}

@endforeach @endif ``` ## Cart Totals After calculation, the cart provides all the totals needed to build a summary. ```blade theme={null}
Subtotal
{{ $cart->subTotal->formatted() }}
@if($cart->discountTotal?->value > 0)
Discount
-{{ $cart->discountTotal->formatted() }}
@endif @if($cart->shippingTotal?->value > 0)
Shipping
{{ $cart->shippingTotal->formatted() }}
@endif
Tax
{{ $cart->taxTotal->formatted() }}
Total
{{ $cart->total->formatted() }}
``` ### Computed Properties on the Cart | Property | Type | Description | | :------------------- | :---------------------- | :-------------------------------------------------- | | `subTotal` | `Lunar\DataTypes\Price` | Sum of all line subtotals, before tax and discounts | | `subTotalDiscounted` | `Lunar\DataTypes\Price` | Subtotal after line-level discounts | | `discountTotal` | `Lunar\DataTypes\Price` | Total of all discounts applied | | `discountBreakdown` | `Collection` | Breakdown of each discount with affected lines | | `taxTotal` | `Lunar\DataTypes\Price` | Total tax across all lines and shipping | | `taxBreakdown` | `TaxBreakdown` | Breakdown of taxes by rate | | `shippingSubTotal` | `Lunar\DataTypes\Price` | Shipping cost before tax | | `shippingTaxTotal` | `Lunar\DataTypes\Price` | Tax on shipping | | `shippingTotal` | `Lunar\DataTypes\Price` | Shipping cost including tax | | `total` | `Lunar\DataTypes\Price` | Final cart total | ### Tax Breakdown To display a detailed tax breakdown (useful for stores with multiple tax rates): ```blade theme={null} @foreach($cart->taxBreakdown->amounts as $tax)

{{ $tax->description }} ({{ $tax->percentage }}%): {{ $tax->price->formatted() }}

@endforeach ``` ## Estimated Shipping Shipping costs are typically calculated during checkout once a full address is provided. However, the cart page can show an estimated shipping cost based on a partial address. ```php theme={null} $cart->getEstimatedShipping([ 'postcode' => $request->postcode, 'country_id' => $request->country_id, ], setOverride: true); $cart->recalculate(); ``` Passing `setOverride: true` tells the cart to use the returned shipping option when calculating totals for that request. The `shippingTotal` on the cart will then reflect the estimate. When using `CartSession`, set the estimation parameters once and they persist for the session: ```php theme={null} CartSession::estimateShippingUsing([ 'postcode' => $request->postcode, 'country_id' => $request->country_id, ]); $cart = CartSession::current(estimateShipping: true); ``` ## Routes ```php theme={null} use App\Http\Controllers\CartController; Route::get('/cart', [CartController::class, 'show'])->name('cart.show'); Route::patch('/cart/line', [CartController::class, 'updateLine'])->name('cart.update-line'); Route::delete('/cart/line', [CartController::class, 'removeLine'])->name('cart.remove-line'); Route::post('/cart/coupon', [CartController::class, 'applyCoupon'])->name('cart.apply-coupon'); ``` ## Putting It All Together Here is a complete controller for the cart page: ```php theme={null} lines->isEmpty()) { return view('cart.empty'); } return view('cart.show', compact('cart')); } public function updateLine(Request $request) { $request->validate([ 'line_id' => 'required|integer', 'quantity' => 'required|integer|min:1', ]); $cart = CartSession::current(); try { $cart->updateLine( cartLineId: $request->line_id, quantity: $request->quantity, ); } catch (CartException $e) { return redirect()->route('cart.show') ->withErrors(['quantity' => $e->getMessage()]); } return redirect()->route('cart.show'); } public function removeLine(Request $request) { $request->validate([ 'line_id' => 'required|integer', ]); $cart = CartSession::current(); $cart->remove(cartLineId: $request->line_id); return redirect()->route('cart.show'); } public function applyCoupon(Request $request) { $request->validate([ 'coupon_code' => 'required|string', ]); $cart = CartSession::current(); $cart->update([ 'coupon_code' => $request->coupon_code, ]); $cart->recalculate(); if ($cart->discountTotal?->value === 0) { return redirect()->route('cart.show') ->withErrors(['coupon_code' => 'This coupon code is not valid.']); } return redirect()->route('cart.show') ->with('message', 'Coupon applied.'); } } ``` ## Next Steps * Review the [Carts reference](/1.x/reference/carts) for the full list of cart and cart line fields, configuration options, and session management. * Review the [Extending Carts](/1.x/extending/carts) for customizing the cart calculation pipeline, adding validators, and overriding actions. * Review the [Product Display Page guide](/1.x/guides/product-display-page) for adding items to the cart from a product page. # Catalog Menu Source: https://docs.lunarphp.com/1.x/guides/catalog-menu Build a catalog navigation menu with Lunar, covering collection groups, hierarchical collections, active state tracking, and caching. ## Overview A catalog menu is the primary navigation element that allows customers to browse the store by category. It is built from Lunar's collection hierarchy: collection groups organize top-level navigation sections, and collections within each group form nested trees of categories and subcategories. This guide walks through querying collection groups and their nested collections, rendering a multi-level navigation menu, highlighting the active collection, and caching the result for performance. The examples below use standard Laravel controllers and Blade templates. The same concepts apply whether the storefront is built with Livewire, Inertia, or a headless API. ## How Collections Are Organized Lunar organizes collections into a two-level structure: * **Collection Groups** act as top-level containers (e.g., "Main Menu", "Footer Links", "Seasonal Promotions"). Each group has a unique `handle` for identification. * **Collections** within a group form a nested tree using parent-child relationships. A root collection has no parent, and each root can have unlimited levels of children. ## Querying the Menu Data ### Fetching a Collection Group Retrieve a collection group by its handle. The handle is set when creating the group in the admin panel or via code. ```php theme={null} use Lunar\Models\CollectionGroup; $menuGroup = CollectionGroup::where('handle', 'main-menu')->first(); ``` ### Fetching Root Collections Root collections are those with no parent. Use the nested set's `whereIsRoot()` scope to retrieve them. ```php theme={null} use Lunar\Models\Collection; $rootCollections = Collection::where('collection_group_id', $menuGroup->id) ->whereIsRoot() ->defaultOrder() ->with(['defaultUrl', 'media']) ->get(); ``` The `defaultOrder()` scope orders collections by their nested set position, which matches the order configured in the admin panel. ### Fetching Children Each collection's children can be loaded via the `children` relationship. To build a full menu tree, eager load the children (and their children) to avoid N+1 queries. ```php theme={null} $rootCollections = Collection::where('collection_group_id', $menuGroup->id) ->whereIsRoot() ->defaultOrder() ->with([ 'defaultUrl', 'children' => fn ($query) => $query->defaultOrder(), 'children.defaultUrl', 'children.children' => fn ($query) => $query->defaultOrder(), 'children.children.defaultUrl', ]) ->get(); ``` This loads three levels of the menu hierarchy. Add additional `children` levels as needed for deeper menus. Eager loading children with constrained queries (using the closure syntax) ensures each level is ordered correctly by its nested set position. ### Using `descendantsOf` for the Full Tree For menus that need the entire tree regardless of depth, the nested set provides a `descendantsOf` method. However, for navigation menus it is generally better to eager load a fixed number of levels to keep queries predictable and avoid loading unnecessary data. ```php theme={null} // Load the entire subtree (all depths) for a collection group $allCollections = Collection::where('collection_group_id', $menuGroup->id) ->defaultOrder() ->with('defaultUrl') ->get() ->toTree(); ``` The `toTree()` method on the resulting collection restructures the flat list into a nested tree, with each node's `children` relation populated. ## Building a Menu Service Encapsulate the menu query in a dedicated service class to keep controllers clean and make caching straightforward. ```php theme={null} first(); if (! $group) { return collect(); } $query = Collection::where('collection_group_id', $group->id) ->whereIsRoot() ->defaultOrder() ->with($this->buildEagerLoads($depth)); return $query->get(); } protected function buildEagerLoads(int $depth): array { $eagerLoads = ['defaultUrl']; $prefix = ''; for ($i = 1; $i < $depth; $i++) { $prefix .= ($i === 1) ? 'children' : '.children'; $eagerLoads[$prefix] = fn ($query) => $query->defaultOrder(); $eagerLoads["{$prefix}.defaultUrl"] = fn ($query) => $query; } return $eagerLoads; } } ``` Register it in a service provider or resolve it directly: ```php theme={null} use App\Services\CatalogMenuService; $menu = app(CatalogMenuService::class)->getMenu('main-menu'); ``` ## Rendering the Menu ### Sharing Menu Data with All Views Use a view composer or middleware to make the menu available to every page without passing it from each controller. ```php theme={null} with( 'catalogMenu', app(CatalogMenuService::class)->getMenu() ); }); } } ``` When using a view composer with `'*'`, the menu query runs on every request. Pair this with caching (covered below) to avoid repeated database queries. ### Basic Menu Template ```blade theme={null}
``` ### Recursive Menu for Unlimited Depth For menus with an arbitrary number of levels, use a recursive Blade partial. Create a partial at `resources/views/partials/menu-item.blade.php`: ```blade theme={null}
  • id, $activeCollectionIds)) aria-current="true" @endif > {{ $collection->attr('name') }} @if($collection->children->isNotEmpty()) @endif
  • ``` Then render the top-level menu: ```blade theme={null} ``` ## Active State Tracking Highlighting the active collection (and its ancestors) helps customers understand where they are in the catalog hierarchy. ### Determining the Active Collection In the collection controller, pass the current collection and its ancestor IDs to the view: ```php theme={null} use Lunar\Models\Collection; use Lunar\Models\Url; class CollectionController extends Controller { public function show(string $slug) { $url = Url::where('slug', $slug) ->where('element_type', (new Collection)->getMorphClass()) ->firstOrFail(); $collection = Collection::where('id', $url->element_id) ->with(['ancestors', 'media']) ->firstOrFail(); $activeCollectionIds = $collection->ancestors ->pluck('id') ->push($collection->id) ->all(); return view('collections.show', [ 'collection' => $collection, 'activeCollectionIds' => $activeCollectionIds, ]); } } ``` ### Applying Active Styles Share the active IDs with the layout so the menu partial can use them: ```blade theme={null} {{-- In the layout or menu partial --}} isset($activeCollectionIds) && in_array($collection->id, $activeCollectionIds), ]) > {{ $collection->attr('name') }} ``` This highlights both the current collection and all its parent collections in the menu, giving customers a clear visual trail. ## Caching The catalog menu changes infrequently compared to how often it is rendered. Caching prevents a database query on every page load. ### Cache the Menu Result Update the `CatalogMenuService` to cache the menu: ```php theme={null} addHour(), fn () => $this->buildMenu($handle, $depth) ); } public function clearCache(string $handle = 'main-menu'): void { Cache::forget("catalog-menu:{$handle}"); } protected function buildMenu(string $handle, int $depth): LaravelCollection { $group = CollectionGroup::where('handle', $handle)->first(); if (! $group) { return collect(); } return Collection::where('collection_group_id', $group->id) ->whereIsRoot() ->defaultOrder() ->with($this->buildEagerLoads($depth)) ->get(); } protected function buildEagerLoads(int $depth): array { $eagerLoads = ['defaultUrl']; $prefix = ''; for ($i = 1; $i < $depth; $i++) { $prefix .= ($i === 1) ? 'children' : '.children'; $eagerLoads[$prefix] = fn ($query) => $query->defaultOrder(); $eagerLoads["{$prefix}.defaultUrl"] = fn ($query) => $query; } return $eagerLoads; } } ``` ### Invalidating the Cache Clear the cache whenever collections are updated. A model observer is a clean way to handle this: ```php theme={null} menuService->clearCache(); } public function deleted(Collection $collection): void { $this->menuService->clearCache(); } } ``` Register the observer in a service provider: ```php theme={null} use App\Observers\CollectionObserver; use Lunar\Models\Collection; public function boot(): void { Collection::observe(CollectionObserver::class); } ``` ## Multiple Menus A store may need more than one navigation menu, for example a main header menu and a footer menu. Use separate collection groups for each. ```php theme={null} // Header menu $headerMenu = app(CatalogMenuService::class)->getMenu('main-menu'); // Footer menu $footerMenu = app(CatalogMenuService::class)->getMenu('footer-menu'); ``` Share both via a view composer: ```php theme={null} View::composer('*', function ($view) { $menuService = app(CatalogMenuService::class); $view->with('headerMenu', $menuService->getMenu('main-menu')); $view->with('footerMenu', $menuService->getMenu('footer-menu')); }); ``` Each menu is cached independently under its own key. ## Channel and Customer Group Filtering Collections support channel scheduling and customer group visibility. To display only collections available to the current browsing context, apply these scopes when building the menu. ```php theme={null} use Lunar\Facades\StorefrontSession; $channel = StorefrontSession::getChannel(); $customerGroups = StorefrontSession::getCustomerGroups(); $rootCollections = Collection::where('collection_group_id', $group->id) ->whereIsRoot() ->defaultOrder() ->channel($channel) ->customerGroup($customerGroups) ->with(['defaultUrl']) ->get(); ``` When filtering by channel or customer group, the cache key should include the channel and customer group IDs to prevent one customer group from seeing another group's cached menu. ```php theme={null} $cacheKey = sprintf( 'catalog-menu:%s:%s:%s', $handle, $channel->id, $customerGroups->pluck('id')->sort()->implode('-') ); ``` ## Putting It All Together Here is a complete service, view composer, and Blade template for a cached, multi-level catalog menu: ### Service ```php theme={null} addHour(), fn () => $this->buildMenu($handle, $depth) ); } public function clearCache(string $handle = 'main-menu'): void { Cache::forget("catalog-menu:{$handle}"); } protected function buildMenu(string $handle, int $depth): LaravelCollection { $group = CollectionGroup::where('handle', $handle)->first(); if (! $group) { return collect(); } return Collection::where('collection_group_id', $group->id) ->whereIsRoot() ->defaultOrder() ->with($this->buildEagerLoads($depth)) ->get(); } protected function buildEagerLoads(int $depth): array { $eagerLoads = ['defaultUrl']; $prefix = ''; for ($i = 1; $i < $depth; $i++) { $prefix .= ($i === 1) ? 'children' : '.children'; $eagerLoads[$prefix] = fn ($query) => $query->defaultOrder(); $eagerLoads["{$prefix}.defaultUrl"] = fn ($query) => $query; } return $eagerLoads; } } ``` ### View Composer ```php theme={null} with( 'catalogMenu', app(CatalogMenuService::class)->getMenu() ); }); } } ``` ### Blade Template ```blade theme={null} {{-- resources/views/partials/catalog-menu.blade.php --}} ``` ## Next Steps * Review the [Collections reference](/1.x/reference/collections) for the full list of model fields, relationships, and sort options. * Review the [Product Listing Page guide](/1.x/guides/product-listing-page) for displaying products when a customer clicks a collection in the menu. * Review the [URLs reference](/1.x/reference/urls) for URL generation and slug management. * Review the [Channels reference](/1.x/reference/channels) for multi-channel configuration. # Checkout Source: https://docs.lunarphp.com/1.x/guides/checkout Build a checkout flow with Lunar, covering address collection, shipping options, order creation, and payment processing. ## Overview The checkout flow takes a customer from their cart through to a placed order. This typically involves collecting billing and shipping addresses, selecting a shipping method, reviewing the order, and processing payment. This guide walks through building a checkout using Lunar's `Cart` model, `CartSession` facade, and `Payments` facade. The examples below use standard Laravel controllers and Blade templates. The same concepts apply whether the storefront is built with Livewire, Inertia, or a headless API. ## Checkout Flow A typical checkout follows these steps: 1. **Collect addresses** — billing address (always required) and shipping address (required for shippable carts) 2. **Select shipping** — choose a shipping method from the available options 3. **Review order** — show the customer a summary before they commit 4. **Process payment** — authorize the payment and place the order Lunar does not enforce a specific page structure. These steps can be spread across multiple pages, combined into a single page, or presented as an accordion. The underlying API calls are the same regardless of layout. ## Collecting Addresses Before an order can be created, the cart needs at least a billing address. If the cart contains shippable items, a shipping address is also required. ### Setting the Billing Address ```php theme={null} use Lunar\Facades\CartSession; $cart = CartSession::current(); $cart->setBillingAddress([ 'country_id' => $request->country_id, 'first_name' => $request->first_name, 'last_name' => $request->last_name, 'company_name' => $request->company_name, 'line_one' => $request->line_one, 'line_two' => $request->line_two, 'line_three' => $request->line_three, 'city' => $request->city, 'state' => $request->state, 'postcode' => $request->postcode, 'contact_email' => $request->contact_email, 'contact_phone' => $request->contact_phone, ]); ``` ### Setting the Shipping Address ```php theme={null} $cart->setShippingAddress([ 'country_id' => $request->country_id, 'first_name' => $request->first_name, 'last_name' => $request->last_name, 'company_name' => $request->company_name, 'line_one' => $request->line_one, 'line_two' => $request->line_two, 'line_three' => $request->line_three, 'city' => $request->city, 'state' => $request->state, 'postcode' => $request->postcode, 'delivery_instructions' => $request->delivery_instructions, 'contact_email' => $request->contact_email, 'contact_phone' => $request->contact_phone, ]); ``` Both methods accept either an associative array or an object implementing the `Lunar\Base\Addressable` interface. When called, any existing address of that type on the cart is replaced. ### Required Address Fields When creating an order, Lunar validates that the following fields are present on the billing address (and shipping address, if applicable): | Field | Description | | :----------- | :----------------------------------------- | | `country_id` | Foreign key to the `lunar_countries` table | | `first_name` | Customer's first name | | `line_one` | First line of the address | | `city` | City or town | | `postcode` | Postal or ZIP code | ### Using the Same Address for Both A common pattern is to let the customer check a box to copy their billing address to the shipping address: ```php theme={null} $cart->setBillingAddress($request->billing); if ($request->boolean('ship_to_billing')) { $cart->setShippingAddress($request->billing); } else { $cart->setShippingAddress($request->shipping); } ``` ### Populating the Country Select Lunar ships with a `Country` model that can be used to populate a country dropdown: ```php theme={null} use Lunar\Models\Country; $countries = Country::orderBy('name')->get(); ``` ```blade theme={null} ``` ## Shipping Options Once a shipping address is set, the available shipping methods can be retrieved using the `ShippingManifest` facade. Shipping options are configured by extending Lunar's shipping system (see [Extending Shipping](/1.x/extending/shipping)). ### Fetching Available Options ```php theme={null} use Lunar\Facades\ShippingManifest; $cart = CartSession::current(); $shippingOptions = ShippingManifest::getOptions($cart); ``` This returns a collection of `Lunar\DataTypes\ShippingOption` objects. Each option has the following properties: | Property | Type | Description | | :------------ | :---------------------- | :---------------------------------------------------------------- | | `name` | `string` | Display name for the option (e.g., "Standard Delivery") | | `description` | `string` | A longer description of the option | | `identifier` | `string` | Unique identifier used to select this option | | `price` | `Lunar\DataTypes\Price` | The cost of this shipping method | | `collect` | `bool` | Whether this is a collection/pickup option rather than a delivery | ```blade theme={null} @foreach($shippingOptions as $option) @endforeach ``` ### Setting the Shipping Option After the customer selects a shipping method, apply it to the cart. Use the `ShippingManifest` facade to retrieve the full `ShippingOption` object by its identifier: ```php theme={null} use Lunar\DataTypes\ShippingOption; use Lunar\Facades\ShippingManifest; $cart = CartSession::current(); $shippingOption = ShippingManifest::getOption($cart, $request->shipping_option); $cart->setShippingOption($shippingOption); ``` `setShippingOption()` automatically recalculates the cart. After it completes, the `shippingSubTotal`, `shippingTaxTotal`, and `shippingTotal` properties on the cart reflect the selected shipping option. ## Order Review Before placing the order, display a summary so the customer can verify their selections. At this point the cart has addresses, a shipping option (if applicable), and calculated totals. ```php theme={null} $cart = CartSession::current(); ``` ```blade theme={null}

    Order Summary

    Items

    @foreach($cart->lines as $line)

    {{ $line->purchasable->product->attr('name') }}

    Qty: {{ $line->quantity }}

    {{ $line->total->formatted() }}

    @endforeach

    Shipping Address

    @if($cart->shippingAddress)

    {{ $cart->shippingAddress->first_name }} {{ $cart->shippingAddress->last_name }}

    {{ $cart->shippingAddress->line_one }}

    {{ $cart->shippingAddress->city }}, {{ $cart->shippingAddress->postcode }}

    @endif

    Billing Address

    @if($cart->billingAddress)

    {{ $cart->billingAddress->first_name }} {{ $cart->billingAddress->last_name }}

    {{ $cart->billingAddress->line_one }}

    {{ $cart->billingAddress->city }}, {{ $cart->billingAddress->postcode }}

    @endif

    Totals

    Subtotal
    {{ $cart->subTotal->formatted() }}
    @if($cart->discountTotal?->value > 0)
    Discount
    -{{ $cart->discountTotal->formatted() }}
    @endif @if($cart->shippingTotal?->value > 0)
    Shipping
    {{ $cart->shippingTotal->formatted() }}
    @endif
    Tax
    {{ $cart->taxTotal->formatted() }}
    Total
    {{ $cart->total->formatted() }}
    ``` ## Creating the Order Once the customer is ready to proceed, create the order from the cart. This validates the cart, copies all data (lines, addresses, totals) to a new `Lunar\Models\Order`, and returns it as a draft (with `placed_at` set to `null`). ```php theme={null} use Lunar\Exceptions\Carts\CartException; use Lunar\Facades\CartSession; try { $order = CartSession::createOrder(); } catch (CartException $e) { return redirect()->back()->withErrors(['checkout' => $e->getMessage()]); } ``` `CartSession::createOrder()` removes the cart from the session by default. To keep the cart in the session (for example, to allow the customer to return to their cart if payment fails), pass `false`: `CartSession::createOrder(forget: false)`. ### What Happens During Order Creation When `createOrder()` is called, Lunar runs through the following steps: 1. Recalculates the cart to ensure totals are up to date 2. Validates the cart (billing address, shipping address, and shipping option) 3. Creates the order with financial data, currency, and channel from the cart 4. Copies cart lines to order lines 5. Copies cart addresses to order addresses 6. Creates a shipping line on the order (if a shipping option is set) 7. Maps any discount breakdowns to the order ### Handling Validation Errors If validation fails, a `Lunar\Exceptions\Carts\CartException` is thrown. The exception contains a `MessageBag` accessible via the `errors()` method, and its `getMessage()` returns a human-readable summary. ```php theme={null} use Lunar\Exceptions\Carts\CartException; try { $order = CartSession::createOrder(); } catch (CartException $e) { return redirect()->route('checkout.show') ->withErrors(['checkout' => $e->getMessage()]); } ``` For more detail, access the underlying `MessageBag`: ```php theme={null} try { $order = CartSession::createOrder(); } catch (CartException $e) { // $e->errors() returns a MessageBag with the validation failures return redirect()->route('checkout.show') ->withErrors($e->errors()); } ``` ### Checking if the Cart Can Create an Order To check whether the cart is ready without throwing exceptions, use `canCreateOrder()`: ```php theme={null} $cart = CartSession::current(); if (! $cart->canCreateOrder()) { // Show a message or redirect back } ``` ## Processing Payment After the order is created, process the payment using the `Payments` facade. Lunar uses a driver-based approach, so the same API works regardless of the payment provider. ### Basic Payment Flow ```php theme={null} use Lunar\Facades\Payments; $payment = Payments::driver('card') ->cart($cart) ->withData([ 'payment_token' => $request->payment_token, ]) ->authorize(); if ($payment->success) { return redirect()->route('checkout.complete', [ 'order' => $payment->orderId, ]); } return redirect()->back()->withErrors([ 'payment' => $payment->message ?? 'Payment could not be processed.', ]); ``` The `authorize()` method returns a `Lunar\Base\DataTransferObjects\PaymentAuthorize` object with the following properties: | Property | Type | Description | | :------------ | :-------- | :--------------------------------- | | `success` | `bool` | Whether the payment was authorized | | `message` | `?string` | An error or status message | | `orderId` | `?int` | The ID of the placed order | | `paymentType` | `?string` | The type/driver of the payment | An order is only considered "placed" when its `placed_at` column has a datetime value. The payment driver is responsible for setting this. A draft order (where `placed_at` is `null`) has not been placed, even if an order record exists. ### Offline Payments For manual or offline payments (cash on delivery, bank transfer, etc.), use the built-in `offline` driver: ```php theme={null} $payment = Payments::driver('cash-in-hand') ->cart($cart) ->authorize(); ``` The offline driver automatically creates a draft order (if one does not exist), sets `placed_at`, and updates the order status to the configured "authorized" status. ### Configuring Payment Types Payment types are configured in `config/lunar/payments.php`: ```php theme={null} return [ 'default' => env('PAYMENTS_TYPE', 'offline'), 'types' => [ 'cash-in-hand' => [ 'driver' => 'offline', 'authorized' => 'payment-offline', ], 'card' => [ 'driver' => 'stripe', 'released' => 'payment-received', ], ], ]; ``` See the [Payments reference](/1.x/reference/payments) for details on configuring drivers, and the [Extending Payments](/1.x/extending/payments) guide for building custom payment drivers. ## Order Confirmation After a successful payment, redirect the customer to a confirmation page. The order is now placed and contains all the data needed for a receipt. ```php theme={null} use Lunar\Models\Order; class CheckoutController extends Controller { public function complete(Order $order) { if ($order->isDraft()) { abort(404); } $order->load([ 'lines.purchasable.product', 'addresses', 'transactions', ]); return view('checkout.complete', compact('order')); } } ``` ```blade theme={null}

    Order Confirmed

    Thank you for your order!

    Order Reference: {{ $order->reference }}

    Items

    @foreach($order->productLines as $line)

    {{ $line->description }}

    Qty: {{ $line->quantity }}

    {{ $line->total->formatted() }}

    @endforeach @if($order->shippingLines->isNotEmpty())

    Shipping

    @foreach($order->shippingLines as $line)

    {{ $line->description }}: {{ $line->total->formatted() }}

    @endforeach @endif

    Totals

    Subtotal
    {{ $order->sub_total->formatted() }}
    @if($order->discount_total->value > 0)
    Discount
    -{{ $order->discount_total->formatted() }}
    @endif @if($order->shipping_total->value > 0)
    Shipping
    {{ $order->shipping_total->formatted() }}
    @endif
    Tax
    {{ $order->tax_total->formatted() }}
    Total
    {{ $order->total->formatted() }}
    ``` ## Cart Fingerprinting Lunar provides a fingerprint mechanism to detect when cart contents change between checkout steps. This is useful for verifying that the cart has not been modified (in another tab, for example) between the time the customer reviewed their order and the time they submitted payment. ```php theme={null} // Generate a fingerprint when showing the review page $fingerprint = $cart->fingerprint(); ``` ```blade theme={null} ``` ```php theme={null} // Verify the fingerprint before processing payment use Lunar\Exceptions\FingerprintMismatchException; try { $cart->checkFingerprint($request->fingerprint); } catch (FingerprintMismatchException $e) { return redirect()->route('checkout.review') ->withErrors(['cart' => 'Your cart has changed. Please review your order again.']); } ``` ## Routes ```php theme={null} use App\Http\Controllers\CheckoutController; Route::get('/checkout', [CheckoutController::class, 'show'])->name('checkout.show'); Route::post('/checkout/addresses', [CheckoutController::class, 'saveAddresses'])->name('checkout.addresses'); Route::post('/checkout/shipping', [CheckoutController::class, 'saveShipping'])->name('checkout.shipping'); Route::post('/checkout/payment', [CheckoutController::class, 'payment'])->name('checkout.payment'); Route::get('/checkout/complete/{order}', [CheckoutController::class, 'complete'])->name('checkout.complete'); ``` ## Putting It All Together Here is a complete controller covering the checkout flow: ```php theme={null} lines->isEmpty()) { return redirect()->route('cart.show'); } $countries = Country::orderBy('name')->get(); $shippingOptions = $cart->shippingAddress ? ShippingManifest::getOptions($cart) : collect(); return view('checkout.show', [ 'cart' => $cart, 'countries' => $countries, 'shippingOptions' => $shippingOptions, ]); } public function saveAddresses(Request $request) { $request->validate([ 'billing.country_id' => 'required|exists:lunar_countries,id', 'billing.first_name' => 'required|string', 'billing.last_name' => 'required|string', 'billing.line_one' => 'required|string', 'billing.city' => 'required|string', 'billing.postcode' => 'required|string', 'billing.contact_email' => 'required|email', 'shipping.country_id' => 'required_unless:ship_to_billing,true|exists:lunar_countries,id', 'shipping.first_name' => 'required_unless:ship_to_billing,true|string', 'shipping.last_name' => 'required_unless:ship_to_billing,true|string', 'shipping.line_one' => 'required_unless:ship_to_billing,true|string', 'shipping.city' => 'required_unless:ship_to_billing,true|string', 'shipping.postcode' => 'required_unless:ship_to_billing,true|string', ]); $cart = CartSession::current(); $cart->setBillingAddress($request->billing); $cart->setShippingAddress( $request->boolean('ship_to_billing') ? $request->billing : $request->shipping ); return redirect()->route('checkout.show'); } public function saveShipping(Request $request) { $request->validate([ 'shipping_option' => 'required|string', ]); $cart = CartSession::current(); $shippingOption = ShippingManifest::getOption($cart, $request->shipping_option); if (! $shippingOption) { return redirect()->back() ->withErrors(['shipping_option' => 'The selected shipping option is not available.']); } $cart->setShippingOption($shippingOption); return redirect()->route('checkout.show'); } public function payment(Request $request) { $cart = CartSession::current(); try { $cart->checkFingerprint($request->fingerprint); } catch (FingerprintMismatchException $e) { return redirect()->route('checkout.show') ->withErrors(['cart' => 'Your cart has changed. Please review your order again.']); } try { $order = CartSession::createOrder(forget: false); } catch (CartException $e) { return redirect()->route('checkout.show') ->withErrors(['checkout' => $e->getMessage()]); } $payment = Payments::driver($request->input('payment_type', 'cash-in-hand')) ->cart($cart) ->withData($request->only('payment_token')) ->authorize(); if (! $payment->success) { return redirect()->route('checkout.show') ->withErrors(['payment' => $payment->message ?? 'Payment could not be processed.']); } CartSession::forget(); return redirect()->route('checkout.complete', [ 'order' => $payment->orderId ?? $order->id, ]); } public function complete(Order $order) { if ($order->isDraft()) { abort(404); } $order->load([ 'lines.purchasable.product', 'addresses', ]); return view('checkout.complete', compact('order')); } } ``` ## Next Steps * Review the [Payments reference](/1.x/reference/payments) for payment driver configuration, transactions, and refunds. * Review the [Extending Shipping](/1.x/extending/shipping) guide for adding custom shipping options. * Review the [Extending Payments](/1.x/extending/payments) guide for building a custom payment driver. * Review the [Extending Orders](/1.x/extending/orders) guide for customizing order creation and adding post-creation behavior. * Review the [Cart guide](/1.x/guides/cart) for building the cart page that precedes checkout. # Customer Addresses Source: https://docs.lunarphp.com/1.x/guides/customer-addresses Build a customer address management page with Lunar, covering address listing, creation, editing, deletion, and default address selection. ## Overview The customer addresses page allows authenticated customers to manage their saved addresses. These addresses can be selected during checkout to speed up the purchasing process. This guide walks through listing, creating, editing, and deleting addresses, as well as setting default shipping and billing addresses. The examples below use standard Laravel controllers and Blade templates. The same concepts apply whether the storefront is built with Livewire, Inertia, or a headless API. ## How Addresses Work Each `Lunar\Models\Address` belongs to a `Lunar\Models\Customer`. A customer can have any number of saved addresses, and each address can be flagged as the default for shipping, billing, or both. During checkout, saved addresses can be used to pre-fill the address forms. Addresses stored on a customer are separate from order addresses. When an order is created, the cart's addresses are copied to the order as `Lunar\Models\OrderAddress` records, creating an immutable snapshot. ## Resolving the Current Customer Like all account pages, the addresses page requires an authenticated user with a linked customer. Use the `StorefrontSession` facade to resolve the current customer. ```php theme={null} use Lunar\Facades\StorefrontSession; $customer = StorefrontSession::getCustomer(); ``` The `StorefrontSession` automatically resolves the customer from the authenticated user when the `LunarUser` trait is applied to the `User` model. See the [Storefront Session reference](/1.x/storefront-utils/storefront-session) for details on how customer resolution works. ## Listing Addresses Query the customer's addresses using the `addresses` relationship. Eager load the `country` relationship to display the country name. ```php theme={null} use Lunar\Facades\StorefrontSession; class AddressController extends Controller { public function index() { $customer = StorefrontSession::getCustomer(); if (! $customer) { abort(404); } $addresses = $customer->addresses() ->with('country') ->orderBy('shipping_default', 'desc') ->orderBy('created_at', 'desc') ->get(); return view('account.addresses.index', compact('addresses')); } } ``` ### Displaying the Address List ```blade theme={null}

    Your Addresses

    Add New Address @if($addresses->isEmpty())

    No saved addresses.

    @else @foreach($addresses as $address)

    {{ $address->first_name }} {{ $address->last_name }} @if($address->shipping_default) Default Shipping @endif @if($address->billing_default) Default Billing @endif

    @if($address->company_name)

    {{ $address->company_name }}

    @endif

    {{ $address->line_one }}

    @if($address->line_two)

    {{ $address->line_two }}

    @endif

    {{ $address->city }}, {{ $address->state }} {{ $address->postcode }}

    {{ $address->country?->name }}

    Edit
    @csrf @method('DELETE')
    @endforeach @endif ``` ## Creating an Address ### The Create Form ```php theme={null} use Lunar\Models\Country; class AddressController extends Controller { public function create() { $countries = Country::orderBy('name')->get(); return view('account.addresses.create', compact('countries')); } } ``` ```blade theme={null}

    Add Address

    @csrf
    ``` ### Storing the Address When setting a new default address, clear the default flag from all other addresses first to ensure only one address is the default at a time. ```php theme={null} use Illuminate\Http\Request; use Lunar\Facades\StorefrontSession; class AddressController extends Controller { public function store(Request $request) { $customer = StorefrontSession::getCustomer(); if (! $customer) { abort(404); } $validated = $request->validate([ 'first_name' => 'required|string|max:255', 'last_name' => 'required|string|max:255', 'company_name' => 'nullable|string|max:255', 'line_one' => 'required|string|max:255', 'line_two' => 'nullable|string|max:255', 'city' => 'required|string|max:255', 'state' => 'nullable|string|max:255', 'postcode' => 'required|string|max:255', 'country_id' => 'required|exists:lunar_countries,id', 'contact_phone' => 'nullable|string|max:255', 'contact_email' => 'nullable|email|max:255', 'shipping_default' => 'nullable|boolean', 'billing_default' => 'nullable|boolean', ]); if ($request->boolean('shipping_default')) { $customer->addresses()->update(['shipping_default' => false]); } if ($request->boolean('billing_default')) { $customer->addresses()->update(['billing_default' => false]); } $customer->addresses()->create([ ...$validated, 'shipping_default' => $request->boolean('shipping_default'), 'billing_default' => $request->boolean('billing_default'), ]); return redirect()->route('account.addresses') ->with('message', 'Address added.'); } } ``` ## Editing an Address ### The Edit Form ```php theme={null} use Lunar\Models\Address; use Lunar\Models\Country; use Lunar\Facades\StorefrontSession; class AddressController extends Controller { public function edit(Address $address) { $customer = StorefrontSession::getCustomer(); if (! $customer || $address->customer_id !== $customer->id) { abort(404); } $countries = Country::orderBy('name')->get(); return view('account.addresses.edit', compact('address', 'countries')); } } ``` The edit form is identical in structure to the create form, with fields pre-populated from the existing address: ```blade theme={null} ``` ### Updating the Address ```php theme={null} use Illuminate\Http\Request; use Lunar\Models\Address; use Lunar\Facades\StorefrontSession; class AddressController extends Controller { public function update(Request $request, Address $address) { $customer = StorefrontSession::getCustomer(); if (! $customer || $address->customer_id !== $customer->id) { abort(404); } $validated = $request->validate([ 'first_name' => 'required|string|max:255', 'last_name' => 'required|string|max:255', 'company_name' => 'nullable|string|max:255', 'line_one' => 'required|string|max:255', 'line_two' => 'nullable|string|max:255', 'city' => 'required|string|max:255', 'state' => 'nullable|string|max:255', 'postcode' => 'required|string|max:255', 'country_id' => 'required|exists:lunar_countries,id', 'contact_phone' => 'nullable|string|max:255', 'contact_email' => 'nullable|email|max:255', 'shipping_default' => 'nullable|boolean', 'billing_default' => 'nullable|boolean', ]); if ($request->boolean('shipping_default')) { $customer->addresses()->where('id', '!=', $address->id) ->update(['shipping_default' => false]); } if ($request->boolean('billing_default')) { $customer->addresses()->where('id', '!=', $address->id) ->update(['billing_default' => false]); } $address->update([ ...$validated, 'shipping_default' => $request->boolean('shipping_default'), 'billing_default' => $request->boolean('billing_default'), ]); return redirect()->route('account.addresses') ->with('message', 'Address updated.'); } } ``` Always verify that the address belongs to the current customer before allowing edits or deletions. Without this check, a customer could modify another customer's address by manipulating the URL. ## Deleting an Address ```php theme={null} use Lunar\Models\Address; use Lunar\Facades\StorefrontSession; class AddressController extends Controller { public function destroy(Address $address) { $customer = StorefrontSession::getCustomer(); if (! $customer || $address->customer_id !== $customer->id) { abort(404); } $address->delete(); return redirect()->route('account.addresses') ->with('message', 'Address deleted.'); } } ``` ## Using Saved Addresses at Checkout Saved addresses can be loaded during checkout to let customers select from their existing addresses instead of entering a new one. ```php theme={null} use Lunar\Facades\CartSession; use Lunar\Facades\StorefrontSession; $customer = StorefrontSession::getCustomer(); $addresses = $customer?->addresses() ->with('country') ->get(); ``` When a customer selects a saved address, pass it to the cart. The `Address` model implements the `Addressable` interface, so it can be passed directly to `setBillingAddress()` and `setShippingAddress()`: ```php theme={null} use Lunar\Facades\CartSession; use Lunar\Models\Address; $cart = CartSession::current(); $address = Address::find($request->address_id); $cart->setBillingAddress($address); $cart->setShippingAddress($address); ``` When using a saved address at checkout, Lunar copies the address data to the cart. Changes to the saved address after checkout do not affect existing orders. ## Address Fields Reference The `Lunar\Models\Address` model has the following fields: | Field | Type | Description | | :---------------------- | :--------------------- | :------------------------------------------- | | `id` | `bigIncrements` | Primary key | | `customer_id` | `foreignId` `nullable` | The owning customer | | `country_id` | `foreignId` `nullable` | Reference to `lunar_countries` | | `title` | `string` `nullable` | Label for the address (e.g., "Home", "Work") | | `first_name` | `string` | | | `last_name` | `string` | | | `company_name` | `string` `nullable` | | | `tax_identifier` | `string` `nullable` | VAT or tax ID | | `line_one` | `string` | | | `line_two` | `string` `nullable` | | | `line_three` | `string` `nullable` | | | `city` | `string` | | | `state` | `string` `nullable` | | | `postcode` | `string` `nullable` | | | `delivery_instructions` | `string` `nullable` | | | `contact_email` | `string` `nullable` | | | `contact_phone` | `string` `nullable` | | | `meta` | `json` `nullable` | Flexible metadata | | `shipping_default` | `boolean` | Default shipping address | | `billing_default` | `boolean` | Default billing address | | `created_at` | `timestamp` | | | `updated_at` | `timestamp` | | ## Routes ```php theme={null} use App\Http\Controllers\AddressController; Route::middleware('auth')->group(function () { Route::get('/account/addresses', [AddressController::class, 'index'])->name('account.addresses'); Route::get('/account/addresses/create', [AddressController::class, 'create'])->name('account.addresses.create'); Route::post('/account/addresses', [AddressController::class, 'store'])->name('account.addresses.store'); Route::get('/account/addresses/{address}/edit', [AddressController::class, 'edit'])->name('account.addresses.edit'); Route::put('/account/addresses/{address}', [AddressController::class, 'update'])->name('account.addresses.update'); Route::delete('/account/addresses/{address}', [AddressController::class, 'destroy'])->name('account.addresses.destroy'); }); ``` ## Putting It All Together Here is a complete controller for address management: ```php theme={null} addresses() ->with('country') ->orderBy('shipping_default', 'desc') ->orderBy('created_at', 'desc') ->get(); return view('account.addresses.index', compact('addresses')); } public function create() { $countries = Country::orderBy('name')->get(); return view('account.addresses.create', compact('countries')); } public function store(Request $request) { $customer = StorefrontSession::getCustomer(); if (! $customer) { abort(404); } $validated = $request->validate([ 'first_name' => 'required|string|max:255', 'last_name' => 'required|string|max:255', 'company_name' => 'nullable|string|max:255', 'line_one' => 'required|string|max:255', 'line_two' => 'nullable|string|max:255', 'city' => 'required|string|max:255', 'state' => 'nullable|string|max:255', 'postcode' => 'required|string|max:255', 'country_id' => 'required|exists:lunar_countries,id', 'contact_phone' => 'nullable|string|max:255', 'contact_email' => 'nullable|email|max:255', 'shipping_default' => 'nullable|boolean', 'billing_default' => 'nullable|boolean', ]); if ($request->boolean('shipping_default')) { $customer->addresses()->update(['shipping_default' => false]); } if ($request->boolean('billing_default')) { $customer->addresses()->update(['billing_default' => false]); } $customer->addresses()->create([ ...$validated, 'shipping_default' => $request->boolean('shipping_default'), 'billing_default' => $request->boolean('billing_default'), ]); return redirect()->route('account.addresses') ->with('message', 'Address added.'); } public function edit(Address $address) { $customer = StorefrontSession::getCustomer(); if (! $customer || $address->customer_id !== $customer->id) { abort(404); } $countries = Country::orderBy('name')->get(); return view('account.addresses.edit', compact('address', 'countries')); } public function update(Request $request, Address $address) { $customer = StorefrontSession::getCustomer(); if (! $customer || $address->customer_id !== $customer->id) { abort(404); } $validated = $request->validate([ 'first_name' => 'required|string|max:255', 'last_name' => 'required|string|max:255', 'company_name' => 'nullable|string|max:255', 'line_one' => 'required|string|max:255', 'line_two' => 'nullable|string|max:255', 'city' => 'required|string|max:255', 'state' => 'nullable|string|max:255', 'postcode' => 'required|string|max:255', 'country_id' => 'required|exists:lunar_countries,id', 'contact_phone' => 'nullable|string|max:255', 'contact_email' => 'nullable|email|max:255', 'shipping_default' => 'nullable|boolean', 'billing_default' => 'nullable|boolean', ]); if ($request->boolean('shipping_default')) { $customer->addresses()->where('id', '!=', $address->id) ->update(['shipping_default' => false]); } if ($request->boolean('billing_default')) { $customer->addresses()->where('id', '!=', $address->id) ->update(['billing_default' => false]); } $address->update([ ...$validated, 'shipping_default' => $request->boolean('shipping_default'), 'billing_default' => $request->boolean('billing_default'), ]); return redirect()->route('account.addresses') ->with('message', 'Address updated.'); } public function destroy(Address $address) { $customer = StorefrontSession::getCustomer(); if (! $customer || $address->customer_id !== $customer->id) { abort(404); } $address->delete(); return redirect()->route('account.addresses') ->with('message', 'Address deleted.'); } } ``` ## Next Steps * Review the [Addresses reference](/1.x/reference/addresses) for the full list of address fields and the Country model. * Review the [Customers reference](/1.x/reference/customers) for customer model details and the user-customer relationship. * Review the [Checkout guide](/1.x/guides/checkout) for how addresses are used during the checkout flow. * Review the [Order History guide](/1.x/guides/order-history) for building an order history page. # Customer Authentication Source: https://docs.lunarphp.com/1.x/guides/customer-authentication Link Laravel users to Lunar customers, manage sessions, and handle cart association on login and logout. ## Overview Lunar separates authentication (handled by Laravel) from customer data (stored in Lunar's `Customer` model). The two are linked through the `LunarUser` trait, which adds customer relationships to the application's `User` model. This guide walks through setting up the connection, managing the storefront session, and handling cart persistence across login and logout. The examples below use standard Laravel controllers and Blade templates. The same concepts apply whether the storefront is built with Livewire, Inertia, or a headless API. ## Setting Up the User Model Add the `LunarUser` trait to the application's `User` model. This adds relationships to customers, carts, and orders. ```php theme={null} latestCustomer(); // Returns the most recently created Customer, or null ``` A user can be associated with multiple customers. This supports scenarios like a sales representative managing multiple accounts. For most storefronts, each user has a single customer record. ## Creating a Customer on Registration When a new user registers, create a corresponding `Lunar\Models\Customer` record and link the two together. ### Registration Controller ```php theme={null} validate([ 'first_name' => 'required|string|max:255', 'last_name' => 'required|string|max:255', 'email' => 'required|email|unique:users,email', 'password' => 'required|string|min:8|confirmed', ]); $user = User::create([ 'name' => $request->first_name . ' ' . $request->last_name, 'email' => $request->email, 'password' => Hash::make($request->password), ]); $customer = Customer::create([ 'first_name' => $request->first_name, 'last_name' => $request->last_name, ]); $customer->users()->attach($user); Auth::login($user); return redirect()->route('home'); } } ``` ### Adding to an Existing Registration Flow If the application already has registration (for example, via Laravel Breeze or Fortify), add customer creation using a listener on the `Registered` event: ```php theme={null} user; $customer = Customer::create([ 'first_name' => $user->name, 'last_name' => '', ]); $customer->users()->attach($user); } } ``` Register the listener in `EventServiceProvider` or using the `Event` facade: ```php theme={null} use App\Listeners\CreateCustomerForUser; use Illuminate\Auth\Events\Registered; Event::listen(Registered::class, CreateCustomerForUser::class); ``` ## The Storefront Session The `StorefrontSession` facade manages the current customer, channel, currency, and customer groups for the session. It initializes automatically and resolves the customer from the authenticated user. ```php theme={null} use Lunar\Facades\StorefrontSession; // Get the current customer (null if guest) $customer = StorefrontSession::getCustomer(); // Get the current channel $channel = StorefrontSession::getChannel(); // Get the current currency $currency = StorefrontSession::getCurrency(); // Get the current customer groups $customerGroups = StorefrontSession::getCustomerGroups(); ``` ### How Customer Resolution Works When the `StorefrontSession` initializes, it resolves the current customer using this logic: 1. Check the session for a previously stored customer ID 2. If none found and a user is authenticated (with the `LunarUser` trait), call `$user->latestCustomer()` to find the most recent customer 3. Store the resolved customer ID in the session for subsequent requests ### Setting the Customer Manually In some cases, the customer needs to be set explicitly, for example when a user has multiple customer accounts: ```php theme={null} use Lunar\Facades\StorefrontSession; use Lunar\Models\Customer; $customer = Customer::find($request->customer_id); StorefrontSession::setCustomer($customer); ``` When a user is authenticated, `setCustomer()` validates that the customer belongs to the user (via the `customer_user` pivot table). If the customer does not belong to the user, a `Lunar\Exceptions\CustomerNotBelongsToUserException` is thrown. ### Changing Channel or Currency ```php theme={null} use Lunar\Facades\StorefrontSession; use Lunar\Models\Channel; use Lunar\Models\Currency; // Switch channel $channel = Channel::where('handle', 'wholesale')->firstOrFail(); StorefrontSession::setChannel($channel); // Switch currency $currency = Currency::where('code', 'EUR')->firstOrFail(); StorefrontSession::setCurrency($currency); ``` ## Cart Behavior on Login and Logout Lunar automatically handles cart persistence when users log in and out through the `CartSessionAuthListener`. This listener is registered by Lunar's service provider and responds to Laravel's `Login` and `Logout` authentication events. ### What Happens on Login When a user logs in, the listener follows this logic: 1. If a guest cart exists in the session (no `user_id`), it is associated with the user. Depending on the configured policy, the guest cart items are either **merged** with the user's existing cart or **override** it. 2. If no guest cart exists, the listener looks for the user's most recent active cart and restores it to the session. ### What Happens on Logout When a user logs out, the cart session is cleared. The cart remains in the database and will be restored on the next login. ### Cart Association Policy The association policy is configured in `config/lunar/cart.php`: ```php theme={null} return [ 'auth_policy' => 'merge', // 'merge' or 'override' ]; ``` | Policy | Behavior | | :--------- | :---------------------------------------------------------- | | `merge` | Guest cart items are combined with the user's existing cart | | `override` | The guest cart replaces the user's existing cart | The `merge` policy is the default and recommended for most storefronts. It ensures customers do not lose items they added while browsing as a guest. ## Customer Account Page Build an account dashboard that displays the customer's profile, linked addresses, and recent orders. ### Account Controller ```php theme={null} route('login'); } $customer->load([ 'addresses.country', 'orders' => fn ($query) => $query->whereNotNull('placed_at') ->latest('placed_at') ->limit(5), ]); return view('account.show', compact('customer')); } } ``` ### Displaying Customer Details ```blade theme={null}

    My Account

    Profile

    {{ $customer->fullName }}

    @if($customer->company_name)

    {{ $customer->company_name }}

    @endif

    Recent Orders

    @forelse($customer->orders as $order)

    {{ $order->reference }}

    {{ $order->placed_at->format('M d, Y') }}

    {{ $order->total->formatted() }}

    @empty

    No orders yet.

    @endforelse

    Addresses

    @forelse($customer->addresses as $address)

    {{ $address->first_name }} {{ $address->last_name }}

    {{ $address->line_one }}

    {{ $address->city }}, {{ $address->postcode }}

    {{ $address->country?->name }}

    @if($address->shipping_default) Default Shipping @endif @if($address->billing_default) Default Billing @endif
    @empty

    No saved addresses.

    @endforelse ``` ## Updating Customer Profile ```php theme={null} validate([ 'first_name' => 'required|string|max:255', 'last_name' => 'required|string|max:255', 'company_name' => 'nullable|string|max:255', ]); $customer = StorefrontSession::getCustomer(); $customer->update([ 'first_name' => $request->first_name, 'last_name' => $request->last_name, 'company_name' => $request->company_name, ]); return redirect()->route('account.show') ->with('message', 'Profile updated.'); } } ``` ## Checking Authentication in Views Use the `StorefrontSession` to conditionally display content based on whether a customer is linked: ```blade theme={null} @php $customer = \Lunar\Facades\StorefrontSession::getCustomer(); @endphp @if($customer) Hi, {{ $customer->first_name }} @else Sign In Register @endif ``` ## Routes ```php theme={null} use App\Http\Controllers\AccountController; use App\Http\Controllers\Auth\RegisterController; // Registration Route::get('/register', [RegisterController::class, 'create'])->name('register'); Route::post('/register', [RegisterController::class, 'store']); // Account (requires authentication) Route::middleware('auth')->group(function () { Route::get('/account', [AccountController::class, 'show'])->name('account.show'); Route::patch('/account', [AccountController::class, 'update'])->name('account.update'); }); ``` ## Putting It All Together Here is a complete registration controller and account controller: ```php theme={null} validate([ 'first_name' => 'required|string|max:255', 'last_name' => 'required|string|max:255', 'email' => 'required|email|unique:users,email', 'password' => 'required|string|min:8|confirmed', ]); $user = User::create([ 'name' => $request->first_name . ' ' . $request->last_name, 'email' => $request->email, 'password' => Hash::make($request->password), ]); $customer = Customer::create([ 'first_name' => $request->first_name, 'last_name' => $request->last_name, ]); $customer->users()->attach($user); Auth::login($user); return redirect()->route('home'); } } ``` ```php theme={null} route('login'); } $customer->load([ 'addresses.country', 'orders' => fn ($query) => $query->whereNotNull('placed_at') ->latest('placed_at') ->limit(5), ]); return view('account.show', compact('customer')); } public function update(Request $request) { $request->validate([ 'first_name' => 'required|string|max:255', 'last_name' => 'required|string|max:255', 'company_name' => 'nullable|string|max:255', ]); $customer = StorefrontSession::getCustomer(); $customer->update([ 'first_name' => $request->first_name, 'last_name' => $request->last_name, 'company_name' => $request->company_name, ]); return redirect()->route('account.show') ->with('message', 'Profile updated.'); } } ``` ## Next Steps * Review the [Customers reference](/1.x/reference/customers) for the full list of customer model fields, relationships, and scopes. * Review the [Storefront Session reference](/1.x/storefront-utils/storefront-session) for all session management methods. * Review the [Customer Addresses guide](/1.x/guides/customer-addresses) for managing saved addresses. * Review the [Order History guide](/1.x/guides/order-history) for displaying past orders. * Review the [Cart guide](/1.x/guides/cart) for details on how cart calculation and session management work. # Order History Source: https://docs.lunarphp.com/1.x/guides/order-history Build an order history page with Lunar, covering customer authentication, order listing, pagination, and order detail views. ## Overview The order history page allows authenticated customers to view their past orders, check order statuses, and review the details of individual orders. This guide walks through resolving the current customer, querying their orders, displaying a paginated order list, and building an order detail page. The examples below use standard Laravel controllers and Blade templates. The same concepts apply whether the storefront is built with Livewire, Inertia, or a headless API. ## Resolving the Current Customer Lunar separates the concept of a `Customer` from Laravel's `User` model. An authenticated user can be linked to one or more customers through the `LunarUser` trait. The `StorefrontSession` facade provides the current customer for the session. ```php theme={null} use Lunar\Facades\StorefrontSession; $customer = StorefrontSession::getCustomer(); ``` If no customer is associated with the current session, `getCustomer()` returns `null`. Protect account pages with middleware that checks for an authenticated user and a valid customer: ```php theme={null} use Lunar\Facades\StorefrontSession; class AccountController extends Controller { public function orders() { $customer = StorefrontSession::getCustomer(); if (! $customer) { abort(404); } // ... } } ``` The `StorefrontSession` automatically resolves the customer from the authenticated user when the `LunarUser` trait is applied to the `User` model. See the [Storefront Session reference](/1.x/storefront-utils/storefront-session) for details on how customer resolution works. ## Listing Orders Query the customer's orders using the `orders` relationship. Only orders with a `placed_at` value should be shown, as draft orders (where `placed_at` is `null`) have not been completed. ```php theme={null} use Lunar\Facades\StorefrontSession; class AccountController extends Controller { public function orders() { $customer = StorefrontSession::getCustomer(); if (! $customer) { abort(404); } $orders = $customer->orders() ->whereNotNull('placed_at') ->orderBy('placed_at', 'desc') ->paginate(10); return view('account.orders', compact('orders')); } } ``` ### Displaying the Order List Each order has its financial totals cast to `Lunar\DataTypes\Price` objects, so `formatted()` can be called directly. ```blade theme={null}

    Order History

    @if($orders->isEmpty())

    No orders have been placed yet.

    @else @foreach($orders as $order) @endforeach
    Order Date Status Total
    {{ $order->reference }} {{ $order->placed_at->format('M d, Y') }} {{ $order->status }} {{ $order->total->formatted() }} View
    {{ $orders->links() }} @endif ``` ### Order Financial Properties After retrieval, each `Lunar\Models\Order` has the following price properties: | Property | Type | Description | | :--------------- | :---------------------- | :-------------------------------- | | `sub_total` | `Lunar\DataTypes\Price` | Subtotal before discounts and tax | | `discount_total` | `Lunar\DataTypes\Price` | Total discounts applied | | `shipping_total` | `Lunar\DataTypes\Price` | Shipping cost including tax | | `tax_total` | `Lunar\DataTypes\Price` | Total tax amount | | `total` | `Lunar\DataTypes\Price` | Final order total | All values are `Lunar\DataTypes\Price` objects with access to `value` (integer in minor units), `decimal()` (float), and `formatted()` (currency string). ## Order Detail Page The order detail page shows the full breakdown of a specific order, including line items, addresses, and totals. ```php theme={null} use Lunar\Facades\StorefrontSession; use Lunar\Models\Order; class AccountController extends Controller { public function showOrder(Order $order) { $customer = StorefrontSession::getCustomer(); if (! $customer || $order->customer_id !== $customer->id) { abort(404); } if ($order->isDraft()) { abort(404); } $order->load([ 'productLines.purchasable.product', 'shippingAddress.country', 'billingAddress.country', 'shippingLines', ]); return view('account.orders.show', compact('order')); } } ``` Always verify that the order belongs to the current customer. Without this check, a customer could view another customer's order by guessing the URL. ### Displaying Order Lines Order lines hold a snapshot of each purchased item. The `description` field contains the product name at the time of purchase, and `option` holds any variant options. ```blade theme={null}

    Order {{ $order->reference }}

    Placed on {{ $order->placed_at->format('M d, Y \a\t g:i A') }}

    Status: {{ $order->status }}

    Items

    @foreach($order->productLines as $line) @endforeach
    Product Price Quantity Total

    {{ $line->description }}

    @if($line->option)

    {{ $line->option }}

    @endif

    {{ $line->identifier }}

    {{ $line->unit_price->formatted() }} {{ $line->quantity }} {{ $line->total->formatted() }}
    ``` ### Displaying Addresses Each order stores a billing address and (for shippable orders) a shipping address. These are snapshots taken at the time of order creation and are separate from the customer's saved addresses. ```blade theme={null}

    Shipping Address

    @if($order->shippingAddress)

    {{ $order->shippingAddress->first_name }} {{ $order->shippingAddress->last_name }}

    @if($order->shippingAddress->company_name)

    {{ $order->shippingAddress->company_name }}

    @endif

    {{ $order->shippingAddress->line_one }}

    @if($order->shippingAddress->line_two)

    {{ $order->shippingAddress->line_two }}

    @endif

    {{ $order->shippingAddress->city }}, {{ $order->shippingAddress->state }} {{ $order->shippingAddress->postcode }}

    {{ $order->shippingAddress->country?->name }}

    @endif

    Billing Address

    @if($order->billingAddress)

    {{ $order->billingAddress->first_name }} {{ $order->billingAddress->last_name }}

    @if($order->billingAddress->company_name)

    {{ $order->billingAddress->company_name }}

    @endif

    {{ $order->billingAddress->line_one }}

    @if($order->billingAddress->line_two)

    {{ $order->billingAddress->line_two }}

    @endif

    {{ $order->billingAddress->city }}, {{ $order->billingAddress->state }} {{ $order->billingAddress->postcode }}

    {{ $order->billingAddress->country?->name }}

    @endif
    ``` ### Displaying Order Totals ```blade theme={null}

    Order Summary

    Subtotal
    {{ $order->sub_total->formatted() }}
    @if($order->discount_total->value > 0)
    Discount
    -{{ $order->discount_total->formatted() }}
    @endif @if($order->shippingLines->isNotEmpty())
    Shipping
    {{ $order->shipping_total->formatted() }}
    @endif
    Tax
    {{ $order->tax_total->formatted() }}
    Total
    {{ $order->total->formatted() }}
    ``` ### Tax Breakdown To display a detailed tax breakdown: ```blade theme={null} @foreach($order->tax_breakdown->amounts as $tax)

    {{ $tax->description }} ({{ $tax->percentage }}%): {{ $tax->price->formatted() }}

    @endforeach ``` ## Eager Loading for Performance When displaying an order list, eager load the relationships needed for the list view to avoid N+1 queries: ```php theme={null} $orders = $customer->orders() ->whereNotNull('placed_at') ->orderBy('placed_at', 'desc') ->with('currency') ->paginate(10); ``` For the detail page, load everything needed in a single query: ```php theme={null} $order->load([ 'productLines.purchasable.product', 'shippingAddress.country', 'billingAddress.country', 'shippingLines', ]); ``` ## Routes ```php theme={null} use App\Http\Controllers\AccountController; Route::middleware('auth')->group(function () { Route::get('/account/orders', [AccountController::class, 'orders'])->name('account.orders'); Route::get('/account/orders/{order}', [AccountController::class, 'showOrder'])->name('account.orders.show'); }); ``` ## Putting It All Together Here is a complete controller for the order history pages: ```php theme={null} orders() ->whereNotNull('placed_at') ->orderBy('placed_at', 'desc') ->paginate(10); return view('account.orders', compact('orders')); } public function showOrder(Order $order) { $customer = StorefrontSession::getCustomer(); if (! $customer || $order->customer_id !== $customer->id) { abort(404); } if ($order->isDraft()) { abort(404); } $order->load([ 'productLines.purchasable.product', 'shippingAddress.country', 'billingAddress.country', 'shippingLines', ]); return view('account.orders.show', compact('order')); } } ``` ## Next Steps * Review the [Orders reference](/1.x/reference/orders) for the full list of order fields, relationships, and status configuration. * Review the [Customers reference](/1.x/reference/customers) for customer model details and the user-customer relationship. * Review the [Storefront Session reference](/1.x/storefront-utils/storefront-session) for how customer resolution works. * Review the [Customer Addresses guide](/1.x/guides/customer-addresses) for building an address management page. # Payment Integration Source: https://docs.lunarphp.com/1.x/guides/payment-integration Integrate payment processing with Lunar using Stripe, covering payment intents, frontend elements, webhooks, and the complete payment lifecycle. ## Overview Lunar uses a driver-based payment system through the `Payments` facade. This guide focuses on integrating Stripe as the payment provider, covering the complete flow from creating a payment intent to handling webhooks. The same high-level patterns apply to other payment drivers. The examples below use standard Laravel controllers and Blade templates. The same concepts apply whether the storefront is built with Livewire, Inertia, or a headless API. ## Payment Architecture Lunar's payment system has three layers: 1. **Payment Manager** (`Lunar\Facades\Payments`) — routes payment calls to the correct driver based on configuration 2. **Payment Type** (e.g., `Lunar\Stripe\StripePaymentType`) — handles authorization, capture, and refund logic for a specific provider 3. **Transactions** (`Lunar\Models\Transaction`) — records of every payment event stored against the order Payment types are configured in `config/lunar/payments.php`: ```php theme={null} return [ 'default' => env('PAYMENTS_TYPE', 'offline'), 'types' => [ 'cash-in-hand' => [ 'driver' => 'offline', 'authorized' => 'payment-offline', ], 'card' => [ 'driver' => 'stripe', 'released' => 'payment-received', ], ], ]; ``` Each type maps a name (used in `Payments::driver()`) to a driver class and the order statuses to assign on success. ## Setting Up Stripe ### Installation ```bash theme={null} composer require lunarphp/stripe ``` ### Configuration Publish the configuration file: ```bash theme={null} php artisan vendor:publish --tag=lunar.stripe.config ``` Set the Stripe keys in the application's `.env` file: ```bash theme={null} STRIPE_SECRET=sk_test_... STRIPE_PUBLIC=pk_test_... ``` ### Stripe Configuration Options The published config file (`config/lunar/stripe.php`) contains: ```php theme={null} return [ 'webhook_path' => 'stripe/webhook', 'policy' => 'automatic', 'sync_addresses' => true, 'status_mapping' => [ // Maps Stripe PaymentIntent statuses to Lunar order statuses ], ]; ``` | Option | Description | | :--------------- | :------------------------------------------------------------------------------------- | | `webhook_path` | The URL path where Stripe sends webhook events | | `policy` | `automatic` captures payment immediately; `manual` authorizes first and captures later | | `sync_addresses` | When `true`, billing and shipping addresses from Stripe are synced to the order | | `status_mapping` | Maps Stripe `PaymentIntent` statuses to Lunar order statuses | ### Register the Payment Driver In a service provider (e.g., `AppServiceProvider`), register the Stripe driver: ```php theme={null} use Lunar\Facades\Payments; use Lunar\Stripe\StripePaymentType; public function boot(): void { Payments::extend('stripe', function ($app) { return $app->make(StripePaymentType::class); }); } ``` Update the payments configuration to use the Stripe driver: ```php theme={null} // config/lunar/payments.php return [ 'default' => 'card', 'types' => [ 'card' => [ 'driver' => 'stripe', 'released' => 'payment-received', ], ], ]; ``` ## Payment Flow The Stripe integration follows this lifecycle: 1. **Create a PaymentIntent** — when the customer reaches checkout, a Stripe PaymentIntent is created for the cart total 2. **Collect payment details** — the frontend renders Stripe's Payment Element to collect card details 3. **Confirm the payment** — Stripe.js confirms the payment on the client side and redirects back 4. **Authorize in Lunar** — the return handler calls `Payments::authorize()` to complete the order 5. **Webhook backup** — Stripe sends webhook events to handle edge cases (browser closed, network issues) ## Creating a Payment Intent Use the `Stripe` facade to create or fetch a PaymentIntent for the cart. The intent amount is automatically calculated from the cart total. ```php theme={null} use Lunar\Facades\CartSession; use Lunar\Stripe\Facades\Stripe; $cart = CartSession::current(); $intent = Stripe::fetchOrCreateIntent($cart); $clientSecret = $intent->client_secret; ``` `fetchOrCreateIntent()` checks whether an active intent already exists for the cart. If one exists, it syncs the amount and returns it. If none exists, it creates a new one. The PaymentIntent is linked to the cart through the `lunar_stripe_payment_intents` table. This table tracks the intent ID, cart ID, status, and processing state. ### Syncing the Intent If the cart total changes (for example, after applying a coupon or changing the shipping method), sync the intent to update the amount: ```php theme={null} Stripe::syncIntent($cart); ``` This updates the existing PaymentIntent on Stripe to match the current cart total. ## Frontend Integration Stripe's [Payment Element](https://stripe.com/docs/payments/payment-element) renders a prebuilt UI for collecting payment details. It supports cards, wallets, bank transfers, and other payment methods automatically. ### Including Stripe.js Add the Stripe.js script to the checkout layout: ```html theme={null} ``` ### Rendering the Payment Element ```blade theme={null}

    ``` Setting `billingDetails: 'never'` on the Payment Element prevents Stripe from showing its own billing address fields, since the address is already collected during checkout and passed in `confirmParams`. ### What Happens After Confirmation After `confirmPayment()` succeeds, Stripe redirects the customer to the `return_url` with query parameters including `payment_intent` and `payment_intent_client_secret`. The callback handler uses these to authorize the payment in Lunar. ## Handling the Payment Callback When Stripe redirects back to the storefront, authorize the payment through the `Payments` facade: ```php theme={null} route('cart.show'); } $payment = Payments::driver('card') ->cart($cart) ->withData([ 'payment_intent' => $request->get('payment_intent'), ]) ->authorize(); if (! $payment->success) { return redirect()->route('checkout.show') ->withErrors(['payment' => $payment->message ?? 'Payment could not be processed.']); } CartSession::forget(); return redirect()->route('checkout.complete', [ 'order' => $payment->orderId, ]); } } ``` The `authorize()` method returns a `PaymentAuthorize` object: | Property | Type | Description | | :------------ | :-------- | :--------------------------------- | | `success` | `bool` | Whether the payment was authorized | | `message` | `?string` | Error or status message | | `orderId` | `?int` | The ID of the placed order | | `paymentType` | `?string` | The payment driver used | ### What Happens During Authorization When `authorize()` is called on the Stripe payment type: 1. Retrieves the `StripePaymentIntent` record linked to the cart 2. Fetches the current status from Stripe's API 3. Creates an order from the cart (if one does not already exist) 4. If the policy is `automatic` and the status is `requires_capture`, captures the payment immediately 5. Updates the order status based on Stripe's status mapping 6. Sets `placed_at` on the order when the payment succeeds 7. Stores charge details (card brand, last four digits) as transaction records ## Webhooks Stripe sends webhook events for payment status changes. These handle edge cases where the customer's browser closes before the callback completes, or when 3D Secure authentication completes asynchronously. ### Webhook Route The Stripe package registers a webhook route automatically at the path configured in `config/lunar/stripe.php` (default: `stripe/webhook`). Configure the webhook endpoint in the Stripe dashboard: ``` https://your-store.com/stripe/webhook ``` Set the webhook signing secret in `.env`: ```bash theme={null} STRIPE_WEBHOOK_SECRET=whsec_... ``` ### How Webhooks Are Processed When a webhook event arrives: 1. The signature is verified against the webhook secret 2. A `ProcessStripeWebhook` job is dispatched (with a short delay to allow the callback to complete first) 3. The job fetches the PaymentIntent from Stripe and updates the order status accordingly This ensures orders are placed even if the customer never returns to the callback URL. During local development, use the [Stripe CLI](https://stripe.com/docs/stripe-cli) to forward webhook events to the local server: `stripe listen --forward-to localhost:8000/stripe/webhook`. ## Capture and Refund ### Manual Capture When using the `manual` capture policy, payments are authorized but not captured immediately. This is useful for stores that want to review orders before charging the customer. ```php theme={null} use Lunar\Facades\Payments; use Lunar\Models\Order; $order = Order::find($orderId); $transaction = $order->transactions()->where('type', 'intent')->first(); $result = Payments::driver('card') ->order($order) ->capture($transaction, $amount); if ($result->success) { // Payment captured } ``` The `$amount` parameter is in the currency's smallest unit (e.g., cents). Pass `0` to capture the full amount. ### Refunds Issue a full or partial refund through the payment driver: ```php theme={null} use Lunar\Facades\Payments; use Lunar\Models\Order; $order = Order::find($orderId); $transaction = $order->transactions()->where('type', 'capture')->first(); $result = Payments::driver('card') ->order($order) ->refund($transaction, amount: 1500, notes: 'Customer return'); if ($result->success) { // Refund processed, transaction record created } ``` A new transaction record with type `refund` is created automatically. ## Transactions Every payment event is recorded as a `Lunar\Models\Transaction` on the order. Transactions provide a complete audit trail. | Field | Type | Description | | :------------ | :--------------------- | :------------------------------------------ | | `id` | `bigint` | Primary key | | `order_id` | `foreignId` | The associated order | | `success` | `boolean` | Whether the transaction succeeded | | `type` | `string` | `intent`, `capture`, or `refund` | | `driver` | `string` | The payment driver (e.g., `stripe`) | | `amount` | `integer` | Amount in the currency's smallest unit | | `reference` | `string` | Provider reference (e.g., Stripe charge ID) | | `status` | `string` | Provider-specific status | | `card_type` | `string` `nullable` | Card brand (e.g., `visa`, `mastercard`) | | `last_four` | `string` `nullable` | Last four digits of the card | | `captured_at` | `timestamp` `nullable` | When the payment was captured | | `meta` | `json` `nullable` | Additional provider data | | `created_at` | `timestamp` | | | `updated_at` | `timestamp` | | ### Displaying Transaction History ```blade theme={null}

    Payments

    @foreach($order->transactions as $transaction)

    {{ ucfirst($transaction->type) }} — {{ $transaction->success ? 'Successful' : 'Failed' }}

    {{ \Lunar\DataTypes\Price::from($transaction->amount, $order->currency)->formatted() }}

    @if($transaction->card_type)

    {{ ucfirst($transaction->card_type) }} ending {{ $transaction->last_four }}

    @endif

    {{ $transaction->created_at->format('M d, Y H:i') }}

    @endforeach ``` ## Offline Payments For payment methods that do not require online processing (cash on delivery, bank transfer, purchase orders), use the built-in `offline` driver: ```php theme={null} $payment = Payments::driver('cash-in-hand') ->cart($cart) ->authorize(); ``` The offline driver creates the order, sets `placed_at`, and updates the status to the configured `authorized` status. No external API calls are made. ## Routes ```php theme={null} use App\Http\Controllers\CheckoutController; Route::get('/checkout', [CheckoutController::class, 'show'])->name('checkout.show'); Route::post('/checkout/payment', [CheckoutController::class, 'payment'])->name('checkout.payment'); Route::get('/checkout/callback', [CheckoutController::class, 'callback'])->name('checkout.callback'); Route::get('/checkout/complete/{order}', [CheckoutController::class, 'complete'])->name('checkout.complete'); ``` ## Putting It All Together Here is a complete checkout controller covering the Stripe payment flow: ```php theme={null} lines->isEmpty()) { return redirect()->route('cart.show'); } $countries = Country::orderBy('name')->get(); $shippingOptions = $cart->shippingAddress ? ShippingManifest::getOptions($cart) : collect(); $clientSecret = null; if ($cart->billingAddress) { $intent = Stripe::fetchOrCreateIntent($cart); $clientSecret = $intent->client_secret; } return view('checkout.show', [ 'cart' => $cart, 'countries' => $countries, 'shippingOptions' => $shippingOptions, 'clientSecret' => $clientSecret, ]); } public function callback(Request $request) { $cart = CartSession::current(); if (! $cart) { return redirect()->route('cart.show'); } $payment = Payments::driver('card') ->cart($cart) ->withData([ 'payment_intent' => $request->get('payment_intent'), ]) ->authorize(); if (! $payment->success) { return redirect()->route('checkout.show') ->withErrors(['payment' => $payment->message ?? 'Payment could not be processed.']); } CartSession::forget(); return redirect()->route('checkout.complete', [ 'order' => $payment->orderId, ]); } public function complete(Order $order) { if ($order->isDraft()) { abort(404); } $order->load([ 'lines.purchasable.product', 'addresses', 'transactions', ]); return view('checkout.complete', compact('order')); } } ``` ## Next Steps * Review the [Stripe add-on documentation](/1.x/addons/payments/stripe) for advanced configuration options. * Review the [Payments reference](/1.x/reference/payments) for the full payment API and transaction model. * Review the [Extending Payments](/1.x/extending/payments) guide for building a custom payment driver. * Review the [Checkout guide](/1.x/guides/checkout) for the complete checkout flow including address collection and shipping. # Product Display Page Source: https://docs.lunarphp.com/1.x/guides/product-display-page Build a product display page (PDP) with Lunar, covering product resolution, variant selection, pricing, images, and add-to-cart functionality. ## Overview A Product Display Page (PDP) is the page where customers view a single product, select options like size or color, see pricing and images, and add items to their cart. This guide walks through building a PDP using Lunar's models and facades. The examples below use standard Laravel controllers and Blade templates. The same concepts apply whether the storefront is built with Livewire, Inertia, or a headless API. ## Resolving a Product from a URL Lunar's `Url` model provides slug-based lookups for products, removing the need to expose database IDs in URLs. ### Define the Route ```php theme={null} use App\Http\Controllers\ProductController; Route::get('/products/{slug}', [ProductController::class, 'show'])->name('products.show'); ``` ### Resolve the Product ```php theme={null} use Lunar\Facades\StorefrontSession; use Lunar\Models\Product; use Lunar\Models\Url; class ProductController extends Controller { public function show(string $slug) { $url = Url::where('slug', $slug) ->where('element_type', (new Product)->getMorphClass()) ->firstOrFail(); $channel = StorefrontSession::getChannel(); $customerGroups = StorefrontSession::getCustomerGroups(); $product = Product::where('id', $url->element_id) ->where('status', 'published') ->channel($channel) ->customerGroup($customerGroups) ->with([ 'variants.prices.currency', 'variants.values.option', 'variants.images', 'media', 'brand', 'productOptions.values', ]) ->firstOrFail(); return view('products.show', compact('product')); } } ``` Eager loading relationships in a single query avoids N+1 performance issues. The `with()` call above loads everything needed to render the full PDP. The `channel()` and `customerGroup()` scopes ensure that a product not published to the current channel or customer group returns a 404 rather than rendering. See the [Storefront Session reference](/1.x/storefront-utils/storefront-session) for how to manage these values. ## Displaying Product Information ### Product Name and Description Product names and descriptions are stored as attribute data. Use the `attr()` method to retrieve translated values. ```php theme={null} $product->attr('name'); // Returns the name for the current locale $product->attr('description'); // Returns the description for the current locale ``` In a Blade template: ```blade theme={null}

    {{ $product->attr('name') }}

    {!! $product->attr('description') !!}
    @if($product->brand)

    {{ $product->brand->name }}

    @endif ``` ### Custom Attributes Any custom attributes defined on the product type are accessible in the same way. For example, if the product type includes a "Material" attribute: ```php theme={null} $product->attr('material'); // e.g. "Premium Leather" ``` ## Product Images Lunar uses [Spatie MediaLibrary](https://spatie.be/docs/laravel-medialibrary) for image management. Products store images in the `images` media collection. ```blade theme={null} {{-- Primary / thumbnail image --}} {{ $product->attr('name') }} {{-- All product images --}} ``` The available conversion sizes (`large`, `medium`, `zoom`, etc.) depend on the media definitions configured in `config/lunar/media.php`. See the [Media reference](/1.x/reference/media) for details on customizing conversions. ## Variant Selection Most products have at least one variant. When a product offers multiple variants (different sizes, colors, etc.), the storefront needs to let customers select the variant they want. ### Understanding the Data Structure Each variant is associated with one or more `ProductOptionValue` records. For example, a T-shirt might have: * **Product Options:** Color, Size * **Product Option Values:** Blue, Red (for Color); S, M, L (for Size) * **Variants:** Blue/S, Blue/M, Blue/L, Red/S, Red/M, Red/L ### Building an Option Selector ```blade theme={null} @foreach($product->productOptions as $option)
    @endforeach ``` ### Mapping Option Values to Variants To determine which variant corresponds to a given set of option selections, build a lookup map and pass it to the frontend. ```php theme={null} $variantOptionMap = $product->variants->map(function ($variant) { return [ 'id' => $variant->id, 'option_value_ids' => $variant->values->pluck('id')->sort()->values()->all(), 'sku' => $variant->sku, 'stock' => $variant->stock, 'purchasable' => $variant->purchasable, ]; }); ``` Pass this as JSON to JavaScript for client-side variant resolution: ```blade theme={null} ``` ### Single-Variant Products When a product has only one variant, option selectors are unnecessary. Handle this in the template: ```blade theme={null} @if($product->variants->count() > 1) {{-- Show option selectors --}} @endif ``` ## Pricing Pricing in Lunar is resolved through the `Pricing` facade, which accounts for the current currency, customer group, and quantity. ### Displaying the Price The `Pricing` facade accepts an optional currency and customer group. Pass these from `StorefrontSession` so the returned price matches the browsing context. ```php theme={null} use Lunar\Facades\Pricing; use Lunar\Facades\StorefrontSession; $variant = $product->variants->first(); $pricing = Pricing::for($variant) ->currency(StorefrontSession::getCurrency()) ->customerGroups(StorefrontSession::getCustomerGroups()) ->get(); ``` The `get()` method returns a `PricingResponse` with the following properties: | Property | Type | Description | | :-------------------- | :------------------- | :---------------------------------------------------- | | `matched` | `Lunar\Models\Price` | The best matching price for the given context | | `base` | `Lunar\Models\Price` | The base price (min quantity of 1, no customer group) | | `priceBreaks` | `Collection` | All available quantity break prices | | `customerGroupPrices` | `Collection` | Prices specific to customer groups | ### Formatting Prices in Blade ```blade theme={null} @php $pricing = \Lunar\Facades\Pricing::for($product->variants->first()) ->currency(\Lunar\Facades\StorefrontSession::getCurrency()) ->customerGroups(\Lunar\Facades\StorefrontSession::getCustomerGroups()) ->get(); $price = $pricing->matched; @endphp

    {{ $price->price->formatted() }}

    @if($price->compare_price?->value)

    {{ $price->compare_price->formatted() }}

    @endif ``` ### Tax-Inclusive and Tax-Exclusive Prices If prices are stored inclusive of tax, use the helper methods on the `Price` model to show both: ```blade theme={null}

    {{ $price->priceIncTax()->formatted() }} inc. tax

    {{ $price->priceExTax()->formatted() }} ex. tax

    ``` See the [Pricing reference](/1.x/reference/pricing) for more on configuring whether prices are stored inclusive or exclusive of tax. ### Price Breaks If the product offers quantity-based pricing, display the available tiers: ```blade theme={null} @if($pricing->priceBreaks->count() > 1) @foreach($pricing->priceBreaks as $break) @endforeach
    Quantity Price
    {{ $break->min_quantity }}+ {{ $break->price->formatted() }}
    @endif ``` ## Stock and Availability Each variant tracks its own inventory. Display stock status to help customers make purchasing decisions. ```blade theme={null} @php $variant = $product->variants->first(); @endphp @if($variant->purchasable === 'always') Available @elseif($variant->stock > 0) In Stock @else Out of Stock @endif ``` The `purchasable` field on a variant controls its availability: | Value | Behavior | | :--------- | :------------------------------------------------------------------------- | | `always` | Can always be purchased, even when stock reaches zero (backorders allowed) | | `in_stock` | Can only be purchased when `stock` is greater than zero | Use `canBeFulfilledAtQuantity()` to verify a specific quantity can be fulfilled before adding to cart: ```php theme={null} $variant->canBeFulfilledAtQuantity(3); // Returns true or false ``` ## Adding to Cart Lunar provides a `CartSession` facade for managing the active cart. Use it to add variants to the cart from the PDP. ### The Add-to-Cart Form ```blade theme={null}
    @csrf
    ``` ### The Controller ```php theme={null} use Lunar\Facades\CartSession; use Lunar\Models\ProductVariant; class CartController extends Controller { public function add(Request $request) { $request->validate([ 'variant_id' => 'required|exists:lunar_product_variants,id', 'quantity' => 'required|integer|min:1', ]); $variant = ProductVariant::findOrFail($request->variant_id); $cart = CartSession::current(); $cart->add($variant, $request->quantity); return redirect()->back()->with('message', 'Item added to cart.'); } } ``` ### The Route ```php theme={null} Route::post('/cart/add', [CartController::class, 'add'])->name('cart.add'); ``` ## Related Products Lunar supports product associations for cross-sells, upsells, and alternate products. Display these on the PDP to encourage additional purchases. ```php theme={null} use Lunar\Models\ProductAssociation; $crossSells = $product->associations() ->crossSell() ->with('target.variants.prices.currency', 'target.media') ->get() ->pluck('target'); $upSells = $product->associations() ->upSell() ->with('target.variants.prices.currency', 'target.media') ->get() ->pluck('target'); ``` ```blade theme={null} @if($crossSells->isNotEmpty())

    You might also like

    @foreach($crossSells as $related) {{ $related->attr('name') }}

    {{ $related->attr('name') }}

    @endforeach
    @endif ``` See the [Products reference](/1.x/reference/products#product-associations) for more on association types. ## Variant-Specific Images When variants have their own images (for example, showing a different image per color), swap the displayed image based on the selected variant. ```php theme={null} $variantImageMap = $product->variants->mapWithKeys(function ($variant) use ($product) { $thumbnail = $variant->getThumbnail(); return [ $variant->id => $thumbnail ? $thumbnail->getUrl('large') : $product->getFirstMediaUrl('images', 'large'), ]; }); ``` Pass this map to JavaScript alongside the variant option map: ```blade theme={null} ``` ## Putting It All Together Here is a complete controller that prepares all the data a PDP needs: ```php theme={null} where('element_type', (new Product)->getMorphClass()) ->firstOrFail(); $channel = StorefrontSession::getChannel(); $customerGroups = StorefrontSession::getCustomerGroups(); $currency = StorefrontSession::getCurrency(); $product = Product::where('id', $url->element_id) ->where('status', 'published') ->channel($channel) ->customerGroup($customerGroups) ->with([ 'variants.prices.currency', 'variants.values.option', 'variants.images', 'media', 'brand', 'productOptions.values', 'associations.target.variants.prices.currency', 'associations.target.media', ]) ->firstOrFail(); $defaultVariant = $product->variants->first(); $pricing = Pricing::for($defaultVariant) ->currency($currency) ->customerGroups($customerGroups) ->get(); $variantOptionMap = $product->variants->map(function ($variant) use ($currency, $customerGroups) { $variantPricing = Pricing::for($variant) ->currency($currency) ->customerGroups($customerGroups) ->get(); return [ 'id' => $variant->id, 'sku' => $variant->sku, 'option_value_ids' => $variant->values->pluck('id')->sort()->values()->all(), 'stock' => $variant->stock, 'purchasable' => $variant->purchasable, 'price' => $variantPricing->matched->price->formatted(), 'image' => $variant->getThumbnailImage(), ]; }); $crossSells = $product->associations ->where('type', 'cross-sell') ->pluck('target'); return view('products.show', [ 'product' => $product, 'defaultVariant' => $defaultVariant, 'pricing' => $pricing, 'variantOptionMap' => $variantOptionMap, 'crossSells' => $crossSells, ]); } } ``` ## Next Steps * Review the [Products reference](/1.x/reference/products) for the full list of model fields, relationships, and scopes. * Review the [Pricing reference](/1.x/reference/pricing) for custom price formatters and additional formatting options. * Review the [Media reference](/1.x/reference/media) for image conversion configuration. * Review the [Storefront Session reference](/1.x/storefront-utils/storefront-session) to manage channel, currency, and customer group context. # Product Listing Page Source: https://docs.lunarphp.com/1.x/guides/product-listing-page Build a product listing page (PLP) with Lunar, covering collection resolution, product querying, filtering, sorting, pagination, and display. ## Overview A Product Listing Page (PLP) displays a grid or list of products within a collection (category). Customers browse products, filter by attributes, sort results, and click through to individual product pages. This guide walks through building a PLP using Lunar's models and facades. The examples below use standard Laravel controllers and Blade templates. The same concepts apply whether the storefront is built with Livewire, Inertia, or a headless API. ## Resolving a Collection from a URL Like products, collections use Lunar's `Url` model for slug-based lookups. ### Define the Route ```php theme={null} use App\Http\Controllers\CollectionController; Route::get('/collections/{slug}', [CollectionController::class, 'show'])->name('collections.show'); ``` ### Resolve the Collection ```php theme={null} use Lunar\Models\Collection; use Lunar\Models\Url; class CollectionController extends Controller { public function show(string $slug) { $url = Url::where('slug', $slug) ->where('element_type', (new Collection)->getMorphClass()) ->firstOrFail(); $collection = Collection::where('id', $url->element_id) ->with('media') ->firstOrFail(); // ... } } ``` ## Displaying Collection Information Collection names and descriptions are stored as attribute data, accessed with the `attr()` method. ```blade theme={null}

    {{ $collection->attr('name') }}

    @if($collection->attr('description'))
    {!! $collection->attr('description') !!}
    @endif ``` ### Collection Image Collections support media through Spatie MediaLibrary, just like products. ```blade theme={null} @if($collection->getFirstMediaUrl('images', 'large')) {{ $collection->attr('name') }} @endif ``` ### Breadcrumbs Collections form a nested hierarchy. The `breadcrumb` attribute returns the translated names of all ancestor collections, which is useful for building breadcrumb navigation. ```blade theme={null} ``` The `breadcrumb` attribute uses the `ancestors` relationship from the nested set. If displaying breadcrumbs on every page load, consider eager loading ancestors to avoid extra queries: `$collection->load('ancestors')`. ## Querying Products The `products()` relationship on a Collection returns products ordered by the collection's configured `position` pivot column. ### Basic Query ```php theme={null} $products = $collection->products() ->where('status', 'published') ->with([ 'variants.prices.currency', 'media', 'brand', ]) ->paginate(24); ``` ### Filtering by Channel Products can be scheduled against channels. Use the `channel` scope to return only products that are active on the current channel. ```php theme={null} use Lunar\Facades\StorefrontSession; $channel = StorefrontSession::getChannel(); $products = $collection->products() ->where('status', 'published') ->channel($channel) ->with([ 'variants.prices.currency', 'media', 'brand', ]) ->paginate(24); ``` ### Filtering by Customer Group Similarly, use the `customerGroup` scope to return only products visible to the current customer group. ```php theme={null} $customerGroups = StorefrontSession::getCustomerGroups(); $products = $collection->products() ->where('status', 'published') ->channel($channel) ->customerGroup($customerGroups) ->with([ 'variants.prices.currency', 'media', 'brand', ]) ->paginate(24); ``` ## Sorting Products in a collection have a default sort order determined by the collection's `sort` field. The `products()` relationship already orders by the `position` pivot column, which reflects this configured sort. The available sort options built into Lunar are: | Sort Value | Description | | :--------------- | :-------------------------------------------------------- | | `custom` | Manual ordering via the `position` pivot column (default) | | `min_price:asc` | Lowest base price first | | `min_price:desc` | Highest base price first | | `sku:asc` | SKU ascending (alphabetical) | | `sku:desc` | SKU descending | The `position` values on the pivot table are automatically recalculated when the collection's `sort` field is changed. For example, setting `sort` to `min_price:asc` triggers a background job that reorders the position values by price. ### Custom Sort on the Query To let customers choose a sort order at browse time, override the default pivot ordering on the query. ```php theme={null} $sortOptions = [ 'price_asc' => 'Price: Low to High', 'price_desc' => 'Price: High to Low', 'newest' => 'Newest First', ]; $sort = $request->get('sort', 'default'); $query = $collection->products() ->where('status', 'published') ->channel($channel) ->customerGroup($customerGroups) ->with([ 'variants.prices.currency', 'media', 'brand', ]); $query = match ($sort) { 'newest' => $query->reorder()->latest(), 'price_asc' => $query->reorder() ->join( 'lunar_product_variants', 'lunar_product_variants.product_id', '=', 'lunar_products.id' ) ->join( 'lunar_prices', 'lunar_prices.priceable_id', '=', 'lunar_product_variants.id' ) ->where('lunar_prices.priceable_type', (new \Lunar\Models\ProductVariant)->getMorphClass()) ->where('lunar_prices.min_quantity', 1) ->whereNull('lunar_prices.customer_group_id') ->orderBy('lunar_prices.price', 'asc'), 'price_desc' => $query->reorder() ->join( 'lunar_product_variants', 'lunar_product_variants.product_id', '=', 'lunar_products.id' ) ->join( 'lunar_prices', 'lunar_prices.priceable_id', '=', 'lunar_product_variants.id' ) ->where('lunar_prices.priceable_type', (new \Lunar\Models\ProductVariant)->getMorphClass()) ->where('lunar_prices.min_quantity', 1) ->whereNull('lunar_prices.customer_group_id') ->orderBy('lunar_prices.price', 'desc'), default => $query, // Uses the collection's configured position ordering }; $products = $query->paginate(24); ``` For simpler sorting needs, the default `position` ordering is often sufficient. The collection's `sort` field can be changed in the admin panel to reorder products by price or SKU without requiring custom query logic. ## Filtering Product filtering depends on the storefront's requirements. Below are some common filtering approaches. ### Filtering by Brand ```php theme={null} if ($brandId = $request->get('brand')) { $query->where('brand_id', $brandId); } ``` To build a brand filter list from the products in the collection: ```php theme={null} use Lunar\Models\Brand; $brandIds = $collection->products() ->where('status', 'published') ->pluck('brand_id') ->unique() ->filter(); $brands = Brand::whereIn('id', $brandIds)->orderBy('name')->get(); ``` ### Filtering by Price Range ```php theme={null} if ($minPrice = $request->get('min_price')) { $query->whereHas('variants.prices', function ($priceQuery) use ($minPrice) { $priceQuery->where('price', '>=', (int) $minPrice * 100); }); } if ($maxPrice = $request->get('max_price')) { $query->whereHas('variants.prices', function ($priceQuery) use ($maxPrice) { $priceQuery->where('price', '<=', (int) $maxPrice * 100); }); } ``` Prices are stored as integers in the lowest denomination (e.g., cents). Multiply the customer-facing price by 100 (or the currency's factor) before comparing. ### Filtering by Product Availability ```php theme={null} if ($request->boolean('in_stock')) { $query->whereHas('variants', function ($variantQuery) { $variantQuery->where(function ($q) { $q->where('purchasable', 'always') ->orWhere('stock', '>', 0); }); }); } ``` ## Displaying Products ### Product Grid ```blade theme={null}
    @foreach($products as $product) @php $variant = $product->variants->first(); $pricing = \Lunar\Facades\Pricing::for($variant) ->currency(\Lunar\Facades\StorefrontSession::getCurrency()) ->customerGroups(\Lunar\Facades\StorefrontSession::getCustomerGroups()) ->get(); $price = $pricing->matched; @endphp {{ $product->attr('name') }}

    {{ $product->attr('name') }}

    @if($product->brand)

    {{ $product->brand->name }}

    @endif

    {{ $price->price->formatted() }}

    @if($price->compare_price?->value)

    {{ $price->compare_price->formatted() }}

    @endif
    @endforeach
    ``` ### Pagination Laravel's built-in pagination works directly with the collection product query. ```blade theme={null} {{ $products->withQueryString()->links() }} ``` Using `withQueryString()` preserves any active filter and sort parameters in the pagination links. ## Child Collections Collections can contain child collections (subcategories). Display them to help customers navigate deeper into the catalog. ```php theme={null} $children = $collection->children() ->with('media') ->get(); ``` ```blade theme={null} @if($children->isNotEmpty())
    @foreach($children as $child) @if($child->getFirstMediaUrl('images', 'medium')) {{ $child->attr('name') }} @endif {{ $child->attr('name') }} @endforeach
    @endif ``` ## Putting It All Together Here is a complete controller that prepares all the data a PLP needs: ```php theme={null} where('element_type', (new Collection)->getMorphClass()) ->firstOrFail(); $collection = Collection::where('id', $url->element_id) ->with('media') ->firstOrFail(); $channel = StorefrontSession::getChannel(); $customerGroups = StorefrontSession::getCustomerGroups(); $query = $collection->products() ->where('status', 'published') ->channel($channel) ->customerGroup($customerGroups) ->with([ 'variants.prices.currency', 'media', 'brand', 'defaultUrl', ]); // Apply filters if ($brandId = $request->get('brand')) { $query->where('brand_id', $brandId); } if ($request->boolean('in_stock')) { $query->whereHas('variants', function ($q) { $q->where(function ($q) { $q->where('purchasable', 'always') ->orWhere('stock', '>', 0); }); }); } // Apply sort $sort = $request->get('sort', 'default'); $query = match ($sort) { 'newest' => $query->reorder()->latest(), default => $query, }; $products = $query->paginate(24); // Build filter options from the collection's products $brandIds = $collection->products() ->where('status', 'published') ->pluck('brand_id') ->unique() ->filter(); $brands = Brand::whereIn('id', $brandIds)->orderBy('name')->get(); $children = $collection->children() ->with('media', 'defaultUrl') ->get(); return view('collections.show', [ 'collection' => $collection, 'products' => $products, 'brands' => $brands, 'children' => $children, 'activeFilters' => $request->only(['brand', 'in_stock', 'sort']), ]); } } ``` ## Next Steps * Review the [Collections reference](/1.x/reference/collections) for the full list of model fields, relationships, and sort options. * Review the [Products reference](/1.x/reference/products) for product scopes and variant details. * Review the [Storefront Session reference](/1.x/storefront-utils/storefront-session) to manage channel, currency, and customer group context. * Review the [Product Display Page guide](/1.x/guides/product-display-page) for displaying individual products after a customer clicks through from the listing. # Search & Product Discovery Source: https://docs.lunarphp.com/1.x/guides/search Build product search with Lunar, covering search setup, querying, faceted filtering, and URL-based product resolution. ## Overview Product search lets customers find products by typing keywords, browsing faceted filters, and navigating directly to products via URLs. This guide walks through building search and discovery features using Lunar's `Search` facade and `Url` model. The examples below use standard Laravel controllers and Blade templates. The same concepts apply whether the storefront is built with Livewire, Inertia, or a headless API. ## Search Setup Lunar's search system is provided by the `lunarphp/search` add-on package. It is built on top of [Laravel Scout](https://laravel.com/docs/scout) with a driver-based architecture that supports Meilisearch, Typesense, and a database fallback. The `Search` facade provides a consistent API regardless of which driver is active. ### Installing the Search Package ```bash theme={null} composer require lunarphp/search ``` This installs the `Search` facade, search indexers, and data classes used throughout this guide. ### Installing a Search Driver For production storefronts, install either Meilisearch or Typesense. The database driver works out of the box but lacks faceting and advanced relevance features. ```bash theme={null} # Meilisearch composer require meilisearch/meilisearch-php # Typesense composer require typesense/typesense-php ``` Set the driver in the application's `.env` file: ```bash theme={null} SCOUT_DRIVER=meilisearch MEILISEARCH_HOST=http://127.0.0.1:7700 MEILISEARCH_KEY=your-master-key ``` ### Indexing Products After configuring the driver, import existing products into the search index: ```bash theme={null} php artisan scout:import "Lunar\Models\Product" ``` Lunar automatically keeps the index in sync as products are created, updated, or deleted. ### Searchable Models The following models are searchable by default: | Model | Description | | :--------------------------- | :------------------------------------------------------ | | `Lunar\Models\Product` | Products with attributes, SKUs, brand, and product type | | `Lunar\Models\Collection` | Collections with their attribute data | | `Lunar\Models\Brand` | Brands | | `Lunar\Models\Customer` | Customers (typically for admin use) | | `Lunar\Models\Order` | Orders (typically for admin use) | | `Lunar\Models\ProductOption` | Product options | ## Searching Products Use the `Search` facade to query products. The `query()` method accepts a search string, and `get()` returns a `Lunar\Search\Data\SearchResults` object. Products are the default model, so no additional configuration is needed. ```php theme={null} use Lunar\Search\Facades\Search; $results = Search::query('running shoes')->get(); ``` To search a different model, use `model()` to specify it: ```php theme={null} use Lunar\Models\Collection; use Lunar\Search\Facades\Search; $results = Search::model(Collection::class)->query('hoodies')->get(); ``` ### Search Results The `SearchResults` object contains everything needed to render a results page: | Property | Type | Description | | :----------- | :-------------- | :------------------------------------ | | `query` | `?string` | The search query that was executed | | `count` | `int` | Total number of matching results | | `page` | `int` | Current page number | | `perPage` | `int` | Results per page | | `totalPages` | `int` | Total number of pages | | `hits` | `SearchHit[]` | Array of search result hits | | `facets` | `SearchFacet[]` | Array of available facets with counts | | `links` | `View` | Pagination links | Each `SearchHit` contains: | Property | Type | Description | | :----------- | :--------------------- | :------------------------------------------------------ | | `document` | `array` | The indexed document data (id, name, brand, SKUs, etc.) | | `highlights` | `SearchHitHighlight[]` | Highlighted matches (Typesense only) | ### Building a Search Controller ```php theme={null} get('q', ''); if (empty($query)) { return view('search.index', ['results' => null, 'query' => '']); } $results = Search::query($query) ->perPage(24) ->get(); return view('search.index', [ 'results' => $results, 'query' => $query, ]); } } ``` ### Displaying Search Results ```blade theme={null}
    @if($results)

    {{ $results->count }} results for "{{ $results->query }}"

    @foreach($results->hits as $hit) @if(! empty($hit->document['thumbnail'])) {{ $hit->document['name'] ?? '' }} @endif

    {{ $hit->document['name'] ?? 'Untitled' }}

    @if(! empty($hit->document['brand']))

    {{ $hit->document['brand'] }}

    @endif
    @endforeach
    {{ $results->links }} @endif ``` Search hits contain the indexed document data, not Eloquent models. The indexed fields for products include `id`, `status`, `product_type`, `brand`, `thumbnail`, `skus`, and any attributes marked as searchable. To load full Eloquent models from search results, collect the IDs and query the database. ### Loading Eloquent Models from Results When full model data is needed (for example, to use the `Pricing` facade or access relationships), load the models from the hit IDs: ```php theme={null} $productIds = collect($results->hits)->pluck('document.id'); $products = Product::whereIn('id', $productIds) ->with([ 'variants.prices.currency', 'media', 'brand', 'defaultUrl', ]) ->get() ->keyBy('id'); ``` ```blade theme={null} @foreach($results->hits as $hit) @php $product = $products[$hit->document['id']] ?? null; @endphp @if($product) {{ $product->attr('name') }}

    {{ $product->attr('name') }}

    @endif @endforeach ``` ## Sorting Results Use the `sort()` method with a `field:direction` string to control result ordering. ```php theme={null} $results = Search::query('shoes') ->sort('created_at:desc') ->get(); ``` The sortable fields for products are `created_at`, `updated_at`, `skus`, and `status`. Additional sortable fields can be configured in a custom indexer. ### Letting Customers Choose a Sort Order ```php theme={null} $sort = $request->get('sort', ''); $engine = Search::query($request->get('q', '')); if (in_array($sort, ['created_at:asc', 'created_at:desc'])) { $engine->sort($sort); } $results = $engine->get(); ``` ## Faceted Filtering Facets allow customers to narrow results by attributes like brand, size, or color. Facets are configured per model in the search configuration and are only available when using Meilisearch or Typesense. ### Configuring Facets Publish the search configuration and define facets for the `Product` model: ```php theme={null} // config/lunar/search.php return [ 'facets' => [ \Lunar\Models\Product::class => [ 'brand' => [], ], ], ]; ``` Each key in the facets array corresponds to a field in the product's search index. Additional facets can be added for any indexed field. ### Displaying Facets After executing a search, the `facets` property on the results contains the available facet groups with value counts. ```blade theme={null} @foreach($results->facets as $facet)

    {{ $facet->label }}

    @foreach($facet->values as $value) @endforeach
    @endforeach ``` Each `SearchFacet` has: | Property | Type | Description | | :------- | :------------------- | :-------------------------------- | | `label` | `string` | Display label for the facet group | | `field` | `string` | The indexed field name | | `values` | `SearchFacetValue[]` | Available values with counts | Each `SearchFacetValue` has: | Property | Type | Description | | :------- | :------- | :--------------------------------------- | | `label` | `string` | Display label for the value | | `value` | `string` | The value to filter by | | `count` | `int` | Number of matching results | | `active` | `bool` | Whether this filter is currently applied | ### Applying Facet Filters Pass filters as an associative array to the `filter()` method. Multiple values for the same facet use OR logic. ```php theme={null} $engine = Search::query($request->get('q', '')); if ($filters = $request->get('filters')) { $engine->filter($filters); } $results = $engine->get(); ``` Filters can also be added individually: ```php theme={null} $engine->addFilter('brand', ['Nike', 'Adidas']); $engine->addFilter('status', 'published'); ``` ## URL-Based Product Resolution Lunar stores SEO-friendly slugs in the `Lunar\Models\Url` model. Products, collections, and brands all support URL resolution through the `HasUrls` trait. ### Defining a Catch-All Route A common pattern is to define a catch-all route that resolves the URL to the correct model type: ```php theme={null} use App\Http\Controllers\ResolverController; // Place this after all other routes Route::get('/{slug}', [ResolverController::class, 'show'])->where('slug', '.*'); ``` ### Resolving the URL ```php theme={null} firstOrFail(); $element = $url->element; return match ($element::class) { Product::class, Product::modelClass() => $this->showProduct($element), Collection::class, Collection::modelClass() => $this->showCollection($element), Brand::class, Brand::modelClass() => $this->showBrand($element), default => abort(404), }; } protected function showProduct(Product $product) { $product->load([ 'variants.prices.currency', 'media', 'brand', ]); return view('products.show', compact('product')); } protected function showCollection(Collection $collection) { return view('collections.show', compact('collection')); } protected function showBrand(Brand $brand) { return view('brands.show', compact('brand')); } } ``` ### Working with URLs on Models ```php theme={null} use Lunar\Models\Product; $product = Product::find(1); // Get the default URL $defaultUrl = $product->defaultUrl; echo $defaultUrl->slug; // e.g., "blue-running-shoes" // Get all URLs $urls = $product->urls; // Build a link $href = '/' . $product->defaultUrl?->slug; ``` ## Searchable Attributes Product attributes marked as `searchable` in the admin panel are automatically included in the search index. This means customers can search by any attribute value, such as material, color, or specifications. The search index for a product includes: | Field | Source | | :---------------- | :-------------------------------- | | `id` | Product ID | | `status` | Product status (published, draft) | | `product_type` | Product type name | | `brand` | Brand name | | `skus` | Array of variant SKUs | | `thumbnail` | Thumbnail URL | | `created_at` | Unix timestamp | | Attribute handles | Values from searchable attributes | To make an attribute searchable, edit the attribute in the Lunar admin panel and enable the "Searchable" option. After changing searchable attributes, reimport the search index with `php artisan scout:import "Lunar\Models\Product"`. ## Routes ```php theme={null} use App\Http\Controllers\ResolverController; use App\Http\Controllers\SearchController; Route::get('/search', [SearchController::class, 'index'])->name('search'); Route::get('/{slug}', [ResolverController::class, 'show'])->where('slug', '.*'); ``` ## Putting It All Together Here is a complete search controller with faceted filtering and sorting: ```php theme={null} get('q', ''); if (empty($query)) { return view('search.index', [ 'results' => null, 'query' => '', ]); } $engine = Search::query($query) ->perPage(24); if ($filters = $request->get('filters')) { $engine->filter($filters); } if ($sort = $request->get('sort')) { if (in_array($sort, ['created_at:asc', 'created_at:desc'])) { $engine->sort($sort); } } $results = $engine->get(); // Load full Eloquent models for display $productIds = collect($results->hits)->pluck('document.id'); $products = Product::whereIn('id', $productIds) ->with([ 'variants.prices.currency', 'media', 'brand', 'defaultUrl', ]) ->get() ->keyBy('id'); return view('search.index', [ 'results' => $results, 'products' => $products, 'query' => $query, 'activeFilters' => $request->get('filters', []), 'activeSort' => $request->get('sort', ''), ]); } } ``` ## Next Steps * Review the [Search reference](/1.x/reference/search) for the full list of searchable models and configuration options. * Review the [Search add-on](/1.x/addons/search) for advanced search engine configuration. * Review the [Extending Search](/1.x/extending/search) guide for customizing indexers and adding custom fields to the search index. * Review the [Product Listing Page guide](/1.x/guides/product-listing-page) for collection-based product browsing as an alternative to search. # Activity Log Source: https://docs.lunarphp.com/1.x/reference/activity-log Lunar tracks changes on Eloquent models using built-in activity logging. ## Overview Lunar has activity logging built in to track changes on Eloquent models. This provides an invaluable insight into what updates are happening in a store and who is making them. ## How it works Lunar uses the [laravel-activitylog](https://spatie.be/docs/laravel-activitylog) package by Spatie under the hood, wrapped in a custom trait at `Lunar\Base\Traits\LogsActivity`. This trait configures the following defaults: * All attribute changes are logged (except `updated_at`) * Only dirty (changed) attributes are recorded * Empty logs are not submitted * All entries are stored under the `lunar` log name The following models have activity logging enabled: AttributeGroup, Brand, Cart, CartAddress, CartLine, Channel, CollectionGroup, Currency, Customer, CustomerGroup, Discount, Order, OrderAddress, OrderLine, Product, ProductOption, ProductType, ProductVariant, Tag, TaxClass, TaxRate, TaxZone, and Transaction. ## Enabling on your own models To enable logging on custom models, use Lunar's trait rather than Spatie's directly. This ensures consistent configuration across the application. ```php theme={null} addresses()->create([ 'title' => 'Mr', 'first_name' => 'Tony', 'last_name' => 'Stark', 'company_name' => 'Stark Industries', 'line_one' => '10880 Malibu Point', 'line_two' => null, 'line_three' => null, 'city' => 'Malibu', 'state' => 'California', 'postcode' => '90265', 'country_id' => $country->id, 'delivery_instructions' => 'Leave at the front gate', 'contact_email' => 'tony@starkindustries.com', 'contact_phone' => '555-0123', 'shipping_default' => true, 'billing_default' => true, 'meta' => [ 'type' => 'headquarters', ], ]); ``` An address can also be created directly. ```php theme={null} $address = \Lunar\Models\Address::create([ 'customer_id' => $customer->id, 'first_name' => 'Tony', 'last_name' => 'Stark', 'line_one' => '200 Park Avenue', 'city' => 'New York', 'postcode' => '10166', 'country_id' => $country->id, 'contact_email' => 'tony@starkindustries.com', 'contact_phone' => '555-0456', 'shipping_default' => false, 'billing_default' => false, ]); ``` ### Retrieving addresses ```php theme={null} // Get all addresses for a customer $addresses = $customer->addresses; // Get the default shipping address $shippingAddress = $customer->addresses() ->where('shipping_default', true) ->first(); // Get the default billing address $billingAddress = $customer->addresses() ->where('billing_default', true) ->first(); ``` ### Default addresses Each customer can have one default shipping address and one default billing address. These are controlled by the `shipping_default` and `billing_default` boolean fields. ```php theme={null} // Set an address as the default shipping address $address->update([ 'shipping_default' => true, ]); // Set an address as the default billing address $address->update([ 'billing_default' => true, ]); ``` When an address is set as the default, Lunar automatically unsets the previous default for that customer via an observer. There is no need to manually manage this. ### Relationships | Relationship | Type | Related Model | Description | | :----------- | :---------- | :---------------------- | :--------------------------------- | | `customer` | `BelongsTo` | `Lunar\Models\Customer` | The customer who owns this address | | `country` | `BelongsTo` | `Lunar\Models\Country` | The country this address is in | ```php theme={null} // Get the country for an address $country = $address->country; // Get the customer for an address $customer = $address->customer; ``` # Associations Source: https://docs.lunarphp.com/1.x/reference/associations Relate products to each other as cross-sells, up-sells, alternates, or custom types. Associations define relationships between products, such as cross-sells, up-sells, and alternates. ## Overview Associations allow products to be related to each other. The type of association defines how the relationship should be presented on a storefront and how Lunar interprets it. Lunar ships with three built-in types (cross-sell, up-sell, and alternate), but custom types can also be created. ## Model Associations are stored as `Lunar\Models\ProductAssociation` models. ### Fields | Field | Type | Description | | :------------------ | :-------------- | :--------------------------------------------------------------- | | `id` | `bigIncrements` | Primary key | | `product_parent_id` | `foreignId` | The owning product | | `product_target_id` | `foreignId` | The associated product | | `type` | `string` | The association type (e.g. `cross-sell`, `up-sell`, `alternate`) | | `created_at` | `timestamp` | | | `updated_at` | `timestamp` | | ### Relationships | Relationship | Type | Related Model | Description | | :----------- | :-------- | :--------------------- | :--------------------- | | `parent` | BelongsTo | `Lunar\Models\Product` | The owning product | | `target` | BelongsTo | `Lunar\Models\Product` | The associated product | ### Scopes | Scope | Description | | :--------------------------------------------------- | :-------------------------------- | | `crossSell()` | Filter to cross-sell associations | | `upSell()` | Filter to up-sell associations | | `alternate()` | Filter to alternate associations | | `type(ProvidesProductAssociationType\|string $type)` | Filter by a specific type | ### Association Types Enum The built-in association types are defined using the `Lunar\Base\Enums\ProductAssociation` enum: ```php theme={null} use Lunar\Base\Enums\ProductAssociation; ProductAssociation::CROSS_SELL; // 'cross-sell' ProductAssociation::UP_SELL; // 'up-sell' ProductAssociation::ALTERNATE; // 'alternate' ``` The `Lunar\Models\ProductAssociation` model also defines `CROSS_SELL`, `UP_SELL`, and `ALTERNATE` as class constants, but these are deprecated since v1.2.0. Use the `Lunar\Base\Enums\ProductAssociation` enum instead. ## Loading Associations ```php theme={null} $product->associations; ``` This returns a collection of `Lunar\Models\ProductAssociation` models: ```php theme={null} use Lunar\Models\ProductAssociation; $association->parent; // The owning product $association->target; // The associated product $association->type; // The association type string ``` ### Inverse Associations To find products that associate *to* a given product (i.e. where the product is the target), use the `inverseAssociations` relationship: ```php theme={null} $product->inverseAssociations; ``` ## Types of Association ### Cross-Sell Cross-selling encourages customers to purchase complementary products in addition to the item they intended to buy. For example, if a store sells a phone, cross-sell associations could include headphones or a case that works with that phone. **Adding a cross-sell association** ```php theme={null} use Lunar\Base\Enums\ProductAssociation; $product->associate($crossSellProduct, ProductAssociation::CROSS_SELL); // Or associate multiple products at once $product->associate([$productA, $productB], ProductAssociation::CROSS_SELL); ``` **Fetching cross-sell associations** ```php theme={null} use Lunar\Base\Enums\ProductAssociation; // Using the convenience scope $product->associations()->crossSell()->get(); // Using the type scope $product->associations()->type(ProductAssociation::CROSS_SELL)->get(); ``` ### Up-Sell Up-selling encourages customers to upgrade or include add-ons to the product they are buying, typically to a higher-value option. For example, given two phones: * Phone 16GB 5" Screen * Phone 32GB 6" Screen The 32GB version could be added as an up-sell association on the 16GB product, allowing it to be presented as an upgrade when a customer views the 16GB version. **Adding an up-sell association** ```php theme={null} use Lunar\Base\Enums\ProductAssociation; $product->associate($upSellProduct, ProductAssociation::UP_SELL); // Or associate multiple products at once $product->associate([$productA, $productB], ProductAssociation::UP_SELL); ``` **Fetching up-sell associations** ```php theme={null} use Lunar\Base\Enums\ProductAssociation; // Using the convenience scope $product->associations()->upSell()->get(); // Using the type scope $product->associations()->type(ProductAssociation::UP_SELL)->get(); ``` ### Alternate Alternate products are alternatives to the current product. This is useful when a product is out of stock or not quite the right fit, allowing the storefront to suggest similar options. **Adding an alternate association** ```php theme={null} use Lunar\Base\Enums\ProductAssociation; $product->associate($alternateProduct, ProductAssociation::ALTERNATE); // Or associate multiple products at once $product->associate([$productA, $productB], ProductAssociation::ALTERNATE); ``` **Fetching alternate associations** ```php theme={null} use Lunar\Base\Enums\ProductAssociation; // Using the convenience scope $product->associations()->alternate()->get(); // Using the type scope $product->associations()->type(ProductAssociation::ALTERNATE)->get(); ``` ### Custom Types In addition to the built-in types, custom association types can be defined by passing any string as the type. No registration or configuration is required since the `type` column is a plain string in the database. ```php theme={null} $product->associate($relatedProduct, 'my-custom-type'); ``` Custom types can be queried using the `type` scope: ```php theme={null} $product->associations()->type('my-custom-type')->get(); ``` If using the admin panel, custom types can be added to the association type dropdown by replacing the default enum. See [Admin Panel Configuration](/1.x/admin/extending/configuration#product-association-types) for details. ## Removing Associations The `dissociate` method removes associations between products. It accepts a single product, an array, or a collection. If no type is specified, all association types for the given product(s) are removed. ```php theme={null} use Lunar\Base\Enums\ProductAssociation; // Remove all associations with a product, regardless of type $product->dissociate($associatedProduct); // Also accepts an array or collection of products $product->dissociate([$productA, $productB]); // Remove only a specific association type $product->dissociate($associatedProduct, ProductAssociation::CROSS_SELL); ``` ## Queued Operations Both `associate()` and `dissociate()` dispatch queued jobs (`Lunar\Jobs\Products\Associations\Associate` and `Lunar\Jobs\Products\Associations\Dissociate`). This means the changes may not be reflected immediately if using an asynchronous queue driver. # Attributes Source: https://docs.lunarphp.com/1.x/reference/attributes Store custom, translatable data on Eloquent models using configurable field types. Attributes store custom, translatable data against Eloquent models using configurable field types. ## Overview Attributes allow custom data to be stored against Eloquent models. They are most commonly used with products, where different information needs to be stored and presented to visitors. For example, a television might have the following attributes assigned: * Screen Size * Screen Technology * Tuner * Resolution Attributes are organized into **Attribute Groups** for display purposes. A group like "SEO" might contain attributes for "Meta Title" and "Meta Description". ## Attribute Groups ```php theme={null} Lunar\Models\AttributeGroup ``` Attribute groups form a logical collection of attributes. Each group belongs to a specific model type (e.g. products, collections, customers). ### Fields | Field | Type | Description | | :------------------ | :---------- | :-------------------------------------------------------------------------- | | `id` | `id` | Primary key | | `attributable_type` | `string` | The morph map name of the model type this group belongs to (e.g. `product`) | | `name` | `json` | Translated name, e.g. `{"en": "SEO"}` | | `handle` | `string` | Kebab-cased reference, e.g. `seo`. Must be unique | | `position` | `integer` | Sort order of the group | | `created_at` | `timestamp` | | | `updated_at` | `timestamp` | | ### Relationships | Relationship | Type | Related Model | Description | | :----------- | :------ | :----------------------- | :------------------------------------------------ | | `attributes` | HasMany | `Lunar\Models\Attribute` | All attributes in this group, ordered by position | ## Attributes ```php theme={null} Lunar\Models\Attribute ``` ### Fields | Field | Type | Description | | :------------------- | :------------------ | :------------------------------------------------------------------------------- | | `id` | `id` | Primary key | | `attribute_type` | `string` | The morph map name of the model type that can use this attribute, e.g. `product` | | `attribute_group_id` | `foreignId` | The associated attribute group | | `position` | `integer` | Sort order within the attribute group | | `name` | `json` | Translated name, e.g. `{"en": "Screen Size"}` | | `description` | `json` `nullable` | Translated description | | `handle` | `string` | Kebab-cased reference, e.g. `screen-size`. Unique per `attribute_type` | | `section` | `string` `nullable` | An optional label to define where an attribute should be used in the UI | | `type` | `string` | The field type class, e.g. `Lunar\FieldTypes\Number` | | `required` | `boolean` | Whether a value must be provided | | `default_value` | `string` `nullable` | Default value for the attribute | | `configuration` | `json` | Field-type-specific configuration stored as a collection | | `system` | `boolean` | If `true`, the attribute should not be deleted | | `validation_rules` | `string` `nullable` | Laravel validation rules, e.g. `required\|max:255` | | `filterable` | `boolean` | Whether the attribute can be used for filtering (default `false`) | | `searchable` | `boolean` | Whether the attribute is included in search indexing (default `true`) | | `created_at` | `timestamp` | | | `updated_at` | `timestamp` | | ### Relationships | Relationship | Type | Related Model | Description | | :--------------- | :-------- | :---------------------------- | :---------------------------------- | | `attributeGroup` | BelongsTo | `Lunar\Models\AttributeGroup` | The group this attribute belongs to | ### Scopes | Scope | Description | | :--------------------- | :------------------------------------------------- | | `system(string $type)` | Filter to system attributes for a given model type | ## Field Types Field types determine how an attribute's value is stored and retrieved. Each type implements the `Lunar\Base\FieldType` interface. | Type | Description | | :-------------------------------- | :------------------------------------------------- | | `Lunar\FieldTypes\Text` | Single-line, multi-line, or rich text | | `Lunar\FieldTypes\TranslatedText` | Translatable text with a `Text` value per locale | | `Lunar\FieldTypes\Number` | Integer or decimal value | | `Lunar\FieldTypes\Toggle` | Boolean on/off value | | `Lunar\FieldTypes\Dropdown` | Single selection from a list of predefined options | | `Lunar\FieldTypes\ListField` | A reorderable list of text values | | `Lunar\FieldTypes\File` | Single or multiple file references | | `Lunar\FieldTypes\YouTube` | A YouTube video ID or URL | | `Lunar\FieldTypes\Vimeo` | A Vimeo video ID or URL | ### Custom Field Types Custom field types can be created by implementing the `Lunar\Base\FieldType` interface. Once created, the field type should be registered with the `FieldTypeManifest` in a service provider: ```php theme={null} use Lunar\Facades\FieldTypeManifest; FieldTypeManifest::add(\App\FieldTypes\CustomField::class); ``` To make a custom field type editable in the admin panel, a corresponding Filament component and Livewire synthesizer are also needed. See [Extending Attributes](/1.x/admin/extending/attributes) for a full walkthrough. ## Models That Use Attributes The following models support attributes out of the box: * `Lunar\Models\Product` * `Lunar\Models\ProductVariant` * `Lunar\Models\Collection` * `Lunar\Models\Customer` * `Lunar\Models\Brand` * `Lunar\Models\CustomerGroup` ## Saving Attribute Data Attribute values are stored in an `attribute_data` JSON column on the model. Each key is an attribute handle, and each value is a field type instance. ```php theme={null} use Lunar\FieldTypes\Number; use Lunar\FieldTypes\Text; use Lunar\FieldTypes\TranslatedText; $product->attribute_data = collect([ 'meta_title' => new Text('The best screwdriver you will ever buy!'), 'pack_qty' => new Number(2), 'description' => new TranslatedText(collect([ 'en' => new Text('Blue'), 'fr' => new Text('Bleu'), ])), ]); $product->save(); ``` ## Accessing Attribute Data When the `attribute_data` property is accessed, it is cast to a collection of field type instances. ```php theme={null} dump($product->attribute_data); Illuminate\Support\Collection {#1522 #items: array:2 [ "name" => Lunar\FieldTypes\TranslatedText {#1533 #value: Illuminate\Support\Collection {#1505 #items: array:3 [ "de" => Lunar\FieldTypes\Text {#1506 #value: "Leren laarzen" } "en" => Lunar\FieldTypes\Text {#1514 #value: "Leather boots" } "fr" => Lunar\FieldTypes\Text {#1502 #value: "Bottes en cuir" } ] } } "description" => Lunar\FieldTypes\Text {#1537 #value: "

    I'm a description!

    " } ] } ``` ### Retrieving a Single Attribute Value The `translateAttribute` method returns the resolved value for a single attribute. For `TranslatedText` fields, it resolves the correct locale automatically. ```php theme={null} // Returns the value for the current app locale $product->translateAttribute('name'); // Returns the French translation $product->translateAttribute('name', 'fr'); // Falls back to the first available value $product->translateAttribute('name', 'FOO'); ``` The shorthand `attr` method does the same thing: ```php theme={null} $product->attr('name'); $product->attr('name', 'fr'); ``` For non-translatable fields, `translateAttribute` returns the raw value directly: ```php theme={null} // Returns the integer value $product->translateAttribute('pack_qty'); ``` ## Adding Attributes to a Custom Model To make a custom model support attributes: 1. Add the `HasAttributes` trait. 2. Cast the `attribute_data` column using `AsAttributeData`. 3. Add an `attribute_data` JSON column to the model's database table. ```php theme={null} use Lunar\Base\Casts\AsAttributeData; use Lunar\Base\Traits\HasAttributes; use Lunar\Base\Traits\HasTranslations; class MyModel extends Model { use HasAttributes; use HasTranslations; protected $casts = [ 'attribute_data' => AsAttributeData::class, ]; } ``` Then add the JSON column via a migration: ```php theme={null} Schema::table('my_models', function (Blueprint $table) { $table->json('attribute_data')->nullable(); }); ``` Finally, register the model as an attributable type so that attribute groups and attributes can be created for it: ```php theme={null} use Lunar\Facades\AttributeManifest; // In a service provider's boot method AttributeManifest::addType(\App\Models\MyModel::class); ``` ## Attribute Manifest The `AttributeManifest` manages which model types support attributes and provides access to searchable attribute data. ```php theme={null} use Lunar\Facades\AttributeManifest; // Get all registered attributable types $types = AttributeManifest::getTypes(); // Get a specific type by key (lowercase class basename) $type = AttributeManifest::getType('product'); // Register a new attributable type AttributeManifest::addType(\App\Models\MyModel::class); // Get all searchable attributes for a model type $searchable = AttributeManifest::getSearchableAttributes('product'); ``` # Carts Source: https://docs.lunarphp.com/1.x/reference/carts Manage shopping carts, calculate totals, and handle cart sessions in Lunar. Carts manage the collection of items a customer intends to purchase, with dynamic price calculation. ## Overview Carts hold a collection of purchasable items (typically product variants) that a customer intends to order. They belong to users (which relate to customers) and support a single currency each. Cart prices are dynamically calculated and are **not** stored in the database. Once a cart is converted to an order, the prices are persisted on the order instead. ## Cart Model ```php theme={null} Lunar\Models\Cart ``` | Field | Type | Description | | :------------ | :--------------------- | :------------------------------------------ | | id | `bigIncrements` | Primary key | | user\_id | `foreignId` `nullable` | For guest carts | | customer\_id | `foreignId` `nullable` | | | merged\_id | `foreignId` `nullable` | References the cart this was merged into | | currency\_id | `foreignId` | | | channel\_id | `foreignId` | | | order\_id | `foreignId` `nullable` | References the order created from this cart | | coupon\_code | `string` `nullable` | Promotional coupon code | | completed\_at | `dateTime` `nullable` | | | meta | `json` `nullable` | | | created\_at | `timestamp` | | | updated\_at | `timestamp` | | | deleted\_at | `timestamp` `nullable` | | ### Creating a Cart ```php theme={null} use Lunar\Models\Cart; $cart = Cart::create([ 'currency_id' => 1, 'channel_id' => 2, ]); ``` ### Relationships | Relationship | Type | Related Model | Description | | :---------------- | :--------- | :-------------- | :---------------------------------------------------- | | `lines` | Has many | `CartLine` | The line items in the cart. | | `addresses` | Has many | `CartAddress` | All addresses associated with the cart. | | `shippingAddress` | Has one | `CartAddress` | The shipping address (filtered by `type = shipping`). | | `billingAddress` | Has one | `CartAddress` | The billing address (filtered by `type = billing`). | | `currency` | Belongs to | `Currency` | The currency for the cart. | | `user` | Belongs to | Auth user model | The authenticated user who owns the cart. | | `customer` | Belongs to | `Customer` | The customer associated with the cart. | | `orders` | Has many | `Order` | All orders created from the cart. | | `draftOrder` | Has one | `Order` | The unplaced (draft) order. | | `completedOrder` | Has one | `Order` | A single placed (completed) order. | | `completedOrders` | Has many | `Order` | All placed (completed) orders. | The `draftOrder` and `completedOrder` relationships accept an optional order ID parameter to filter to a specific order: ```php theme={null} $cart->draftOrder; $cart->completedOrder; // Filter to a specific order $cart->draftOrder($orderId)->first(); ``` ### Scopes | Scope | Description | | :--------- | :---------------------------------------------------------------------------------- | | `unmerged` | Filters to carts that have not been merged into another cart (`merged_id` is null). | | `active` | Filters to carts that have no orders, or only have unplaced (draft) orders. | ```php theme={null} use Lunar\Models\Cart; $unmergedCarts = Cart::unmerged()->get(); $activeCarts = Cart::active()->get(); ``` ## Cart Lines Each item in a cart is represented by a `CartLine`. Lines link to a purchasable model (usually a `ProductVariant`) and track the desired quantity. ```php theme={null} Lunar\Models\CartLine ``` | Field | Type | Description | | :---------------- | :------------------- | :---------------------------------- | | id | `bigIncrements` | Primary key | | cart\_id | `foreignId` | | | purchasable\_type | `string` | Morph type for the purchasable item | | purchasable\_id | `unsignedBigInteger` | Morph ID for the purchasable item | | quantity | `unsignedInteger` | | | meta | `json` `nullable` | | | created\_at | `timestamp` | | | updated\_at | `timestamp` | | ### Relationships | Relationship | Type | Related Model | Description | | :------------ | :-------------- | :------------ | :---------------------------------------------------- | | `cart` | Belongs to | `Cart` | The cart this line belongs to. | | `purchasable` | Morph to | (polymorphic) | The purchasable item, typically a `ProductVariant`. | | `taxClass` | Has one through | `TaxClass` | The tax class, resolved through the purchasable item. | | `discounts` | Belongs to many | `Discount` | Discounts applied to this line. | ### Adding Lines Use the `add` method to add a purchasable item to the cart. If the item already exists in the cart, its quantity is updated instead. ```php theme={null} $cart->add($purchasable, quantity: 2, meta: [ 'personalization' => 'Happy Birthday!', ]); ``` Multiple lines can be added at once using `addLines`: ```php theme={null} use Lunar\Models\ProductVariant; $cart->addLines([ [ 'purchasable' => ProductVariant::find(1), 'quantity' => 2, 'meta' => ['foo' => 'bar'], ], [ 'purchasable' => ProductVariant::find(2), 'quantity' => 1, ], ]); ``` Lines can also be created directly via the relationship: ```php theme={null} $cart->lines()->create([ 'purchasable_type' => $purchasable->getMorphClass(), 'purchasable_id' => $purchasable->id, 'quantity' => 2, 'meta' => [ 'personalization' => 'Happy Birthday!', ], ]); ``` ### Updating Lines ```php theme={null} // Update a single line's quantity and meta $cart->updateLine($cartLineId, quantity: 3, meta: ['foo' => 'bar']); // Update multiple lines at once $cart->updateLines(collect([ ['id' => 1, 'quantity' => 5, 'meta' => ['foo' => 'bar']], ['id' => 2, 'quantity' => 3], ])); ``` ### Removing Lines ```php theme={null} // Remove a specific line $cart->remove($cartLineId); // Remove all lines $cart->clear(); ``` ### Line Validation When adding, updating, or removing items, a series of validation actions are run (defined in `config/lunar/cart.php`). These throw a `CartException` on failure. ```php theme={null} use Lunar\Exceptions\Carts\CartException; try { $cart->add($purchasable, quantity: 500); } catch (CartException $e) { $error = $e->getMessage(); } ``` ## Exceptions Lunar throws specific exceptions during cart operations to help identify what went wrong. All cart exceptions extend `Lunar\Exceptions\LunarException`. ### CartException ```php theme={null} Lunar\Exceptions\Carts\CartException ``` The primary exception for cart validation failures. It is thrown by the validation pipeline when adding, updating, or removing cart lines, or when creating an order. It wraps a `MessageBag` containing one or more error messages. ```php theme={null} use Lunar\Exceptions\Carts\CartException; try { $cart->add($purchasable, quantity: 500); } catch (CartException $e) { $e->getMessage(); // Summary of the first error $e->errors(); // Returns the full MessageBag } ``` When creating an order, the `ValidateCartForOrderCreation` validator may throw a `CartException` with one of the following messages: | Error Key | Cause | | :------------------------------ | :----------------------------------------------------------------------------------------------------------- | | `carts.order_exists` | The cart already has a completed order. | | `carts.billing_missing` | No billing address has been set on the cart. | | `carts.billing_incomplete` | The billing address is missing required fields (`country_id`, `first_name`, `line_one`, `city`, `postcode`). | | `carts.shipping_missing` | The cart contains shippable items but has no shipping address. | | `carts.shipping_incomplete` | The shipping address is missing required fields. | | `carts.shipping_option_missing` | The cart contains shippable items but no shipping option has been selected. | ### InvalidCartLineQuantityException ```php theme={null} Lunar\Exceptions\InvalidCartLineQuantityException ``` Thrown when attempting to add a purchasable to the cart with a quantity of zero or less. ```php theme={null} use Lunar\Exceptions\InvalidCartLineQuantityException; try { $cart->add($purchasable, quantity: 0); } catch (InvalidCartLineQuantityException $e) { // Quantity must be at least 1 } ``` ### NonPurchasableItemException ```php theme={null} Lunar\Exceptions\NonPurchasableItemException ``` Thrown when creating or updating a cart line with a model that does not implement the `Lunar\Base\Purchasable` interface. This is checked automatically by the `CartLineObserver`. ### CartLineIdMismatchException ```php theme={null} Lunar\Exceptions\CartLineIdMismatchException ``` Thrown when attempting to remove a cart line that does not belong to the specified cart. ```php theme={null} use Lunar\Exceptions\CartLineIdMismatchException; try { $cart->remove($cartLineId); } catch (CartLineIdMismatchException $e) { // The cart line ID does not belong to this cart } ``` ### DisallowMultipleCartOrdersException ```php theme={null} Lunar\Exceptions\DisallowMultipleCartOrdersException ``` Thrown when calling `createOrder()` on a cart that already has a completed order, unless `allowMultipleOrders: true` is passed. ```php theme={null} use Lunar\Exceptions\DisallowMultipleCartOrdersException; try { $order = $cart->createOrder(); } catch (DisallowMultipleCartOrdersException $e) { // Cart already has a completed order } ``` ### FingerprintMismatchException ```php theme={null} Lunar\Exceptions\FingerprintMismatchException ``` Thrown when the fingerprint passed to `checkFingerprint()` does not match the cart's current fingerprint. See [Detecting Cart Changes](#detecting-cart-changes) for usage details. ## Calculating Totals Call `calculate()` to hydrate the cart with computed prices and tax breakdowns. ```php theme={null} $cart->calculate(); ``` All monetary values return a `Lunar\DataTypes\Price` object, providing access to `value`, `formatted`, and `decimal` properties. To force recalculation (bypassing the cached result), use `recalculate`: ```php theme={null} $cart->recalculate(); ``` To check whether a cart has already been calculated: ```php theme={null} if ($cart->isCalculated()) { // Totals are available } ``` ### Cart-Level Properties | Property | Description | | :------------------- | :--------------------------------------------- | | `total` | The total price for the cart (including tax). | | `subTotal` | The cart sub total, excluding tax. | | `subTotalDiscounted` | The cart sub total, minus the discount amount. | | `taxTotal` | The total tax applied. | | `discountTotal` | The total discount applied. | | `shippingSubTotal` | The shipping total, excluding tax. | | `shippingTaxTotal` | The tax amount applied to shipping. | | `shippingTotal` | The shipping total, including tax. | ### Line-Level Properties After calculation, each cart line is also hydrated with its own totals: | Property | Description | | :--------------------- | :------------------------------------------------- | | `unitPrice` | Price for a single item. | | `unitPriceInclTax` | Price for a single item, including tax. | | `total` | Total price for this line. | | `subTotal` | Sub total, excluding tax. | | `subTotalDiscounted` | Sub total, minus the discount amount. | | `taxAmount` | Tax applied to this line. | | `taxBreakdown` | Collection of all taxes applied to this line. | | `discountTotal` | Discount applied to this line. | | `promotionDescription` | Description of the promotion applied to this line. | ```php theme={null} foreach ($cart->lines as $cartLine) { $cartLine->unitPrice; $cartLine->subTotal; $cartLine->taxAmount; // ... } ``` ### Breakdowns The cart also provides detailed breakdowns for tax, discounts, and shipping: ```php theme={null} // Tax breakdown foreach ($cart->taxBreakdown->amounts as $taxRate) { $taxRate->description; $taxRate->price->value; } // Discount breakdown foreach ($cart->discountBreakdown as $discountBreakdown) { $discountBreakdown->discount->id; $discountBreakdown->price->value; foreach ($discountBreakdown->lines as $discountLine) { $discountLine->quantity; $discountLine->line; } } // Shipping breakdown foreach ($cart->shippingBreakdown->items as $shippingBreakdown) { $shippingBreakdown->name; $shippingBreakdown->identifier; $shippingBreakdown->price->formatted(); } ``` ### Extending Cart Calculations To programmatically change cart values (e.g. custom discounts or prices), see [Cart Extending](/1.x/extending/carts). ## Cart Addresses Each cart can have a shipping and billing address, represented by the `CartAddress` model. These addresses are used when calculating tax breakdowns and shipping costs. ```php theme={null} Lunar\Models\CartAddress ``` | Field | Type | Description | | :--------------------- | :--------------------- | :----------------------------------------------------- | | id | `bigIncrements` | Primary key | | cart\_id | `foreignId` | | | country\_id | `foreignId` `nullable` | | | title | `string` `nullable` | | | first\_name | `string` `nullable` | | | last\_name | `string` `nullable` | | | company\_name | `string` `nullable` | | | tax\_identifier | `string` `nullable` | Tax ID or VAT number | | line\_one | `string` `nullable` | | | line\_two | `string` `nullable` | | | line\_three | `string` `nullable` | | | city | `string` `nullable` | | | state | `string` `nullable` | | | postcode | `string` `nullable` | | | delivery\_instructions | `string` `nullable` | | | contact\_email | `string` `nullable` | | | contact\_phone | `string` `nullable` | | | type | `string` | Either `shipping` or `billing`. Defaults to `shipping` | | shipping\_option | `string` `nullable` | The selected shipping option identifier | | meta | `json` `nullable` | | | created\_at | `timestamp` | | | updated\_at | `timestamp` | | ### Relationships | Relationship | Type | Related Model | Description | | :----------- | :--------- | :------------ | :-------------------------------- | | `cart` | Belongs to | `Cart` | The cart this address belongs to. | | `country` | Belongs to | `Country` | The country for this address. | ### Cached Properties After the cart is calculated, shipping addresses are hydrated with shipping-related totals: | Property | Description | | :----------------- | :------------------------------------------ | | `shippingOption` | The resolved `ShippingOption` data type. | | `shippingSubTotal` | The shipping sub total, excluding tax. | | `shippingTaxTotal` | The tax amount applied to shipping. | | `shippingTotal` | The shipping total, including tax. | | `taxBreakdown` | Breakdown of all taxes applied to shipping. | ## Setting Addresses Shipping and billing addresses can be set on the cart, which are used when calculating tax breakdowns. ```php theme={null} $cart->setShippingAddress([ 'first_name' => null, 'last_name' => null, 'line_one' => null, 'line_two' => null, 'line_three' => null, 'city' => null, 'state' => null, 'postcode' => null, 'country_id' => null, ]); $cart->setBillingAddress([ 'first_name' => null, 'last_name' => null, 'line_one' => null, 'line_two' => null, 'line_three' => null, 'city' => null, 'state' => null, 'postcode' => null, 'country_id' => null, ]); ``` An `Address` model or a `CartAddress` model can also be passed: ```php theme={null} use Lunar\Models\Address; $shippingAddress = Address::first(); $cart->setShippingAddress($shippingAddress); // Use the same address for billing $cart->setBillingAddress($cart->shippingAddress); ``` Retrieve addresses via the properties: ```php theme={null} $cart->shippingAddress; $cart->billingAddress; ``` During a cart's early lifetime, address information may not yet be available. Some countries don't display tax until checkout. The address-based tax calculation is designed to handle this: the addresses can be set when they become available. ## Shipping Options A shipping option can be set on the cart after an address has been provided. The available options are determined by the shipping manifest. ```php theme={null} $cart->setShippingOption($shippingOption); ``` To retrieve the currently selected shipping option: ```php theme={null} $shippingOption = $cart->getShippingOption(); ``` To check whether the cart contains any shippable items: ```php theme={null} if ($cart->isShippable()) { // Cart has items that require shipping } ``` ## Shipping Estimates It may be useful to show an estimated shipping cost before a full address is provided. The `getEstimatedShipping` method returns the cheapest available shipping option based on partial address data. ```php theme={null} use Lunar\Models\Country; $shippingOption = $cart->getEstimatedShipping([ 'postcode' => '123456', 'state' => 'Essex', 'country' => Country::first(), ]); ``` By default, this estimate is **not** used in cart total calculations. To include it, pass `setOverride: true`: ```php theme={null} use Lunar\Models\Country; $shippingOption = $cart->getEstimatedShipping([ 'postcode' => '123456', 'state' => 'Essex', 'country' => Country::first(), ], setOverride: true); ``` When `setOverride` is enabled, the returned shipping option bypasses other shipping logic in the cart pipelines, but only for that single request. The override can also be set manually: ```php theme={null} use Lunar\DataTypes\ShippingOption; $cart->shippingOptionOverride = new ShippingOption(/* .. */); ``` ## Creating Orders Once a cart has been calculated and all required information (addresses, shipping, etc.) has been provided, an order can be created from the cart. ```php theme={null} $order = $cart->createOrder(); ``` By default, a cart can only have one order. To allow multiple orders from the same cart (e.g. for split shipments), pass `allowMultipleOrders`: ```php theme={null} $order = $cart->createOrder(allowMultipleOrders: true); ``` To update an existing order instead of creating a new one, pass the order ID: ```php theme={null} $order = $cart->createOrder(orderIdToUpdate: $existingOrderId); ``` ### Checking Order Readiness Before attempting to create an order, check whether the cart has sufficient information: ```php theme={null} if ($cart->canCreateOrder()) { $order = $cart->createOrder(); } ``` To check whether the cart already has completed (placed) orders: ```php theme={null} $cart->hasCompletedOrders(); // bool ``` To retrieve the current draft order (matching the cart's fingerprint and total): ```php theme={null} $draftOrder = $cart->currentDraftOrder(); ``` ### Stock Validation Stock levels can be validated before order creation. This checks each cart line against the available stock for its purchasable item. ```php theme={null} use Lunar\Exceptions\Carts\CartException; try { $cart->validateStock(); } catch (CartException $e) { // One or more items are out of stock } ``` ## Associating Users and Customers A user can be associated directly on the cart model. The `policy` parameter controls how the association behaves when the user already has an existing cart: `merge` combines the carts, while `override` replaces the existing one. ```php theme={null} $cart->associate($user, policy: 'merge'); ``` A customer can also be associated: ```php theme={null} $cart->setCustomer($customer); ``` ## Cart Session Manager The cart session manager is useful when building a traditional Laravel storefront that uses sessions. The session manager provides a convenient API for managing carts tied to the current user's session. ### Configuration Cart configuration lives in `config/lunar/cart.php`: | Option | Description | Default | | :------------ | :------------------------------------------------------------------- | :-------- | | `auth_policy` | How to handle merging when a user logs in (`merge` or `override`). | `merge` | | `eager_load` | Relationships to eager load by default when calculating cart totals. | See below | The default `eager_load` relationships are: ```php theme={null} 'eager_load' => [ 'currency', 'lines.purchasable.taxClass', 'lines.purchasable.values', 'lines.purchasable.product.thumbnail', 'lines.purchasable.prices.currency', 'lines.purchasable.prices.priceable', 'lines.purchasable.product', 'lines.cart.currency', ], ``` Additional session-specific config is in `config/lunar/cart_session.php`: | Option | Description | Default | | :------------------------------- | :---------------------------------------------------------- | :----------- | | `session_key` | Key used when storing the cart ID in the session. | `lunar_cart` | | `auto_create` | Create a cart automatically if none exists for the session. | `false` | | `allow_multiple_orders_per_cart` | Whether carts can have multiple orders associated to them. | `false` | ### Getting the Session Instance Use the facade or inject the interface: ```php theme={null} use Lunar\Facades\CartSession; $cart = CartSession::current(); // Or via dependency injection use Lunar\Base\CartSessionInterface; public function __construct( protected CartSessionInterface $cartSession ) { // ... } ``` When `current()` is called, the behavior depends on the `auto_create` config. With `auto_create` set to `false` (default), `null` is returned if no cart exists, preventing unnecessary database records. ### Managing Lines ```php theme={null} use Lunar\Facades\CartSession; use Lunar\Models\ProductVariant; // Add a single line CartSession::add($purchasable, $quantity); // Add multiple lines (accepts a collection or array) CartSession::addLines([ [ 'purchasable' => ProductVariant::find(123), 'quantity' => 25, 'meta' => ['foo' => 'bar'], ], // ... ]); // Update a single line CartSession::updateLine($cartLineId, $quantity, $meta); // Update multiple lines CartSession::updateLines(collect([ [ 'id' => 1, 'quantity' => 25, 'meta' => ['foo' => 'bar'], ], // ... ])); // Remove a line CartSession::remove($cartLineId); // Remove all lines CartSession::clear(); ``` ### Using a Specific Cart ```php theme={null} use Lunar\Facades\CartSession; use Lunar\Models\Cart; $cart = Cart::first(); CartSession::use($cart); ``` ### Associating a User ```php theme={null} use Lunar\Facades\CartSession; // Associate a cart with a user and set it as the session cart // The third argument is the auth policy: 'merge' or 'override' CartSession::associate($cart, $user, 'merge'); ``` ### Associating a Customer A customer can be associated directly on the cart model: ```php theme={null} $cart->setCustomer($customer); ``` ### Forgetting the Cart Forgetting a cart removes it from the session and soft-deletes it from the database: ```php theme={null} use Lunar\Facades\CartSession; CartSession::forget(); ``` To remove it from the session without deleting: ```php theme={null} use Lunar\Facades\CartSession; CartSession::forget(delete: false); ``` ### Shipping Estimates When using the `CartSession` manager, shipping estimation parameters can be persisted so they do not need to be passed each time: ```php theme={null} use Lunar\Facades\CartSession; use Lunar\Models\Country; CartSession::estimateShippingUsing([ 'postcode' => '123456', 'state' => 'Essex', 'country' => Country::first(), ]); // Default behavior: no shipping estimate applied CartSession::current(); // Apply the shipping estimate set above CartSession::current(estimateShipping: true); ``` See [Shipping Estimates](#shipping-estimates) above for more on how the underlying estimation works. ## Handling User Login When a user logs in, Lunar automatically listens to authentication events and handles cart association. If the user had a guest cart, it will be merged with (or override) any existing cart on their account, depending on the `auth_policy` config. ## Detecting Cart Changes Carts are dynamic: items, quantities, and prices can change at any moment. To detect whether a cart has been modified (e.g. on a different browser tab during checkout), use the fingerprint system: ```php theme={null} use Lunar\Exceptions\FingerprintMismatchException; $fingerprint = $cart->fingerprint(); try { $cart->checkFingerprint('previously_stored_fingerprint'); } catch (FingerprintMismatchException $e) { // Cart has changed, refresh it } ``` The fingerprint generator class can be customized in `config/lunar/cart.php`: ```php theme={null} return [ // ... 'fingerprint_generator' => Lunar\Actions\Carts\GenerateFingerprint::class, ]; ``` ## Pruning Old Carts Over time, unused carts accumulate in the database. Lunar can automatically prune carts that have no associated order. Enable pruning in `config/lunar/cart.php`: ```php theme={null} return [ // ... 'prune_tables' => [ 'enabled' => false, // Set to true to enable 'pipelines' => [ Lunar\Pipelines\CartPrune\PruneAfter::class, Lunar\Pipelines\CartPrune\WithoutOrders::class, Lunar\Pipelines\CartPrune\WhereNotMerged::class, ], 'prune_interval' => 90, // days ], ]; ``` | Option | Description | Default | | :--------------- | :-------------------------------------------------------------------- | :------ | | `enabled` | Whether automatic pruning is active. | `false` | | `prune_interval` | Number of days to retain carts before pruning. | `90` | | `pipelines` | Pipeline classes that determine which carts are eligible for removal. | | ## Activity Logging The Cart model uses [Spatie Activity Log](https://spatie.be/docs/laravel-activitylog) to automatically record changes. All attribute changes are logged except for `updated_at`. ## Macros The Cart model supports macros, allowing custom methods to be added at runtime: ```php theme={null} use Lunar\Models\Cart; Cart::macro('myCustomMethod', function () { // ... }); $cart->myCustomMethod(); ``` # Channels Source: https://docs.lunarphp.com/1.x/reference/channels Channels control where products and other models are published across different storefronts. ## Overview Channels allow products and other models to be published to different storefronts or sales channels. Lunar installs a default `webstore` channel during setup. ```php theme={null} Lunar\Models\Channel ``` ### Fields | Field | Type | Description | | :----------- | :--------------------- | :------------------------------------------------------- | | `id` | `id` | Primary key | | `name` | `string` | The display name of the channel | | `handle` | `string` | URL-friendly identifier, automatically slugified on save | | `default` | `boolean` | Whether this is the default channel | | `url` | `string` `nullable` | An optional URL associated with the channel | | `created_at` | `timestamp` | | | `updated_at` | `timestamp` | | | `deleted_at` | `timestamp` `nullable` | | ### Relationships | Relationship | Type | Related Model | Description | | :------------ | :------------ | :------------------------ | :----------------------------------- | | `products` | `MorphToMany` | `Lunar\Models\Product` | Products assigned to this channel | | `collections` | `MorphToMany` | `Lunar\Models\Collection` | Collections assigned to this channel | | `discounts` | `MorphToMany` | `Lunar\Models\Discount` | Discounts assigned to this channel | ### Scopes | Scope | Description | | :------------------------- | :---------------------------- | | `default($default = true)` | Filter to the default channel | ## Assigning channels to models Models can be assigned to different channels and optionally scheduled for availability within a date range. To add this functionality to a model, use the `HasChannels` trait: ```php theme={null} scheduleChannel($channel, now()->addDays(14), now()->addDays(24)); // Enable the product on the channel immediately. $product->scheduleChannel($channel); // The method also accepts a collection of channels. $product->scheduleChannel(Channel::get()); ``` There is also a `channel` scope available to models using this trait: ```php theme={null} use Lunar\Models\Product; // Limit to a single channel Product::channel($channel)->get(); // Limit to multiple channels Product::channel([$channelA, $channelB])->get(); // Limit to a channel available the next day Product::channel($channelA, now()->addDay())->get(); // Limit to a channel within a date range Product::channel($channelA, now()->addDay(), now()->addDays(2))->get(); ``` # Collections Source: https://docs.lunarphp.com/1.x/reference/collections Group products into flexible, hierarchical collections with scheduling, channel availability, and sorting. Collections group products together for storefront categories, promotions, and other purposes. ## Overview Collections provide a flexible way to group products together for any purpose: storefront categories, hero sliders, landing page features, seasonal promotions, or any other grouping a store requires. Products can be added to a collection explicitly or filtered by criteria, and collections can be nested to form a hierarchy. Each collection belongs to a collection group, allowing different sets of collections to be maintained independently for different parts of a storefront. ## Collection Groups Collection groups act as top-level containers for collections. A store might have separate groups for a main catalog, seasonal promotions, or landing page features. ```php theme={null} Lunar\Models\CollectionGroup ``` ### Fields | Field | Type | Description | | :----------- | :-------------- | :--------------------- | | `id` | `bigIncrements` | Primary key | | `name` | `string` | The group name | | `handle` | `string` | Unique slug identifier | | `created_at` | `timestamp` | | | `updated_at` | `timestamp` | | ### Relationships | Relationship | Type | Related Model | Description | | :------------ | :------ | :------------------------ | :---------------------------- | | `collections` | HasMany | `Lunar\Models\Collection` | All collections in this group | ### Creating a collection group ```php theme={null} use Lunar\Models\CollectionGroup; $group = CollectionGroup::create([ 'name' => 'Main Catalogue', 'handle' => 'main-catalogue', ]); ``` ## Collections ```php theme={null} Lunar\Models\Collection ``` ### Fields | Field | Type | Description | | :-------------------- | :--------------------------- | :----------------------------------------------- | | `id` | `bigIncrements` | Primary key | | `collection_group_id` | `foreignId` | The parent collection group | | `_lft` | `unsignedInteger` | Read-only nested set left pointer | | `_rgt` | `unsignedInteger` | Read-only nested set right pointer | | `parent_id` | `unsignedInteger` `nullable` | Parent collection for tree hierarchy | | `type` | `string` | Default: `static` | | `attribute_data` | `json` | Translatable attributes (e.g. name, description) | | `sort` | `string` | Product sort method, default: `custom` | | `created_at` | `timestamp` | | | `updated_at` | `timestamp` | | | `deleted_at` | `timestamp` `nullable` | Soft deletes | ### Relationships | Relationship | Type | Related Model | Description | | :--------------- | :------------------ | :----------------------------- | :-------------------------------------------------- | | `group` | BelongsTo | `Lunar\Models\CollectionGroup` | The parent collection group | | `products` | BelongsToMany | `Lunar\Models\Product` | Products in this collection; pivot: `position` | | `customerGroups` | BelongsToMany | `Lunar\Models\CustomerGroup` | Pivot: `visible`, `enabled`, `starts_at`, `ends_at` | | `channels` | MorphToMany | `Lunar\Models\Channel` | Pivot: `enabled`, `starts_at`, `ends_at` | | `discounts` | BelongsToMany | `Lunar\Models\Discount` | Pivot: `type` (`limitation` or `assignment`) | | `brands` | BelongsToMany | `Lunar\Models\Brand` | | | `urls` | MorphMany | `Lunar\Models\Url` | | | `children` | HasMany | `Lunar\Models\Collection` | Direct child collections (from NodeTrait) | | `ancestors` | AncestorsRelation | `Lunar\Models\Collection` | All parent collections up the tree (from NodeTrait) | | `descendants` | DescendantsRelation | `Lunar\Models\Collection` | All children recursively (from NodeTrait) | ### Scopes | Scope | Description | | :------------------------------------------ | :------------------------------------------------------------- | | `inGroup(int $id)` | Filter by collection group ID | | `channel($channel, $startsAt, $endsAt)` | Filter by channel availability with optional scheduling | | `customerGroup($group, $startsAt, $endsAt)` | Filter by customer group availability with optional scheduling | ### Creating a collection ```php theme={null} use Lunar\Models\Collection; use Lunar\FieldTypes\Text; $collection = Collection::create([ 'collection_group_id' => $group->id, 'attribute_data' => [ 'name' => new Text('Clearance'), ], ]); ``` Lunar internally expects a `name` attribute on collection attribute data. It must be present in the attributes, otherwise the admin panel may throw unexpected errors. ### Nested Collections Collections form a tree hierarchy using the [kalnoy/nestedset](https://github.com/lazychaser/laravel-nestedset) package. Child collections can be added using the `appendNode` method: ```php theme={null} use Lunar\Models\Collection; $parent = Collection::create([ 'collection_group_id' => $group->id, 'attribute_data' => [ 'name' => new \Lunar\FieldTypes\Text('Clothing'), ], ]); $child = Collection::create([ 'collection_group_id' => $group->id, 'attribute_data' => [ 'name' => new \Lunar\FieldTypes\Text('T-Shirts'), ], ]); $parent->appendNode($child); ``` This produces the following hierarchy: ``` - Clothing - T-Shirts ``` At least one root (parent) collection must exist before child collections can be created. This is important when using seeders to set up the initial catalog state. #### Breadcrumbs The `breadcrumb` accessor returns the translated names of all ancestor collections, which is useful for building breadcrumb navigation: ```php theme={null} $collection->breadcrumb; // Illuminate\Support\Collection of ancestor names ``` #### Querying the tree The nested set package provides many methods for working with the tree. Here are some common examples: ```php theme={null} // Get all root collections Collection::whereIsRoot()->get(); // Get direct children $collection->children; // Get all descendants (recursive) $collection->descendants; // Get ancestors $collection->ancestors; // Get siblings $collection->getSiblings(); // Get depth in tree (requires withDepth() scope) Collection::withDepth()->find($collection->id)->depth; ``` Refer to the [kalnoy/nestedset documentation](https://github.com/lazychaser/laravel-nestedset) for the full API. ## Products Products are associated using a `BelongsToMany` relationship with a `position` pivot column for ordering. ### Adding products ```php theme={null} $collection->products()->sync([ $productA->id => ['position' => 1], $productB->id => ['position' => 2], ]); ``` ### Sorting products The `sort` field on a collection determines how products are ordered. Lunar ships with the following sort options: | Sort | Description | | :--------------- | :------------------------------------------------------------------ | | `min_price:asc` | Sort by base price ascending | | `min_price:desc` | Sort by base price descending | | `sku:asc` | Sort by SKU ascending | | `sku:desc` | Sort by SKU descending | | `custom` | Manually specify the order of each product via the `position` pivot | When a collection is updated, Lunar automatically dispatches the `UpdateProductPositions` job to reorder products based on the current sort setting. ## Channels Collections use the `HasChannels` trait, enabling channel-based availability with optional scheduling. See [Channels](/1.x/reference/channels) for full details. ```php theme={null} use Lunar\Models\Channel; $channel = Channel::first(); // Enable for a channel immediately $collection->scheduleChannel($channel); // Schedule availability with a start and end date $collection->scheduleChannel($channel, now()->addDays(7), now()->addDays(30)); ``` ### Querying by channel ```php theme={null} use Lunar\Models\Collection; Collection::channel($channel)->get(); Collection::channel($channel, now()->addDay(), now()->addDays(7))->get(); ``` ## Customer Groups Collections use the `HasCustomerGroups` trait, enabling customer group visibility with optional scheduling. See [Customers](/1.x/reference/customers) for full details. ```php theme={null} use Lunar\Models\CustomerGroup; $group = CustomerGroup::first(); // Enable for a customer group immediately $collection->scheduleCustomerGroup($group); // Schedule with a date range $collection->scheduleCustomerGroup($group, starts: now()->addDays(7), ends: now()->addDays(30)); // Disable for a customer group $collection->unscheduleCustomerGroup($group); ``` ### Querying by customer group ```php theme={null} use Lunar\Models\Collection; Collection::customerGroup($group)->get(); Collection::customerGroup([$groupA, $groupB])->get(); ``` ## URLs Collections use the `HasUrls` trait, which automatically generates URLs when a collection is created. ```php theme={null} // Get the default URL $collection->defaultUrl; // Get all URLs $collection->urls; ``` See [URLs](/1.x/reference/urls) for full details on managing URLs. ## Media Collections use the `HasMedia` trait (via Spatie Media Library), allowing images and other media to be attached. ```php theme={null} $collection->addMedia($pathToFile)->toMediaCollection('images'); // Get the thumbnail URL $collection->getThumbnailImage(); ``` ## Deletion Behavior Collections use soft deletes. When a collection is deleted, all related pivot data is automatically detached: * Products * Channels * URLs (hard deleted) * Customer groups * Discounts # Countries & States Source: https://docs.lunarphp.com/1.x/reference/countries-states Lunar ships with a full dataset of countries and states used for addresses, shipping, and tax calculations. ## Overview Lunar ships with a full dataset of countries and states that can be imported into your database. These are used across the system for [Addresses](/1.x/reference/addresses), shipping zone configuration, tax calculations, and more. ## Countries ```php theme={null} Lunar\Models\Country ``` | Field | Description | | :----------- | :----------------------------------- | | `id` | | | `name` | Full country name e.g. United States | | `iso3` | ISO 3166-1 alpha-3 code e.g. USA | | `iso2` | ISO 3166-1 alpha-2 code e.g. US | | `phonecode` | International dialling code e.g. 1 | | `capital` | Capital city | | `currency` | Currency code e.g. USD | | `native` | Native name of the country | | `emoji` | Country flag emoji | | `emoji_u` | Country flag unicode | | `created_at` | | | `updated_at` | | ### Retrieving countries ```php theme={null} use Lunar\Models\Country; // Find a country by ISO code $country = Country::where('iso2', 'US')->first(); // Get all countries $countries = Country::all(); // Get a country with its states $country = Country::with('states')->where('iso2', 'GB')->first(); ``` ### Relationships | Relationship | Type | | :----------- | :-------- | | `states` | `HasMany` | ## States States (also known as provinces or regions) belong to a country. They are useful for tax calculations and shipping zone configuration. ```php theme={null} Lunar\Models\State ``` | Field | Description | | :----------- | :------------------------------ | | `id` | | | `country_id` | The related country | | `name` | Full state name e.g. California | | `code` | State code e.g. CA | | `created_at` | | | `updated_at` | | ### Retrieving states ```php theme={null} use Lunar\Models\State; // Get all states for a country $states = $country->states; // Find a state by code within a country $state = State::where('country_id', $country->id) ->where('code', 'CA') ->first(); ``` ### Relationships | Relationship | Type | | :----------- | :---------- | | `country` | `BelongsTo` | ## Importing Data Country and state data is provided by the [countries-states-cities-database](https://github.com/dr5hn/countries-states-cities-database). Use the following Artisan command to import the data into your database. ```sh theme={null} php artisan lunar:import:address-data ``` This command should be run after installing Lunar and after any updates that may include new country or state data. # Currencies Source: https://docs.lunarphp.com/1.x/reference/currencies Currencies define the monetary units available for pricing, with exchange rates for conversion. ## Overview Currencies allow pricing to be managed in different monetary units. Each currency has an exchange rate relative to the default currency, enabling price conversion. ```php theme={null} Lunar\Models\Currency ``` ### Fields | Field | Type | Description | | :--------------- | :-------------- | :------------------------------------------------------------------- | | `id` | `id` | Primary key | | `code` | `string` | The [ISO 4217](https://en.wikipedia.org/wiki/ISO_4217) currency code | | `name` | `string` | A descriptive name for the currency | | `exchange_rate` | `decimal(10,4)` | The exchange rate relative to the default currency | | `decimal_places` | `integer` | The number of decimal places, e.g. `2` | | `enabled` | `boolean` | Whether the currency is enabled | | `default` | `boolean` | Whether the currency is the default | | `sync_prices` | `boolean` | Whether to synchronize prices for this currency | | `created_at` | `timestamp` | | | `updated_at` | `timestamp` | | ### Relationships | Relationship | Type | Related Model | Description | | :----------- | :-------- | :------------------- | :---------------------- | | `prices` | `HasMany` | `Lunar\Models\Price` | Prices in this currency | ### Scopes | Scope | Description | | :------------------------- | :------------------------------------ | | `enabled($enabled = true)` | Filter to enabled/disabled currencies | | `default($default = true)` | Filter to the default currency | ## Creating a currency ```php theme={null} use Lunar\Models\Currency; Currency::create([ 'code' => 'GBP', 'name' => 'British Pound', 'exchange_rate' => 1.0000, 'decimal_places' => 2, 'enabled' => true, 'default' => true, ]); ``` ## Exchange rates Exchange rates are relative to the default currency. For example, given the following default currency: ```php theme={null} use Lunar\Models\Currency; Currency::create([ 'code' => 'GBP', 'name' => 'British Pound', 'exchange_rate' => 1.0000, 'decimal_places' => 2, 'enabled' => true, 'default' => true, ]); ``` The default currency's exchange rate is set to `1` as the base. To add EUR (Euros) when the exchange rate from GBP to EUR is `1.17`, the value should be relative to the default: 1 / 1.17 = 0.8547. The exchange rate is independent of product pricing, as prices can be specified per currency directly. The exchange rate serves as a helper when working with prices. # Customers Source: https://docs.lunarphp.com/1.x/reference/customers Customers represent the buyers in a store, kept separate from the application's User model. ## Overview Lunar uses a dedicated Customer model to store customer details, rather than the User model. This keeps the application's User models untouched and provides greater flexibility. ## Customers ```php theme={null} Lunar\Models\Customer ``` | Field | Type | Description | | :--------------- | :------------------ | :---------------------------- | | `id` | | primary key | | `title` | `string` `nullable` | Salutation e.g. Mr, Mrs, Miss | | `first_name` | `string` | | | `last_name` | `string` | | | `company_name` | `string` `nullable` | | | `tax_identifier` | `string` `nullable` | | | `account_ref` | `string` `nullable` | | | `attribute_data` | `json` `nullable` | | | `meta` | `json` `nullable` | | | `created_at` | `timestamp` | | | `updated_at` | `timestamp` | | ### Creating a customer ```php theme={null} Lunar\Models\Customer::create([ 'title' => 'Mr.', 'first_name' => 'Tony', 'last_name' => 'Stark', 'company_name' => 'Stark Enterprises', 'tax_identifier' => 'GB123456789', 'meta' => [ 'account_no' => 'TNYSTRK1234' ], ]); ``` ### Accessors The Customer model provides a `full_name` accessor that combines the `title`, `first_name`, and `last_name` fields. ```php theme={null} $customer = Lunar\Models\Customer::create([ 'title' => 'Mr.', 'first_name' => 'Tony', 'last_name' => 'Stark', ]); $customer->full_name; // "Mr. Tony Stark" ``` ### Relationships | Relationship | Type | Related Model | Description | | :----------------- | :-------------- | :------------------------------------------ | :------------------------------- | | `customerGroups` | `BelongsToMany` | `Lunar\Models\CustomerGroup` | Pivot: `customer_customer_group` | | `users` | `BelongsToMany` | Configured via `auth.providers.users.model` | Pivot: `customer_user` | | `discounts` | `BelongsToMany` | `Lunar\Models\Discount` | Pivot: `customer_discount` | | `addresses` | `HasMany` | `Lunar\Models\Address` | | | `orders` | `HasMany` | `Lunar\Models\Order` | | | `mappedAttributes` | `MorphToMany` | `Lunar\Models\Attribute` | | ## Users Customers are typically associated with a user so that they can place orders. It is also possible to have multiple users associated with a single customer. This is useful in B2B e-commerce where a customer may have multiple buyers. ### Attaching users to a customer ```php theme={null} $customer = \Lunar\Models\Customer::create([/* ... */]); $customer->users()->attach($user); $customer->users()->sync([$userA->id, $userB->id, $userC->id]); ``` ## Attaching a customer to a customer group ```php theme={null} $customer = \Lunar\Models\Customer::create([/* ... */]); $customer->customerGroups()->attach($customerGroup); $customer->customerGroups()->sync([$groupA->id, $groupB->id]); ``` ## Customer Groups Default `retail` Customer groups allow customers to be organized into logical segments, enabling different criteria to be defined on models based on which group a customer belongs to. These criteria include: ### Pricing Different pricing can be specified per customer group. For example, customers in the `trade` customer group may have different prices than those in `retail`. ### Product Availability Product visibility can be toggled depending on the customer group, meaning only certain products will show depending on the group a customer belongs to. Scheduling availability is also supported, allowing products to be released earlier or later to different groups. *** At least one customer group must exist in the store. When Lunar is installed, a default group named `retail` is created. ## Creating a customer group ```php theme={null} $customerGroup = Lunar\Models\CustomerGroup::create([ 'name' => 'Retail', 'handle' => 'retail', // Must be unique 'default' => false, ]); ``` Only one default customer group can exist at a time. If a new customer group is created with `default` set to `true`, the existing default will be set to `false`. ## Scheduling availability To add customer group availability to custom models, the `HasCustomerGroups` trait can be used. ```php theme={null} // ... use Lunar\Base\Traits\HasCustomerGroups; use Illuminate\Database\Eloquent\Relations\BelongsToMany; class MyModel extends Model { use HasCustomerGroups; public function customerGroups(): BelongsToMany { $prefix = config('lunar.database.table_prefix'); return $this->belongsToMany( \Lunar\Models\CustomerGroup::class, "{$prefix}customer_group_my_model" )->withTimestamps()->withPivot(['enabled', 'visible', 'starts_at', 'ends_at']); } } ``` This provides access to the following methods: ### Scheduling customer groups ```php theme={null} // Schedule with a specific start and end date. $myModel->scheduleCustomerGroup( $customerGroup, starts: now()->addDays(14), ends: now()->addDays(28), ); // Schedule the model to be enabled immediately (starts_at defaults to null). $myModel->scheduleCustomerGroup($customerGroup); // The schedule method will accept an array or collection of customer groups. $myModel->scheduleCustomerGroup(CustomerGroup::get()); ``` ### Unscheduling customer groups To disable a model for a customer group, it can be unscheduled. This sets `enabled` to `false` and clears the `starts_at` and `ends_at` dates. ```php theme={null} $myModel->unscheduleCustomerGroup($customerGroup); // Additional pivot data can also be passed. $myModel->unscheduleCustomerGroup($customerGroup, pivotData: ['visible' => false]); ``` ### Parameters | Field | Description | Type | | :----------- | :------------------------------------------------------------------- | :--------------- | | `$models` | A CustomerGroup model, array, or collection of CustomerGroup models. | mixed | | `$starts` | The date the customer group will be active from. | `DateTime\|null` | | `$ends` | The date the customer group will be active until. | `DateTime\|null` | | `$pivotData` | Any additional pivot data on the link table. | `array` | **Pivot Data** By default the following values are used for `$pivotData`: * `enabled` - Whether the customer group is enabled. Defaults to `true` when scheduling and `false` when unscheduling. * `starts_at` - When scheduling, set to the `$starts` value. When unscheduling, set to `null`. * `ends_at` - When scheduling, set to the `$ends` value. When unscheduling, set to `null`. Any of these values can be overridden, as they are merged internally. ### Querying by customer group The `HasCustomerGroups` trait adds a `customerGroup` scope to the model. This allows querying based on availability for one or more customer groups. The scope accepts a `CustomerGroup` model instance, an array, or a collection. ```php theme={null} $results = MyModel::customerGroup($customerGroup)->paginate(); $results = MyModel::customerGroup([ $groupA, $groupB, ])->paginate(50); ``` The `$startsAt` and `$endsAt` parameters can optionally be passed to filter by scheduling window. Both should be `DateTime` instances. * If neither is provided, both default to `now()` (with `$endsAt` set one second ahead). * The scope returns models where `starts_at` is `null` or before the given start, `ends_at` is `null` or after the given end, and either `enabled` or `visible` is `true`. ```php theme={null} // Filter by a specific date window. $results = MyModel::customerGroup($customerGroup, now()->addDay(), now()->addDays(7))->get(); ``` A model will only be returned if the `enabled` or `visible` column is `true` on the pivot, regardless of whether the start and end dates match. ### Limit by customer group Eloquent models that use the `HasCustomerGroups` trait have a useful scope available: ```php theme={null} // Limit products available to a single customer group Product::customerGroup($customerGroup)->get(); // Limit products available to multiple customer groups Product::customerGroup([$groupA, $groupB])->get(); // Limit to products that are available the next day Product::customerGroup($groupA, now()->addDay())->get(); // Limit to products that are available within a date range. Product::customerGroup($groupA, now()->addDay(), now()->addDays(2))->get(); ``` # Discounts Source: https://docs.lunarphp.com/1.x/reference/discounts Flexible discount system supporting coupons, percentage/fixed reductions, and buy-x-get-y promotions. Lunar provides a flexible discount system supporting coupons, percentage and fixed reductions, and buy-x-get-y promotions. ## Overview Lunar provides a discount system that supports multiple discount types out of the box, including amount-off (percentage or fixed) and buy-x-get-y promotions. Discounts can be scoped to specific channels, customer groups, collections, brands, and individual products or variants. ```php theme={null} Lunar\Models\Discount ``` | Field | Type | Description | | :------------------ | :--------------------------------- | :--------------------------------------------------------------------- | | `id` | `bigIncrements` | Primary key | | `name` | `string` | | | `handle` | `string` | Unique identifier | | `coupon` | `string` `nullable` | Coupon code customers can enter to apply the discount | | `type` | `string` | Fully qualified class name of the discount type | | `starts_at` | `dateTime` | When the discount becomes active | | `ends_at` | `dateTime` `nullable` | When the discount expires, if `null` the discount does not expire | | `uses` | `unsignedInteger` | How many times the discount has been used | | `max_uses` | `unsignedMediumInteger` `nullable` | Maximum times this discount can be applied storewide | | `max_uses_per_user` | `unsignedMediumInteger` `nullable` | Maximum times a single user can use this discount | | `priority` | `unsignedMediumInteger` | Order of priority (default: `1`) | | `stop` | `boolean` | Whether to stop processing further discounts after this one is applied | | `restriction` | `string` `nullable` | Restriction type | | `data` | `json` `nullable` | Discount type-specific configuration data | | `created_at` | `timestamp` | | | `updated_at` | `timestamp` | | ### Relationships | Relationship | Type | Related Model | Description | | :------------------------ | :-------------- | :--------------------------- | :---------------------------------------------------------------------- | | `users` | `BelongsToMany` | `User` | Users who have used this discount | | `customers` | `BelongsToMany` | `Lunar\Models\Customer` | Customers the discount is restricted to | | `customerGroups` | `BelongsToMany` | `Lunar\Models\CustomerGroup` | Customer groups the discount is available to | | `channels` | `MorphToMany` | `Lunar\Models\Channel` | Channels the discount is available on | | `collections` | `BelongsToMany` | `Lunar\Models\Collection` | Collections associated with the discount | | `brands` | `BelongsToMany` | `Lunar\Models\Brand` | Brands associated with the discount | | `discountables` | `HasMany` | `Lunar\Models\Discountable` | All discountable entries (conditions, exclusions, limitations, rewards) | | `discountableConditions` | `HasMany` | `Lunar\Models\Discountable` | Products or variants that must be in the cart to activate the discount | | `discountableExclusions` | `HasMany` | `Lunar\Models\Discountable` | Products or variants excluded from the discount | | `discountableLimitations` | `HasMany` | `Lunar\Models\Discountable` | Products or variants the discount is limited to | | `discountableRewards` | `HasMany` | `Lunar\Models\Discountable` | Reward products or variants (used by BuyXGetY) | ### Scopes | Scope | Description | | :------------------------------------- | :--------------------------------------------------------------------------------- | | `active()` | Filters to discounts that have started and have not expired | | `usable()` | Filters to discounts where `uses` is less than `max_uses`, or `max_uses` is `null` | | `products($productIds, $types)` | Filters by associated product IDs and discountable types | | `productVariants($variantIds, $types)` | Filters by associated variant IDs and discountable types | | `collections($collectionIds, $types)` | Filters by associated collection IDs and discountable types | | `brands($brandIds, $types)` | Filters by associated brand IDs and discountable types | | `channel($channel)` | Filters by channel | | `customerGroup($customerGroup)` | Filters by customer group | ## Creating a Discount ```php theme={null} use Lunar\Models\Discount; Discount::create([ 'name' => '20% Coupon', 'handle' => '20_coupon', 'coupon' => '20OFF', 'type' => 'Lunar\DiscountTypes\AmountOff', 'data' => [ 'fixed_value' => false, 'percentage' => 20, 'min_prices' => [ 'USD' => 2000, // $20.00 minimum spend ], ], 'starts_at' => '2024-01-01 00:00:00', 'ends_at' => null, 'max_uses' => null, ]); ``` ## Discount Status The `Discount` model provides a `status` attribute that returns the current state of the discount based on its dates and usage. ```php theme={null} use Lunar\Models\Discount; $discount = Discount::find(1); $discount->status; // 'active', 'pending', 'expired', or 'scheduled' ``` | Status | Description | | :---------- | :------------------------------------------- | | `active` | The discount has started and has not expired | | `pending` | The discount has not started yet | | `expired` | The discount has passed its `ends_at` date | | `scheduled` | The discount is scheduled for a future date | ## Resetting the Discount Cache For performance reasons, applicable discounts are cached per request. To reset this cache (for example, after adding a discount code to a cart), call `resetDiscounts()` on the `Discounts` facade: ```php theme={null} use Lunar\Facades\Discounts; Discounts::resetDiscounts(); ``` ## Validating Coupons The `Discounts` facade provides a method to validate whether a coupon code is valid: ```php theme={null} use Lunar\Facades\Discounts; $isValid = Discounts::validateCoupon('20OFF'); ``` The default coupon validator checks that the coupon exists on an active, usable discount. The validator class can be customized in the `config/lunar/discounts.php` configuration file: ```php theme={null} return [ 'coupon_validator' => \Lunar\Base\Validation\CouponValidator::class, ]; ``` ## Discountable The `Discountable` model links products, product variants, or collections to a discount. Each entry has a `type` that determines its role. ```php theme={null} Lunar\Models\Discountable ``` | Field | Type | Description | | :------------------ | :------------------- | :------------------------------------------------------------ | | `id` | `bigIncrements` | Primary key | | `discount_id` | `foreignId` | | | `discountable_type` | `string` | Morph type (e.g., `product`, `product_variant`, `collection`) | | `discountable_id` | `unsignedBigInteger` | Morph ID | | `type` | `string` | The role: `condition`, `exclusion`, `limitation`, or `reward` | | `created_at` | `timestamp` | | | `updated_at` | `timestamp` | | The `type` field determines how the discountable relates to the discount: * **`condition`** — The product or variant must be in the cart for the discount to activate. * **`exclusion`** — The product or variant is excluded from the discount. * **`limitation`** — The discount only applies to these products or variants. * **`reward`** — These products or variants are given as the reward (used by BuyXGetY). ### Relationships | Relationship | Type | Related Model | Description | | :------------- | :---------- | :------------------------------------------- | :--------------------------------------- | | `discount` | `BelongsTo` | `Lunar\Models\Discount` | The parent discount | | `discountable` | `MorphTo` | `Product`, `ProductVariant`, or `Collection` | The associated purchasable or collection | ## Built-in Discount Types Lunar ships with two discount types. Both extend `Lunar\DiscountTypes\AbstractDiscountType`. ### AmountOff ```php theme={null} Lunar\DiscountTypes\AmountOff ``` Applies either a percentage or fixed amount discount to eligible cart lines. The `data` column stores the discount configuration: **Percentage discount:** ```php theme={null} use Lunar\Models\Discount; Discount::create([ 'name' => '10% Off', 'handle' => '10_percent_off', 'type' => 'Lunar\DiscountTypes\AmountOff', 'data' => [ 'fixed_value' => false, 'percentage' => 10, 'min_prices' => [ 'USD' => 5000, // $50.00 minimum spend ], ], 'starts_at' => now(), 'ends_at' => null, ]); ``` **Fixed value discount:** ```php theme={null} use Lunar\Models\Discount; Discount::create([ 'name' => '$5 Off', 'handle' => '5_dollars_off', 'type' => 'Lunar\DiscountTypes\AmountOff', 'data' => [ 'fixed_value' => true, 'fixed_values' => [ 'USD' => 500, // $5.00 off (stored in minor units) 'EUR' => 450, ], ], 'starts_at' => now(), 'ends_at' => null, ]); ``` ### BuyXGetY ```php theme={null} Lunar\DiscountTypes\BuyXGetY ``` Allows "buy X, get Y free" style promotions. Condition products are defined through the `discountableConditions` relationship, and reward products through the `discountableRewards` relationship. ```php theme={null} use Lunar\Models\Discount; $discount = Discount::create([ 'name' => 'Buy 2 Get 1 Free', 'handle' => 'buy_2_get_1', 'type' => 'Lunar\DiscountTypes\BuyXGetY', 'data' => [ 'min_qty' => 2, 'reward_qty' => 1, 'max_reward_qty' => 5, 'automatically_add_rewards' => false, ], 'starts_at' => now(), 'ends_at' => null, ]); ``` | Data Field | Type | Description | | :-------------------------- | :-------- | :---------------------------------------------------------------------- | | `min_qty` | `integer` | Minimum quantity of condition products required to trigger the discount | | `reward_qty` | `integer` | Number of reward items per qualifying group | | `max_reward_qty` | `integer` | Maximum total reward items (optional) | | `automatically_add_rewards` | `boolean` | Whether to automatically add reward items to the cart | ## Custom Discount Types Custom discount types can be created by extending `Lunar\DiscountTypes\AbstractDiscountType`: ```php theme={null} There should only ever be one default language. Setting more than one language as default will likely cause unexpected behavior. # Media Source: https://docs.lunarphp.com/1.x/reference/media Managing images and files on Lunar models using Spatie's Media Library. Lunar uses Spatie's Media Library package to manage images and files across models. ## Overview Lunar uses the [Laravel-medialibrary](https://spatie.be/docs/laravel-medialibrary) package by Spatie to handle media across all models. Rather than reinvent the wheel, Lunar leverages this battle-tested package and adds its own conventions on top, including configurable media definitions, automatic image conversions, and primary image management. ### Supported Models The following models support media out of the box via the `Lunar\Base\Traits\HasMedia` trait: | Model | Class | Has Thumbnail | | -------------------- | --------------------------------- | ------------- | | Product | `Lunar\Models\Product` | Yes | | Product Option | `Lunar\Models\ProductOption` | No | | Product Option Value | `Lunar\Models\ProductOptionValue` | No | | Collection | `Lunar\Models\Collection` | Yes | | Brand | `Lunar\Models\Brand` | No | | Asset | `Lunar\Models\Asset` | No | `Lunar\Models\ProductVariant` does not use the `HasMedia` trait directly. Instead, it uses a many-to-many relationship with media through a pivot table. See [Product Variant Images](#product-variant-images) for details. ## Configuration The media configuration is published at `config/lunar/media.php`. ```php theme={null} use Lunar\Base\StandardMediaDefinitions; return [ 'definitions' => [ 'asset' => StandardMediaDefinitions::class, 'brand' => StandardMediaDefinitions::class, 'collection' => StandardMediaDefinitions::class, 'product' => StandardMediaDefinitions::class, 'product-option' => StandardMediaDefinitions::class, 'product-option-value' => StandardMediaDefinitions::class, ], 'collection' => 'images', 'fallback' => [ 'url' => env('FALLBACK_IMAGE_URL', null), 'path' => env('FALLBACK_IMAGE_PATH', null), ], ]; ``` | Key | Description | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `definitions` | Maps model aliases to their media definition class. Each model can have its own definition class to customize collections and conversions. | | `collection` | The default media collection name used across all models. | | `fallback.url` | A fallback URL returned when a model has no media. | | `fallback.path` | A fallback file path returned when a model has no media. | The `definitions` keys use kebab-case aliases derived from the model class name (e.g., `Product` becomes `product`, `ProductOptionValue` becomes `product-option-value`). You can also use the fully qualified class name as the key. ## Adding Media Adding media to a model follows the standard Spatie Media Library API. ```php theme={null} use Lunar\Models\Product; $product = Product::find(123); $product->addMedia($request->file('image'))->toMediaCollection('images'); ``` For more information, see [Associating files](https://spatie.be/docs/laravel-medialibrary/v11/basic-usage/associating-files) in the Spatie documentation. ## Retrieving Media ```php theme={null} use Lunar\Models\Product; $product = Product::find(123); // Get all images $product->getMedia('images'); // Get the first image URL $product->getFirstMediaUrl('images'); // Get a specific conversion URL $product->getFirstMediaUrl('images', 'medium'); ``` For more information, see [Retrieving media](https://spatie.be/docs/laravel-medialibrary/v11/basic-usage/retrieving-media) in the Spatie documentation. ## Primary Images Lunar adds a concept of "primary" images on top of Spatie's media library. Each model can have one image marked as primary per collection, tracked via a `primary` custom property on the media item. ### Thumbnail Relationship Models using the `HasMedia` trait have a `thumbnail` relationship that returns the primary image: ```php theme={null} use Lunar\Models\Product; $product = Product::find(123); // Get the primary image via the thumbnail relationship $product->thumbnail; ``` ### Automatic Primary Management Lunar includes a `MediaObserver` that automatically enforces the following rules: * When a media item is marked as primary, all other items in the same collection are unmarked. * When the primary image is deleted, the first remaining image in the collection is automatically promoted to primary. * When a new image is added to an empty collection, it is automatically marked as primary. ### Setting the Primary Image ```php theme={null} $media = $product->getMedia('images')->first(); $media->setCustomProperty('primary', true)->save(); ``` ## Product Variant Images `ProductVariant` handles media differently from other models. Instead of using the `HasMedia` trait, it uses a many-to-many relationship with the `media` table through a `media_product_variant` pivot table. ```php theme={null} use Lunar\Models\ProductVariant; $variant = ProductVariant::find(456); // Get all images (ordered by position) $variant->images; // Get the thumbnail (primary image, or falls back to the product's thumbnail) $variant->getThumbnail(); ``` The pivot table includes: | Column | Type | Description | | ---------- | -------------- | ------------------------------------------------- | | `primary` | `boolean` | Whether this is the primary image for the variant | | `position` | `smallInteger` | Display order of the image | ## Fallback Images If a model has no media, calling `getFirstMediaUrl` or `getFirstMediaPath` returns an empty string by default. Fallback images can be configured in `config/lunar/media.php` or via environment variables: ```php theme={null} 'fallback' => [ 'url' => env('FALLBACK_IMAGE_URL', null), 'path' => env('FALLBACK_IMAGE_PATH', null), ] ``` These values are passed to Spatie's `useFallbackUrl` and `useFallbackPath` methods on the media collection. ## Default Conversions The `StandardMediaDefinitions` class registers the following image conversions by default: | Conversion | Width | Height | Notes | | ---------- | ----- | ------ | --------------------------------------------------------------------------------------- | | `small` | 300 | 300 | Used by the admin panel for thumbnails. Registered globally (runs for all collections). | | `medium` | 500 | 500 | Registered on the `images` collection. | | `large` | 800 | 800 | Registered on the `images` collection. | | `zoom` | 500 | 500 | Registered on the `images` collection. | All conversions use `Fit::Fill` for sizing, a white background, and preserve the original image format. ## Custom Media Definitions To customize the media collections and conversions for a model, create a class that implements `Lunar\Base\MediaDefinitionsInterface`: ```php theme={null} namespace App\Media; use Lunar\Base\MediaDefinitionsInterface; use Spatie\Image\Enums\Fit; use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\MediaCollections\MediaCollection; use Spatie\MediaLibrary\MediaCollections\Models\Media; class CustomMediaDefinitions implements MediaDefinitionsInterface { public function registerMediaConversions(HasMedia $model, ?Media $media = null): void { $model->addMediaConversion('small') ->fit(Fit::Fill, 300, 300) ->sharpen(10) ->keepOriginalImageFormat(); } public function registerMediaCollections(HasMedia $model): void { $fallbackUrl = config('lunar.media.fallback.url'); $fallbackPath = config('lunar.media.fallback.path'); // Reset to avoid duplication $model->mediaCollections = []; $collection = $model->addMediaCollection('images'); if ($fallbackUrl) { $collection = $collection->useFallbackUrl($fallbackUrl); } if ($fallbackPath) { $collection = $collection->useFallbackPath($fallbackPath); } $this->registerCollectionConversions($collection, $model); } protected function registerCollectionConversions(MediaCollection $collection, HasMedia $model): void { $conversions = [ 'zoom' => [ 'width' => 500, 'height' => 500, ], 'large' => [ 'width' => 800, 'height' => 800, ], 'medium' => [ 'width' => 500, 'height' => 500, ], ]; $collection->registerMediaConversions(function (Media $media) use ($model, $conversions) { foreach ($conversions as $key => $conversion) { $model->addMediaConversion($key) ->fit( Fit::Fill, $conversion['width'], $conversion['height'] )->keepOriginalImageFormat(); } }); } public function getMediaCollectionTitles(): array { return [ 'images' => 'Images', ]; } public function getMediaCollectionDescriptions(): array { return [ 'images' => '', ]; } } ``` Then register the class in `config/lunar/media.php`: ```php theme={null} 'definitions' => [ 'product' => \App\Media\CustomMediaDefinitions::class, // ... ], ``` ### Regenerating Conversions After changing conversion definitions, regenerate existing conversions using the Spatie artisan command: ```bash theme={null} php artisan media-library:regenerate ``` This creates queue jobs for each media entry to be reprocessed. See the [Spatie documentation](https://spatie.be/docs/laravel-medialibrary/v11/converting-images/regenerating-images) for more options. ## Extending Your Own Models Custom models can be given media support using the Lunar `HasMedia` trait: ```php theme={null} namespace App\Models; use Illuminate\Database\Eloquent\Model; use Lunar\Base\Traits\HasMedia; use Spatie\MediaLibrary\HasMedia as SpatieHasMedia; class YourCustomModel extends Model implements SpatieHasMedia { use HasMedia; } ``` This provides access to all Lunar media features including configurable definitions and automatic conversions. To use Lunar's media definitions and conversions, models must use the `Lunar\Base\Traits\HasMedia` trait. Using Spatie's `InteractsWithMedia` trait directly bypasses Lunar's media definition system. See the [Spatie documentation](https://spatie.be/docs/laravel-medialibrary/v11/basic-usage/preparing-your-model) for using the library directly. ### Definition Resolution The `HasMedia` trait resolves the media definition class for a model using the following priority: 1. A snake\_case alias in `config('lunar.media.definitions')` (e.g., `product` for `Product`) 2. The fully qualified class name in the config 3. The parent class name in the config (useful for extended models) 4. Falls back to `Lunar\Base\StandardMediaDefinitions` # Orders Source: https://docs.lunarphp.com/1.x/reference/orders Orders represent completed or in-progress purchases, created when a cart is converted at checkout. ## Overview Orders represent completed or in-progress purchases in a store. Orders are linked to carts, and although there is generally only one order per cart, the system supports multiple orders per cart if needed. All monetary values (such as `sub_total`, `total`, `tax_total`) are cast to `Lunar\DataTypes\Price` objects, providing access to `value`, `formatted()`, and `decimal` properties. ```php theme={null} Lunar\Models\Order ``` ### Fields | Field | Type | Description | | :---------------------- | :--------------------- | :-------------------------------------------------------------------- | | id | `id` | Primary key | | customer\_id | `foreignId` `nullable` | | | user\_id | `foreignId` `nullable` | The authenticated user who placed the order | | channel\_id | `foreignId` | The channel the order was placed through | | new\_customer | `boolean` | Whether the customer is a first-time buyer | | cart\_id | `foreignId` `nullable` | The cart used to create the order | | status | `string` | The order status | | reference | `string` `nullable` | The generated order reference | | customer\_reference | `string` `nullable` | A reference provided by the customer | | sub\_total | `unsignedBigInteger` | The subtotal minus any discounts, excluding tax | | discount\_total | `unsignedBigInteger` | The discount amount, excluding tax | | discount\_breakdown | `json` `nullable` | Breakdown of applied discounts | | shipping\_breakdown | `json` `nullable` | Breakdown of shipping charges | | shipping\_total | `unsignedBigInteger` | The shipping total including tax | | tax\_breakdown | `json` | Breakdown of applied taxes | | tax\_total | `unsignedBigInteger` | The total amount of tax applied | | total | `unsignedBigInteger` | The grand total including tax | | notes | `text` `nullable` | Additional order notes | | currency\_code | `string` | The currency code the order was placed in | | compare\_currency\_code | `string` `nullable` | The default currency code at the time of the order | | exchange\_rate | `decimal` | The exchange rate between `currency_code` and `compare_currency_code` | | placed\_at | `dateTime` `nullable` | The datetime the order was considered placed | | fingerprint | `string` `nullable` | A hash used to detect cart changes | | meta | `json` `nullable` | Custom metadata | | created\_at | `timestamp` | | | updated\_at | `timestamp` | | ### Relationships | Relationship | Type | Related Model | Description | | :---------------- | :-------- | :-------------------------- | :----------------------------------------- | | `channel` | BelongsTo | `Lunar\Models\Channel` | | | `cart` | BelongsTo | `Lunar\Models\Cart` | | | `currency` | BelongsTo | `Lunar\Models\Currency` | Matched on `currency_code` | | `customer` | BelongsTo | `Lunar\Models\Customer` | | | `user` | BelongsTo | User | The authenticatable model from auth config | | `lines` | HasMany | `Lunar\Models\OrderLine` | | | `physicalLines` | HasMany | `Lunar\Models\OrderLine` | Lines where type is `physical` | | `digitalLines` | HasMany | `Lunar\Models\OrderLine` | Lines where type is `digital` | | `shippingLines` | HasMany | `Lunar\Models\OrderLine` | Lines where type is `shipping` | | `productLines` | HasMany | `Lunar\Models\OrderLine` | All lines excluding shipping | | `addresses` | HasMany | `Lunar\Models\OrderAddress` | | | `shippingAddress` | HasOne | `Lunar\Models\OrderAddress` | Address where type is `shipping` | | `billingAddress` | HasOne | `Lunar\Models\OrderAddress` | Address where type is `billing` | | `transactions` | HasMany | `Lunar\Models\Transaction` | | | `captures` | HasMany | `Lunar\Models\Transaction` | Transactions where type is `capture` | | `intents` | HasMany | `Lunar\Models\Transaction` | Transactions where type is `intent` | | `refunds` | HasMany | `Lunar\Models\Transaction` | Transactions where type is `refund` | ## Creating an Order An order can be created directly or, the recommended approach, via a `Lunar\Models\Cart` model. ```php theme={null} use Lunar\Models\Order; use Lunar\Models\Cart; $order = Order::create([/** .. */]); // Recommended approach $order = Cart::first()->createOrder( allowMultipleOrders: false, orderIdToUpdate: null, ); ``` * `allowMultipleOrders` - Carts generally only have one draft order associated. Pass `true` to allow multiple orders per cart. * `orderIdToUpdate` - Optionally pass the ID of an existing draft order to update instead of creating a new one. The order must have a null `placed_at` value and belong to the cart. The underlying class for creating an order is `Lunar\Actions\Carts\CreateOrder`. This can be overridden in `config/lunar/cart.php`: ```php theme={null} return [ // ... 'actions' => [ // ... 'order_create' => CustomCreateOrder::class, ] ]; ``` At minimum, a custom class should extend `Lunar\Actions\AbstractAction`: ```php theme={null} use Lunar\Actions\AbstractAction; use Lunar\Models\Cart; final class CreateOrder extends AbstractAction { public function execute( Cart $cart, bool $allowMultipleOrders = false, ?int $orderIdToUpdate = null ): self { // ... return $this; } } ``` ### Validating a Cart Before Creation To check whether a cart is ready to create an order: ```php theme={null} $cart->canCreateOrder(); ``` This uses the `Lunar\Validation\Cart\ValidateCartForOrderCreation` class, which throws validation exceptions with helpful messages if the cart is not ready. A custom validation class can be specified in `config/lunar/cart.php`: ```php theme={null} return [ // ... 'validators' => [ 'order_create' => [ MyCustomValidator::class, ], ] ]; ``` A custom validator should extend `Lunar\Validation\BaseValidator`: ```php theme={null} use Lunar\Validation\BaseValidator; class MyCustomValidator extends BaseValidator { public function validate(): bool { $cart = $this->parameters['cart']; if ($somethingWentWrong) { return $this->fail('cart', 'There was an issue'); } return $this->pass(); } } ``` ## Order Reference Generation By default, Lunar generates an order reference when creating an order from a cart. The format is: ``` {prefix?}{0..0}{orderId} ``` `{0..0}` indicates the order ID is padded to 8 digits (not including the prefix). The prefix is optional and defined in `config/lunar/orders.php`. ### Custom Generators To use a custom reference generator, update `config/lunar/orders.php`: ```php theme={null} return [ 'reference_generator' => App\Generators\MyCustomGenerator::class, ]; ``` To disable reference generation entirely (not recommended), set the value to `null`. A custom generator must implement `Lunar\Base\OrderReferenceGeneratorInterface`: ```php theme={null} namespace App\Generators; use Lunar\Base\OrderReferenceGeneratorInterface; use Lunar\Models\Contracts\Order; class MyCustomGenerator implements OrderReferenceGeneratorInterface { public function generate(Order $order): string { // ... return 'my-custom-reference'; } } ``` ## Modifying Orders To programmatically change order values or add new behavior, the order system can be extended. See [Order Modifiers](/1.x/extending/orders) for more details. ## Order Status The `placed_at` field determines whether an order is considered draft or placed. The `Lunar\Models\Order` model provides two helper methods: ```php theme={null} $order->isDraft(); $order->isPlaced(); ``` ## Order Lines ```php theme={null} Lunar\Models\OrderLine ``` ### Fields | Field | Type | Description | | :---------------- | :--------------------- | :------------------------------------------------------ | | id | `id` | Primary key | | order\_id | `foreignId` | | | purchasable\_type | `string` | Polymorphic type for the purchasable item | | purchasable\_id | `unsignedBigInteger` | Polymorphic ID for the purchasable item | | type | `string` | The line type, e.g. `physical`, `digital`, `shipping` | | description | `string` | A description of the line item | | option | `string` `nullable` | Option information if the item is a variant | | identifier | `string` | An identifier for the purchasable item, typically a SKU | | unit\_price | `unsignedBigInteger` | The unit price of the line | | unit\_quantity | `unsignedSmallInteger` | The unit quantity, typically `1` | | quantity | `unsignedInteger` | The quantity purchased | | sub\_total | `unsignedBigInteger` | The subtotal minus any discounts, excluding tax | | discount\_total | `unsignedBigInteger` | The discount amount, excluding tax | | tax\_breakdown | `json` | Breakdown of applied taxes | | tax\_total | `unsignedBigInteger` | The total amount of tax applied | | total | `unsignedBigInteger` | The grand total including tax | | notes | `text` `nullable` | Additional line notes | | meta | `json` `nullable` | Custom metadata | | created\_at | `timestamp` | | | updated\_at | `timestamp` | | ### Relationships | Relationship | Type | Related Model | Description | | :------------ | :------------ | :---------------------- | :------------------------------- | | `order` | BelongsTo | `Lunar\Models\Order` | | | `purchasable` | MorphTo | — | The polymorphic purchasable item | | `currency` | HasOneThrough | `Lunar\Models\Currency` | Resolved through the order | ### Creating an Order Line When using the `createOrder` method on a cart, order lines are created automatically. ```php theme={null} use Lunar\Models\OrderLine; OrderLine::create([ // ... ]); // Or via the relationship $order->lines()->create([ // ... ]); ``` ## Order Addresses An order can have many addresses, typically one for billing and one for shipping. ```php theme={null} Lunar\Models\OrderAddress ``` When using the `createOrder` method on a cart, order addresses are created automatically. ### Fields | Field | Type | Description | | :--------------------- | :--------------------- | :--------------------------------------------------- | | id | `id` | Primary key | | order\_id | `foreignId` | | | country\_id | `foreignId` `nullable` | | | title | `string` `nullable` | | | first\_name | `string` `nullable` | | | last\_name | `string` `nullable` | | | company\_name | `string` `nullable` | | | tax\_identifier | `string` `nullable` | A tax identification number | | line\_one | `string` `nullable` | | | line\_two | `string` `nullable` | | | line\_three | `string` `nullable` | | | city | `string` `nullable` | | | state | `string` `nullable` | | | postcode | `string` `nullable` | | | delivery\_instructions | `string` `nullable` | | | contact\_email | `string` `nullable` | | | contact\_phone | `string` `nullable` | | | type | `string` | The address type: `billing` or `shipping` | | shipping\_option | `string` `nullable` | A unique identifier for the selected shipping option | | meta | `json` `nullable` | Custom metadata | | created\_at | `timestamp` | | | updated\_at | `timestamp` | | ### Relationships | Relationship | Type | Related Model | Description | | :----------- | :-------- | :--------------------- | :---------- | | `order` | BelongsTo | `Lunar\Models\Order` | | | `country` | BelongsTo | `Lunar\Models\Country` | | ### Creating an Order Address ```php theme={null} use Lunar\Models\OrderAddress; OrderAddress::create([ 'order_id' => 1, 'country_id' => 1, 'title' => null, 'first_name' => 'Jacob', 'last_name' => null, 'company_name' => null, 'line_one' => '123 Foo Street', 'line_two' => null, 'line_three' => null, 'city' => 'London', 'state' => null, 'postcode' => 'NW1 1WN', 'delivery_instructions' => null, 'contact_email' => null, 'contact_phone' => null, 'type' => 'shipping', 'shipping_option' => null, ]); // Or via the relationship $order->addresses()->create([ // ... ]); ``` The shipping and billing addresses can be accessed directly: ```php theme={null} $order->shippingAddress; $order->billingAddress; ``` ## Shipping Options A Shipping Tables add-on is planned to simplify shipping configuration in the admin panel. To add shipping options, [extend Lunar](/1.x/extending/shipping) with custom logic. Shipping options can be fetched using the `ShippingManifest` facade: ```php theme={null} \Lunar\Facades\ShippingManifest::getOptions(\Lunar\Models\Cart $cart); ``` This returns a collection of `Lunar\DataTypes\ShippingOption` objects. ### Adding a Shipping Option to the Cart Once a shipping option has been selected, add it to the cart so totals can be recalculated: ```php theme={null} $cart->setShippingOption(\Lunar\DataTypes\ShippingOption $option); ``` ## Transactions ```php theme={null} Lunar\Models\Transaction ``` ### Fields | Field | Type | Description | | :---------------------- | :--------------------- | :----------------------------------------------------- | | id | `id` | Primary key | | parent\_transaction\_id | `foreignId` `nullable` | A reference to a parent transaction | | order\_id | `foreignId` | | | success | `boolean` | Whether the transaction was successful | | type | `enum` | The transaction type: `capture`, `intent`, or `refund` | | driver | `string` | The payment driver used, e.g. `stripe` | | amount | `unsignedBigInteger` | The transaction amount | | reference | `string` | The reference returned from the payment provider | | status | `string` | The transaction status, e.g. `settled` | | notes | `string` `nullable` | Any relevant notes | | card\_type | `string` | The card type, e.g. `visa` | | last\_four | `string` `nullable` | The last four digits of the card | | meta | `json` `nullable` | Custom metadata | | captured\_at | `dateTime` `nullable` | When the transaction was captured | | created\_at | `timestamp` | | | updated\_at | `timestamp` | | ### Relationships | Relationship | Type | Related Model | Description | | :----------- | :------------ | :---------------------- | :------------------------- | | `order` | BelongsTo | `Lunar\Models\Order` | | | `currency` | HasOneThrough | `Lunar\Models\Currency` | Resolved through the order | ### Creating a Transaction An order having transactions does not mean it has been placed. Lunar determines whether an order is placed based on whether the `placed_at` column has a datetime value, regardless of any transactions. Most stores will want to store transactions against orders to track how much has been paid, the payment method used, and how to issue refunds if needed. ```php theme={null} use Lunar\Models\Transaction; Transaction::create([ //... ]); // Or via the order $order->transactions()->create([ //.. ]); ``` Transactions can be retrieved via relationships: ```php theme={null} $order->transactions; // All transactions $order->captures; // Capture transactions $order->intents; // Intent transactions $order->refunds; // Refund transactions ``` ## Payments Lunar is payment-provider agnostic. Any payment provider can be integrated with a storefront. The key factor for an order is whether the `placed_at` column is populated. Everything else about payment handling is left to the store implementation. Lunar provides helper utilities (as described above) to manage the payment lifecycle. ## Order Notifications Lunar allows specifying which Laravel mailers and notifications should be available when updating an order's status. These are configured in `config/lunar/orders.php`: ```php theme={null} 'statuses' => [ 'awaiting-payment' => [ 'label' => 'Awaiting Payment', 'color' => '#848a8c', 'mailers' => [ App\Mail\MyMailer::class, App\Mail\MyOtherMailer::class, ], 'notifications' => [], ], // ... ], ``` When updating an order's status in the admin panel, any configured mailers for the new status are available to select. Email addresses can be chosen, and additional addresses can be added. Lunar stores a render of the sent email in the activity log, providing a clear history of communications. These email notifications are not sent automatically when updating the status programmatically outside of the admin panel. ### Mailer Template When building a mailer template, the `$order` model is available in the view data. When the status is updated, the order is passed through along with any additional content entered. Since additional content may not always be present, check for its existence first. Example template: ```blade theme={null}

    It's on the way!

    Your order with reference {{ $order->reference }} has been dispatched!

    {{ $order->total->formatted() }}

    @if($content ?? null)

    Additional notes

    {{ $content }}

    @endif @foreach($order->lines as $line) @endforeach ``` ## Order Invoice PDF By default, clicking "Download PDF" in the admin panel when viewing an order generates a basic PDF. The view powering this PDF can be published for customization: ```bash theme={null} php artisan vendor:publish --tag=lunarpanel.pdf ``` This creates a view at `resources/vendor/lunarpanel/pdf/order.blade.php` that can be freely customized. # Payments Source: https://docs.lunarphp.com/1.x/reference/payments Lunar's driver-based payment system for handling payment authorization, capture, and refunds. Lunar uses a driver-based payment system for handling authorization, capture, and refunds. ## Overview Lunar uses a driver-based approach for payments. This means any payment provider can be supported, either through first-party add-ons, community packages, or a custom driver built for the specific needs of a storefront. By default, Lunar ships with an `OfflinePayment` driver suitable for cash-in-hand or manual payment scenarios. For online payments, a driver for the desired payment provider should be installed or created. To learn how to build a custom payment driver, see the [Extending Payments](/1.x/extending/payments) guide. ## Configuration Payment configuration is located in `config/lunar/payments.php`. This file defines the default payment type and a list of available payment types, each mapped to a driver. ```php theme={null} env('PAYMENTS_TYPE', 'cash-in-hand'), 'types' => [ 'cash-in-hand' => [ 'driver' => 'offline', 'authorized' => 'payment-offline', ], 'card' => [ 'driver' => 'stripe', 'authorized' => 'payment-received', ], ], ]; ``` Each type entry supports the following keys: | Key | Description | | :----------- | :------------------------------------------------- | | `driver` | The registered payment driver to use for this type | | `authorized` | The order status to set when payment is authorized | Any additional keys defined on a type are passed to the driver via the `setConfig` method automatically. ## Usage ### Getting a payment driver To retrieve a payment driver instance, pass the desired payment type to the `Payments` facade. This returns an instance of the driver registered for that type. ```php theme={null} use Lunar\Facades\Payments; $driver = Payments::driver('card'); ``` If no type is specified, the default type from the configuration is used. ```php theme={null} $driver = Payments::driver(); ``` ### Setting the cart Before authorizing a payment, set the cart on the driver. ```php theme={null} use Lunar\Models\Cart; $driver->cart($cart); ``` ### Passing additional data Some payment providers require additional data such as payment tokens or intent IDs. Pass this data using the `withData` method. ```php theme={null} $driver->withData([ 'payment_token' => $token, ]); ``` ### Setting an order In some cases, it may be necessary to set an existing order on the driver rather than a cart. The `order` method handles this. ```php theme={null} use Lunar\Models\Order; $driver->order($order); ``` ### Authorizing a payment Once the driver is configured, call the `authorize` method to process the payment. This returns a `PaymentAuthorize` response. ```php theme={null} use Lunar\Base\DataTransferObjects\PaymentAuthorize; $response = $driver->authorize(); $response->success; // bool $response->message; // ?string $response->orderId; // ?int $response->paymentType; // ?string ``` ### Method chaining All setter methods on the driver return `self`, allowing for a fluent API. ```php theme={null} use Lunar\Facades\Payments; $response = Payments::driver('card') ->cart($cart) ->withData(['payment_token' => $token]) ->authorize(); ``` ## Response types Payment drivers return one of three data transfer objects depending on the operation. ### PaymentAuthorize ```php theme={null} Lunar\Base\DataTransferObjects\PaymentAuthorize ``` Returned by the `authorize` method. | Property | Type | Description | | :------------ | :-------- | :---------------------------------------- | | `success` | `bool` | Whether the authorization was successful | | `message` | `?string` | An optional message from the provider | | `orderId` | `?int` | The ID of the created or associated order | | `paymentType` | `?string` | The payment type identifier | ### PaymentCapture ```php theme={null} Lunar\Base\DataTransferObjects\PaymentCapture ``` Returned by the `capture` method when capturing a previously authorized payment. | Property | Type | Description | | :-------- | :------- | :--------------------------------- | | `success` | `bool` | Whether the capture was successful | | `message` | `string` | A message from the provider | ### PaymentRefund ```php theme={null} Lunar\Base\DataTransferObjects\PaymentRefund ``` Returned by the `refund` method when refunding a captured payment. | Property | Type | Description | | :-------- | :-------- | :------------------------------------ | | `success` | `bool` | Whether the refund was successful | | `message` | `?string` | An optional message from the provider | ## Events ### PaymentAttemptEvent The `Lunar\Events\PaymentAttemptEvent` is dispatched whenever a payment authorization is attempted. This event receives the `PaymentAuthorize` response object. ```php theme={null} use Lunar\Events\PaymentAttemptEvent; PaymentAttemptEvent::dispatch($paymentAuthorize); ``` This event can be used to log payment attempts, trigger notifications, or perform post-payment processing via a listener. ## Payment checks Some payment providers return verification checks related to 3D Secure, address verification, or other fraud prevention measures. These checks are accessible on a `Lunar\Models\Transaction` model via the `paymentChecks` method. ```php theme={null} foreach ($transaction->paymentChecks() as $check) { $check->successful; // bool $check->label; // string $check->message; // string } ``` For details on the Transaction model, order creation, and order management, see the [Orders](/1.x/reference/orders) reference. ## Available drivers ### First party | Driver | Package | | :----- | :---------------------------------------------------- | | Stripe | [lunarphp/stripe](https://github.com/lunarphp/stripe) | ### Community Community-built payment drivers can be found in the [Add-ons](/1.x/addons/payments/stripe) section. To list a custom driver, reach out on the [Lunar Discord](https://discord.gg/v6qVWaf). # Pricing Source: https://docs.lunarphp.com/1.x/reference/pricing Format and display prices with currency-aware formatting, decimal conversion, and unit pricing support. Lunar provides currency-aware price formatting and display for storefront use. ## Overview When displaying prices on a storefront, it is important to show the correct format relative to the currency the customer is purchasing in. Every storefront is different. Lunar provides a default price formatter that suits most use cases, while also making it straightforward to swap in a custom implementation for stores with specific formatting requirements. ## The Price model ```php theme={null} Lunar\Models\Price ``` | Field | Type | Description | | :------------------ | :------------------------------ | :------------------------------------------------------------ | | `id` | `bigIncrements` | Primary key | | `customer_group_id` | `foreignId` `nullable` | Associated customer group for tiered pricing | | `currency_id` | `foreignId` | Associated currency | | `priceable_type` | `string` | Morph type for the parent model | | `priceable_id` | `unsignedBigInteger` | Morph ID for the parent model | | `price` | `unsignedBigInteger` | Price value stored in the smallest currency unit (e.g. cents) | | `compare_price` | `unsignedBigInteger` `nullable` | Comparison/original price for displaying discounts | | `min_quantity` | `integer` | Minimum quantity required for this price tier (default: 1) | | `created_at` | `timestamp` | | | `updated_at` | `timestamp` | | Both the `price` and `compare_price` fields are automatically cast to `Lunar\DataTypes\Price` objects when accessed. ### Relationships | Relationship | Type | Related Model | Description | | :-------------- | :---------- | :--------------------------- | :------------------------------------------------------ | | `priceable` | `MorphTo` | Various | The model this price belongs to (e.g. `ProductVariant`) | | `currency` | `BelongsTo` | `Lunar\Models\Currency` | The currency for this price | | `customerGroup` | `BelongsTo` | `Lunar\Models\CustomerGroup` | Optional customer group for group-specific pricing | ### Tax helper methods The `Lunar\Models\Price` model provides methods for retrieving prices with or without tax applied, based on the `lunar.pricing.stored_inclusive_of_tax` configuration value. ```php theme={null} use Lunar\Models\Price; $price = Price::find(1); $price->priceExTax(); // Lunar\DataTypes\Price $price->priceIncTax(); // Lunar\DataTypes\Price $price->comparePriceIncTax(); // Lunar\DataTypes\Price ``` ## The Price data type The `Lunar\DataTypes\Price` class is used throughout Lunar whenever a price value needs formatting. It is not limited to the `Lunar\Models\Price` model. The following models also have attributes that return `Lunar\DataTypes\Price` instances: ### `Lunar\Models\Order` * `sub_total` * `discount_total` * `shipping_total` * `tax_total` * `total` ### `Lunar\Models\OrderLine` * `unit_price` * `sub_total` * `discount_total` * `tax_total` * `total` ### `Lunar\Models\Transaction` * `amount` ## Price formatting The class responsible for price formatting is configured in the `config/lunar/pricing.php` file: ```php theme={null} return [ // ... 'formatter' => \Lunar\Pricing\DefaultPriceFormatter::class, ]; ``` ### DefaultPriceFormatter The `Lunar\Pricing\DefaultPriceFormatter` ships with Lunar and handles most use cases for formatting a price. To demonstrate, start by creating a standard price model: ```php theme={null} use Lunar\Models\Price; $priceModel = Price::create([ // ... 'price' => 1000, // Stored as an integer in the smallest currency unit 'min_quantity' => 1, ]); // Lunar\DataTypes\Price $priceDataType = $priceModel->price; ``` #### Raw value Return the raw integer value as stored in the database: ```php theme={null} $priceDataType->value; // 1000 ``` #### Decimal value Return the decimal representation of the price. The decimal value accounts for the number of decimal places configured on the currency. For example, if the currency has 2 decimal places: ```php theme={null} $priceDataType->decimal(rounding: true); // 10.00 $priceDataType->unitDecimal(rounding: true); // 10.00 ``` These two values are identical in this example. The `unitDecimal` method factors in the `unit_quantity` of the purchasable model. Consider the following: ```php theme={null} use Lunar\Models\ProductVariant; $productVariant = ProductVariant::create([ // ... 'unit_quantity' => 10, ]); ``` By setting `unit_quantity` to 10, Lunar is told that 10 individual units make up this product at this price point. This is useful for items where a single unit would cost less than the smallest currency denomination (e.g. 0.001 EUR). ```php theme={null} $priceModel = $productVariant->prices()->create([ 'price' => 10, // 0.10 EUR 'currency_id' => $currency->id, ]); // Lunar\DataTypes\Price $priceDataType = $priceModel->price; ``` Now the difference becomes clear: ```php theme={null} $priceDataType->decimal(rounding: true); // 0.10 $priceDataType->unitDecimal(rounding: true); // 0.01 ``` The `unitDecimal` method divides by the unit quantity, giving the per-unit cost of `0.01`. #### Formatted currency string The formatted price uses the native PHP [NumberFormatter](https://www.php.net/manual/en/class.numberformatter.php). A locale and formatting style can be specified: ```php theme={null} $priceDataType->formatted('fr'); // 10,00 £GB $priceDataType->formatted('en-gb', \NumberFormatter::SPELLOUT); // ten point zero zero $priceDataType->unitFormatted('en-gb'); // £10.00 ``` ### Full method reference ```php theme={null} $priceDataType->decimal( rounding: false ); $priceDataType->unitDecimal( rounding: false ); $priceDataType->formatted( locale: null, formatterStyle: NumberFormatter::CURRENCY, decimalPlaces: null, trimTrailingZeros: true ); $priceDataType->unitFormatted( locale: null, formatterStyle: NumberFormatter::CURRENCY, decimalPlaces: null, trimTrailingZeros: true ); ``` ## Creating a custom formatter A custom formatter must implement `Lunar\Pricing\PriceFormatterInterface` and accept `$value`, `$currency`, and `$unitQty` as constructor parameters. ```php theme={null} currency) { $this->currency = \Lunar\Models\Currency::getDefault(); } } public function decimal(): float { // ... } public function unitDecimal(): float { // ... } public function formatted(): mixed { // ... } public function unitFormatted(): mixed { // ... } } ``` The methods can accept any number of arguments beyond those defined in the interface. The formatter is not bound to the same parameter signatures as `DefaultPriceFormatter`. Once implemented, register the custom formatter in `config/lunar/pricing.php`: ```php theme={null} return [ // ... 'formatter' => \App\Pricing\CustomPriceFormatter::class, ]; ``` ## Model casting For custom models that need price formatting, Lunar provides a cast class. The only requirement is that the column stores an `integer` value. ```php theme={null} use Illuminate\Database\Eloquent\Model; use Lunar\Base\Casts\Price; class MyModel extends Model { protected $casts = [ // ... 'price' => Price::class, ]; } ``` The `Lunar\Base\Casts\Price` cast resolves the currency from the model's `currency` relationship. If no currency relationship exists, it falls back to the default currency. # Products Source: https://docs.lunarphp.com/1.x/reference/products Define, categorize, and manage products with variants, pricing, options, and associations. Products are the core catalog model in Lunar, representing items available for sale with variants, pricing, and options. ## Overview Products represent the items available for sale in a store. All custom attributes are defined against the product, and products can have multiple variations. In Lunar, a product always has at least one variant. When a product has only a single variant, the editing experience appears as though the product itself is being edited directly, but behind the scenes the data lives on the variant. Every product belongs to a `ProductType`, which determines which attributes are available during editing. Products can also optionally belong to a `Brand`. ```php theme={null} Lunar\Models\Product ``` ### Fields | Field | Type | Description | | :---------------- | :--------------------- | :----------------------------------------- | | `id` | `bigIncrements` | Primary key | | `brand_id` | `foreignId` `nullable` | Optional brand association | | `product_type_id` | `foreignId` | The product type | | `status` | `string` | Product status (e.g. `published`, `draft`) | | `attribute_data` | `json` `nullable` | Custom attribute data | | `created_at` | `timestamp` | | | `updated_at` | `timestamp` | | | `deleted_at` | `timestamp` `nullable` | Soft deletes | ### Relationships | Relationship | Type | Related Model | Description | | :-------------------- | :------------- | :-------------------------------- | :----------------------------------------------------------------- | | `productType` | BelongsTo | `Lunar\Models\ProductType` | The product's type | | `brand` | BelongsTo | `Lunar\Models\Brand` | The product's brand | | `variants` | HasMany | `Lunar\Models\ProductVariant` | All variants of the product | | `variant` | HasOne | `Lunar\Models\ProductVariant` | Single variant convenience accessor | | `prices` | HasManyThrough | `Lunar\Models\Price` | All prices across variants | | `collections` | BelongsToMany | `Lunar\Models\Collection` | Pivot: `position` | | `associations` | HasMany | `Lunar\Models\ProductAssociation` | Outgoing product associations | | `inverseAssociations` | HasMany | `Lunar\Models\ProductAssociation` | Incoming product associations | | `customerGroups` | BelongsToMany | `Lunar\Models\CustomerGroup` | Pivot: `purchasable`, `visible`, `enabled`, `starts_at`, `ends_at` | | `channels` | MorphToMany | `Lunar\Models\Channel` | Pivot: `enabled`, `starts_at`, `ends_at` | | `productOptions` | BelongsToMany | `Lunar\Models\ProductOption` | Pivot: `position` | | `urls` | MorphMany | `Lunar\Models\Url` | SEO-friendly URLs | | `tags` | MorphToMany | `Lunar\Models\Tag` | | | `images` | MorphMany | Media | Product images | | `thumbnail` | MorphOne | Media | Primary thumbnail image | ### Scopes | Scope | Description | | :------------------------------------------ | :---------------------------------- | | `status($status)` | Filter by product status | | `channel($channel, $startsAt, $endsAt)` | Filter by channel availability | | `customerGroup($group, $startsAt, $endsAt)` | Filter by customer group visibility | ## Creating a Product ```php theme={null} use Lunar\Models\Product; use Lunar\FieldTypes\Text; use Lunar\FieldTypes\TranslatedText; Product::create([ 'product_type_id' => $productType->id, 'status' => 'published', 'brand_id' => $brand->id, 'attribute_data' => [ 'name' => new TranslatedText(collect([ 'en' => new Text('FooBar'), ])), 'description' => new Text('This is a Foobar product.'), ], ]); ``` Lunar internally expects and uses the `name` attribute in product attribute data. It must be included in the product type's attributes and populated; otherwise the admin panel may throw unexpected errors. ### Filtering by status ```php theme={null} use Lunar\Models\Product; Product::status('published')->get(); ``` ## Channels Products support multi-channel availability through the `HasChannels` trait. When a product is created, all channels are automatically synced. Each channel can be independently enabled or disabled, with optional start and end dates for scheduled availability. ### Scheduling a channel ```php theme={null} // Enable for a channel immediately $product->scheduleChannel($channel); // Schedule availability to start in 14 days $product->scheduleChannel($channel, now()->addDays(14)); // Accepts a collection of channels $product->scheduleChannel(Channel::get()); ``` ### Filtering by channel ```php theme={null} use Lunar\Models\Product; $products = Product::channel($channel)->get(); ``` ## Customer Groups Products can be assigned to customer groups with optional scheduling. This controls whether the product is visible and purchasable for members of each group. ### Scheduling a customer group ```php theme={null} // Enable for this customer group immediately $product->scheduleCustomerGroup($customerGroup); // Schedule the product to be enabled in 14 days for this customer group $product->scheduleCustomerGroup($customerGroup, now()->addDays(14)); // Accepts an array or collection of customer groups $product->scheduleCustomerGroup(CustomerGroup::get()); ``` ### Filtering by customer group The `customerGroup` scope accepts a single customer group (or ID), or a collection/array of customer groups or IDs. ```php theme={null} use Lunar\Models\Product; $products = Product::customerGroup(CustomerGroup::find(1))->paginate(50); $products = Product::customerGroup([ $groupA, $groupB, ])->paginate(50); ``` ## Product Types Product types categorize products and determine which attributes are available during editing (e.g. Television, T-Shirt, Book, Phone). ```php theme={null} Lunar\Models\ProductType ``` ### Fields | Field | Type | Description | | :----------- | :-------------- | :-------------------- | | `id` | `bigIncrements` | Primary key | | `name` | `string` | The product type name | | `created_at` | `timestamp` | | | `updated_at` | `timestamp` | | ### Relationships | Relationship | Type | Related Model | Description | | :------------------ | :---------- | :----------------------- | :--------------------------------- | | `products` | HasMany | `Lunar\Models\Product` | All products of this type | | `mappedAttributes` | MorphToMany | `Lunar\Models\Attribute` | All attributes mapped to this type | | `productAttributes` | MorphToMany | `Lunar\Models\Attribute` | Attributes for products | | `variantAttributes` | MorphToMany | `Lunar\Models\Attribute` | Attributes for variants | ### Creating a product type ```php theme={null} use Lunar\Models\ProductType; $productType = ProductType::create([ 'name' => 'Boots', ]); ``` Product types have [Attributes](/1.x/reference/attributes) associated to them. These associated attributes determine which fields are available to products when editing. For example, an attribute of `Screen Type` associated to a `TVs` product type would make that field available on any product with that type. Attributes can be associated using a standard [polymorphic relationship](https://laravel.com/docs/eloquent-relationships#many-to-many-polymorphic-relations): ```php theme={null} $productType->mappedAttributes()->attach([/* attribute ids ... */]); ``` Both `Product` and `ProductVariant` attributes can be associated to a product type, and each will display on the corresponding model when editing. Deleting an attribute will drop the association and could result in data loss. ### Retrieving the product type relationship ```php theme={null} $product->productType; $product->load(['productType']); ``` ## Product Options Product options define the different variations available for a product. Each `ProductOption` has a set of `ProductOptionValue` models. For example, a `ProductOption` called "Color" could have values like "Blue", "Red", and "Green". Product options and product option values are defined at a system level and are translatable. ### Creating a ProductOption ```php theme={null} use Lunar\Models\ProductOption; $option = ProductOption::create([ 'name' => [ 'en' => 'Color', 'fr' => 'Couleur', ], 'label' => [ 'en' => 'Color', 'fr' => 'Couleur', ], ]); ``` Values can then be created for the option: ```php theme={null} // Lunar\Models\ProductOptionValue $option->values()->createMany([ [ 'name' => [ 'en' => 'Blue', 'fr' => 'Bleu', ], ], [ 'name' => [ 'en' => 'Red', 'fr' => 'Rouge', ], ], ]); ``` This product option and its values are now ready to be used with product variants. ### Product Option Meta Both `ProductOption` and `ProductOptionValue` models include a `meta` field for storing custom information such as color hex values, image links, or other display data. Lunar makes no assumptions about the structure of the `meta` JSON field. Any values can be stored in whatever format the application requires. ## Product Associations Products can be associated with other products as cross-sells, up-sells, or alternates. See the [Associations](/1.x/reference/associations) reference for full details on creating and managing product associations. ## Variants Variants represent the different purchasable permutations of a product, such as "Small Blue T-shirt" or "Size 9 Leather Boots". The product acts as the parent, and variants hold the specific data including pricing, inventory, shipping information, and product identifiers. A product always has at least one variant. When additional variants are generated, Lunar uses the first variant as a baseline for pricing, inventory, and other data. ```php theme={null} Lunar\Models\ProductVariant ``` ### Fields | Field | Type | Description | | :------------------- | :------------------------- | :------------------------------------------------------------- | | `id` | `bigIncrements` | Primary key | | `product_id` | `foreignId` | The parent product | | `tax_class_id` | `foreignId` | Tax classification | | `tax_ref` | `string` `nullable` | Tax reference identifier | | `unit_quantity` | `integer` | Units per single purchase, default: `1` | | `min_quantity` | `integer` | Minimum purchasable quantity, default: `1` | | `quantity_increment` | `integer` | Purchase quantity step, default: `1` | | `sku` | `string` `nullable` | Stock keeping unit | | `gtin` | `string` `nullable` | Global Trade Item Number | | `mpn` | `string` `nullable` | Manufacturer Part Number | | `ean` | `string` `nullable` | European Article Number | | `length_value` | `decimal(10,4)` `nullable` | Length dimension | | `length_unit` | `string` `nullable` | Length unit of measure | | `width_value` | `decimal(10,4)` `nullable` | Width dimension | | `width_unit` | `string` `nullable` | Width unit of measure | | `height_value` | `decimal(10,4)` `nullable` | Height dimension | | `height_unit` | `string` `nullable` | Height unit of measure | | `weight_value` | `decimal(10,4)` `nullable` | Weight value | | `weight_unit` | `string` `nullable` | Weight unit of measure | | `volume_value` | `decimal(10,4)` `nullable` | Volume value | | `volume_unit` | `string` `nullable` | Volume unit of measure | | `shippable` | `boolean` | Whether the variant requires shipping, default: `true` | | `stock` | `integer` | Current stock quantity, default: `0` | | `backorder` | `integer` | Backorder quantity, default: `0` | | `purchasable` | `string` | Purchasability mode: `always` or `in_stock`, default: `always` | | `attribute_data` | `json` `nullable` | Custom attribute data | | `created_at` | `timestamp` | | | `updated_at` | `timestamp` | | | `deleted_at` | `timestamp` `nullable` | Soft deletes | ### Product Identifiers Each variant can store product identifiers for use in internal systems or external services. **SKU** (Stock Keeping Unit) — A code (usually eight alphanumeric digits) used to track stock levels internally. Each variant of a product typically has a unique SKU. **GTIN** (Global Trade Item Number) — An internationally recognized product identifier, often accompanying a barcode. Useful with services like Google Shopping to help classify products. **MPN** (Manufacturer Part Number) — An identifier from the manufacturer that differentiates a product among similar items from the same brand. **EAN** (European Article Number) — A series of characters that identifies specific products within an inventory system. ### Creating Variants A product variant requires a product, currency (for pricing), and a tax class. ```php theme={null} use Lunar\Models\Product; use Lunar\Models\ProductVariant; use Lunar\Models\TaxClass; use Lunar\Models\Currency; $product = Product::where(...)->first(); $taxClass = TaxClass::where(...)->first(); $currency = Currency::where(...)->first(); ``` Create the product option and its values: ```php theme={null} use Lunar\Models\ProductOption; $option = ProductOption::create([ 'name' => [ 'en' => 'Color', ], 'label' => [ 'en' => 'Color', ], ]); $blueOption = $option->values()->create([ 'name' => [ 'en' => 'Blue', ], ]); $redOption = $option->values()->create([ 'name' => [ 'en' => 'Red', ], ]); ``` Create the variants and attach their option values: ```php theme={null} $blueVariant = ProductVariant::create([ 'product_id' => $product->id, 'tax_class_id' => $taxClass->id, 'sku' => 'blue-product', ]); $blueVariant->values()->attach($blueOption); $redVariant = ProductVariant::create([ 'product_id' => $product->id, 'tax_class_id' => $taxClass->id, 'sku' => 'red-product', ]); $redVariant->values()->attach($redOption); ``` Then create pricing for each variant: ```php theme={null} $blueVariant->prices()->create([ 'price' => 199, 'currency_id' => $currency->id, ]); $redVariant->prices()->create([ 'price' => 199, 'currency_id' => $currency->id, ]); ``` ### Exceptions | Exception | Conditions | | :------------------------------------------ | :---------------------------------------------------- | | `Illuminate\Validation\ValidationException` | Thrown if validation fails on the value options array | ## Shipping By default, all product variants are marked as shippable. To mark a variant as non-shippable: ```php theme={null} $variant->update([ 'shippable' => false, ]); ``` ### Dimensions Products can store dimension data on each variant. The available dimensions are: * Length * Width * Height * Weight * Volume For handling unit conversions, Lunar uses the [Cartalyst Converter](https://github.com/cartalyst/converter) package, which supports a wide range of units of measure. Each dimension has a corresponding `_value` and `_unit` column in the database: ```php theme={null} $variant->length_value; $variant->length_unit; $variant->width_value; $variant->width_unit; // etc. ``` ### Configuring measurements Available units of measure can be configured in the `lunar/shipping.php` config file. The defaults include: **Length:** m, mm, cm, ft, in **Weight:** kg, g, lbs **Volume:** l, ml, gal, floz ### Getting and converting measurement values The raw `*_value` and `*_unit` values can be accessed directly, but Lunar also provides an accessor for each dimension that supports conversion: ```php theme={null} $variant->length->to('length.ft')->convert(); ``` #### Volume calculation Volume can be calculated automatically from the length, width, and height dimensions, or set manually: ```php theme={null} $variant->update([ 'length_value' => 50, 'length_unit' => 'mm', 'height_value' => 50, 'height_unit' => 'mm', 'width_value' => 50, 'width_unit' => 'mm', ]); // Returns ml by default $variant->volume->getValue(); // 125.0 // Convert to any supported volume unit $variant->volume->to('volume.l')->convert()->getValue(); // 0.125 // Setting a manual volume overrides the automatic calculation $variant->update([ 'volume_unit' => 'floz', 'volume_value' => 100, ]); $variant->volume->getValue(); // 100 $variant->volume->to('volume.l')->convert()->getValue(); // 2.95735... ``` **Formatted values** ```php theme={null} $variant->length->to('length.cm')->convert()->format(); // 50cm ``` ## Pricing ### Overview Prices are stored in the database as integers. When retrieving a `Price` model, the `price` and `compare_price` attributes are cast to a `Price` datatype with useful helpers for display. | Field | Description | Default | Required | | :------------------ | :---------------------------------------------------------------- | :------ | :------- | | `price` | An integer value for the price | `null` | yes | | `compare_price` | A comparison price for display purposes (e.g. RRP) | `null` | no | | `currency_id` | The ID of the related currency | `null` | yes | | `min_quantity` | The minimum quantity required to get this price | `1` | no | | `customer_group_id` | The customer group this price applies to; `null` means all groups | `null` | no | | `priceable_type` | The class reference to the related model that owns the price | `null` | yes | | `priceable_id` | The ID of the related model that owns the price | `null` | yes | ```php theme={null} use Lunar\Models\ProductVariant; use Lunar\Models\Price; $priceable = ProductVariant::create([/* ... */]); Price::create([ 'price' => 199, 'compare_price' => 299, 'currency_id' => 1, 'min_quantity' => 1, 'customer_group_id' => null, 'priceable_type' => $priceable->getMorphClass(), 'priceable_id' => $priceable->id, ]); ``` For full details on price formatting, see the [Pricing Reference](/1.x/reference/pricing). The same formatting methods apply to the `compare_price` attribute. ### Base Pricing Pricing is defined at the variant level, meaning each variant has its own price for each currency. Prices can be created directly or through the relationship: ```php theme={null} use Lunar\Models\ProductVariant; use Lunar\Models\Price; $priceable = ProductVariant::create([/* ... */]); Price::create([ 'price' => 199, 'compare_price' => 299, 'currency_id' => 1, 'min_quantity' => 1, 'customer_group_id' => null, 'priceable_type' => $priceable->getMorphClass(), 'priceable_id' => $priceable->id, ]); // Or via the relationship $variant->prices()->create([/* ... */]); ``` ### Customer Group Pricing Setting the `customer_group_id` column controls which customer group a price applies to. When left as `null`, the price applies to all customer groups. This allows different pricing and quantity breaks per customer group. ### Price Breaks Price breaks adjust the unit price based on purchase quantity. The `min_quantity` column determines when each price tier applies: ```php theme={null} use Lunar\Models\Price; Price::create([ // ... 'price' => 199, 'compare_price' => 399, 'min_quantity' => 1, ]); Price::create([ // ... 'price' => 150, 'compare_price' => 399, 'min_quantity' => 10, ]); ``` In the above example, ordering 1–9 items costs 1.99 per item, while ordering 10 or more costs 1.50 per item. ### Fetching Prices The `PricingManager` facade provides a fluent API for retrieving the correct price based on various criteria. #### Minimum example A quantity of 1 is implied when not passed. ```php theme={null} $pricing = \Lunar\Facades\Pricing::for($variant)->get(); ``` #### With quantities ```php theme={null} $pricing = \Lunar\Facades\Pricing::qty(5)->for($variant)->get(); ``` #### With customer groups If no customer group is passed, Lunar uses the default group and includes pricing that is not specific to any group. ```php theme={null} $pricing = \Lunar\Facades\Pricing::customerGroups($groups)->for($variant)->get(); // Or a single customer group $pricing = \Lunar\Facades\Pricing::customerGroup($group)->for($variant)->get(); ``` #### For a specific user The `PricingManager` assumes the current authenticated user by default. ```php theme={null} // Always return the guest price $pricing = \Lunar\Facades\Pricing::guest()->for($variant)->get(); // Specify a different user $pricing = \Lunar\Facades\Pricing::user($user)->for($variant)->get(); ``` #### With a specific currency If no currency is passed, the default currency is used. ```php theme={null} $pricing = \Lunar\Facades\Pricing::currency($currency)->for($variant)->get(); ``` #### From a model Any model using the `HasPrices` trait (such as `ProductVariant`) exposes a `pricing()` method: ```php theme={null} $pricing = $variant->pricing()->qty(5)->get(); ``` Fetching a price for a currency that has no pricing defined will throw a `Lunar\Exceptions\MissingCurrencyPriceException`. *** The `get()` method returns a `PricingResponse` object. Unless noted as a collection, each property returns a `Lunar\Models\Price` object. ```php theme={null} // The price that matched the given criteria $pricing->matched; // The base price associated with the variant $pricing->base; // A collection of all price quantity breaks for the given criteria $pricing->priceBreaks; // All customer group pricing for the given criteria $pricing->customerGroupPrices; ``` To retrieve all prices across a product's variants without loading the variants individually, use the `prices` relationship on the product: ```php theme={null} $product->prices; ``` ### Storing Prices Inclusive of Tax Lunar supports storing pricing inclusive of tax, which is useful for charm pricing (e.g. \$9.99) that may not be achievable when storing prices exclusive of tax due to rounding. To enable this, set the `stored_inclusive_of_tax` config value in `lunar/pricing` to `true` and ensure the default tax zone is configured with the correct tax rates. The cart will then automatically calculate tax correctly. To display both tax-inclusive and tax-exclusive prices on product pages: ```php theme={null} $price->priceIncTax(); $price->priceExTax(); $price->comparePriceIncTax(); ``` ### Customizing Prices with Pipelines Pricing pipelines are defined in `config/lunar/pricing.php`: ```php theme={null} 'pipelines' => [ // ], ``` Custom pipelines can modify pricing during resolution: ```php theme={null} pricing->matched; $matchedPrice->price->value = 200; $pricingManager->pricing->matched = $matchedPrice; return $next($pricingManager); } } ``` ```php theme={null} 'pipelines' => [ // ... App\Pipelines\Pricing\CustomPricingPipeline::class, ], ``` Pipelines run from top to bottom. ## Full Example This example walks through creating a pair of Dr. Martens boots with multiple size and color variants. The steps involved are: * Create the product type * Create the initial product * Create product options and their values * Create the variants ### Set up the product type ```php theme={null} use Lunar\Models\ProductType; $productType = ProductType::create([ 'name' => 'Boots', ]); ``` This example assumes attributes for name and description already exist and are assigned to the product type. ### Create the initial product ```php theme={null} use Lunar\Models\Product; use Lunar\FieldTypes\Text; use Lunar\FieldTypes\TranslatedText; $product = Product::create([ 'product_type_id' => $productType->id, 'status' => 'published', 'brand_id' => $brandId, 'attribute_data' => [ 'name' => new TranslatedText(collect([ 'en' => new Text('1460 PATENT LEATHER BOOTS'), ])), 'description' => new Text('Even more shades from the archive...'), ], ]); ``` ### Create product options Based on the example above, two options are needed: Size and Color. ```php theme={null} use Lunar\Models\ProductOption; $color = ProductOption::create([ 'name' => [ 'en' => 'Color', ], 'label' => [ 'en' => 'Color', ], ]); $size = ProductOption::create([ 'name' => [ 'en' => 'Size', ], 'label' => [ 'en' => 'Size', ], ]); ``` ### Create product option values ```php theme={null} $color->values()->createMany([ [ 'name' => [ 'en' => 'Black', ], ], [ 'name' => [ 'en' => 'White', ], ], [ 'name' => [ 'en' => 'Pale Pink', ], ], [ 'name' => [ 'en' => 'Mid Blue', ], ], ]); $size->values()->createMany([ [ 'name' => [ 'en' => '3', ], ], [ 'name' => [ 'en' => '6', ], ], ]); ``` ### Create the variants With the options and values defined, variants can be created for each combination. Each variant needs a product, tax class, SKU, and at least one price. ```php theme={null} use Lunar\Models\ProductVariant; use Lunar\Models\TaxClass; use Lunar\Models\Currency; $taxClass = TaxClass::first(); $currency = Currency::first(); $count = 0; foreach ($color->values as $colorValue) { foreach ($size->values as $sizeValue) { $count++; $variant = ProductVariant::create([ 'product_id' => $product->id, 'tax_class_id' => $taxClass->id, 'sku' => "DRBOOT-{$count}", ]); $variant->values()->attach([$colorValue->id, $sizeValue->id]); $variant->prices()->create([ 'price' => 16900, 'currency_id' => $currency->id, ]); } } ``` The resulting variants: | SKU | Color | Size | | :------- | :-------- | :--- | | DRBOOT-1 | Black | 3 | | DRBOOT-2 | Black | 6 | | DRBOOT-3 | White | 3 | | DRBOOT-4 | White | 6 | | DRBOOT-5 | Pale Pink | 3 | | DRBOOT-6 | Pale Pink | 6 | | DRBOOT-7 | Mid Blue | 3 | | DRBOOT-8 | Mid Blue | 6 | SKUs, pricing, and other variant details can be adjusted as needed before publishing. # Search Source: https://docs.lunarphp.com/1.x/reference/search Configure and manage search indexing with Laravel Scout, including searchable models, indexers, engine mapping, and artisan commands. Lunar provides search indexing and querying through Laravel Scout with support for multiple search engines. ## Overview Search in Lunar is built on [Laravel Scout](https://laravel.com/docs/scout), providing a flexible, driver-based search system. Scout's database driver offers basic search out of the box, while more powerful engines like [Meilisearch](https://www.meilisearch.com/) and [Typesense](https://typesense.org/) can be swapped in for advanced features such as faceted filtering and relevance tuning. All search configuration lives in `config/lunar/search.php`. This file controls which models are indexed, which search engine each model uses, and which indexer class prepares the data for each model. For building storefront search with faceted filtering, sorting, and structured results, see the [Storefront Search add-on](/1.x/addons/search). It provides a `Search` facade with a consistent API across all search engines. ## Configuration ### Soft Deletes By default, Scout sets the `soft_delete` option to `false`. This must be set to `true` in `config/scout.php`, otherwise soft-deleted models will appear in search results. ```php theme={null} // config/scout.php 'soft_delete' => true, ``` ### Searchable Models The `models` array in `config/lunar/search.php` defines which models are indexed. Lunar registers the following models by default: ```php theme={null} 'models' => [ // These models are required by the system, do not change them. Lunar\Models\Brand::class, Lunar\Models\Collection::class, Lunar\Models\Customer::class, Lunar\Models\Order::class, Lunar\Models\Product::class, Lunar\Models\ProductOption::class, // Below you can add your own models for indexing... // App\Models\Example::class, ], ``` To add custom models to the search index, append them to this array. Custom models must use the `Lunar\Base\Traits\Searchable` trait. ### Engine Mapping By default, Scout uses the driver defined by the `SCOUT_DRIVER` environment variable for all models. This means if `SCOUT_DRIVER` is set to `meilisearch`, every searchable model gets indexed via Meilisearch. This may not always be desirable. For example, indexing orders in a paid service like Algolia alongside products would unnecessarily increase record counts and cost. Lunar allows specifying a different driver per model using the `engine_map` configuration: ```php theme={null} 'engine_map' => [ Lunar\Models\Product::class => 'typesense', Lunar\Models\Order::class => 'meilisearch', Lunar\Models\Collection::class => 'meilisearch', ], ``` Any model not listed in the `engine_map` falls back to the default Scout driver. ## Searchable Trait All searchable Lunar models use the `Lunar\Base\Traits\Searchable` trait. This trait extends Scout's `Searchable` trait and delegates key behavior to an indexer class: * `searchableAs()` — returns the index name * `toSearchableArray()` — returns the data to index * `shouldBeSearchable()` — determines whether the model should be indexed * `searchableUsing()` — returns the engine based on the `engine_map` configuration * `getFilterableAttributes()` — returns filterable fields for the engine * `getSortableAttributes()` — returns sortable fields for the engine The trait resolves the appropriate indexer from the `indexers` config, falling back to the base `Lunar\Search\ScoutIndexer` if no specific indexer is mapped. ## Indexers Each searchable model is paired with an indexer class that controls what data is sent to the search engine. The `indexers` config maps models to their indexer: ```php theme={null} 'indexers' => [ Lunar\Models\Brand::class => Lunar\Search\BrandIndexer::class, Lunar\Models\Collection::class => Lunar\Search\CollectionIndexer::class, Lunar\Models\Customer::class => Lunar\Search\CustomerIndexer::class, Lunar\Models\Order::class => Lunar\Search\OrderIndexer::class, Lunar\Models\Product::class => Lunar\Search\ProductIndexer::class, Lunar\Models\ProductOption::class => Lunar\Search\ProductOptionIndexer::class, ], ``` ### Default Indexer The base `Lunar\Search\ScoutIndexer` class indexes: * The model's `id` * Any attributes marked as `searchable` It also handles `TranslatedText` attribute fields, creating locale-suffixed entries (e.g., `name_en`, `name_fr`) in the index. ### Product Indexer The `Lunar\Search\ProductIndexer` indexes the following fields: | Field | Source | | :---------------- | :-------------------------------- | | `id` | Product ID | | `status` | Product status | | `product_type` | Product type name | | `brand` | Brand name (if present) | | `created_at` | Unix timestamp | | `thumbnail` | Thumbnail URL (small variant) | | `skus` | Array of variant SKUs | | Attribute handles | Values from searchable attributes | **Sortable fields:** `created_at`, `updated_at`, `skus`, `status` **Filterable fields:** `__soft_deleted`, `skus`, `status` ### Order Indexer The `Lunar\Search\OrderIndexer` indexes the following fields: | Field | Source | | :-------------------------------------------- | :------------------------------------------------- | | `id` | Order ID | | `channel` | Channel name | | `reference` | Order reference | | `customer_reference` | Customer reference | | `status` | Order status | | `placed_at` | Placement timestamp | | `created_at` | Unix timestamp | | `sub_total` | Sub-total value | | `total` | Total value | | `currency_code` | Currency code | | `charges` | Transaction references | | `lines` | Product line descriptions and identifiers | | `{type}_first_name`, `{type}_last_name`, etc. | Address fields prefixed by type (shipping/billing) | | `tags` | Array of tag values | **Sortable fields:** `customer_id`, `user_id`, `channel_id`, `created_at`, `updated_at`, `total` **Filterable fields:** `customer_id`, `user_id`, `status`, `placed_at`, `channel_id`, `tags`, `__soft_deleted` ### Customer Indexer The `Lunar\Search\CustomerIndexer` indexes the following fields: | Field | Source | | :---------------- | :-------------------------------- | | `id` | Customer ID | | `name` | Full name | | `company_name` | Company name | | `tax_identifier` | Tax identifier | | `account_ref` | Account reference | | `created_at` | Unix timestamp | | `user_emails` | Array of associated user emails | | Meta fields | Any meta key-value pairs | | Attribute handles | Values from searchable attributes | **Sortable fields:** `created_at`, `updated_at`, `name`, `company_name` **Filterable fields:** `__soft_deleted`, `name`, `company_name` ### Brand Indexer The `Lunar\Search\BrandIndexer` indexes the following fields: | Field | Source | | :---------------- | :-------------------------------- | | `id` | Brand ID | | `name` | Brand name | | `created_at` | Unix timestamp | | Attribute handles | Values from searchable attributes | **Sortable fields:** `created_at`, `updated_at`, `name` **Filterable fields:** `__soft_deleted`, `name` ### Collection Indexer The `Lunar\Search\CollectionIndexer` indexes the following fields: | Field | Source | | :---------------- | :-------------------------------- | | `id` | Collection ID | | `created_at` | Unix timestamp | | Attribute handles | Values from searchable attributes | **Sortable fields:** `created_at`, `updated_at`, `name` **Filterable fields:** `__soft_deleted`, `name` ### ProductOption Indexer The `Lunar\Search\ProductOptionIndexer` indexes the following fields: | Field | Source | | :--------------------- | :---------------------------- | | `id` | ProductOption ID | | `name_{locale}` | Option name per locale | | `label_{locale}` | Option label per locale | | `option_{id}_{locale}` | Option value names per locale | **Sortable fields:** `created_at`, `updated_at` **Filterable fields:** `__soft_deleted` ## Indexing Records To import or refresh search indexes, use the `lunar:search:index` artisan command: ```sh theme={null} php artisan lunar:search:index ``` This imports all models listed in the `models` configuration. ### Command Options | Option | Description | | :---------- | :---------------------------------------------------------------------------------------------- | | `models` | One or more model class names to index (space-separated). Merged with config models by default. | | `--ignore` | Only index the models specified in the command, ignoring the config file. | | `--refresh` | Delete existing records from the index before reimporting. | | `--flush` | Delete records from the index without reimporting. Cannot be combined with `--refresh`. | ```sh theme={null} # Refresh only the product index php artisan lunar:search:index "Lunar\Models\Product" --refresh # Flush the order index php artisan lunar:search:index "Lunar\Models\Order" --flush # Index only specific models, ignoring config php artisan lunar:search:index "Lunar\Models\Product" "Lunar\Models\Brand" --ignore ``` ## Meilisearch Setup When using Meilisearch, run the setup command to configure filterable and sortable attributes on Meilisearch indexes: ```sh theme={null} php artisan lunar:meilisearch:setup ``` This command reads the filterable and sortable fields from each model's indexer and applies them to the corresponding Meilisearch index. ## Storefront Search Add-on For storefront search features such as faceted filtering, sorting, pagination, and structured response data, install the [Storefront Search add-on](/1.x/addons/search): ```sh theme={null} composer require lunarphp/search ``` The add-on provides a `Search` facade with support for Meilisearch, Typesense, and database drivers, returning consistent `SearchResults` objects with hits, facets, and pagination. ## Extending Search To customize what data is indexed for a model, create a custom indexer class. See the [Extending Search](/1.x/extending/search) guide for details on creating custom indexers, adding sortable and filterable fields, and mapping searchable attributes. # Tags Source: https://docs.lunarphp.com/1.x/reference/tags Tags provide a way to relate otherwise unrelated models, enabling features like dynamic collections. ## Overview Tags provide a way to relate otherwise unrelated models in the system. They also impact other features such as Dynamic Collections. For example, two products "Blue T-Shirt" and "Blue Shoes" are unrelated by nature, but adding a `BLUE` tag to each product allows a Dynamic Collection to include any products with that tag. ```php theme={null} Lunar\Models\Tag ``` ### Fields | Field | Type | Description | | :----------- | :---------- | :------------ | | `id` | `id` | Primary key | | `value` | `string` | The tag value | | `created_at` | `timestamp` | | | `updated_at` | `timestamp` | | Tags are automatically converted to uppercase when saved via the `syncTags` method. ## Enabling tags To enable tagging on a model, add the `HasTags` trait: ```php theme={null} 'TAG ONE']), Tag::firstOrCreate(['value' => 'TAG TWO']), Tag::firstOrCreate(['value' => 'TAG THREE']), ]); $model = SomethingWithTags::first(); $model->syncTags($tags); ``` The `syncTags` method accepts a collection of `Tag` models. Tags are automatically converted to uppercase when saved. The sync process runs via a queued job (`Lunar\Jobs\SyncTags`), so changes may not be reflected immediately if using an asynchronous queue driver. # Taxation Source: https://docs.lunarphp.com/1.x/reference/taxation Lunar provides configurable tax rules for calculating sales tax on orders. ## Overview Lunar provides manual tax rules to implement the correct sales tax for each order. For complex taxation scenarios (e.g. US states), integrating with a service such as [TaxJar](https://www.taxjar.com/) is recommended. ## Tax Classes Tax Classes are assigned to products and allow classification into taxable groups that may have differing tax rates. ```php theme={null} Lunar\Models\TaxClass ``` | Field | Type | Description | | :----------- | :---------- | :------------------------------------ | | `id` | `id` | Primary key | | `name` | `string` | e.g. `Clothing` | | `default` | `boolean` | Whether this is the default tax class | | `created_at` | `timestamp` | | | `updated_at` | `timestamp` | | ```php theme={null} use Lunar\Models\TaxClass; $taxClass = TaxClass::create([ 'name' => 'Clothing', ]); ``` ## Tax Zones Tax Zones specify a geographic zone for tax rates to be applied. They can be based on countries, states, or zip/postcodes. ```php theme={null} Lunar\Models\TaxZone ``` | Field | Type | Description | | :-------------- | :---------- | :---------------------------------- | | `id` | `id` | Primary key | | `name` | `string` | e.g. `UK` | | `zone_type` | `string` | `country`, `states`, or `postcodes` | | `price_display` | `string` | `tax_inclusive` or `tax_exclusive` | | `active` | `boolean` | Whether the zone is active | | `default` | `boolean` | Whether the zone is the default | | `created_at` | `timestamp` | | | `updated_at` | `timestamp` | | ```php theme={null} use Lunar\Models\TaxZone; $taxZone = TaxZone::create([ 'name' => 'UK', 'zone_type' => 'country', 'price_display' => 'tax_inclusive', 'active' => true, 'default' => true, ]); ``` ### Tax Zone Countries ```php theme={null} Lunar\Models\TaxZoneCountry ``` | Field | Type | Description | | :------------ | :--------------------- | :---------- | | `id` | `id` | Primary key | | `tax_zone_id` | `foreignId` `nullable` | | | `country_id` | `foreignId` `nullable` | | | `created_at` | `timestamp` | | | `updated_at` | `timestamp` | | ```php theme={null} $taxZone->countries()->create([ 'country_id' => \Lunar\Models\Country::first()->id, ]); ``` ### Tax Zone States ```php theme={null} Lunar\Models\TaxZoneState ``` | Field | Type | Description | | :------------ | :--------------------- | :---------- | | `id` | `id` | Primary key | | `tax_zone_id` | `foreignId` `nullable` | | | `state_id` | `foreignId` `nullable` | | | `created_at` | `timestamp` | | | `updated_at` | `timestamp` | | ```php theme={null} $taxZone->states()->create([ 'state_id' => \Lunar\Models\State::first()->id, ]); ``` ### Tax Zone Postcodes ```php theme={null} Lunar\Models\TaxZonePostcode ``` | Field | Type | Description | | :------------ | :--------------------- | :------------------------------- | | `id` | `id` | Primary key | | `tax_zone_id` | `foreignId` `nullable` | | | `country_id` | `foreignId` `nullable` | | | `postcode` | `string` | Supports wildcards, e.g. `9021*` | | `created_at` | `timestamp` | | | `updated_at` | `timestamp` | | ### Tax Zone Customer Groups ```php theme={null} Lunar\Models\TaxZoneCustomerGroup ``` | Field | Type | Description | | :------------------ | :--------------------- | :---------- | | `id` | `id` | Primary key | | `tax_zone_id` | `foreignId` `nullable` | | | `customer_group_id` | `foreignId` `nullable` | | | `created_at` | `timestamp` | | | `updated_at` | `timestamp` | | ## Tax Rates Tax Zones have one or many tax rates. For example, a zone might have a tax rate for the state and also the city, which collectively make up the total tax amount. ```php theme={null} Lunar\Models\TaxRate ``` | Field | Type | Description | | :------------ | :--------------------- | :--------------------------------------------- | | `id` | `id` | Primary key | | `tax_zone_id` | `foreignId` `nullable` | | | `priority` | `tinyInteger` | The priority order for this rate (default `1`) | | `name` | `string` | e.g. `UK` | | `created_at` | `timestamp` | | | `updated_at` | `timestamp` | | ### Tax Rate Amounts ```php theme={null} Lunar\Models\TaxRateAmount ``` | Field | Type | Description | | :------------- | :--------------------- | :-------------- | | `id` | `id` | Primary key | | `tax_class_id` | `foreignId` `nullable` | | | `tax_rate_id` | `foreignId` `nullable` | | | `percentage` | `decimal(7,3)` | e.g. `6` for 6% | | `created_at` | `timestamp` | | | `updated_at` | `timestamp` | | ## Settings * Shipping and other specific costs are assigned to tax classes in the settings. * Tax calculation can be based on the shipping or billing address. * A default Tax Zone can be configured. ## Extending Sometimes the standard tax calculations are not sufficient, and custom logic may be needed, perhaps connecting to a tax service such as TaxJar. Lunar allows custom tax drivers to be implemented. See the [Extending Taxation](/1.x/extending/taxation) section for more information. # URLs Source: https://docs.lunarphp.com/1.x/reference/urls Create SEO-friendly slugs for products, collections, brands, and custom models. URLs provide SEO-friendly slugs for models such as products, collections, and brands. ## Overview URLs provide SEO-friendly slugs for models such as products, collections, and brands. Instead of exposing database IDs in storefront routes: ``` /products/1 ``` A URL slug allows routes like: ``` /products/apple-iphone ``` URLs are polymorphic, so any Eloquent model can support them. Each URL belongs to a language, and each model can have one default URL per language. URLs are not to be confused with Laravel routes. They provide a slug-based lookup mechanism for storefront routing, not route definitions. ## Model ```php theme={null} Lunar\Models\Url ``` ### Fields | Field | Type | Description | | :------------- | :------------------- | :------------------------------------------------------------------------ | | `id` | `bigIncrements` | Primary key | | `language_id` | `foreignId` | The language this URL belongs to | | `element_type` | `string` | Morph type of the parent model | | `element_id` | `unsignedBigInteger` | Morph ID of the parent model | | `slug` | `string` | The URL slug | | `default` | `boolean` | Whether this is the default URL for this element and language combination | | `created_at` | `timestamp` | | | `updated_at` | `timestamp` | | ### Relationships | Relationship | Type | Related Model | Description | | :----------- | :-------- | :---------------------- | :------------------------------------------------- | | `element` | MorphTo | Polymorphic | The parent model (e.g. Product, Collection, Brand) | | `language` | BelongsTo | `Lunar\Models\Language` | The language this URL is for | ### Scopes | Scope | Description | | :---------- | :--------------------------------------- | | `default()` | Filter to URLs where `default` is `true` | ## Creating a URL URLs can be created directly or through a model's `urls()` relationship. ```php theme={null} use Lunar\Models\Url; Url::create([ 'slug' => 'apple-iphone', 'language_id' => $language->id, 'element_type' => $product->getMorphClass(), 'element_id' => $product->id, 'default' => true, ]); // Or through the relationship $product->urls()->create([ 'slug' => 'apple-iphone', 'language_id' => $language->id, 'default' => true, ]); ``` ### Default URL behavior Only one URL can be the default per element and language combination. When a new URL is created or updated with `default` set to `true`, any existing default URL for that same element and language is automatically set to `false`. ```php theme={null} $urlA = $product->urls()->create([ 'slug' => 'apple-iphone', 'language_id' => 1, 'default' => true, ]); $urlA->default; // true $urlB = $product->urls()->create([ 'slug' => 'apple-iphone-16', 'language_id' => 1, 'default' => true, ]); $urlA->refresh()->default; // false $urlB->default; // true ``` This behavior is scoped to each language independently. Setting a default for one language does not affect defaults in another language. ```php theme={null} // This URL is for a different language, so $urlB remains the default for language 1 $urlC = $product->urls()->create([ 'slug' => 'apple-iphone-fr', 'language_id' => 2, 'default' => true, ]); $urlB->refresh()->default; // true (still default for language 1) $urlC->default; // true (default for language 2) ``` ## Deleting a URL When a default URL is deleted, Lunar automatically promotes another URL for the same element and language to become the new default. ```php theme={null} $urlA = $product->urls()->create([ 'slug' => 'apple-iphone', 'language_id' => 1, 'default' => true, ]); $urlB = $product->urls()->create([ 'slug' => 'apple-iphone-16', 'language_id' => 1, 'default' => false, ]); $urlA->delete(); $urlB->refresh()->default; // true ``` ## Adding URL support to models Lunar ships with URL support on the following models: * `Lunar\Models\Product` * `Lunar\Models\Collection` * `Lunar\Models\Brand` To add URL support to a custom model, use the `HasUrls` trait: ```php theme={null} urls; // The default URL (where default is true) $model->defaultUrl; // The URL for the application's current locale $model->localeUrl; // The URL for a specific locale (matches against the language code) $model->localeUrl('fr')->first(); ``` When a model using the `HasUrls` trait is deleted (hard delete only), all associated URLs are automatically removed. ## Automatic URL generation Lunar can automatically generate a URL when a model with the `HasUrls` trait is created. This is controlled by the `generator` option in `config/lunar/urls.php`. ```php theme={null} true, 'generator' => UrlGenerator::class, ]; ``` | Option | Type | Default | Description | | :---------- | :------------- | :-------------------- | :------------------------------------------------------------------------------------------------------------------------ | | `required` | `bool` | `true` | Whether URLs are required when creating or editing models in the admin panel. Has no effect if a generator is configured. | | `generator` | `string\|null` | `UrlGenerator::class` | The class responsible for generating URLs on model creation. Set to `null` to disable automatic generation. | ### Default generator behavior The built-in `Lunar\Generators\UrlGenerator` performs the following steps when a model is created: 1. Checks whether the model already has any URLs (skips generation if it does). 2. Reads the model's `name` column, falling back to the `name` attribute (via `attr('name')`). 3. Converts the name to a slug using `Str::slug()`. 4. Ensures uniqueness by appending a numeric suffix if the slug already exists (e.g. `test-product`, `test-product-2`, `test-product-3`). 5. Creates the URL as the default for the system's default language. ### Custom generator To customize URL generation, create a class with a `handle` method that accepts an Eloquent model: ```php theme={null} true, 'generator' => \App\Generators\CustomUrlGenerator::class, ]; ``` To disable automatic generation entirely, set the generator to `null`: ```php theme={null} return [ 'required' => true, 'generator' => null, ]; ``` # Storefront Session Source: https://docs.lunarphp.com/1.x/storefront-utils/storefront-session The StorefrontSession facade manages session-bound resources like the active channel, currency, and customer. ## Overview The `StorefrontSession` facade helps manage session-bound resources that a storefront typically needs, such as the active channel, currency, customer, and customer groups. ```php theme={null} use Lunar\Facades\StorefrontSession; ``` When the facade is first resolved, it automatically calls `initChannel()`, `initCustomerGroups()`, `initCurrency()`, and `initCustomer()` to restore any previously set values from the session or fall back to defaults. ## Channels ### Initialize the Channel This will set the channel based on what has been previously stored in the session, otherwise it will use the default channel. ```php theme={null} StorefrontSession::initChannel(); ``` This is automatically called when the facade is resolved. ### Set the Channel ```php theme={null} use Lunar\Models\Channel; $channel = Channel::where('handle', 'webstore')->first(); StorefrontSession::setChannel($channel); ``` The `setChannel` method accepts a `Channel` model instance. The channel's handle is stored in the session so it can be restored on subsequent requests. ### Get the Channel Returns the currently active `Channel` model. ```php theme={null} StorefrontSession::getChannel(); ``` ## Customer Groups ### Initialize the Customer Groups This will set the customer groups based on what has been previously stored in the session, otherwise it will use the default customer group. ```php theme={null} StorefrontSession::initCustomerGroups(); ``` This is automatically called when the facade is resolved. ### Set the Customer Groups ```php theme={null} use Lunar\Models\CustomerGroup; $retail = CustomerGroup::where('handle', 'retail')->first(); $wholesale = CustomerGroup::where('handle', 'wholesale')->first(); // Set multiple customer groups StorefrontSession::setCustomerGroups(collect([$retail, $wholesale])); // Set a single customer group (calls setCustomerGroups under the hood) StorefrontSession::setCustomerGroup($retail); ``` ### Get the Customer Groups Returns a `Collection` of `CustomerGroup` models. ```php theme={null} StorefrontSession::getCustomerGroups(); ``` ### Reset the Customer Groups Clears the customer groups from the session and resets the collection to empty. ```php theme={null} StorefrontSession::resetCustomerGroups(); ``` ## Customer ### Initialize the Customer This will set the customer based on what has been previously stored in the session, otherwise it will retrieve the latest customer associated with the authenticated user. ```php theme={null} StorefrontSession::initCustomer(); ``` This is automatically called when the facade is resolved. ### Set the Customer ```php theme={null} use Lunar\Models\Customer; $customer = Customer::find($customerId); StorefrontSession::setCustomer($customer); ``` If a user is authenticated and the given customer does not belong to that user, a `Lunar\Exceptions\CustomerNotBelongsToUserException` will be thrown. ### Get the Customer Returns the currently active `Customer` model, or `null` if no customer has been set. ```php theme={null} StorefrontSession::getCustomer(); ``` ## Currencies ### Initialize the Currency This will set the currency based on what has been previously stored in the session, otherwise it will use the default currency. ```php theme={null} StorefrontSession::initCurrency(); ``` This is automatically called when the facade is resolved. ### Set the Currency ```php theme={null} use Lunar\Models\Currency; $currency = Currency::where('code', 'USD')->first(); StorefrontSession::setCurrency($currency); ``` The `setCurrency` method accepts a `Currency` model instance. The currency code is stored in the session so it can be restored on subsequent requests. ### Get the Currency Returns the currently active `Currency` model. ```php theme={null} StorefrontSession::getCurrency(); ``` ## Clearing the Session To remove all storefront session data (channel, currency, customer, and customer groups), call the `forget` method. ```php theme={null} StorefrontSession::forget(); ``` This clears the stored values from the session but does not delete any database records. # Welcome to the New Lunar Docs Source: https://docs.lunarphp.com/logs/2025/new-docs We’ve rebuilt the Lunar documentation from the ground up. The mission was simple: make it faster to find what you need, clearer to understand, and easier to explore. The new docs are powered by [Mintlify](https://www.mintlify.com/), giving you a faster interface, improved search, and smooth navigation whether you’re on desktop or mobile. You’ll notice a cleaner structure around the core concepts — getting started, configuration, extending Lunar — all organized to match how developers actually build. This launch isn’t just a redesign — it’s the foundation for the next chapter. As we approach Lunar v2, expect deeper guides, architectural overviews, and examples from live production sites. Our goal is to make Lunar not only powerful, but deeply understandable. If you spot something missing or unclear, open an issue or suggest an edit — collaboration keeps this mission on course. Welcome aboard the new Lunar docs. # Lunar Flight Plan Source: https://docs.lunarphp.com/logs/flight-plan ## Introduction Lunar’s goal has always been simple — to be the most flexible, modern e-commerce foundation for Laravel developers. Our roadmap evolves continuously as we refine our vision, collaborate with the community, and push the limits of what’s possible in Laravel commerce. Below is our current direction — shaped by our community, guided by experience, and always moving forward. *** ## Lunar v2 * **Laravel 13** - Full support for the latest Laravel release, dropping Laravel 11. * **Filament v5** - Full compatibility with Filament v5, ensuring Lunar’s admin panel remains fast, stable, and future-ready. * **Event Hooks** - A robust event system designed for fine-grained control and reliable cache invalidation across orders, carts, products, and other key processes. * **Multi-Vendor Foundation** - The concept of Vendors will be introduced to support marketplace builds. * **Lunar Marketplace** - Discover community-built add-ons or publish your own — from simple integrations to fully-featured extensions — free or paid. * **API Guides** - Comprehensive guides for building custom API endpoints powering Nuxt, Next.js, Remix, and other modern storefronts. * **Inertia.js + Vue Storefront Starter Kit** - A premium production-ready Inertia.js + Vue storefront with authentication, cart, checkout, and account area — designed to help you launch faster. Targeting release: March/April 2026 (after Laravel 13). *** ## Beyond Q1 2026 #### MCP Server Early exploration into how Lunar can connect with the Model Context Protocol — paving the way for Conversational Commerce and AI-assisted store interactions. The MCP Server will bridge Lunar and emerging AI ecosystems, opening new possibilities for how merchants and customers interact with their commerce data. #### API Starter Kit A storefront API has long been one of the most requested features. But with Lunar’s flexibility — and the endless variety of e-commerce use cases — a one-size-fits-all API simply isn’t practical. Just as developers want to shape their own storefronts, those building in Nuxt or Next.js will want to design APIs that fit their projects. Some prefer REST, others JSON:API, JSON-LD, GraphQL, or even SOAP for legacy integration. Rather than dictating a single standard, we’re building a starter kit that gives you a strong foundation and proven patterns — while leaving full control in your hands. *** ### Stay Involved We actively evolve our plans based on community feedback and real-world implementations. Follow progress, contribute ideas, or join discussions on [GitHub Discussions](https://github.com/lunarphp/lunar/discussions) and [Discord](https://discord.gg/HvV6bgrEkR). # Neon Digital Source: https://docs.lunarphp.com/support/alliance/neon-digital * Location: Essex, United Kingdom * Website: [neondigital.co.uk](https://neondigital.co.uk?utm_source=lunar\&utm_medium=referral\&utm_campaign=lunar_alliance) Neon Digital is the founding agency behind Lunar and a leading Laravel-focused studio in the UK. With over a decade of experience delivering large-scale e-commerce solutions, Neon combines technical expertise with real-world understanding of B2B and D2C commerce. Their work powers brands including Orbital Fasteners, Edwardes Bros, Powerflex, and Plumo, with a strong focus on performance, maintainability, and long-term client partnerships. Neon continues to lead the development of Lunar’s core platform, while helping other agencies and merchants successfully adopt it. # Thought Collective Source: https://docs.lunarphp.com/support/alliance/thought-collective * Location: Dublin, Ireland & Belfast, Northern Ireland * Website: [thoughtcollective.com](https://www.thoughtcollective.com?utm_source=lunar\&utm_medium=referral\&utm_campaign=lunar_alliance) Thought Collective bring over 20 years of experience designing and developing award-winning digital experiences for clients across a wide range of industries. Whether you’re building a high-performing online store, a complex multi-vendor marketplace, or a bespoke commerce platform powered by Lunar, we have the expertise and insight to help you achieve your strategic goals. Based from offices in Belfast and Dublin, our team of brand strategists, UX designs, developers, and digital marketeers will bring all their skills and ideas the table to ensure the success of your project. # Weby Digital Source: https://docs.lunarphp.com/support/alliance/weby-digital * Location: Kuala Lumpur, Malaysia * Website: [weby.digital](https://weby.digital?utm_source=lunar\&utm_medium=referral\&utm_campaign=lunar_alliance) At Weby Digital, we craft custom software solutions designed around each business’s unique goals. Specializing in e-commerce platforms powered by Lunar, we believe that no two businesses are the same — and neither should their solutions be. Our team blends technical expertise with real-world understanding to help businesses across Malaysia, Singapore, and Indonesia build, scale, and sustain powerful online platforms in the growing ASEAN digital landscape. Whether you’re launching your first online store or revamping an existing system, Weby Digital delivers solutions that work seamlessly, grow intelligently, and truly reflect your brand’s potential. Let’s build something made just for you. # Community Support Source: https://docs.lunarphp.com/support/community Free help from the community We’re here to help you get the most out of Lunar. Whether you need quick answers from the community or dedicated assistance from our team, we offer multiple ways to get support for your Lunar-powered project. ## Community Support Lunar is an open-source project, and we have an active community of developers who are happy to help. If you’re looking for general advice or troubleshooting tips, try the following: [Discord Community](https://lunarphp.com/discord) – Join the discussion, ask questions, and collaborate with other Lunar users. [GitHub Discussions](https://github.com/lunarphp/lunar/discussions) – Browse existing topics or start a new discussion about features and issues. [Bug Reports](https://github.com/lunarphp/lunar/issues) – Found a bug? Report it on our GitHub repository so we can improve Lunar. Community support is a great way to get help from other developers and contributors, though response times may vary. If you need dedicated expertise or hands-on project support, explore the Lunar Alliance — a network of trusted partners who work alongside us to help you deliver with confidence. # Overview Source: https://docs.lunarphp.com/support/lunar-alliance Working together to build the future of Laravel e-commerce The Lunar Alliance is a collective of trusted agencies and independent developers who work alongside the Lunar team to deliver world-class e-commerce experiences. Members of the Alliance share our values of craftsmanship, transparency, and innovation — helping businesses of all sizes get the most out of the Lunar ecosystem. Whether you need implementation support, a custom integration, or ongoing technical partnership, the Alliance is here to help. ### Why the Alliance exists Lunar’s vision has always been bigger than a single product — it’s a growing ecosystem. The Alliance exists to: * Connect merchants and developers with experienced Laravel e-commerce experts * Strengthen the open-source community through collaboration and shared knowledge * Ensure consistent quality and best practices across real-world Lunar projects Each Alliance member is independently operated but closely aligned with Lunar’s principles and technical roadmap. ### How it works * You choose your partner — work directly with an Alliance member suited to your project. * You stay in control — each partner manages their own pricing, process, and delivery. * We stay connected — the Lunar core team collaborates regularly with Alliance members to share feedback, insights, and early access to new features. *** ## Current Alliance Members * [Neon Digital](/support/alliance/neon-digital) * [Thought Collective](/support/alliance/thought-collective) * [Weby Digital](/support/alliance/weby-digital) *** ## Join the Lunar Alliance If your agency or freelance practice delivers exceptional Lunar-based e-commerce work and you share our open-source values, we’d love to hear from you.