›
byrcsc/laravel-custom-fields · 1.x
Find records by a custom field value with two query scopes that compile to an indexed column comparison.
The trait adds two scopes to the model:
Customer::query()->whereCustomField('account_tier', 'premium');
Customer::query()->whereCustomFieldIn('account_tier', ['standard', 'premium']);Both compile to a comparison on one typed column inside an exists clause over the record's value rows, narrowed to one field:
select * from "customers" where exists (
select * from "custom_field_values"
where "customers"."id" = "custom_field_values"."model_id"
and "custom_field_values"."model_type" = ?
and "custom_field_definition_id" = ?
and "string_value" = ?
and "tenant" is null
)That is the query under the null resolver. Acting for a tenant, the last clause
becomes "tenant" = ?.
The shape is the reason the package stores each type in its own column. A comparison against a typed column is something an index can serve; a comparison against a JSON path is not.
Both scopes compose with everything else on the query, including each other:
Customer::query()
->where('active', true)
->whereCustomField('account_tier', 'premium')
->whereCustomFieldIn('region', ['brisbane', 'sydney'])
->orderByDesc('created_at')
->paginate();The value you pass goes through the same casting a write does, so
whereCustomField('onboarded_on', '2026-08-25') compares a date to a date, and
whereCustomField('annual_revenue', '125000') finds records holding the number 125000.
A value the field could not store throws InvalidValueException, the same
exception writing it would throw. A filter that quietly matched nothing because
the value was the wrong shape is a bug that survives a release.
Both scopes resolve the field through the current tenant's definitions, so filtering names the field that tenant can see. Where a tenant has overridden a global field, the filter runs against the tenant's field.
The value rows are scoped to the current tenant too, which is what matters for a
global definition shared by several tenants: a filter under acme matches
acme's values and not globex's.
Filtering by a key the current tenant has no definition for throws
UnknownCustomFieldException.
Takes a list and matches a record holding any of its values. It is for single-value types only.
An empty list matches nothing rather than everything, which is what Laravel's
own whereIn does with no values.
Customer::query()->whereCustomFieldIn('account_tier', [])->count(); // 0Filtering a multi-select means contains. Pass one option, and the scope matches every record whose list holds it:
Customer::query()->whereCustomField('service_interests', 'implementation')->get();Passing a list instead throws UnfilterableFieldException, pointing at the
single-option form:
The [service_interests] field is a multi_select, so a filter on it asks which records contain
one option. Pass that option on its own rather than a list of them.whereCustomFieldIn() refuses a multi-select outright. A multi-select is a list
already, and "is any of these lists" is not the question anyone is asking. To
match records carrying any of several options, chain the contains form:
use Illuminate\Database\Eloquent\Builder;
Customer::query()
->where(fn (Builder $query) => $query->whereCustomField('service_interests', 'implementation'))
->orWhere(fn (Builder $query) => $query->whereCustomField('service_interests', 'training'))
->get();The scopes are where scopes, so there is no orWhereCustomField. Nesting each
one in a closure is how they are combined with or.
A json field cannot be filtered by either scope, and throws
UnfilterableFieldException:
The [payload] field is a json and cannot be filtered by equality. Comparing a
JSON column to a whole value differs across MySQL, PostgreSQL, and SQLite, down
to key order and whitespace.The package refuses rather than compiling something that returns different rows
on different engines. If you need to query inside a JSON field, query
custom_field_values yourself with your database's own JSON operators, and
accept that the query is engine-specific.
The values table indexes four typed columns alongside the definition id:
| Column | Indexed | Types |
|---|---|---|
string_value | Yes | text, email, url, select |
number_value | Yes | number |
boolean_value | Yes | boolean |
date_value | Yes | date, datetime |
text_value | No | textarea |
json_value | No | multi_select, json |
text_value and json_value carry no index because no portable index covers a
column wide enough to hold them. Filtering a textarea, or a multi_select by
contains, scans the value rows for that field. That is fine at the sizes most
applications reach, and worth knowing before you put a contains filter on an
index page.
If a field is going to be filtered often, text is the type to reach for rather
than textarea.
A filter matches value rows. A record reading a field's default has no row, so it is not returned:
CustomFields::define(Customer::class, [
'key' => 'account_tier',
'type' => FieldType::Select,
'options' => ['standard', 'premium'],
'default' => 'standard',
]);
Customer::query()->whereCustomField('account_tier', 'standard')->get();
// only the records that were written 'standard', not every unset recordTo include records that never set the field, ask for the absence of a row alongside the comparison:
$definition = CustomFields::definition(Customer::class, 'account_tier');
Customer::query()
->where(function (Builder $query) use ($definition): void {
$query->whereCustomField('account_tier', 'standard')
->orWhereDoesntHave(
'customFieldValues',
fn (Builder $rows) => $rows->where('custom_field_definition_id', $definition->id),
);
})
->get();There is no scope for ordering by a custom field value, and it is out of
scope. Sort in PHP after loading the page,
or join custom_field_values yourself when the page is large enough that it
matters.