47 KiB
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog.
[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()).