Skip to content

Customer panel

Customers have their own Filament panel at /account, on every site, in the customer-panel module (app-modules/customer-panel). The staff-side description is customer account.

The module depends on sales for the Customer model, never the reverse. sales stays a domain and admin module, and public-facing presentation lives here.

Providers\CustomerPanelProvider is a separate panel from the admin’s, and not the default:

$panel->id('customer')->path('account')->authGuard('customer')->login(Login::class)

It lists its pages and resources explicitly, rather than using plugins:

Screen Class Notes
Dashboard Pages\Dashboard Widgets, from Account\AccountOverview
Quotes Resources\QuoteResource Sent onward, never drafts or cancelled
Orders Resources\OrderResource Confirmed onward
Proofs Resources\ProofResource Sent proofs. The account’s only workflow writes.
Invoices Resources\InvoiceResource Issued only
Credit notes Resources\CreditNoteResource Issued only
Addresses Resources\AddressResource Their own contacts, editable
My quote requests Resources\QuoteRequestResource Submitted and converted requests
My details Pages\MyDetails Their name. Email and tax numbers are read-only.

One panel serves every site, so its chrome can’t be static. The provider passes closures to colors(), brandName(), brandLogo() and favicon(), each calling Branding\PanelBranding, which finds the site from the request host. Filament evaluates them when the panel boots for the request. The colours come from the site theme’s manifest, and Filament generates its own shades, so the panel is on-brand rather than an exact match. See themes.

Filament’s colour registry caches for the process, which is fine under PHP-FPM but would leak one site’s colours into the next request under Octane.

WebMoves\Sales\Models\Customer is the identity: Authenticatable, Notifiable and Spatie’s HasOneTimePasswords, with no password column. The customer guard and customers provider are in config/auth.php, and the code settings in config/one-time-passwords.php.

Filament\Pages\Auth\Login replaces Filament’s form with two steps:

  1. Email. If a customer has that email, sendOneTimePassword(). Either way the same neutral message shows and the form advances, so the page can’t be used to find out who is a customer. No row is ever created here.
  2. Code. consumeOneTimePassword($code), then Auth::guard('customer')->login($customer).

Don’t swap that for attemptLoginUsingOneTimePassword(), which logs in on the default guard. rateLimit(5) runs at the top of authenticate(), before the steps branch, so sending is throttled too.

Two layers keep the audiences apart: each panel authenticates on its own guard (customer and web), and Customer::canAccessPanel() is true only for the customer panel and only while is_active.

This is the part to get right when adding anything. Authorization here is the query, not the staff policies. A customer has no staff capabilities, so sales policies don’t apply.

Filament\Support\CustomerRecords has the two halves:

  • scope($query, $dateColumn, $stillCurrent) limits a query to customer_id = the signed-in customer, site_id = the site from the request host, and the account’s history window from CustomerPanelSettings::$history_months (24 by default, null for all). A host with no site shows nothing.
  • readOnly($scoped, $action, $record) allows listing, allows viewing only what the query returns, and denies every write.

Each resource uses them the same way:

public static function getEloquentQuery(): Builder
{
return CustomerRecords::scope(
parent::getEloquentQuery(),
'issued_at',
fn (Builder $query): Builder => $query->where('balance_due', '>', 0),
)->whereState('status', Issued::class);
}
public static function getAuthorizationResponse(string|UnitEnum $action, ?Model $record = null): Response
{
return CustomerRecords::readOnly(static::getEloquentQuery(), $action, $record);
}

The closure here is $stillCurrent: an invoice still owed shows whatever its age.

Things that follow from this:

  • Each site is its own business to the customer. The same customer on another site sees that site’s records only. This is deliberate, as each site has its own legal entity.
  • Records are kept, just not shown. The history setting never deletes anything.
  • Keep links inside the account. InvoiceResource finds its order through OrderResource::getEloquentQuery(), so a link only exists when the customer could open the target.
  • Never expose unit_cost, suppliers or staff notes. Filament\Support\DocumentEntries holds the shared line display and leaves cost out. Orders show shipments gathered from their POs, with nothing else of those POs.

Status wording is the resource’s own statusLabel(), as the states’ label() is staff vocabulary (a Lost quote reads Closed).

ProofResource’s view page has Approve and Request changes while a proof is Sent, running the same transitions staff use. An approval keeps the wording as it read, and is refused if a newer version arrived since the page opened. Listeners\NotifyRepOfProofDecision tells the rep. MyDetails and AddressResource edit the customer’s own details, and documents keep their own copies, so nothing issued changes. AddressResource has its own query and authorization: addresses belong to the customer, not to a site.

  1. Create it under src/Filament/Resources/ and add it to resources() in CustomerPanelProvider.
  2. Implement getEloquentQuery() with CustomerRecords::scope() and the date column history counts from, plus the states a customer may see.
  3. Implement getAuthorizationResponse() with CustomerRecords::readOnly(). Do not reach for a policy.
  4. Word statuses for customers, and leave out costs and internal notes.
  5. Test it as CustomerInvoicesTest does in app-modules/customer-panel/tests/Feature/: other customers’ records hidden, other sites’ records hidden, a host with no site shows nothing, cross-customer views 404, writes denied.

CustomerPanelAccessTest covers guard isolation and CustomerLoginTest the code flow.