16 KiB
Cart Admin Visibility
Modules\Core\Cart\Filament\Resources\CartResource gives staff read-only visibility into
every cart in the Filament admin panel, guest carts included. Lunar itself ships no cart
admin view at all — no Filament resource for Cart/CartLine exists anywhere in
lunarphp/lunar or lunarphp/core — this is a from-scratch addition, not an extension of
something Lunar half-built. See docs/lunar.md's "Cart and Checkout" section for the
underlying Lunar cart mechanics this resource reads from.
Scope: every cart, identified or not
CartResource lists every cart the four lifecycle states (below) cover, with no
user_id/customer_id filter — an anonymous guest's session cart is included.
This was a reversal of an earlier, deliberate call to exclude guest carts entirely (on the
reasoning that an anonymous cart carries no identity a staff member could act on — no name, no
email, nothing to follow up with — so listing every guest session cart would be noise, not a
real admin capability). That reasoning holds for "can I click through to a Customer record,"
but not for the resource's other real use — seeing how many carts are ongoing/abandoned right
now regardless of who's shopping. Most real storefront traffic never reaches an identified
user/customer, so excluding it silently undercounts exactly the thing ListCarts's tabs (and
CartLifecycleService, which they and DetectAbandonedCarts both build on) exist to report
on. The Customer/User columns on a guest row just render "—" (Filament's placeholder())
instead of a link — nothing to click into, but the row and its contents are still visible via
ViewCart.
This does not mirror Shopify's admin (Shopify has no "all carts" view at all — only
"Abandoned checkouts," gated on a shopper reaching checkout and entering contact info, a
later/narrower stage than Lunar's Cart).
Four states, not two — and not Cart::completed_at
Lunar\Models\Cart::completed_at is declared and cast ('completed_at' => 'datetime') but
never actually written anywhere in Lunar core — grep vendor/lunarphp/core/src for it;
the only hits are the property declaration and the cast. It is not a real signal. Cart has
no status column at all — every state below is derived from relations/timestamps, not a
single field.
Cart::scopeActive() (Lunar's own "not yet converted to an order" scope) actually mixes two
distinct states together: no order ever started, vs. a draft order exists
(placed_at IS NULL) but was never placed — checkout was started, not finished. Those are
different purchase-intent signals (see "Abandoned Cart vs Abandoned Checkout" below) and
different reachability (checkout usually captures an email even for a guest), so
ListCarts::getTabs() splits them into four tabs instead of scopeActive()'s two-state
split.
Modules\Core\Cart\Services\CartLifecycleService is the single source of truth for these four
query shapes — both ListCarts::getTabs() (staff browsing) and DetectAbandonedCarts
(abandonment-event dispatch) build on it, rather than each reimplementing the same split
independently (which is what happened before this service existed, and is exactly the kind of
drift that lets the admin panel and the recovery-email pipeline quietly disagree about what
"abandoned" means):
- Ongoing (
ongoing()) —scopeActive()and recentupdated_at(withinabandonedCutoff()). Default active tab on page load. - Abandoned Cart (
abandonedCarts()) —whereDoesntHave('orders')and staleupdated_at. - Abandoned Checkout (
abandonedCheckouts()) — has an order withplaced_at IS NULL, and staleupdated_at. - Completed (
completed()) — has an order withplaced_at IS NOT NULL.
Each method takes a Builder and returns it further scoped, so callers compose it onto
whatever base query they already have (CartResource::getEloquentQuery() for the Filament
tabs, a bare Cart::query() for the command). Deliberately query-shape-only: consent
(meta->recovery_consent) and non-empty-lines filtering stay in DetectAbandonedCarts, not on
the service — those gate whether a recovery event should fire, not what "abandoned" means to
a staff member browsing the list.
There is deliberately no "All" tab. Every row shown is always scoped to one of the four
states above — the list never runs an unfiltered Cart::query()->get() over the whole
(potentially large) table.
Abandoned Cart vs Abandoned Checkout — why they're not one bucket
Different purchase intent, different reachability, and different recovery strategy — see
docs/recovery-strategies.md for the full marketing-strategy discussion. In short:
- Abandoned Cart (no order started) is a weak intent signal — often window-shopping, not a near-purchase. Frequently unreachable (no email/identity at all for a true guest). Recovery leans on on-site retargeting and ad remarketing rather than email.
- Abandoned Checkout (draft order, never placed) is a strong intent signal — the shopper committed to buying and something blocked completion. Checkout typically captures contact info even for a guest, so this state is usually reachable. This is the state the researched 1h/24h/72h recovery-email cadence targets specifically.
Modules\Core\Cart\Events\CartAbandoned and Modules\Core\Checkout\Events\CheckoutAbandoned
mirror this same split (see "Events" below) rather than one combined event.
Why this scales fine at a large cart count
Two things keep this cheap regardless of how many carts exist (10,000+):
- The list is always paginated. Filament applies
LIMIT/OFFSETto whichever tab's query is active — a page only ever fetches one page's worth of rows, never the whole table, "All" tab or not (and there is no "All" tab — see above). - No per-row queries.
lines_count/lines_sum_quantityuse Filament's built-in->counts('lines')/->sum('lines', 'quantity'), which fold into the same query as the rest of the list (oneLEFT JOIN-based aggregate, not N separate lookups). There's no per-recordgetStateUsing()closure anywhere in this table doing its own query — that's the pattern to avoid if a future column needs derived data (seeModules\Core\Catalog\ Services\ProductIndexerfor the general "compute once at index time / one aggregate query, never per-row" principle this project follows elsewhere).
The one thing that does scan more rows as the cart count grows is
CartResource::getNavigationBadge() (see below) — but it's a COUNT(*), not a fetch, and
runs once per admin page load, not once per cart row.
Navigation badge — abandoned cart count
public static function getNavigationBadge(): ?string
{
return (string) static::getEloquentQuery()->active()->where('updated_at', '<=', static::abandonedCutoff())->count();
}
Shows the number of abandoned carts (not all carts — a converted cart isn't something a
staff member needs to keep noticing) next to "Carts" in the sidebar. ->count() compiles to
a single SELECT COUNT(*) ... — confirmed via query log — no rows are ever loaded just to
render the badge.
The view page runs the cart's full calculate pipeline — once
ViewCart::resolveRecord() calls $cart->calculate() before rendering, since CartLine's
computed properties (unitPrice, total, etc.) and Cart's own totals (subTotal, total,
...) are plain public properties populated as a side effect of that pipeline — never
persisted, so a plain Eloquent-fetched Cart has them all null/unset (see docs/lunar.md
Gotchas). This only runs on the single-record view page, not per row in the list table —
running the full 5-step pipeline for every row of a paginated list would be needless cost for
data the list doesn't display.
Not built: staff editing a cart
The resource is deliberately read-only (canCreate() returns false, no edit page
registered). A cart is owned by the storefront's own add/update/remove flow
(CartSession/Cart::add()/etc.) — hand-editing cart contents from the admin panel isn't a
supported use case here.
CartService — the storefront-facing API
Modules\Core\Cart\Services\CartService mirrors Modules\Core\Catalog\Services\ ProductService/CollectionService's shape — one boboko-owned API a storefront calls, so
Lunar's own CartSession/Cart stay an implementation detail rather than something a
consuming app depends on directly.
current()/currentOrCreate()— the latter force-creates a cart (CartSession::manager()), the former doesn't (CartSession::current(), returnsnullfor a fresh visitor — seedocs/lunar.md's Cart gotchas).addLine()/updateLine()/removeLine()/clear()— thin wrappers overCart::add()/updateLine()/remove()/clear(). No boboko-owned exception types wrap Lunar's own cart exceptions (InvalidCartLineQuantityException,CartLineIdMismatchException, etc.) — they propagate as-is; a wrapper would add indirection with identical semantics.applyCoupon()/removeCoupon()— sets/clearsCart::coupon_code(there's no dedicated Lunar action for this, unlike add/update/remove).applyCoupon()validates viaDiscounts::validateCoupon()first and throwsModules\Core\Cart\Exceptions\ InvalidCouponExceptionon a bad code —CouponString's cast only normalizes casing, it doesn't validate anything, so settingcoupon_codedirectly would silently accept a bogus code and just not discount anything once calculated.saveForLater()/moveToCart()/activeLines()/savedLines()— see "Save for later" below.
Every mutating method returns the recalculated Cart (matching Lunar's own Cart::add()
etc., which already return $this after refresh()->recalculate()) and dispatches a
matching domain event.
Events — Lunar dispatches none of its own
Lunar dispatches zero cart events — no "item added," no "cart created" (see
docs/lunar.md's Cart gotchas). CartService fills that gap with its own, dispatched after
the underlying Lunar operation completes:
CartLineAdded, CartLineUpdated, CartLineRemoved, CartCleared, CartCouponApplied,
CartCouponRemoved, CartLineSaved, CartLineMovedToCart — all under
Modules\Core\Cart\Events. CartAbandoned/CheckoutAbandoned live under
Modules\Core\Recovery\Events instead, not Cart/Checkout — see "Abandonment detection"
below for why.
None of these currently have a listener. They're dispatched-but-unconsumed by design — built so something downstream (reindexing, notifications, a future read-side reporting service) has a hook to attach to, not because a concrete consumer exists today. This was a deliberate decision, not an oversight — see the "don't build speculative infrastructure" calls made elsewhere in this project (e.g. not wrapping Lunar's cart exceptions).
Why not wired to Spatie's Activity Log: Cart/CartLine already use Lunar's own
LogsActivity trait (Spatie's package, Lunar's defaults) — confirmed from source, this logs
model saves/deletes automatically, independent of actor. Modules\Core\Logging\ ActivityLogService (this project's own wrapper, used by e.g. LogTranslationActivity) is
hardcoded to the staff guard — correctly scoped for staff-driven writes (Filament admin
actions), but wrong for customer-driven cart activity, which would resolve causedBy() to
null every time. Both ActivityLogService and Cart/CartLine's native LogsActivity
write to the same log_name = 'lunar' / activity_log table, with no built-in
separation beyond reading causer_type per row — a real limitation worth knowing about, but
not one this project is fixing by giving Cart a distinct log_name, since every other Lunar
model logs to 'lunar' too and a Cart-only carve-out would just be inconsistent. The
intended fix, if this becomes a real need, is a read-side service that queries activity_log
and classifies by causer_type/log_name — not touching every write site.
Save for later
A CartLine can be moved out of the purchasable cart without being deleted — flagged via
meta.saved_for_later, not a new column (matches the free-form-JSON pattern already used
elsewhere, e.g. ProductOptionValue::meta). Modules\Core\Cart\Pipelines\ ZeroSavedForLaterPrice (registered in config('lunar.cart.pipelines.cart_lines'), after the
stock GetUnitPrice) zeroes unitPrice/unitPriceInclTax for flagged lines before
Lunar's own CalculateLines pipeline step sums the cart — CalculateLines sums every
CartLine unconditionally with no meta-based exclusion of its own, so zeroing the price
upstream is what makes Cart::subTotal/total naturally correct without a second pass or
callers needing a different totals accessor.
Lunar\Actions\Carts\UpdateCartLine replaces the whole meta column on write (plain
update(['meta' => $meta]), not a merge) — saveForLater()/moveToCart() read the line's
existing meta and merge in the flag change before calling Cart::updateLine(), or an
unrelated meta key set by something else would be silently wiped.
Coupons
See CartService::applyCoupon()/removeCoupon() above. Lunar\Base\Casts\CouponString
just upper-cases the code; Lunar\Managers\DiscountManager::validateCoupon() (via the
Discounts facade) is the actual check — does a matching Discount (type AmountOff or
BuyXGetY) exist, active(), with max_uses not exhausted.
Abandonment detection
"Abandoned" is a derived state (Cart::updated_at older than
config('core.cart.abandoned_after'), default 1 hour) — nothing transitions a cart into it
via a normal Eloquent write, so there's no model-event hook to dispatch from directly.
Modules\Core\Cart\Commands\DetectAbandonedCarts (registered on an hourly schedule by
Modules\Core\Providers\CartServiceProvider) is the only place that moment gets detected: it
builds on the same CartLifecycleService::abandonedCarts()/abandonedCheckouts() queries
ListCarts::getTabs() uses (no order at all vs. draft order never placed) and dispatches
Modules\Core\Recovery\Events\CartAbandoned/CheckoutAbandoned for anything currently stale
that also has meta->recovery_consent = true.
Cart/Checkout have zero abandonment-related writes — by design
DetectAbandonedCarts only dispatches — it never writes to Cart/Order at all. An
earlier version recorded an "already notified" marker on Cart::meta/Order::meta to avoid
refiring the same event every run, but that ->save() call bumped Cart::updated_at as an
Eloquent side effect — since updated_at is also the field abandonment staleness is computed
from, the write un-staled the very cart it had just marked abandoned: confirmed live, a
cart that correctly fired CartAbandoned showed back up as "Ongoing," not "Abandoned Cart,"
on the very next tab-count check.
The fix wasn't to write the marker more carefully — it was to stop Cart/Checkout from
having any way to write abandonment state at all. Deduplication ("has this cart already been
notified") is deliberately not this command's job; it belongs to Recovery (not yet
built — see docs/recovery-strategies.md), which will own its own tracking table, keeping
Cart/Order permanently free of abandonment-related columns or meta keys.
Current tradeoff, accepted deliberately: until Recovery exists, every cart still
matching the "abandoned" query refires its event on every hourly run — there is no dedup at
all right now. That's fine today only because nothing consumes these events yet (see
"Events" above); it would need addressing before anything real listens for them.
Recovery Sequences — design only, not built
See docs/recovery-strategies.md — a full marketing-strategy discussion and a first-pass
feature design for an admin-configurable sequence of "touches" (delay + optional discount +
label) per abandonment type. Explicitly parked as an open design question, not scoped for
implementation yet — whether this belongs under Cart, a new Recovery/Marketing concern,
and how far the touch model needs to flex (channel choice, value-based branching, segment
targeting) are all still undecided.