Appearance
E-commerce Store
The demo's "Maison Aurelia" store is a complete shop — home, catalog, product pages, cart, checkout — where every page is a FilamentCraft template built in the visual editor, and every product is a real row in the host app's database. The commerce sections are data-driven: authors design the layout; the data comes from your catalog at render time.
The bridge between the two is one interface. This page walks the whole build:
- The
Storefrontcontract - Implementing it over Eloquent
- Binding it
- Building the pages in the editor
- Serving the store
- Cart & checkout flow
- Managing the catalog in a Filament panel
1. The Storefront contract
FilamentCraft\Commerce\Contracts\Storefront is the only coupling point between the commerce sections and your catalog. Sections never touch your Eloquent models — they receive plain value objects and ask the contract for canonical URLs:
php
namespace FilamentCraft\Commerce\Contracts;
interface Storefront
{
/** @return Collection<int, ProductData> */
public function products(CatalogQuery $query): Collection;
public function product(string $slug): ?ProductData;
/** @return Collection<int, CategoryData> */
public function categories(?int $limit = null): Collection;
public function category(string $slug): ?CategoryData;
/**
* Canonical storefront URL for a named destination. Known names:
* `home`, `shop`, `cart`, `checkout`, `product` (params: ['slug' => …]),
* `category` (params: ['slug' => …]). Returns '#' for anything it cannot build.
*
* @param array<string, string> $params
*/
public function url(string $name, array $params = []): string;
/**
* Place a cash-on-delivery order from the resolved cart lines and return the
* result. No payment is captured.
*
* @param list<array{slug: string, variant: string|null, qty: int}> $lines
*/
public function placeOrder(array $lines, CustomerDetails $customer): OrderResult;
}The package ships NullStorefront as the default binding, so the commerce sections degrade to an empty state when no store is wired — you can drop them on a page before the backend exists.
The data objects
All in FilamentCraft\Commerce\Data, all final readonly. Prices are integer minor units (cents) throughout — no floats cross the boundary.
| Object | Role |
|---|---|
CatalogQuery | A request for products: collection (all / featured / new / category), optional categorySlug, sort, minMinor / maxMinor price window, search, limit. Named constructors: CatalogQuery::featured($limit), CatalogQuery::inCategory($slug, $limit). |
ProductData | A storefront-ready product: slug, title, priceMinor, compareMinor, currencyCode, currency symbol + position, images, category name/slug, rating, reviews, description, variants, attributes, inStock, isNew, isFeatured, url. Plus derived helpers: onSale(), savePercent(), primaryImage(), badge(). |
CategoryData | slug, name, image, product count, url. |
CustomerDetails | Checkout form values: name, email, phone, address, city, postal, country, note, plus the server-computed shippingMinor. |
OrderResult | number, totalMinor, itemCount, placed. OrderResult::failed() signals rejection. |
2. Implementing it over Eloquent
The demo's implementation maps three ordinary models (Product, Category, Order) onto the contract. The product model is nothing special — casts and a couple of scopes:
php
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Product extends Model
{
protected function casts(): array
{
return [
'price' => 'decimal:2',
'compare_at_price' => 'decimal:2',
'rating' => 'float',
'images' => 'array',
'variants' => 'array',
'attributes' => 'array',
'in_stock' => 'boolean',
'is_featured' => 'boolean',
'is_new' => 'boolean',
];
}
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
public function scopeFeatured(Builder $query): void
{
$query->where('is_featured', true);
}
public function scopeNewArrivals(Builder $query): void
{
$query->where('is_new', true);
}
}The storefront translates CatalogQuery into an Eloquent query and everything Eloquent into value objects:
php
use App\Enums\OrderStatus;
use App\Models\Order;
use App\Models\Product;
use FilamentCraft\Commerce\Contracts\Storefront;
use FilamentCraft\Commerce\Data\CatalogQuery;
use FilamentCraft\Commerce\Data\CustomerDetails;
use FilamentCraft\Commerce\Data\OrderResult;
use FilamentCraft\Commerce\Data\ProductData;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
final class EloquentStorefront implements Storefront
{
private const SYMBOL = '€';
private const POSITION = 'before';
private const BASE = '/store';
public function products(CatalogQuery $query): Collection
{
$builder = Product::query()->with('category');
if ($query->collection === 'featured') {
$builder->featured();
} elseif ($query->collection === 'new') {
$builder->newArrivals();
}
if ($query->categorySlug !== null && $query->categorySlug !== '') {
$builder->whereHas('category', fn (Builder $c) => $c->where('slug', $query->categorySlug));
}
// … price window + search filters trimmed
match ($query->sort) {
'price-asc' => $builder->orderBy('price'),
'price-desc' => $builder->orderByDesc('price'),
'rating' => $builder->orderByDesc('rating'),
default => $builder->orderBy('position')->orderBy('id'),
};
if ($query->limit !== null && $query->limit > 0) {
$builder->limit($query->limit);
}
return $builder->get()->map(fn (Product $p): ProductData => $this->toProductData($p))->values();
}
public function product(string $slug): ?ProductData
{
$product = Product::query()->with('category')->where('slug', $slug)->first();
return $product instanceof Product ? $this->toProductData($product) : null;
}
/**
* @param array<string, string> $params
*/
public function url(string $name, array $params = []): string
{
return match ($name) {
'shop' => self::BASE.'/shop',
'cart' => self::BASE.'/cart',
'checkout' => self::BASE.'/checkout',
'product' => self::BASE.'/p/'.($params['slug'] ?? ''),
'category' => self::BASE.'/shop?category='.urlencode($params['slug'] ?? ''),
default => self::BASE,
};
}
/**
* @param list<array{slug: string, variant: string|null, qty: int}> $lines
*/
public function placeOrder(array $lines, CustomerDetails $customer): OrderResult
{
return DB::transaction(function () use ($lines, $customer): OrderResult {
$items = [];
$subtotal = 0;
foreach ($lines as $line) {
$product = Product::query()->where('slug', $line['slug'])->first();
if (! $product instanceof Product) {
continue;
}
$unit = $this->minor((string) $product->price);
$qty = max(1, $line['qty']);
$subtotal += $unit * $qty;
// … order-item row assembly trimmed
}
if ($items === []) {
return OrderResult::failed();
}
$order = Order::query()->create([
'number' => $this->orderNumber(),
'status' => OrderStatus::Pending,
'customer_name' => $customer->name,
// … remaining customer + totals columns trimmed
'subtotal' => $subtotal,
'shipping' => $customer->shippingMinor,
'total' => $subtotal + $customer->shippingMinor,
'payment_method' => 'cod',
'placed_at' => now(),
]);
$order->items()->createMany($items);
return new OrderResult(
number: $order->number,
totalMinor: (int) $order->total,
itemCount: array_sum(array_column($items, 'qty')),
);
});
}
private function toProductData(Product $product): ProductData
{
return new ProductData(
slug: $product->slug,
title: $product->name,
priceMinor: $this->minor((string) $product->price),
currencySymbol: self::SYMBOL,
symbolPosition: self::POSITION,
// … image / variant / attribute mapping trimmed
inStock: (bool) $product->in_stock,
url: $this->url('product', ['slug' => $product->slug]),
);
}
// … categories(), category(), minor(), image helpers trimmed
}Two details worth copying:
placeOrder()re-reads prices from the database inside a transaction. The lines it receives carry only slugs and quantities — never prices — so nothing a customer submits can influence what they're charged.url()returns your canonical paths. Every "View product", "Go to cart", and category link in every commerce section routes through it, so the sections never hard-code a URL scheme.- The
Illuminate\Support\Collectionimport is not optional. The contract'sproducts()/categories()return types reference it, so omitting theuseline is a fatal "return type must be compatible" error the moment the class loads.
3. Binding it
One line in a service provider:
php
use App\Storefront\EloquentStorefront;
use FilamentCraft\Commerce\Contracts\Storefront;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
// Wire FilamentCraft's e-commerce sections to this app's catalog. Bound in
// boot() so it overrides the package's NullStorefront default regardless of
// provider registration order.
$this->app->singleton(Storefront::class, EloquentStorefront::class);
}
}The boot() placement is deliberate: the package registers NullStorefront in its own provider, and binding in your boot() guarantees you win the container regardless of provider order.
4. Building the pages in the editor
With the binding in place, seven built-in sections turn the editor into a store builder (see Built-in Sections for their full setting schemas):
| Section | Slug | Renders |
|---|---|---|
StoreHeroSection | store-hero | Store landing hero with shop CTAs |
ProductListingSection | product-listing | Filterable/sortable catalog grid (the /shop page) |
ProductDetailSection | product-detail | Gallery, price, variants, add-to-cart form |
ProductCarouselSection | product-carousel | A CatalogQuery-driven product strip (featured, new, per-category) |
CategoryGridSection | category-grid | Category cards with product counts |
CartSection | cart | Line items, quantity forms, totals |
CheckoutSection | checkout | Customer form, shipping rules, order summary, confirmation |
The demo store is five templates, each assembled from these plus the ordinary content sections: home (store-hero + carousels + category grid), shop (product-listing), product (product-detail), cart, and checkout. Authors configure selection, not data — a carousel's settings say "featured, limit 8", and the products themselves always come from the bound Storefront at render time. Add a product in the database and it appears everywhere, no republish needed.
Four of them — ProductListingSection, ProductDetailSection, CartSection, and CheckoutSection — declare cacheable(): false and are excluded from the rendered-fragment cache, because their output depends on per-request state — the URL's filters and product slug, and the visitor's session. StoreHeroSection, ProductCarouselSection, and CategoryGridSection keep the default cacheable(): true: a newly added product shows up in carousels and category grids after the fragment TTL expires (cache.ttl, 3600 seconds by default) or a site cache flush — not instantly. See Caching.
5. Serving the store
The demo mounts the store under a fixed /store prefix, registered before the tenant catch-alls so store is never read as a team slug:
php
Route::prefix('store')->name('store.')->group(function (): void {
Route::get('/', [EcommercePublicController::class, 'home'])->name('home');
Route::get('shop', [EcommercePublicController::class, 'shop'])->name('shop');
Route::get('cart', [EcommercePublicController::class, 'cart'])->name('cart');
Route::get('checkout', [EcommercePublicController::class, 'checkout'])->name('checkout');
Route::get('p/{product}', [EcommercePublicController::class, 'product'])->name('product');
});One template, every product
The interesting route is p/{product}. There is one product template — the URL's slug rides into the render as a RenderContext parameter, and ProductDetailSection resolves the live product from it:
php
use FilamentCraft\Models\Site;
use FilamentCraft\Models\Template;
use FilamentCraft\Rendering\TemplateRenderer;
use FilamentCraft\Routing\RenderContext;
final class EcommercePublicController extends Controller
{
public function __construct(private readonly TemplateRenderer $renderer) {}
public function product(Request $request, string $product): Response
{
return $this->renderTemplate($request, 'product', new RenderContext(parameters: ['product' => $product]));
}
private function renderTemplate(Request $request, string $slug, ?RenderContext $context = null): Response
{
$site = $this->site();
app()->instance(Site::class, $site);
// … session-persisted ?lang= locale resolution trimmed
$template = Template::query()
->forSite($site->id)
->published()
->where('slug', $slug)
->first();
abort_if($template === null, 404);
$html = $this->renderer->render($template, 'published', $locale, $context);
return response($html);
}
}This is the standard dynamic-pages pattern: RenderContext carries route parameters (strings) and bindings (objects) into the section layer. In the editor, where no URL parameter exists, the section falls back to an author-chosen preview product so the canvas still shows a real layout.
Expected result: /store renders the published home template with real catalog rows in every carousel and grid, /store/shop lists the filterable catalog, and /store/p/{slug} shows that product's detail page — every product through the one product template.
6. Cart & checkout flow
The cart is deliberately JS-free: every mutation is a plain HTML form POST that redirects back. No client runtime, no hydration, nothing to break when the visitor blocks scripts — and no Livewire on the public page.
The cart itself lives in the server session — no cookies of its own, no localStorage. FilamentCraft\Commerce\CartService owns the session payload: it stores slug/variant/quantity lines and re-resolves each line against the bound Storefront on every read, so prices are never persisted client-side or server-side.
Every cart form also posts a store key that namespaces the session cart. The commerce sections use the rendering site's slug (falling back to default, which the controller also assumes when the field is absent) — so two sites served by the same host app keep fully independent carts within one visitor session.
The endpoints are always registered by the package (independent of the public_routes config, since hosts often own their public routing), under the web middleware group for session + CSRF:
php
Route::middleware(['web'])->prefix('filamentcraft/cart')->name('filamentcraft.cart.')->group(function (): void {
Route::post('add', [CartController::class, 'add'])->name('add');
Route::post('update', [CartController::class, 'update'])->name('update');
Route::post('remove', [CartController::class, 'remove'])->name('remove');
Route::post('checkout', [CartController::class, 'checkout'])->name('checkout');
});The commerce sections render the matching forms; you never build them yourself. Security properties of the flow:
Prices are never trusted from the client. Forms post a product slug and quantity; the controller resolves the product from the bound
Storefronton every request. The cart stores slugs, and checkout re-resolves the whole cart before charging — a product deleted (or repriced) after add-to-cart is handled server-side.Shipping settings are HMAC-signed. The checkout section's author-configured flat fee and free-shipping threshold travel as three hidden inputs —
shipping_flat,free_threshold, andshipping_sig— because the cart routes are site-agnostic and the server can't look the section's settings up on POST. The checkout view emits them next to the customer fields:blade<input type="hidden" name="shipping_flat" value="{{ $flat }}"> <input type="hidden" name="free_threshold" value="{{ $threshold }}"> <input type="hidden" name="shipping_sig" value="{{ \FilamentCraft\Commerce\ShippingSignature::sign($flat, $threshold, $storeKey) }}">FilamentCraft\Commerce\ShippingSignaturesigns the pair (plus the store key) with the app key when the section renders, and checkout verifies it — editing the hidden fields in devtools can't zero out the shipping charge, and because zero values are signed too, stripping the fields fails verification as well:phpfinal class ShippingSignature { public static function sign(int $flatMinor, int $thresholdMinor, string $store): string { return hash_hmac('sha256', $flatMinor.'|'.$thresholdMinor.'|'.$store, self::key()); } public static function valid(string $signature, int $flatMinor, int $thresholdMinor, string $store): bool { return $signature !== '' && hash_equals(self::sign($flatMinor, $thresholdMinor, $store), $signature); } }Redirects are local-only. The
returnURL a form posts is accepted only as a same-site absolute path, so the cart can't be used as an open redirect.
When the order passes validation, the controller hands the resolved lines and a CustomerDetails to your Storefront::placeOrder(), records a confirmation snapshot in the session, and redirects back to the checkout page with ?placed={number} — which the checkout section renders as the order-confirmation state.
Expected result: submitting a valid checkout form lands back on /store/checkout?placed=XXXX, the checkout section swaps to its confirmation panel (order number, line items, totals), the cart is emptied, and an Order row with its items exists in the database with status pending.
7. Managing the catalog in a Filament panel
The demo runs the whole store from one panel: FilamentCraft's editor and custom catalog resources side by side. Nothing special is required — FilamentCraft is a plugin like any other, and your Product / Category / Order resources are ordinary Filament resources:
php
class ShopPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->id('shop')
->path('shop')
->login()
->brandName('Maison Aurelia')
->tenant(Workspace::class, slugAttribute: 'slug')
->plugin(
FilamentCraftPlugin::make()
->navigationGroup('Storefront')
->mirrorEditorStyles()
->publicUrlUsing(fn (Template $template): ?string => $this->storefrontUrl($template))
)
->discoverResources(in: app_path('Filament/Shop/Resources'), for: 'App\Filament\Shop\Resources')
// … pages/widgets/middleware trimmed
;
}
}navigationGroup('Storefront') files FilamentCraft's items under their own sidebar group, next to the panel's Products / Categories / Orders groups. The publicUrlUsing() closure maps templates to their /store/... URLs so the editor's "open live" pill works (the product template points at a representative product, since one template serves them all). The result: a store manager edits the storefront design, curates the catalog, and processes orders without leaving /shop.
Related
- Built-in Sections — setting schemas for the commerce sections
- Dynamic Pages —
RenderContextand the four serving doors - Caching — why cart/checkout/detail sections opt out
- Multi-Tenant SaaS — combine the store with per-tenant sites
- Custom Section — build store sections of your own
