Skip to content

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
Email 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).

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
Email 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

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:

  1. 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".
  2. 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