Customers resource (/admin/customers)¶
Read-only view of App\Models\Customer — customers synced from external e-commerce platforms via system connections. Records are created and updated by the sync pipeline; this resource only exposes them for inspection.
Model attributes¶
| Attribute | Type | Notes |
|---|---|---|
id |
UUID | Primary key via HasUuids |
system_connection_id |
UUID (FK) | Owning SystemConnection |
source_id |
string | ID on the originating platform |
source_custom_id |
string, nullable | Secondary platform identifier |
source_system |
SystemDriver |
Enum: the platform driver |
email |
string | Customer email address |
first_name |
string, nullable | |
last_name |
string, nullable | |
phone |
string, nullable | |
gender |
Gender, nullable |
Enum; platform-specific values |
title |
string, nullable | Salutation or honorific |
zip |
string, nullable | Postal/ZIP code |
date_of_birth |
date, nullable | |
address_billing |
array, nullable | Key-value billing address; cast from JSON |
address_shipping |
array, nullable | Key-value shipping address; cast from JSON |
tags |
array | Cast from JSON |
order_count |
integer | Number of orders attributed to this customer |
total_spent |
integer | Lifetime spend in minor currency units (cents) |
external_data |
array | Raw platform payload; cast from JSON |
last_bulk_sync_batch_id |
UUID (FK), nullable | Most recent SyncBatch that touched this record |
deleted_at |
timestamp | Soft deletes enabled |
List view columns¶
| Column | Source | Behaviour |
|---|---|---|
| ID | customers.id |
Truncated to 8 chars; full UUID shown on hover; searchable |
customers.email |
Searchable | |
| First Name | customers.first_name |
Searchable |
| Last Name | customers.last_name |
Searchable |
| Source System | customers.source_system |
Badge |
| Consent | consents (relation) |
One badge per recorded CustomerConsent channel, e.g. "Email: subscribed"; green when subscribed, red when unsubscribed, gray when unknown (null); placeholder "No consent recorded" when the customer has no consent rows at all |
| Order Count | customers.order_count |
Numeric; sortable |
| Total Spent | customers.total_spent |
Cents ÷ 100, formatted to 2 d.p.; sortable |
| Created At | customers.created_at |
Date-time; sortable; hidden by default |
The consents relation is eager-loaded via ->modifyQueryUsing(... ->with('consents')) to avoid N+1 queries when rendering the badge column.
Filters: System Connection (relationship, searchable), Source System (enum), Consent (three-valued — see below).
Consent filter¶
SelectFilter::make('consent_state') narrows the list to one of three states:
| Option | Query |
|---|---|
| Subscribed on any channel | whereHas('consents', fn ($q) => $q->where('subscribed', true)) |
| Unsubscribed on any channel | whereHas('consents', fn ($q) => $q->where('subscribed', false)) |
| No consent recorded | whereDoesntHave('consents') |
"No consent recorded" is its own option, not folded into "Unsubscribed" — a customer nobody has reported consent for has made no statement at all, which is a different fact than an opt-out. See Missing information is not a withdrawal.
Row action: View — navigates to the detail page.
Detail view¶
The detail page is divided into sections.
Identity section¶
| Field | Source | Notes |
|---|---|---|
customers.email |
||
| First Name | customers.first_name |
|
| Last Name | customers.last_name |
|
| Gender | customers.gender |
Badge; — when null |
| Title | customers.title |
— when null |
| Date of Birth | customers.date_of_birth |
Date; — when null |
| Phone | customers.phone |
— when null |
| ZIP | customers.zip |
— when null |
Billing Address section¶
Displays customers.address_billing as a block of Key: value lines. Empty address renders —.
Shipping Address section¶
Displays customers.address_shipping as a block of Key: value lines. Empty address renders —.
Commerce section¶
| Field | Source | Notes |
|---|---|---|
| Order Count | customers.order_count |
Numeric |
| Total Spent | customers.total_spent |
Cents ÷ 100, 2 d.p. |
| Tags | customers.tags |
Badges, comma-separated; — when empty |
Source section¶
| Field | Source | Notes |
|---|---|---|
| Source System | customers.source_system |
Badge |
| Source ID | customers.source_id |
|
| Source Custom ID | customers.source_custom_id |
— when null |
| System Connection | systemConnection.external_user_id |
Consent section¶
Read-only display of the customer's stored consent state — ConsentRecorder remains the only writer, and each row is one connection's own view of the consent (see ADR-0041); nothing in this panel creates, edits or deletes a CustomerConsent row, and there is no Filament resource for the model at all.
The section shows one block per connection, built by App\Services\CustomerConsentViews:
- The customer's own connection always comes first whenever any block exists. Where it holds no consent, its block reads "No consent recorded on this connection".
- Every connection the customer was delivered to, through a customers rule from the customer's connection whose ledger holds a row for the customer with a remote id. That block is the target connection's consent mirror for that remote customer. A target without a mirror row gets no block. Targets are ordered by connection name and limited to the customer's tenant.
Where none of the connections holds consent, the section is a single "No consent recorded" line — deliberately distinct from an unsubscribed channel, for the same reason the list filter keeps the two apart (see Consent filter above).
The section is built from native Filament components: a RepeatableEntry over the views, each mapped to a plain array that carries the connection's name and nothing else of the connection, and a nested RepeatableEntry over its consents. Each block names its connection ("this connection" marks the own one) and lists one card per ConsentChannel:
| Field | Source | Notes |
|---|---|---|
| Channel | channel |
Badge |
| Subscribed | subscribed |
Icon; Unknown when null |
| Confirmed | confirmed |
Icon; Unknown when null, which is every shop row: neither WooCommerce nor PrestaShop can say an address was confirmed |
| Last change | consent_updated_at |
The source's timestamp, which settles a two-way conflict between mirrors; — when null |
Each channel card also shows the opt-in evidence: Subscribed At, Confirmed At, Unsubscribed At, Consent Source (ConsentSource: api, import, admin, user, integration, offline; labelled "Consent Source" rather than "Source" so it is not read as the customer's Source section above) and IP Address, each — when null.
A target block carries a Differs badge where its subscribed or confirmed value differs from the own block's for the same channel and both sides know the value. A null never counts as a difference, since it says nothing. An own block with no rows has nothing to differ from, so no target is marked. A channel the block's connection reports one way only (Positive User's SMS, which only ever reports a withdrawal) carries Last reported, not live next to it and never counts towards Differs: its value is the last withdrawal seen, not the contact's current state.
Soft-deleted consent rows are excluded, both from this section and from the list column/filter — the default Eloquent query scopes out deleted_at. Per ADR-0013 and ADR-0041, a trashed CustomerConsent row carries a deletion_reason and means only that its connection is in the disconnect grace period (or its customer was deleted at the source), never that consent was withdrawn, so surfacing it here would state something false about the customer.
The list column and the Consent filter read only the customer's own mirror (customer_id), never the target blocks: a customer a target holds as subscribed but whose own connection records nothing still reads "No consent recorded" in the list.
Timestamps section¶
| Field | Source |
|---|---|
| Created At | customers.created_at |
| Updated At | customers.updated_at |
Access scoping¶
CustomerResource::getEloquentQuery() limits results to customers belonging to system connections accessible to the authenticated user, via SystemConnection::accessibleBy($user). SuperAdmins see all records; PlatformSupport and Viewer users see the records of every connection under a tenant they reach, sibling connections included — record visibility follows tenant reach, not the full-versus-status-only split that governs actions on the connection itself.
Direct URL navigation to an out-of-scope or soft-deleted record returns a 404 — getRecordRouteBindingEloquentQuery() uses the default Filament binding query (getEloquentQuery()), so the accessibility filter and the SoftDeletingScope both apply.
Source files¶
| File | Purpose |
|---|---|
app/Filament/Resources/Customers/CustomerResource.php |
Resource definition, query scoping |
app/Filament/Resources/Customers/Tables/CustomersTable.php |
Table columns, filters, record action |
app/Filament/Resources/Customers/Schemas/CustomerView.php |
Detail view infolist with address formatting |
app/Filament/Resources/Customers/Pages/ListCustomers.php |
List page |
app/Filament/Resources/Customers/Pages/ViewCustomer.php |
Detail page |
See also¶
- Orders resource — orders reference the customer via
customer_id - System Connections resource: the flow overview links each domain's mirrored count to this resource pre-filtered by connection
- Roles & Permissions — access scoping model
- Integration Contract —
ConsentRecorder— the service that owns every read and write ofCustomerConsent - ADR-0041 — the decision behind the consent model this panel displays