15 KiB
Cart Admin Visibility
Modules\Core\Cart\Filament\Resources\CartResource gives staff read-only visibility into
customer/user carts in the Filament admin panel. 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: only carts with a known customer or user
CartResource::getEloquentQuery() filters to Cart::whereNotNull('user_id')->orWhereNotNull('customer_id')
— an anonymous guest's session cart is excluded entirely.
This was a deliberate call, not an oversight: 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. 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). Lunar's own Cart model already gets user_id/customer_id set the moment a
shopper is authenticated (via Lunar\Listeners\CartSessionAuthListener on login), with no
checkout step required — so scoping to "identifiable" here is broader than Shopify's
equivalent, not a copy of it.
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:
- Ongoing —
scopeActive()and recentupdated_at(withinabandonedCutoff()). Default active tab on page load. - Abandoned Cart —
whereDoesntHave('orders')and staleupdated_at. - Abandoned Checkout — has an order with
placed_at IS NULL, and staleupdated_at. - Completed — has an order with
placed_at IS NOT NULL.
// Ongoing
$query->active()->where('updated_at', '>', CartResource::abandonedCutoff());
// Abandoned Cart
$query->whereDoesntHave('orders')->where('updated_at', '<=', CartResource::abandonedCutoff());
// Abandoned Checkout
$query->whereHas('orders', fn ($q) => $q->whereNull('placed_at'))
->where('updated_at', '<=', CartResource::abandonedCutoff());
// Completed
$query->whereHas('orders', fn ($q) => $q->whereNotNull('placed_at'));
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()->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
queries the same two branches ListCarts::getTabs() uses (no order at all vs. draft order
never placed) and dispatches Modules\Core\Recovery\Events\CartAbandoned/CheckoutAbandoned
for anything currently stale.
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.