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.
The panel
Section titled “The panel”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. |
Branding per site
Section titled “Branding per site”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.
Identity and login
Section titled “Identity and login”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:
- 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. - Code.
consumeOneTimePassword($code), thenAuth::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.
Scoping to the customer and site
Section titled “Scoping to the customer and site”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 tocustomer_id= the signed-in customer,site_id= the site from the request host, and the account’s history window fromCustomerPanelSettings::$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.
InvoiceResourcefinds its order throughOrderResource::getEloquentQuery(), so a link only exists when the customer could open the target. - Never expose
unit_cost, suppliers or staff notes.Filament\Support\DocumentEntriesholds 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).
The writes
Section titled “The writes”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.
Adding a customer-facing resource
Section titled “Adding a customer-facing resource”- Create it under
src/Filament/Resources/and add it toresources()inCustomerPanelProvider. - Implement
getEloquentQuery()withCustomerRecords::scope()and the date column history counts from, plus the states a customer may see. - Implement
getAuthorizationResponse()withCustomerRecords::readOnly(). Do not reach for a policy. - Word statuses for customers, and leave out costs and internal notes.
- Test it as
CustomerInvoicesTestdoes inapp-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.