5.0 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.
"Abandoned" vs "Completed" — 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.
The list page's tabs (ListCarts::getTabs()) instead key off whether the cart has a
placed order:
- Abandoned — mirrors
Cart::scopeActive()exactly: no orders at all, or only orders still in draft (placed_at IS NULL). Default active tab on page load. - Completed — has at least one order with
placed_at IS NOT NULL.
// Abandoned
$query->active();
// Completed
$query->whereHas('orders', fn ($query) => $query->whereNotNull('placed_at'));
There is deliberately no "All" tab. Every row shown is always scoped to one of the two
states above — the list never runs an unfiltered Cart::query()->get() over the whole
(potentially large) table.
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.