81 KiB
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog.
[0.15.0] - 2026-09-07
Changed
- Breaking:
Modules\Core\Payment\Models\PaymentMethodis now the full DB-instance layer for Payment, same three-layer split (registry / DB instance / cross-cutting config)Shippingalready has viaShippingMethod— seedocs/payments.md. Every value that used to live inconfig('lunar.payments.types.{type}.*')(payment_driver,capture_mode,captured_status) moves onto thePaymentMethodrow itself as real columns:driver(the newPaymentDriverRegistrykey — NOT the same astype; two rows can share one driver),name(admin-facing label, nothing played this role before),capture_mode,captured_status,authorized_status,position(admin-controlled ordering, new — reorderable in the Filament table),driver_missing_at.config('lunar.payments.types')is gone entirely;config/ payment.phpnow holds onlycart_pipeline(genuinely cross-cutting — every store gets the same pipeline wiring regardless of how many payment methods it configures). - Breaking:
Modules\Core\Payment\Services\PaymentDriverResolveris deleted, replaced byModules\Core\Payment\Services\PaymentDriverRegistry—register(string $key, string $driverClass)/resolve(string $key): ?object/all(): array<string, string>. Deliberately knows nothing aboutPaymentMethodor the database (mirrorsLunar\Shipping\Managers\ ShippingManager's built-in-methods +Manager::extend()split, purpose-built rather than extendingIlluminate\Support\Manager— Payment's drivers implement several independent capability interfaces at once, not one uniform contract). Built-ins (OfflinePaymentDriveras'offline',StripePaymentDriveras'stripe') registered inPaymentServiceProvider::boot(), exactly howShipping::extend('acs', ...)already works. - Breaking:
Modules\Core\Checkout\Services\CheckoutService::getPaymentMethods()now returnsIlluminate\Support\Collection<int, PaymentMethod>(ordered byposition), notarray<string>. A method is offered only once three independent checks all pass —enabled(admin turned it on),driver_missing_atis null (the driver class still exists), and the resolved driver's ownConfigurable::isConfigured()(its runtime requirements are met) — each failure meaning something different to an admin diagnosing why a method isn't showing up.initiatePayment()resolves the driver via the selected row's owndrivercolumn, nottype. Modules\Core\Payment\Filament\Resources\PaymentMethodResource—canCreate()/canDelete()now bothtrue(previously hardcodedfalse, since a row could only ever be a config-defined type before this release). New create/edit form (name,type,driver— aSelectpopulated live fromPaymentDriverRegistry::all(),capture_mode,captured_status,authorized_status); reorderable table (->reorderable('position')); a distinct "Driver status" icon column (separate from theenabledtoggle) showing whetherdriver_missing_atis set.Modules\Core\Order\Listeners\ApplyResolvedPaymentStatusreadscaptured_status/authorized_statusoff thePaymentMethodrow (where('type', $event->type)) instead ofconfig(...).Modules\Core\Command\InstallLunarCommand::seedPaymentMethods()no longer iteratesconfig('lunar.payments.types')— it seeds exactly one opinionatedcash-on-deliverystarter row, every value a plain literal in the command itself (not sourced from config or the registry — a driver has no business carrying opinions about what its captured order status should be called; that's a merchant decision). Skip-if-exists, same as before.
Added
-
php artisan boboko:payment:sync-drivers— reconciles everyPaymentMethodrow'sdriveragainstPaymentDriverRegistry, settingdriver_missing_atwhen a driver no longer resolves (a package removed, a customregister()call deleted) and clearing it automatically if that driver is registered again in a later deploy. Deliberately its own standalone command, meant to run unconditionally on every container start/deploy (Dockerfile entrypoint, alongsidemigrate) — "did the set of registered drivers change" is a deploy-time event, cheap enough to check every time regardless of whether anything actually changed. Verified live: flags a row whosedriverwas manually corrupted, and auto-clears the flag once the driver resolves again. -
docs/payments.md— new "Registry, DB instance, and cross-cutting config" section: the three-layer split researched againstShipping's own already-existing pattern and three real e-commerce platforms (Shopify, WooCommerce, Medusa.js), the "would a store ever plausibly want two different answers to this" test for deciding config vs. DB-column placement, and the three-check availability chain. -
Modules\Core\Payment\Services\PaymentMethodCache(Cache::rememberForever, same pattern asLocalization\Services\LanguageCache) +Modules\Core\Payment\Services\PaymentMethodService(create/update/delete/list) — the single read/write gateway forPaymentMethodnow used by every Filament resource action (create, edit, edit-fee, delete, the inlineenabledtoggle) instead of the Eloquent model directly, so the cache is invalidated andPaymentMethodCreated/PaymentMethodUpdated/PaymentMethodDeleted/PaymentMethodsReordereddispatch on every write, with no exceptions other than Filament's own drag-to-reorder (which does a raw bulk SQLUPDATEon the position column directly viaCanReorderRecords/reorderTable(), beforeafterReordering()fires — a confirmed, unavoidable Filament limitation; the reorder hook only clears the cache and dispatchesPaymentMethodsReorderedafterward).CheckoutService::getPaymentMethods()andApplyResolvedPaymentStatusboth now read through the cache instead of queryingPaymentMethoddirectly. -
Modules\Core\Payment\Models\CoreTransaction(aLunar\Models\Transactionsubclass) +Modules\Core\Payment\Support\TransactionDriverAdapter, registered viaLunar\Facades\ModelManifest::replace(Lunar\Models\Contracts\Transaction::class, CoreTransaction::class)— the same contract-swap mechanism already used elsewhere forCustomer/Staff. Fixes a real crash (InvalidArgumentException: Driver [cash-on-delivery] not supported) the first time anything called$transaction->refund()/->capture():Lunar\Models\Transaction::driver()calls Lunar's own, entirely separateLunar\Facades\Payments::driver()manager, which had never heard of any of this codebase's driver keys.CoreTransaction::driver()returnsTransactionDriverAdapterinstead, which resolves the transaction's realPaymentMethod/PaymentDriverRegistrydriver and calls it — Lunar's own admin panel "Refund"/"Capture" buttons now transparently reach the real payment system underneath, including correctly reporting failure (not a silently-faked success) when the resolved driver doesn't implementSupportsRefunds/SupportsCaptures. -
TransactionDriverAdapter::refundVia(Transaction $transaction, ?string $driverKey, int $amount, ?string $notes = null)— refund through an explicitly chosen driver, independent of the one the original payment went through (e.g. a cash-on-delivery order refunded via Bank Transfer, which has no notion of the original offline payment at all). The order page's refund action gained a "Refund via"Select(everyPaymentDriverRegistrydriver implementingSupportsRefunds, defaulting to the transaction's own driver) that routes through this method instead ofLunar\Models\Transaction::refund(), whose fixed signature has no room for a driver override. -
Modules\Core\Payment\Drivers\BankTransferPaymentDriver(registered as'bank-transfer') — manual/attested, same trust model asOfflinePaymentDriver: no gateway call,pay()/refund()decide success immediately on a staff member's say-so. Implements bothSupportsPayandSupportsRefunds; exists specifically so a payment taken through a different method can still be refunded via bank transfer. The admin UI for receiving a payment this way (bank reference, notes, proof-of-transfer upload) is a follow-up — the driver itself is complete and usable via the registry today. -
Modules\Core\Order\Filament\Infolists\TransactionEntry(swapped in for Lunar's ownLunar\Admin\Support\Infolists\Components\Transactionvia a newOrderTransactionsExtension::extendTransactionsRepeatableEntry()hook) — the order page's transaction cards now also show a note recorded inTransaction.meta['notes']when thenotescolumn itself is empty.Order\Services\TransactionRecorderonly ever wrotenotesfromPaymentResult::$failureReason, which is never set on a successful result — a manual driver's staff-entered note (e.g.BankTransferPaymentDriver's) was being recorded but had nowhere to render. -
Modules\Core\Payment\Listeners\LogPaymentMethodActivity—PaymentMethodnow has an admin activity trail, unlikeOrder/Transaction/Staffit previously had none. RoutesPaymentMethodCreated/Updated/Deletedthrough the existingLogging\ActivityLogService(the same oneLocalization\Listeners\LogTranslationActivityalready uses) rather than addingPaymentMethodtoLunar\Base\Traits\LogsActivity's generic model-observer logging —PaymentMethodUpdated::$old/PaymentMethodDeleted::$method's snapshot already carry more deliberate before/after context than Eloquent's own dirty-attribute diffing would reconstruct.PaymentMethodsReorderedis deliberately NOT logged — a multi-row position change doesn't fitActivityLogService's one-Model-subject shape, and isn't worth a new method for a low-stakes, purely-cosmetic setting. -
New
payment_methods.refunded_statuscolumn + form field (sameSelectpattern ascaptured_status/authorized_status) —ApplyResolvedPaymentStatusnow also reacts toPaymentRefunded, soOrder.statusactually changes on a refund; before this, only the derivedOrder::paymentStatus()reflected a refund (readingtransactionslive), while the storedstatuscolumn — what admin filtering, customer emails, etc. actually key off — never moved. Resolves the ORIGINAL payment method for this lookup, not the refund event's own$type: a refund routed through a different driver viarefundVia()(e.g. cash-on-delivery refunded through Bank Transfer) carries the REFUND driver's registry key as$event->type, which usually isn't even a realPaymentMethod.type— the listener now finds the order's earliest successfulcapture/intenttransaction instead and readsrefunded_statusoff that transaction's ownPaymentMethodrow, since that's the payment the refund is actually reversing. Deliberately novoid_statusyet — a void never moved money, so it doesn't carry the same "the customer needs to see this changed" weight a refund does.
Fixed
- Existing
PaymentMethodrows seeded before this release (cash-on-delivery,cash-in-hand) haddriver/capture_mode/captured_statusallNULLafter the migration ran — a data backfill was required (not automated by the migration itself) to restore them to a resolvable state; flagged here since a consuming app upgrading past this release needs the same backfill for its own pre-existing rows beforegetPaymentMethods()will offer them again. Lunar\Admin\Filament\Resources\OrderResource\Pages\ManageOrder::getRefundAction()/getCaptureAction()andOrderItemsTable::getBulkRefundAction()report a failed refund/capture by calling$action->failureNotification(...),$action->failure(), then$action->halt()— butFilament\Actions\Concerns\InteractsWithActions::callMountedAction()only ever sends that notification from a code path that runs after the action's closure returns normally;halt()throwsFilament\Support\Exceptions\Halt, caught by an earliercatchblock that rolls back and returns, so the notification was built but never sent — clicking "Refund" on a payment method that genuinely can't be refunded looked like nothing happened at all, no error, no toast. Real, pre-existing Filament/Lunar bug, invisible until this release'sTransactionDriverAdaptermade an honest failure (rather than a hard crash or a silently-faked success) actually reachable. Fixed via newModules\Core\Order\Filament\Extensions\OrderRefundActionsExtension/OrderItemsTableExtension, which wrap the affected actions' closures to send the queued failure notification themselves before re-throwingHalt.TransactionDriverAdapter::refund()/capture()never includedorder_idin the$contextpassed to the driver, soOrder\Listeners\RecordPaymentTransaction/ApplyResolvedPaymentStatus(both requiring$context['order_id']) silently no-op'd for every admin-initiated refund/capture through any driver — no auditTransactionrow was ever created, regardless of whether the refund/capture itself succeeded. Fixed by passing$transaction->order_idthrough.
[0.14.0] - 2026-09-03
Changed
- Breaking:
Modules\Core\Catalog\Services\ProductSearchService::search()now returnsModules\Core\Catalog\DTOs\ProductListingResult— the exact same shapeProductService::list()already returns — instead of a bareIlluminate\Database\Eloquent\Collection<Product>of hydrated models with no pagination at all. New signature:search(string $query, ?ProductFilters $filters = null, ?ProductSort $sort = null, int $perPage = 24, int $page = 1): ProductListingResult.->productsis a realLengthAwarePaginatorof plain, localized indexed-document arrays (not Eloquent models, not Scout's raw response) — a search results page and a category listing page are now interchangeable from a controller's perspective: same DTO, sameProductCard::fromIndexed()mapping, same pagination/sort/tag/price-slider handling.->priceBounds/->availableTagsare scoped to the search query itself (delegated toProductService::priceSliderBounds()/availableTags(), both of which already accepted a$queryparam for this). Modules\Core\Catalog\Services\ProductService::availableTags()is nowpublic(wasprivate) and takes an optional$queryparameter, soProductSearchService::search()can reuse it directly instead of reimplementing the same facet call.
Added
Modules\Core\Catalog\Support\ProductDocumentLocalizer— the per-locale field resolution and raw-Meilisearch-response unwrapping (withLocalizedFields(),hitsFrom()) extracted out ofProductServiceinto its own class, sinceProductSearchServiceneeded the exact same logic against the exact same kind of document. Both services now depend on this one class instead ofProductServiceowning logic a second service also needed.
[0.13.0] - 2026-09-03
Changed
- Breaking:
Paymentis now a genuinely standalone module — no direct calls intoCheckout/Order, no reaching into their Eloquent models, communication only via events. The entire oldconfirm()-based flow is gone:Modules\Core\Payment\Contracts\PaymentDriver(and the already-staleModules\Core\Checkout\Contracts\PaymentDriverduplicate),Checkout\Events\PaymentConfirmed,Payment\Contracts\InitiatesPayment,Payment\DataTransferObjects\PaymentInitiation,Payment\Enums\PaymentInitiationMode,Payment\Events\PaymentSucceeded/PaymentFailed,Payment\Events\OrderPaymentStatusResolved, andPayment\Exceptions\PaymentNotConfirmedExceptionare all deleted. This flow was non-functional onmasterbefore this release —CheckoutService::confirmPayment()dispatched an event nothing listened for, so no order was ever placed after payment. - Breaking: Every payment operation is now its own explicit, opt-in contract, modeled on how real gateways (Stripe, Mastercard's own gateway, Nexi) actually split these operations — see
docs/payments.md:Modules\Core\Payment\Contracts\SupportsPay(atomic authorize+capture),SupportsAuthorization(hold only),SupportsCaptures(settle a prior hold),SupportsVoids(release a prior hold without settling),SupportsRefunds(reverse settled funds),HandlesPaymentCallback(resolve an async pay()/authorize() later, from a webhook), andConfigurable(isConfigured(), split out of the old singlePaymentDriverinterface). A driver implements only the operations its gateway actually supports. - Breaking: Every amount flowing through these contracts is
Lunar\DataTypes\Price(Lunar's own bundled minor-unit-value +Currencytype) — never a bareintpaired separately with aCurrency. Each driver converts at its own boundary (e.g.StripeManager::toStripeAmount()/fromStripeAmount());Paymentitself only ever speaks Lunar'sPrice. - Breaking:
Modules\Core\Checkout\Services\CheckoutService::placeOrder()andconfirmPayment()are both replaced by a singleinitiatePayment(string $fingerprint, array $data = []): Modules\Core\Payment\DTOs\PaymentResult. It creates the draftOrder(Cart::createOrder(), idempotent against an existing draft) and hands off directly to the resolved driver'spay()/authorize(), per that type's newconfig('lunar.payments.types.{type}.capture_mode')key.Checkout\Events\OrderPlacedno longer dispatches fromCheckoutService— it now fires fromModules\Core\Order\Listeners\ApplyResolvedPaymentStatusonce aPaymentCaptured/PaymentAuthorizedevent actually transitions the order'splaced_at, since a draft order can now exist well before payment resolves (an async gateway). Modules\Core\Payment\Services\PaymentDriverResolver::resolve()now returns?objectinstead of the deletedPaymentDriverinterface — a driver implements several independent capability interfaces at once, so a caller does its owninstanceof SupportsPay/instanceof SupportsAuthorizationcheck, the same pattern the capability interfaces themselves are designed around.config/payment.php'scash-on-deliveryentry gainscapture_mode('pay', sinceOfflinePaymentDriveronly implementsSupportsPay) andcaptured_status('payment-offline', replacing the previously dead'authorized' => 'awaiting-payment'key, which nothing ever read).
Added
Modules\Core\Payment\DTOs\PaymentResult— the one return shape every operation (pay,authorize,capture,void,refund,handleCallback) produces, regardless of gateway:status(Modules\Core\Payment\Enums\PaymentResultStatus:Succeeded/Failed/Pending),reference,amount(aPrice),failureReason,retriable(real on Stripe/Mastercard's own soft-decline classification, alwaysfalseon Nexi — it has no such signal),raw(the untouched gateway response, for audit),meta, andcontinuation(see below).Modules\Core\Payment\DTOs\PaymentContinuation/Modules\Core\Payment\Enums\PaymentContinuationType— what a caller does next with aPendingPaymentResult, gateway-agnostically (RedirectorClientSecret), so a storefront controller never needs gateway-specific knowledge of e.g. Stripe's ownPaymentIntentfields to drive a 3-D Secure/redirect continuation.- Eight new events, one terminal pair per operation, replacing the old single
PaymentSucceeded/PaymentFailed:PaymentAuthorized/PaymentAuthorizationFailed,PaymentCaptured/PaymentCaptureFailed,PaymentVoided/PaymentVoidFailed,PaymentRefunded/PaymentRefundFailed.PaymentCapturedis deliberately the same event whether money was taken viapay()(one gateway call) orauthorize()→capture()(two calls) — "a payment has been captured" is the same business fact either way. Every event carries{type, result: PaymentResult, context}—contextis an opaque bag the caller hands in and gets back untouched, soPaymentnever needs to know what aCartorOrderis. Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus(rewired, not new — previously listened to the now-deletedOrderPaymentStatusResolved) is the only place anOrder'sstatuscolumn is written in reaction to a payment outcome: it listens toPaymentCaptured/PaymentAuthorizeddirectly, reads$event->context['order_id'], and resolves the new status fromconfig('lunar.payments.types.{type}.captured_status')/authorized_status.Modules\Core\Payment\Drivers\StripePaymentDriverrewritten onto the new contracts — implements all six capability interfaces plusConfigurable. SolveshandleCallback()'s async-correlation problem (a webhook is a separate HTTP request from thepay()/authorize()call that started it) the same waylunarphp/stripe's ownStripePaymentType/ProcessStripeWebhookdo: realcart_id/order_idcolumns onLunar\Stripe\Models\StripePaymentIntent, plus two new columns this driver needs (context,payment_type) added by a new migration —database/migrations/2026_09_03_000002_add_context_to_stripe_payment_intents.php.Modules\Core\Payment\Http\Controllers\StripeWebhookController+src/Payment/routes/webhooks.php(POST /payments/stripe/webhook, loaded byPaymentServiceProvider) — a boboko-owned webhook endpoint, deliberately notlunarphp/stripe's own route (which dispatches into Lunar's ownPayments::driver('stripe')flow, the flow this driver replaces). ReusesLunar\Stripe\Http\Middleware\StripeWebhookMiddlewareandStripe\Webhook::constructEvent()directly — both are genuine Stripe SDK signature verification, safe to reuse without touching the rest of that vendor package's flow. Requiresconfig('services.stripe.webhooks.lunar')set in a consuming app; nostripeconfig type entry is added toconfig/payment.phpin this release — enabling Stripe for real is a follow-up.docs/payments.md— full design notes: the operation/contract table cross-referenced against Mastercard/Stripe/Nexi's real APIs, whyPaymentResultnormalizes only what every gateway can always provide, the async-correlation pattern, and what's explicitly out of scope (aTransaction-writing listener, thestripeconfig entry, frontend Stripe Elements integration).
Fixed
Modules\Core\Checkout\Services\CheckoutService::selectPaymentMethod()crashed (Call to a member function toArray() on null) the first time it ran against a cart whosemetacolumn was still a genuine SQLNULL(any freshly-created cart) —Cart::$meta'sAsArrayObjectcast returnsnull, not an empty array-like object, for anullcolumn. Fixed with a null-safe fallback.Modules\Core\Shipping\Carriers\Acs\AcsRateDriver/BoxNowRateDriverreferencedLunar\Shipping\DTOs\ShippingOptionRequest, a namespace that doesn't exist in the installedlunarphp/table-rate-shippingversion (the real class isLunar\Shipping\DataTransferObjects\ShippingOptionRequest) — crashedIlluminate\Support\Manager's interface-compatibility check the moment anything touchedShippingManager::getSupportedDrivers(), including simply adding a line to a cart (viaModules\Core\Shipping\Listeners\FlushLivePricingCache).
[0.13.1] - 2026-09-03
Added
Modules\Core\Order\Listeners\RecordPaymentTransaction— writes thelunar_transactionsrow for a successfulPaymentCaptured/PaymentAuthorized/PaymentVoided/PaymentRefundedevent, via a newModules\Core\Order\Services\TransactionRecorder(moved here fromPayment\Services, and rewritten to take aPaymentResultdirectly instead of the deletedCaptureResult/RefundResultDTOs —Paymentnever writes toOrder's models,Transaction.order_idbeing required is exactly why this lives inOrder, same reasoning asApplyResolvedPaymentStatus). Closes a real gap introduced in0.13.0:Order::paymentStatus()(which derives its answer entirely from$order->transactions) always resolved toPaymentStatus::Offline— its "no transactions at all" fallback — regardless of what actually happened, since nothing had ever written a row. Verified live: a captured offline payment now produces atype: capturetransaction andOrder::paymentStatus()correctly resolves tocaptured.
[0.12.1] - 2026-09-03
Fixed
Modules\Core\MigrateImport\Shopify\ShopifyExportImporternow attaches a variant'sVariant ImageCSV column to thatProductVariant's ownimages()media pivot (media_product_variant,primary/position). Previously the variant image was never read at all — every image from the CSV, including ones the export clearly scopes to one specific variant, went only into the product's own top-level gallery, so a variant swatch/option change had no way to show its own photo.Modules\Core\MigrateImport\Shopify\Resolvers\ProductOptionResolver::resolveOption()now setslabel(same value asname) when creating aLunar\Models\ProductOption, not justname. AProductOptionwith a nulllabelcrashes Lunar's ownProductOptionIndexer::toSearchableArray()(foreach()onnull) the moment that option gets reindexed — every option created by the importer before this fix has a nulllabeland needs a wipe-and-reimport (seedocs/shopify-reimport.md, new in this release) to pick up the fix, sincefirstOrCreate()never revisits an already-existing row.product_reviews.product_id's foreign key had noON DELETEclause, so deleting a reviewedProductthrew a constraint violation instead of the review going with it, unlike every other product-dependent table. New migration addscascadeOnDelete().
Added
Modules\Core\Catalog\Services\ProductIndexer::mapVariant()now embedsgtin,mpn,ean,backorder,unit_quantity,shippable,tax_ref, anddimensions(length/width/height/weight/volume, each withvalue+unit) on every indexed variant — previously onlyid/sku/stock/purchasable/options/prices/mediawere embedded, so a search result or filter needing any of these had no way to get at them without a separate Postgres query per variant.ProductIndexer::toSearchableArray()adds a top-level, filterableskusfield (every variant's SKU, deduplicated) — filtering/matching by SKU no longer requires reaching into the nestedvariantsarray.docs/shopify-reimport.md— runbook for wiping every imported product (cascading through Lunar so Meilisearch documents go too) and re-running the importer from scratch, needed whenever a fix like the two above only takes effect on newly-created rows.
[0.12.0] - 2026-09-03
Changed
- Breaking:
Modules\Core\Catalog\Services\ProductService::list()now returnsModules\Core\Catalog\DTOs\ProductListingResult(->products: the sameIlluminate\Pagination\LengthAwarePaginatoras before,->priceBounds: a newModules\Core\Catalog\DTOs\PriceSliderBounds) instead of returning the paginator directly. A caller doing$service->list(...)->items()/->through(...)must update to$service->list(...)->products->items()/->through(...). This collapses what used to be two separate calls a controller had to orchestrate itself (list()for products,priceRange()+ manual floor/ceil/"is this actually filtered" math for the slider) into one. - Breaking:
Modules\Core\Catalog\Services\ProductSearchService::search()'s signature changed fromsearch(string $query, ?string $locale = null)tosearch(string $query, ?ProductFilters $filters = null, ?ProductSort $sort = null)— the$localeparameter is gone (see "every configured language, always" below);$filters/$sortapply the sameModules\Core\Catalog\Support\ProductFilterBuilder/ProductSort::toMeilisearchSort()semanticsProductService::list()already used, so a text search can now be narrowed by price/brand/stock and sorted the same way a category listing can. ProductSearchServicenow targets every configured store language's fields on every search (Lunar\Models\Language::all()), not just the current request locale plus the store's default language. The old{current, default}pairing silently stopped catching anything outside those two locales whenever they were equal (a single-language store, or a shopper browsing in the default language) — always searching every configured language closes that gap in both directions. Seedocs/product-search.md.- Extracted
Modules\Core\Catalog\Services\ProductService's privatebuildFilter()into a new standaloneModules\Core\Catalog\Support\ProductFilterBuilder, soProductSearchServicecan apply the exact same Meilisearch filter-clause semantics to a text query, instead of reimplementing filter-building a second time.
Added
Modules\Core\Catalog\Services\ProductService::priceSliderBounds()—priceRange()rounded to whole currency units (floor/ceil) plus whether the given selected min/max actually narrows it, returned as aPriceSliderBoundsDTO. Used internally bylist()now; also callable directly for a caller (e.g. a text-search results page) that needs slider bounds without a fulllist()call.Modules\Core\Catalog\Services\ProductService::priceRange()gained an optionalstring $query = ''parameter, so a caller can scope the price range to a text search's own matches (pass the shopper's search text) instead of always spanning the whole catalog.Modules\Core\Catalog\Services\ProductService::random(int $limit)— random products still scoped to the Meilisearch index's own channel/status visibility, unlike a rawProduct::inRandomOrder()(which has no notion of that filtering). Meilisearch has noORDER BY RANDOM()equivalent, so this fetches every matching id only (attributesToRetrieve: ['id']), shuffles in PHP, then fetches the full localized documents for just the ids picked, restoring the shuffled order afterward (Meilisearch'sid IN [...]filter doesn't preserve list order on its own).Modules\Core\Catalog\Services\ProductService::variantSummaries(array $product)— the id/price/image of every variant on a product document, for a variant picker/swatch list, without a caller reaching into$product['variants'][n]['prices'][0]/['media'][0]itself.Modules\Core\Catalog\Services\ProductSearchService::search()now also targetsvariants.options.value— a variant's own option value (e.g. "Κάπτεν Γαμέρικα" on a "Name" option) is matchable by search even when that text never appears in the product's own name or description.php artisan lunar:meilisearch:tune-product-search(Modules\Core\Command\TuneProductSearchCommand) — tightensminWordSizeForTypos(1 typo only at 8+ characters, 2 typos only at 12+) and disables Meilisearch'sprefixSearchon the product index. Meilisearch's defaults for both were loose enough to produce bad matches on short Greek words (confirmed the specific case wasprefixSearch's defaultindexingTimebehavior on a shared word-start, not typo tolerance, viashowMatchesPosition). Consuming apps should run this afterlunar:meilisearch:setupwhenever the product index needs (re)provisioning — requires Meilisearch v1.12+ (prefixSearchdidn't exist as a configurable setting before then).
[0.11.1] - 2026-09-01
Fixed
Modules\Core\Catalog\Services\RecommendationService::recommend()built its result with the baseIlluminate\Support\Collection(collect()) instead ofIlluminate\Database\Eloquent\Collection, even though every element is aProductmodel.ProductIndexer::toSearchableArray()calling->load(['media', 'variants.prices'])on that result threwBadMethodCallException: Method Illuminate\Support\Collection::load does not exist— silently failing everyMakeSearchablequeue job for a saved product (visible only asFAILin the queue log, with the real exception instorage/logs/laravel.log). Fixed by havingRecommendationServiceaccumulate into a realEloquent\Collectionfrom the start.
[0.11.0] - 2026-09-01
Added
Modules\Core\Catalog\Services\RecommendationService— computes "related products" for a given product as a configurable, ordered chain of strategies (config('catalog.recommendation_rules')), not one hardcoded rule. Tops up from each successive rule until the limit (default 4) is reached or every rule is exhausted — e.g. 3 products from a same-category rule plus 1 from a random fallback — deduplicated across rules so the same product is never returned twice. Ships withModules\Core\Catalog\Recommendations\SameCategoryRule(other products sharing the source product's first collection) andRandomRule(the universal fallback, placed last in the default chain). A new rule is just a class implementingModules\Core\Catalog\Contracts\RecommendationRule. Documented indocs/product-recommendations.md.Modules\Core\Catalog\Services\ProductIndexerembeds the result directly into each product's own Meilisearch document asrecommendations: [{id, name, price, image}, ...](recommendations.idfilterable) — a product detail page renders its "related products" section with zero extra queries, same reasoning as the existingcollectionsfield. Deliberately embeds anidfor the view to build a locale-correct URL from, not a resolvedhref—product.showis locale-prefixed, so a URL baked in at index time would only be correct for whichever locale happened to be active during that index run.Modules\Core\Catalog\Events\ProductSaved/ProductDeleted, dispatched fromProduct::saved()/Product::deleted()inCatalogServiceProvider(the latter fires for both a soft delete and a force delete, matching Scout's ownunsearchable()trigger point) — feedModules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct, which reverse-searches Meilisearch for every product currently recommending the changed/deleted one (recommendations.id = "..."— there's no Postgres relation for this, a recommendation only exists inside the index) and re-indexes them via Scout's own->searchable(). Product creation is deliberately not hooked into this: a new product not yet appearing as a recommendation elsewhere is an accepted staleness window, the same tradeoff already documented forin_stock/price— seedocs/product-recommendations.md.CatalogServiceProviderscheduleslunar:search:index "Lunar\Models\Product" --refreshdaily at 03:00 — a safety net on top of the event-driven reindexing above, covering a newly-created product not yet appearing as a recommendation and any other drift already accepted between reindexes.--refreshalso re-syncs filterable/sortable index settings, not just documents.
[0.10.1] - 2026-09-01
Added
Modules\Core\Localization\Services\StorefrontLabels::all()gains three keys found missing from3dealer's actualstorefront.*translation usage:shop.price_min,shop.price_max,shop.reset(the price-filter sidebar's min/max labels and its reset link). Picked up byInstallLunarCommand's existing per-key upsert — re-runninglunar:installon an already-installed store adds only these three rows, leaving everything already seeded or admin-edited untouched.
[0.10.0] - 2026-08-31
Changed
- Breaking: Upgraded
lunarphp/lunar,lunarphp/core,lunarphp/stripe,lunarphp/table-rate-shipping, andlunarphp/searchto1.5.0, andfilament/filamenttov4.12.6— the first Filament v4 admin panel on this codebase.lunarphp/filament3-2faandkalnoy/nestedsetare gone, replaced by Filament v4's native two-factor auth andlunarphp/nestedset. Ran Filament's automatedfilament-v4migration tool acrosssrc/, then hand-fixed three bugs it introduced or left behind: a stale$infolistvariable reference inCartResource'sViewCartpage (the parameter had been renamed to$schemabut the body wasn't updated),ShippingMethodResourceExtensionrewritten to callgetDefaultChildComponents()(returnsarray|Schema) instead of the type-safegetChildComponents()(alwaysarray<Component>), and — unrelated to the tool, but surfaced by the same PHP version bump —InvalidCouponException'sreadonly $codeproperty illegally shadowing the built-inException::$code, renamed to$couponCode.LunarStaff::addActivitylogExcept()updated for the renamedtwo_factor_secret/two_factor_recovery_codesstaff columns (nowapp_authentication_secret/app_authentication_recovery_codes;two_factor_confirmed_atremoved). Consuming apps must runcomposer update boboko/core --with-all-dependenciesandphp artisan migrate.
Added
Modules\Core\Checkout\Contracts\PaymentDriver— the abstraction every payment provider implements:confirm(Cart $cart, string $type, string $fingerprint, array $data): OrderandisConfigured(): bool. A driver only ever callsCheckoutService::placeOrder()once it has, by whatever mechanism is native to that gateway, independently confirmed payment — never Lunar's rawCart::createOrder(). This is what lets the storefront checkout sequence stay uniform regardless of which provider is active: set addresses, select shipping, hand off to whichever driver is configured, and the driver decides when (or whether) the order gets created.Modules\Core\Payment\Drivers\OfflinePaymentDriver— shared by every payment type with no real gateway to confirm against (cash-in-hand,cash-on-delivery): places the order immediately viaCheckoutService::placeOrder(), then sets the order status fromconfig("lunar.payments.types.{$type}.authorized")using the type actually confirmed, not a hardcoded key, since one driver instance serves multiple types.Modules\Core\Payment\Drivers\StripePaymentDriver— a fork, not a decoration, oflunarphp/stripe'sStripePaymentType::authorize(): that method isfinaland callsCart::createOrder()directly with no seam to redirect into our fingerprint-checkedplaceOrder(), so this class reimplements its logic (intent retrieval, capture-on-policy, status mapping viaUpdateOrderFromIntent) with that one substitution. Throws the newModules\Core\Payment\Exceptions\PaymentNotConfirmedExceptionon anything short of a genuinely confirmed payment intent — never falls through to placing an order on ambiguity.CheckoutService::getPaymentMethods(): array— every payment type currently offered to the storefront: every key inconfig('lunar.payments.types')that is both administratively enabled (Modules\Core\Payment\Models\PaymentMethod::enabled) and whose driver reportsisConfigured()(e.g. Stripe with no API key set is never offered, regardless of the enabled toggle).selectPaymentMethod(string $type)andconfirmPayment(string $type, array $data)both validate against this list, throwing the newUnknownPaymentTypeExceptionfor a type that isn't currently offered — re-checked inconfirmPayment()too, since a type could be disabled between selection and confirmation.CheckoutService::selectPaymentMethod()snapshotsCart::fingerprint()intocart->meta['checkout_fingerprint']after saving the chosen type and recalculating — the fingerprint has to reflect the final total including any payment-type-specific adjustment (e.g. a COD surcharge), which only exists oncepayment_methodis set.confirmPayment()reads this stored fingerprint internally rather than taking one as a parameter: a storefront should never need to knowCart::fingerprint()exists or capture it at exactly the right moment itself.Modules\Core\Payment\Models\PaymentMethod— one DB row per payment type key (matchingconfig('lunar.payments.types')),enabledboolean plus adatajsonb column (starting withfee, the flat cash-on-delivery surcharge) — mirrors Lunar's ownDiscountmodel (a single jsonb column of keyed settings, not a fixed column per setting or a separate conditions table). Seeded idempotently byInstallLunarCommand(skip-if-exists per type, safe to re-run after installing a new payment-provider package), alwaysenabled: false— a newly-seeded type shouldn't go live for shoppers before staff have configured and reviewed it. Admin-editable via the newPaymentMethodResource(inline enabled toggle, modal fee editor) under Settings.ApplyCashOnDeliveryFeenow reads its surcharge fromPaymentMethodinstead of static config, so it's admin-editable without a deploy.
Fixed
CashOnDeliveryPaymentDriverrenamed toOfflinePaymentDriverand generalized to work for any offline-style type — it previously hardcoded'cash-on-delivery'when reading the post-placement order status from config, which would have silently read the wrong type's status the moment a second offline type (cash-in-hand) used it.
[0.9.0] - 2026-08-29
Added
Modules\Core\Cart\Services\CartService— the boboko-owned API for all cart mutation, wrapping Lunar'sCartSession/Cartprimitives:addLine(),updateLine(),removeLine(),clear(),applyCoupon()/removeCoupon()(throwsInvalidCouponExceptionon an invalid code), and save-for-later (saveForLater()/moveToCart()/activeLines()/savedLines(), backed by ameta.saved_for_laterflag and a newModules\Core\Cart\Pipelines\ZeroSavedForLaterPricecart-line pipeline step that zeroes a saved line's price so it's excluded from cart totals without being removed). Dispatches 8 real domain events (CartLineAdded/Updated/Removed/Saved/MovedToCart,CartCleared,CartCouponApplied/Removed) — none have a listener yet, built so a future concern (analytics, recovery) has something to attach to. Documented indocs/cart.md.Modules\Core\Checkout\Services\CheckoutService— the boboko-owned API for the checkout stage (address → shipping selection → order placement), sitting betweenCartServiceandOrder:setShippingAddress()/setBillingAddress(),getShippingOptions()/selectShippingOption()(throws the newInvalidShippingOptionExceptionon an identifier that doesn't resolve — previously a silent no-op), andplaceOrder(string $fingerprint)(the fingerprint is mandatory, not optional — forces re-confirmation via Lunar's ownFingerprintMismatchExceptionif the cart changed since the shopper last saw its total). DispatchesShippingAddressSet/BillingAddressSet/ShippingOptionSelected/OrderPlaced, each carrying richer, already-resolved payload (e.g. the resolvedShippingOption, not just its identifier) thanCartService's events. No exception wrapping otherwise — Lunar's ownCartException/FingerprintMismatchExceptionare already the right shape for a storefront to render as form errors. Documented indocs/checkout.md.Modules\Core\Cart\Filament\Resources\CartResource's list view now classifies every cart into one of four states — Ongoing, Abandoned Cart, Abandoned Checkout, Completed — instead of the previous two-tab Abandoned/Completed split, distinguishing a cart that never reached checkout from one that has a started-but-unplaced order (mirrors the real distinction in Lunar's ownCart::scopeActive()). Abandonment threshold is a fixed, configurable cutoff (config('core.cart.abandoned_after'), default 1 hour). Added a customer hyperlink (list column + a "View Customer" header action on the view page, both pointing straight atcustomers/{id}via the plaincustomer_idcolumn, no extra query via thecustomerrelation).Modules\Core\Cart\Commands\DetectAbandonedCarts(boboko:cart:detect-abandoned, scheduled hourly) dispatchesModules\Core\Recovery\Events\CartAbandoned/CheckoutAbandonedfor carts/checkouts past the abandonment cutoff — detection only, no persistence; a real tracking table is left for whenRecoveryis built as its own concern. Fixed a self-defeating bug from an earlier draft: marking a cart as notified by writing to it bumpedupdated_at, which immediately un-staled it for the next run's own cutoff check.- Merged the
Shipping-Carriersbranch: live carrier rate quoting and fulfillment for ACS Courier and Box Now (Modules\Core\Shipping\Carriers\{Acs,BoxNow}) on top oflunarphp/table-rate-shipping—AcsRateDriver/BoxNowRateDriver(live + static price-break resolution),AcsFulfillmentService/BoxNowFulfillmentService(shipment creation, label printing, cancellation via the newModules\Core\Shipping\Contracts\CarrierFulfillmentInterface, resolved per-carrier via contextual container binding),Modules\Core\Shipping\Models\Shipment/ShipmentInfo,PollShipmentTrackingJob(scheduled every 30 minutes),ManagePickupManifests(Filament page for carrier manifest batching), and anOrderViewExtensionadding a "Create Shipment" header action to Lunar's order view. Carrier credentials are published config (config/shippingCarriers/{acs,boxnow}.php), never committed. Modules\Core\Shipping\Concerns\CachesLivePricingcaches a live-priced carrier quote per(rate, cart)for 30 minutes — a real, billed API call that's otherwise re-run on everygetShippingOptions()/selectShippingOption()call within the same checkout attempt.Modules\Core\Shipping\Listeners\FlushLivePricingCacheinvalidates it on the only two things that can change a quote: a cart line changing or the shipping address changing (deliberately not on order placement — the price the shopper was quoted must still be readable afterwards). Scoped generically to anySupportsLivePricingdriver, not hardcoded to ACS.AcsRateDriver::resolveLivePrice()now falls back to the rate's own configured static price if the live ACS API call fails (previously: the shipping option silently disappeared from the list on any API error, including a brief outage).ManageShippingRates(our Filament subclass of the vendor rates page) now allows a static price to be configured and saved on a "live" rate specifically for this fallback — previously those fields were hidden and discarded on save for any live-priced rate.
Fixed
- Fixed a crash (
Attempt to read property "price" on null) opening/editing a live-priced shipping rate with no fallback price configured yet — the vendorManageShippingRatespage'safterStateHydratedcallback for the price field had no null-guard for a rate with zerobasePrices, which is now the routine case for an unconfigured live rate. - Fixed the Filament admin panel's home URL (
/boboko/home) incorrectly resolving to the Shipping module'sManagePickupManifestspage instead of the Dashboard — Filament falls back to the first item of the first registered navigation group when no explicithomeUrl()is set, andManagePickupManifestshad nonavigationGroup/navigationSortof its own. Fixed via explicitnavigationGroup = 'Sales'/navigationSort = 100, placing it after Sales in the nav instead of first overall.
[0.8.0] - 2026-08-27
Added
Modules\Core\Cart\Filament\Resources\CartResourcegives staff read-only visibility into carts in the Filament admin panel — Lunar ships no cart admin view at all. Scoped to carts with a knownuser_id/customer_id(an anonymous guest cart carries no identity staff could act on); list table shows customer/user, line/item counts (via Filament's built-in->counts()/->sum(), no per-row queries), currency, and last activity. List page has only two tabs, Abandoned (default active) and Completed — no "All" tab, so the list never runs an unfiltered fetch over the whole table. They key off whether the cart has a placed order (orders.placed_at IS NOT NULL), notCart::completed_at— that column is declared/cast on the model but never actually written anywhere in Lunar core, so it's not a real signal; "Abandoned" mirrors Lunar's ownCart::scopeActive().getNavigationBadge()shows the abandoned-cart count in the sidebar via a singleCOUNT(*)query, no rows loaded. View page runs$cart->calculate()once so line/cart totals (plain public properties Lunar never persists) are populated, without paying that cost per row in the list. Documented indocs/cart.md.
[0.7.0] - 2026-08-27
Added
Modules\Core\Catalog\Services\CollectionServiceprovides category browsing/nav AND single-collection lookup from Meilisearch, mirroringProductServiceexactly (list(),getById(),getBySlug(), same locale-resolution logic).Modules\Core\Catalog\Services\CollectionIndexerextends Lunar's ownLunar\Search\CollectionIndexer(which only carriedid/name/created_at) to addparent_id,_lft/_rgt(nested-set tree position, filterable/sortable),collection_group_id,slugs, andthumbnail.Modules\Core\Catalog\DTOs\CollectionFilterssupportsparentId(children of a specific collection),groupId, androotOnly(top-level collections,parent_id IS NULL— mutually exclusive withparentId).Modules\Core\Catalog\Enums\CollectionSortaddsPosition(_lft:asc, the recommended default for nav/tree UIs — matches admin arrangement order),Name,Newest. Must be registered in a consuming app'sconfig/lunar/search.php(Lunar\Models\Collection::class => CollectionIndexer::class), same asProductIndexer. Documented indocs/collections.md.Modules\Core\Localization\Services\StorefrontLabels::all()extracts the default storefront UI label list out ofInstallLunarCommandinto its own class, and adds every previously-missing key (nav.contact,product.description/no_image/read_more/reviews,customer_reviews,pagination.*,review.*,shop.*) that had already been seeded manually in some stores but was absent from the command's own list — bringing the code-side default back in sync with what a real store actually has.InstallLunarCommand::seedStorefrontLabels()now does a per-key upsert instead of an all-or-nothing "only seed if the group is empty" guard: a key already present in the database (including one an admin has since edited via the Filament Language Lines resource) is left untouched, and only missing keys are created viaTranslationService::create(). This makes it safe to add new keys toStorefrontLabels::all()later and re-runlunar:installon an already-installed store without either silently skipping the new keys (the old guard's behavior) or reverting an admin's edits back to the hardcoded default. Documented indocs/localization.md("Seeding").Modules\Core\Catalog\Services\CollectionIndexeraddsancestors—[{id, name}, ...]ordered root-first (via the newly eager-loadedancestorsrelation) — so a breadcrumb can render directly fromCollectionService::getById()/getBySlug()with zero extra queries, andproduct_count— how many products are in a collection or any of its descendants, queried from the product Meilisearch index at collection-index time via the samecollection_idsfieldProductFilters(collectionId:)filters against. Documented indocs/collections.md, including the reindex-ordering gotcha (product_countneeds the product index reindexed first).Modules\Core\Catalog\Services\ProductIndexeradds a filterablein_stockboolean —trueif any variant currently passesProductVariant::canBeFulfilledAtQuantity(1)(Lunar's own purchasability rule, not a naivestock > 0check).Modules\Core\Catalog\DTOs\ProductFiltersgets a matchinginStockOnlyflag. Reflects stock as of the last reindex only — nothing currently reindexes a product when an order decrements its stock, since that's a cart/checkout concern this doesn't attempt to solve; seedocs/product-listing.md("Stock goes stale between orders").Modules\Core\Catalog\Services\ProductService::facets(string $field, ?ProductFilters $filters = null): arrayreturns Meilisearch facet value counts (e.g.['Brand A' => 48, 'Brand B' => 135]) for a discrete-value filterable field, scoped to the given filters. Uses Scout's plain->options(['facets' => [...]]), merged directly into the raw Meilisearch query the same wayfilter/sortalready are — no adoption of Lunar's separateSearchManager/Searchfacade needed.ProductService::priceRange(?ProductFilters $filters = null): array{min, max}covers the numeric-field casefacets()explicitly doesn't (pricewould otherwise return one "facet" per exact price) — backed by Meilisearch'sfacetStats, notfacetDistribution.priceRange()always excludesminPrice/maxPricefrom the filter it builds (via a new$excludeparameter on the privatebuildFilter()), so a price slider's own bounds don't shrink to whatever range is already selected on it; other filters (collectionId,brand,inStockOnly) still apply normally. Documented indocs/product-listing.md.
Changed
- Breaking: Renamed the
Productmodule toCatalog, flattened. Every class underModules\Core\Product\*(Contracts,DTOs,Enums,Services,Observers,Filament\Extensions,OptionTypes) now lives underModules\Core\Catalog\*at the same sub-path — e.g.Modules\Core\Product\Services\ProductServiceis nowModules\Core\Catalog\Services\ProductService,Modules\Core\Product\DTOs\ProductFiltersis nowModules\Core\Catalog\DTOs\ProductFilters. Class names themselves are unchanged (stillProductService,ProductIndexer,ProductFilters, etc.) — only the namespace/folder moved, to make room forCollectionas a sibling concern under the sameCatalogumbrella rather than a disconnected top-level module. Consuming apps must update everyuse Modules\Core\Product\...import and any FQCN reference (config/lunar/search.php's indexer registration, service provider bindings). - Breaking:
Modules\Core\Providers\ProductServiceProviderrenamed toModules\Core\Providers\CatalogServiceProvider(composer.json's provider list updated accordingly) — it now only wiresCatalog-namespace classes (ProductOptionTypeManager,ProductOptionReindexObserver), so the name follows the same by-concern convention asLocalizationServiceProvider/ReviewServiceProvider. - Breaking:
Modules\Core\Review's flatExtensions//Pages/folders now nest underFilament/, matching the strict per-concern subfolder convention already applied toProduct(nowCatalog)/Localization.Modules\Core\Review\Extensions\ProductResourceExtensionis nowModules\Core\Review\Filament\Extensions\ProductResourceExtension;Modules\Core\Review\Pages\ManageProductReviewsis nowModules\Core\Review\Filament\Pages\ManageProductReviews.Modules\Core\Review\Models\ProductReviewis unchanged. - Breaking:
ProductFilters(collectionId: ...)now matches a product in that collection or any of its descendant collections, not just direct assignment. Products in a Shopify-imported tree are typically attached only to leaf collections, so filtering strictly on direct assignment meant a parent/root category page (CollectionFilters(rootOnly: true)'s results, or any non-leaf collection) always returned zero products even though real products existed several levels down.Modules\Core\Catalog\Services\ProductIndexeradds a new filterablecollection_idsfield — every directly-assigned collection's id unioned with all of its ancestors' ids (via the newly eager-loadedcollections.ancestors) — andProductService::buildFilter()now filterscollectionIdagainstcollection_idsinstead of the oldcollections.id. The display-onlycollectionsfield ({id, name}, direct assignments) is unchanged and no longer filterable.
[0.6.1] - 2026-08-27
Added
Modules\Core\Product\Contracts\ProductOptionTypeInterfacedescribes how a category ofLunar\Models\ProductOption(e.g. "Color", "Size") behaves — what structured data its values carry in their free-formmetajsonb column, and how an admin edits it via Filament — without introducing a new model. Registered viaModules\Core\Product\Services\ProductOptionTypeManager::get()->register([...])(a singleton registry, same shape asModules\Core\Notification\NotificationRegistry) from a service provider'sboot(). An admin then picks one perProductOptionfrom an "Option Type" dropdown on the option's own edit form (added byModules\Core\Product\Filament\Extensions\ProductOptionResourceExtension), stored inProductOption::meta['option_type']— deliberately not tied to the option'shandle, since a shop's own handle naming shouldn't have to match a type's key.Modules\Core\Product\Filament\Extensions\ValuesRelationManagerExtensionhooks Lunar's ownValuesRelationManager(both extensions viaLunarPanel::extensions(), registered inCorePlugin) to append the resolved type's meta form fields to the stock "Values" tab — no fork of Lunar's classes needed. Ships a reference implementation,Modules\Core\Product\OptionTypes\ColorOptionType, registered automatically by the newModules\Core\Providers\ProductServiceProvider. Documented indocs/product-options.md.Modules\Core\Product\Services\ProductIndexer::mapVariant()now includes each option'shandle(alongside its translated name) in a variant's indexedoptions[]— previously only the translatedoption/valuenames andmetawere indexed, with no stable, locale-independent identifier for which option a value belongs to.Modules\Core\Product\Observers\ProductOptionReindexObserver, wired in the newModules\Core\Providers\ProductServiceProvider, keeps Meilisearch in sync when aProductOptionorProductOptionValueis saved or deleted — e.g. picking an Option Type or editing a color's hex.ProductIndexer::mapVariant()embeds each option value'smetadirectly into a product's indexed document, but saving the option/value never fires the product's own save events, so without this a changed hex would only reach the index on that product's next unrelated reindex. The observer resolves everyLunar\Models\Productwhose variants use the changed option (or option value) via theproduct_option_value_product_variantpivot, and calls->searchable()on each.
Changed
- Breaking:
Modules\Core\Product\Services\ProductIndexer's indexedcollectionsfield is now an array of{id, name}objects instead of two parallel arrays (collectionsas bare ID strings,collection_namesas translated names joined only by array index).collection_namesis removed. Filtering by collection now targets the nested fieldcollections.id(Meilisearch supports filtering on nested object fields), not barecollections—Modules\Core\Product\Services\ProductService::buildFilter()updated accordingly;ProductFilters(collectionId: ...)'s public API is unchanged. Runphp artisan lunar:meilisearch:setupthenlunar:search:index --refreshafter upgrading (see docs/product-listing.md "Gotchas"). - Breaking:
ProductIndexer's indexedreview_count/average_ratingtop-level keys are folded into the existingreviewskey:reviewsis now{items, count, average_rating}instead of a bare array withreview_count/average_ratingas separate sibling keys.reviews(the array of review items) moved toreviews.items.
[0.6.0] - 2026-08-27
Added
Modules\Core\Localization\Models\LanguageLineextendsspatie/laravel-translation-loader'sLanguageLineto fall back to the store's actual default language (LanguageCache::defaultLocale(), backed by Lunar'slanguages.defaultflag) instead of the package's stock behavior of falling back to the staticconfig('app.fallback_locale')— the two were previously disconnected, so changing the default language via the Filament Languages resource had no effect on which locale an untranslated storefront label silently fell back to. Swapped in automatically viaconfig('translation-loader.model')inLocalizationServiceProvider::register(); no consuming app changes needed. Documented indocs/localization.md("Fallback locale follows the store's default language").
Changed
- Breaking:
Modules\Core\Catalog\ProductService::list()now returns a realIlluminate\Pagination\LengthAwarePaginator(built from the localized Meilisearch hits) instead of a plainarray{data, meta}— gives callers normal Laravel pagination behaviour ($products->links(), standard JSON serialization) without ever touching Scout's rawpaginateRaw()response directly.getById()/getBySlug()are unaffected (still return?array). ProductService::withLocalizedFields()(used bylist(),getById(),getBySlug()) no longer hardcodesname/descriptionas the only translated fields — it now reads everyTranslatedTextattribute onProductfromLunar\Base\AttributeManifest(the same source Lunar's own indexer reads), so a store's own custom translated attributes (e.g.seo_title,seo_description) are resolved and locale-stripped automatically with no code change here. Raw{handle}_{locale}keys (e.g.name_el,seo_title_en) are now stripped from every returned product, not justname_*/description_*.- Extracted
Modules\Core\Localization\Services\LanguageCache(cached read layer over Lunar'slanguagestable:all(),defaultLocale(),availableLocales(),forget()) out ofLocaleMiddleware, which previously owned this as private/static methods despite not being middleware-specific behavior.LocaleMiddlewarenow takesLanguageCachevia constructor injection.LocaleMiddleware::defaultLocale()/forgetLanguagesCache()(static) are removed — useapp(LanguageCache::class)or injectLanguageCachedirectly.
Fixed
Modules\Core\MigrateImport\JudgeMe\Resolvers\ProductResolver::resolve()picked whicheverlunar_urlsrow matched a slug first, which can be a soft-deleted product left behind by an earlier import batch rather than the current live one — a store can easily end up with more than oneProductrow sharing the same slug across re-imports, since a soft-deleted product's URL row isn't cleaned up. This silently broke every downstream lookup for that handle (e.g.Modules\Core\MigrateImport\JudgeMe\JudgeMeExportImporterlogging "no product found for handle, skipping review" and dropping the row, even though a live product with that exact handle existed). Rewrote as a join againstlunar_products— viaProduct::query(), so Eloquent'sSoftDeletesglobal scope excludes trashed rows — so only a URL pointing at a live product resolves.Modules\Core\Review\Models\ProductReviewhad noregisterMediaConversions()at all, unlikeProduct/ProductVariantwhich get one automatically from Lunar's ownLunar\Base\StandardMediaDefinitions.Modules\Core\Search\ProductIndexer::mapMedia()is shared across product, variant, and review media and always requests thesmallconversion — the first time a review had an attached image, indexing it threwSpatie\MediaLibrary\MediaCollections\Exceptions\InvalidConversion, silently failing the product'sMakeSearchablequeue job (and everything queued after it, since Scout batches). Added a matchingsmallconversion (300×300, same fit/border/background as Lunar's standard one) directly onProductReview.
Breaking
-
Merged
Modules\Core\CatalogandModules\Core\Searchinto a singleModules\Core\Productconcern, since both existed purely to serveProduct(browsing/filtering vs. indexing/full-text search — two services, one concern), following a stricter subfolder convention (Contracts/,Enums/,Services/,DTOs/,Models/, etc. per concern) going forward:Modules\Core\Catalog\ProductService→Modules\Core\Product\Services\ProductServiceModules\Core\Catalog\ProductFilters→Modules\Core\Product\DTOs\ProductFiltersModules\Core\Catalog\ProductSort→Modules\Core\Product\Enums\ProductSortModules\Core\Search\ProductIndexer→Modules\Core\Product\Services\ProductIndexerModules\Core\Search\ProductSearchService→Modules\Core\Product\Services\ProductSearchService
Consuming apps must update any direct references — notably
config/lunar/search.php's'indexers'map, which points atProductIndexerby FQCN.Modules\Core\Catalog\ProductOptionTypeInterface(in-progress, not yet wired to anything) was deliberately left in place rather than moved. -
Reorganized
Modules\Core\Localizationunder the same stricter per-concern subfolder convention —Events/,Filament/,Listeners/were already correctly categorized; four loose root files moved into typed buckets by structural role:Modules\Core\Localization\LocaleMiddleware→Modules\Core\Localization\Middleware\LocaleMiddlewareModules\Core\Localization\LanguageCacheObserver→Modules\Core\Localization\Observers\LanguageCacheObserverModules\Core\Localization\TranslationReader→Modules\Core\Localization\Services\TranslationReaderModules\Core\Localization\TranslationService→Modules\Core\Localization\Services\TranslationService
Modules\Core\Localization\Services\LanguageCache(added earlier in this same unreleased version) already lived at its correct final path — unaffected. The'locale'route-middleware alias (registered inLocalizationServiceProvider) is unaffected for consuming apps using it by string alias rather than FQCN.
[0.5.4] - 2026-08-26
Added
Modules\Core\Catalog\ProductService::list()accepts asortparameter (newProductSortenum:PriceAsc,PriceDesc,Newest), translated into a Meilisearchsortclause —list()previously had no way to order results, since it always searches with an empty query string and so has no relevance score to fall back on.Modules\Core\Search\ProductIndexer::getSortableFields()now also markspricesortable (Lunar's base indexer only markscreated_at/updated_at/skus/status). Requires re-syncing index settings (php artisan lunar:meilisearch:setup) on existing stores. Documented indocs/product-listing.md("Sorting").
[0.5.3] - 2026-08-26
Fixed
Modules\Core\Search\ProductIndexer::toSearchableArray()threwcolumn reference "id" is ambiguouson Postgres when computingchannel_ids—$model->channels()->wherePivot('enabled', true)->pluck('id')joinslunar_channelsandlunar_channelables, both of which have anidcolumn, and the unqualifiedpluck('id')left Postgres unable to resolve which table's column to select (SQLite/MySQL tolerated the ambiguity). Qualified aspluck('lunar_channels.id').
[0.5.2] - 2026-08-26
Fixed
Modules\Core\Localization\LocaleMiddleware's shared view data only ever surfaced a single alternate locale (altLocale/altLocaleUrl, found viafirstWhere('code', '!=', $current)) — correct by coincidence for a 2-language store, but silently dropped every locale past the first "other" one found for a 3+ language store, with no error. Replaced withaltLocales, a collection of every other configured language (code,name,urlfor the current route each), so a language switcher orhreflangtags scale to any number of locales. Documented indocs/localization.md("Shared view data — language switcher andhreflangtags").
[0.5.1] - 2026-08-25
Added
Modules\Core\Search\ProductIndexernow indexeschannel_ids(filterable) — Lunar's base indexer only marksstatusas filterable, not channel assignment, so storefront search couldn't otherwise scope results to products actually assigned and enabled on the current sales channel. Computed from$product->channels()->wherePivot('enabled', true). Ported from an olderProductsbranch whose remote had been deleted; the branch's other, now-supersededProductIndexerchanges were dropped in favor of the richer indexer already onmaster(collections, price, variants, reviews — see0.5.0).
[0.5.0] - 2026-08-24
Added
Modules\Core\Catalog\ProductService: storefront product listing/filtering (list()) and single-product lookup (getById(),getBySlug()), reading directly from the Meilisearch index rather than the database — one data source, no->get()model hydration. Returns plain arrays (not Eloquent models), meant to be called directly from a consuming app's controllers.ProductFiltersDTO: optionalcollectionId,brand,minPrice,maxPrice, translated into a Meilisearchfilterexpression.- Listing results are locale-aware:
withLocalizedFields()resolvesname/descriptionfrom the indexer's per-locale fields, falling back to the store's default language (viaLocaleMiddleware::defaultLocale()) when the current locale has no translation yet, instead of rendering blank. Modules\Core\Search\ProductIndexerexpanded well beyond its original collection/price additions to carry everything a detail page needs:id/slugs(filterable —getById()/getBySlug()resolve purely from the index, no database read),collection_names,tags, the full media gallery, per-variant data (sku,stock,purchasable, translated option/value names +metafor swatches, per-currency prices, variant media), and reviews (reviews,review_count,average_rating— public-safe fields only,reviewer_emaildeliberately excluded).Modules\Core\Providers\ReviewServiceProvider(newly registered): re-indexes a product whenever one of its reviews is created/updated/deleted, since a review write doesn't touch theProductrow and so never fires the product's own model events.
Modules\Core\Search\ProductSearchService: locale-aware full-text product search on top of the same Meilisearch index, for use by a storefront's search bar — separate fromProductService, which is for browsing/filtering without a query term.docs/product-listing.mdanddocs/product-search.md— usage, full field reference, and design notes for the two services above.docs/lunar.md"Gotchas": three new entries hit while building this —ProductOption/ProductOptionValue::nameisn'tattribute_data(sotranslateAttribute()silently returnsnullfor it), a runningqueue:workprocess not picking up an edited Scout indexer class, and Scout'spaginateRaw()->items()on the Meilisearch driver returning the whole raw response rather than a hit list.
Fixed
- The admin login form (
Modules\Core\Auth\Filament\Pages\Login) had no way back from the OTP-entry step to the email step short of reloading the page. Aback()method resets to the email step; a "← Back" link/button is shown on the OTP step only.
[0.4.0] - 2026-08-06
Added
- Locale-prefixed routing (
Modules\Core\Localization\LocaleMiddleware): alocaleroute-middleware alias, opt-in per shop (not pushed onto thewebgroup globally, since admin/Livewire/webhook routes must not be locale-redirected). Reads the first URL segment against Lunar's ownlanguagestable, setsApp::setLocale(), and redirects unprefixed/unknown-locale requests to a resolved locale (Accept-Languagematch → default language → first language). Every locale is prefixed, including the default (/el/...,/en/...), never a bare root — avoids the hreflang/duplicate-content ambiguity of a bare-root default locale.- Language list cached with
Cache::rememberForever(), invalidated viaModules\Core\Localization\LanguageCacheObserverdispatchingLanguageCreated/LanguageUpdated/LanguageDeletedevents (see below) rather than doing the work itself. - Language rename safety: renaming a
Language::code(e.g.el→gr) no longer strands existing translations.MigrateTranslationsForRenamedLanguage(listening onLanguageUpdated) migrates every affectedLanguageLine.textkey from the old code to the new one and flushes both codes' translation caches — closing a real data-loss gap where a rename would otherwise make existingLanguageLinetranslations permanently unreachable.
- Language list cached with
- Storefront UI label translations: pulled in
spatie/laravel-translation-loader(self-registers via Composer package auto-discovery; its loader extends Laravel's file-basedFileLoaderand merges DB translations on top — existing Filament/Lunar vendorlang/strings are unaffected). Labels are looked up via Laravel's native__('storefront.nav.cart'), kept in its ownstorefrontgroup so nothing collides with Lunar/Filament's own translation groups.Modules\Core\Command\InstallLunarCommand(overridinglunar:install) seeds a starter set of ~15 common e-shop labels (nav.*,cart.*,product.*,auth.*,search.*, English + Greek), idempotently guarded so it's safe on every boot.Modules\Core\Localization\TranslationReader::group('storefront')returns the whole reduced/cached label array for a locale (backed byLanguageLine's own forever-cache) — for sharing to a view as$labelsor@json()-ing to JS, on top of__()for single-key Blade lookups.- Admin UI:
Modules\Core\Localization\Filament\Resources\LanguageLineResource(registered inCorePlugin) lists/searches/filterslanguage_linesand edits each row'sgroup,key, and one text input per locale currently inlunar_languages— locale columns/inputs are generated dynamically from the language list, so a new language needs no resource changes. - Event-driven writes:
Modules\Core\Localization\TranslationService(create/update/delete) is the single write path forLanguageLine— the Filament resource's Create/Edit/Delete pages route through it rather than Filament's default direct-model writes. DispatchesTranslationCreated/TranslationUpdated(carries the full pre-update{group, key, text}snapshot, so a bare rename is tracked the same as a text edit)/TranslationDeleted, each handled by two listeners:FlushTranslationCache— closes a real gap inLanguageLine's own self-invalidation, which only flushes locales/groups present after a save. Flushes the union of old and new group+locale combinations, so a locale removed fromtext, or agroup/keyrename, can't leave a stale cached array behind.LogTranslationActivity— audits every write via the existingModules\Core\Logging\ActivityLogService(lunaractivity log channel), samecreated/updated/deletedshape as every other domain write in this project. Properties are flattened withArr::dot()before logging (text.en,text.elinstead of a nestedtextobject) since Filament's Activity resource renderspropertieswith a flatKeyValuefield that can't display nested arrays.
Modules\Core\Providers\LocalizationServiceProvider— split out of the growingCoreServiceProvider(per this project's own "split when a provider does too much" convention) to own all locale/translation middleware, observer, and event-listener registration.
[0.3.0] - 2026-07-12
Added
- Meilisearch product search: pulled in
lunarphp/search(Lunar's driver-agnostic search abstraction —database/meilisearch/typesenseengines, selectable via Scout's ownSCOUT_DRIVERconfig) andlunarphp/meilisearch, wiring Meilisearch in as the search engine for products.Search\ProductIndexeroverrides Lunar's own indexer to strip HTML tags from string fields (e.g.name_en,description_en) before they reach the search index — Lunar's default indexer sends raw attribute HTML straight through, which pollutes relevance ranking and highlighting with markup.- Meilisearch itself is treated as app-level infrastructure, not a
boboko-coreconcern: the actual Meilisearch container, host port, and master key live in each consuming app's owndocker-compose.yml/.env(e.g.3dealer), the same way Postgres and Valkey do —boboko-coreonly declares the PHP package dependency and the indexing code.
[0.2.0] - 2026-07-10
Added
- Product reviews (
Modules\Core\Review): a newProductReviewmodel +product_reviewstable (plain, unprefixed — same convention asimport_mappings), linked to Lunar'sProductvia aProduct::reviews()macro (registered inCorePlugin, sinceLunar\Models\Productis a vendor model and can't be edited directly).- JudgeMe CSV review importer (
MigrateImport\JudgeMe\JudgeMeExportImporter), wired into the existingboboko:migrate:import --source=judgeme --type=exportcommand: reads a Judge.me review export, resolves each row'sproduct_handleto a Lunar product viaLunar\Models\Url, and creates/updatesProductReviewrows idempotently viaimport_mappings(source=judgeme,source_type=review, keyed on Judge.me'smetaobject_handle). Rows with no matching product are skipped with a logged warning rather than failing the whole import. - Review images (
picture_urlsin the CSV) are downloaded and stored as real media via Spatie MediaLibrary (ProductReview::IMAGES_COLLECTION), not just linked by URL — consistent with how product images are handled. - Admin UI: a new "Reviews" sub-navigation page on the product edit screen (
Review\Pages\ManageProductReviews, wired viaReview\Extensions\ProductResourceExtension), listing rating/title/reviewer with View, Reply, and Delete actions. The Reply action lets staff write/edit a reply directly from the table, settingreplied_at. The View modal shows full review detail (body, reviewer email, location, source, dates, reply, downloaded images).
- JudgeMe CSV review importer (
Fixed
Shopify\ShopifyExportImporternever wrote a LunarUrl(slug) row for imported products, despitedocs/shopify-import.mdspecifying it should — meaning no code outside the importer itself could resolve "which Lunar product has handle X" (only the importer's own privateimport_mappingsbookkeeping could). It now creates/updates a defaultUrlrow (slug= Shopify handle) per product on every import, which the new JudgeMe review importer depends on for product resolution.
[0.1.0] - 2026-07-09
Added
- Shipping: registered Lunar's
lunarphp/table-rate-shippingplugin (ShippingPlugin) directly onCorePlugin, so table-rate shipping is available to every consumer app without per-app wiring. - Product migration/import framework (
Modules\Core\MigrateImport): a source-agnostic pipeline for importing a vendor's product catalog into Lunar.boboko:migrate:importArtisan command — interactively prompts for source, type (export/API), and credentials or file path, then dispatches the import as a queued job (RunMigrateImportJob) on the default queue. The file-path prompt resolves relative tostorage/app/private/imports/, so answering e.g.shopifypicks up the first CSV found inimports/shopify/automatically.ImportSpec,Importerinterface, andImporterFactory(source+type → importer class) as the extension points for future sources (WooCommerce, etc.) and mechanisms (API vs. file export).import_mappingstable +ImportMappingmodel: a polymorphic (source, source_type, external_id) → model mapping used by every resolver to make imports idempotent and safely re-runnable.DefaultLocalehelper wrapping Lunar'sLanguage::getDefault()->code, used anywhere a translatable field needs a locale key, instead of assumingapp()->getLocale()matches Lunar's configured default.- Shopify CSV export importer (
Shopify\ShopifyExportImporter), the first working source/type combination, verified end-to-end against a real 183-product/693-variant/332-image Shopify export (row counts in the CSV match 1:1 with imported Products/Variants/Media):ShopifyCsvReader+ProductGroupgroup Shopify's flat, repeated-handle CSV rows into one row-group per product (product row, variant rows, image rows).- Ten resolvers under
Shopify\Resolvers, each responsible for idempotently resolving-or-creating one Lunar entity:TaxClassResolver,ProductTypeResolver(auto-attaches system attributes to new types),BrandResolver,TagResolver,CollectionResolver(multi-level, multi-collection support via>-delimited breadcrumbs),ProductOptionResolver(dedupes options/values by slugified name so case variants like "Size"/"size" resolve to one row),AssetResolver(Spatie MediaLibrary viaProduct::addMedia(), matches local export images by UUID first, filename fallback),PriceResolver(minor-unit conversion per currency),ImportAttributeResolverandProductAttributeResolver(customcost_per_item/seo_title/seo_descriptionattributes, field-type-awareattribute_datawriting).
docs/shopify-import.md— full CSV-to-Lunar field mapping reference and import design notes.docs/lunar.md— new "Gotchas" section documenting non-obvious Lunar behavior hit while building the importer (table-prefix/nested-set race, requiredProductOption.handle, per-groupAttribute.position, etc.).CONTRIBUTE.md— local dev setup (path-repo +bin/dc-core.sh), and the manual DB-verification workflow used to build this feature.
Fixed
ProductOptionResolvercreated duplicateProductOption/ProductOptionValuerows when the same option or value appeared with different casing across products (e.g. Shopify export rows using both "Size" and "size"), and could create a duplicate value within a single product's own variant rows due to relying on a stale lazy-loaded relation. Both now resolve by normalized (slugified) identity queried fresh from the database.boboko:migrate:importcould dispatch an import job with a blank file path (silent no-op failure) if the file-path prompt was answered empty; it now re-prompts until a valid, existing file is given.
[0.0.1] - 2026-07-03
First release.
Added
- OTP-based authentication built around
Userinstead ofCustomer(UserOtpService,UserOtpMail), replacing the earlier customer-scoped OTP flow. UserCreatedevent with aCreateCustomerForUserlistener to provision a Lunar customer automatically when a user is created.UserRelationManagerfor managing users from the customer resource in the panel.- Stoic image UI component (
resources/views/ui/stoic-image.blade.php) and its YAML-driven config/service (seeStoic::class). config/core.phpfor module-level configuration.AuthServiceProviderandCustomerServiceProvidernow register alongsideCoreServiceProvider.- Migrations: add OTP to
users, drop OTP from Lunarcustomers, droppasswordfromusers, makenamenullable onusersand Lunarcustomers. docs/modules.mddocumenting module structure.
Removed
CustomerOtpMailandCustomerOtpService, superseded by the user-based OTP flow.
Dependencies
- Added explicit
symfony/yamlrequirement (used directly byStoic::loadConfig()).