›
›
›
  1. docs
  2. ›
  3. byrcsc/laravel-custom-fields
1.x
Browse documentationOpenClose

Getting started

  • Introduction
  • Installation and setup
  • Quick start

Core concepts

  • Defining fields
  • Field types and storage
  • Tenant scoping
  • Reading and writing values
  • Validation
  • Filtering records

Operations

  • Changing and deleting fields
  • Queries and eager loading
  • Events and listeners

Reference

  • Configuration
  • Console commands
  • Exceptions
  • Testing
  • Troubleshooting

Getting started

  • Introduction
  • Installation and setup
  • Quick start

Core concepts

  • Defining fields
  • Field types and storage
  • Tenant scoping
  • Reading and writing values
  • Validation
  • Filtering records

Operations

  • Changing and deleting fields
  • Queries and eager loading
  • Events and listeners

Reference

  • Configuration
  • Console commands
  • Exceptions
  • Testing
  • Troubleshooting

byrcsc/laravel-custom-fields · 1.x

Filtering records.

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();

Values are cast before they are compared

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.

Tenant scoping

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.

whereCustomFieldIn

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();   // 0

Multi-select fields

Filtering 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.

JSON fields

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.

Which filters use an index

The values table indexes four typed columns alongside the definition id:

ColumnIndexedTypes
string_valueYestext, email, url, select
number_valueYesnumber
boolean_valueYesboolean
date_valueYesdate, datetime
text_valueNotextarea
json_valueNomulti_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.

Defaults are not matched

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 record

To 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();

Ordering

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.

What to read next

  • Queries and eager loading for keeping a filtered listing to a fixed number of queries.
  • Field types and storage for the column each type uses.
  • Exceptions for what each refusal throws.
PreviousValidationNextChanging and deleting fields
View source

On this page

  1. Values are cast before they are compared
  2. Tenant scoping
  3. whereCustomFieldIn
  4. Multi-select fields
  5. JSON fields
  6. Which filters use an index
  7. Defaults are not matched
  8. Ordering
  9. What to read next