byjesper/laravel-custom-fields
Composer 安装命令:
composer require byjesper/laravel-custom-fields
包简介
PostgreSQL-backed custom fields for Laravel models.
README 文档
README
PostgreSQL-backed custom fields for Laravel models.
Attach user-defined fields to any Eloquent model, store them as JSONB on the model itself, and keep a typed, queryable history of values in a side index table. Optionally scoped by tenant and/or team.
Features
- JSONB storage on the parent model — values live in a single
custom_field_valuescolumn. No N+1 joins to read. - Typed index table — every change is mirrored to a
custom_field_index_valuestable with typed columns (value_string,value_integer,value_decimal,value_boolean,value_date,value_datetime,value_time,value_uuid,value_text,value_json) so you can filter, sort, and report efficiently. - Temporal history — index rows are append-only with
valid_from/valid_toso the full history of every field value is preserved. - 15 built-in field types — string, text, integer, decimal, boolean, date,
datetime, time, date range, datetime range, time range, select, multi-select,
relationship, json. Bring your own by implementing
CustomFieldTypeHandler. - Temporal validation — date/datetime/time fields support fixed and relative constraints, time step validation, and first-class range fields.
- Conditional visibility — show/hide fields based on other field values
using an
and/orrule tree. - Grouping — two-level groups (
group_level_1,group_level_2) andsort_orderdrive form layout. - Multi-tenant aware — opt-in
tenant_idand/orteam_idcontext columns that scope definitions and index rows. - Optional REST API — apiResource endpoints for managing definitions and updating per-entity values.
- Built-in query builder helper — apply filters against the index table
with operators (
=,!=,<,>,<=,>=,in,like,contains,range,range_contains,range_overlaps,range_within,is_null,is_not_null).
Requirements
- PHP 8.4+
- Laravel 13.x (
illuminate/*^13.0) - PostgreSQL (uses JSONB, GIN indexes, and partial unique indexes via
tpetry/laravel-postgresql-enhanced)
Installation
composer require byjesper/laravel-custom-fields
Publish config and migrations:
php artisan custom-fields:install
This publishes:
config/custom-fields.php- Two migrations:
custom_field_definitionsandcustom_field_index_values
Then run migrations:
php artisan migrate
Adding the JSONB value column to your model's table
Each model that holds custom fields needs a JSONB column to store its values
(default name custom_field_values):
Schema::table('contacts', function (Blueprint $table): void { $table->jsonb('custom_field_values')->default('{}'); });
Quick start
1. Register the entity
In config/custom-fields.php:
'entities' => [ 'enabled' => ['contact'], 'models' => [ 'contact' => \App\Models\Contact::class, ], ],
2. Add the trait to your model
use Illuminate\Database\Eloquent\Model; use ByJesper\LaravelCustomFields\Concerns\HasCustomFields; class Contact extends Model { use HasCustomFields; // Optional override — defaults to the key in custom-fields.entities.models // protected string $customFieldEntityType = 'contact'; }
The trait casts custom_field_values to array and gives you helpers:
$contact->getCustomFieldValues(); // ['phone' => ['value' => '...'], ...] $contact->getCustomFieldValue('phone'); // '...' $contact->setCustomFieldValue('phone', '...'); $contact->getCustomFieldDefinitions(); // active definitions for the entity $contact->validateCustomFields($input); // throws ValidationException
The service provider attaches an observer to every model registered in
custom-fields.entities.models that syncs changes to the index table on
saved.
3. Define some fields
Definitions are stored as Eloquent models, so you can seed or create them programmatically:
use ByJesper\LaravelCustomFields\Models\CustomFieldDefinition; CustomFieldDefinition::create([ 'entity_type' => 'contact', 'field_name' => 'lifetime_value', // [a-z0-9_]+ enforced by DB 'field_label' => ['en' => 'Lifetime value', 'de' => 'Customer Lifetime Value'], 'field_type' => 'decimal', 'config' => ['scale' => 2], 'validation_rules' => ['required' => true, 'min' => 0], 'group_level_1' => 'Finance', 'group_level_2' => 'Revenue', 'sort_order' => 10, 'is_active' => true, ]);
4. Read and write values
$contact->setCustomFieldValue('lifetime_value', 1234.50); $contact->save(); // observer mirrors the change into custom_field_index_values $contact->getCustomFieldValue('lifetime_value'); // 1234.50
5. Filter on a custom field
Contact::query() ->whereCustomField('lifetime_value', '>=', 1000) ->whereCustomField('segment', 'vip') // shorthand for = ->get();
Multiple filters at once:
Contact::query() ->whereCustomFields([ 'lifetime_value' => ['>=', 1000], 'segment' => ['in', ['vip', 'gold']], 'active' => true, // bare values use = ]) ->get();
In shorthand filters, a two-item list whose first item is a supported operator
is treated as [operator, value]. Use the normalized shape below when you need
an equality comparison against a list value that starts with an operator string.
Use explicit null operators instead of bare null values:
Contact::query() ->whereCustomField('archived_at', 'is_null') ->get();
The lower-level service still accepts the normalized filter shape, which is useful for generated code or API payloads:
use ByJesper\LaravelCustomFields\Services\CustomFieldQueryBuilder; app(CustomFieldQueryBuilder::class)->applyFilters(Contact::query(), 'contact', [ ['field' => 'lifetime_value', 'operator' => '>=', 'value' => 1000], ['field' => 'segment', 'operator' => 'in', 'value' => ['vip', 'gold']], ]);
Field types
| Type | Index column | Notes |
|---|---|---|
string |
value_string |
Short text, max 512 chars |
text |
value_text |
Long text |
integer |
value_integer |
bigint |
decimal |
value_decimal |
Precision/scale from decimal config |
boolean |
value_boolean |
|
date |
value_date |
Y-m-d |
datetime |
value_datetime |
Normalized through the app timezone |
time |
value_time |
HH:MM[:SS]; optional config.step_minutes |
date_range |
value_json |
['start' => 'Y-m-d', 'end' => 'Y-m-d'] |
datetime_range |
value_json |
['start' => 'Y-m-d H:i:s', 'end' => '...'] |
time_range |
value_json |
['start' => 'HH:MM:SS', 'end' => 'HH:MM:SS'] |
select |
value_string |
config.options; alias enum |
multi_select |
value_json |
config.options; GIN-indexed |
relationship |
value_uuid |
config.target_entity, config.display_field |
json |
value_json |
GIN-indexed |
Temporal validation
Temporal validation rules live in validation_rules. Scalar date and
datetime fields support fixed bounds and relative bounds from today or
now:
'validation_rules' => [ 'required' => false, 'min' => ['type' => 'relative', 'anchor' => 'today', 'offset' => 0, 'unit' => 'days'], 'max' => ['type' => 'relative', 'anchor' => 'today', 'offset' => 5, 'unit' => 'years'], ],
Date fields use the today anchor. Datetime fields use the now anchor and
the app timezone. Supported relative units are minutes, hours, days,
weeks, months, and years.
Time fields support fixed bounds and optional step validation:
'config' => ['step_minutes' => 15], 'validation_rules' => [ 'min' => ['type' => 'fixed', 'value' => '08:00'], 'max' => ['type' => 'fixed', 'value' => '17:00'], ],
Range fields store a start / end JSON object in value_json, and temporal
min / max rules are applied to both endpoints. time_range disallows
overnight ranges by default; set config.allow_overnight to true when ranges
like 22:00 → 06:00 should be valid.
Custom types
Implement ByJesper\LaravelCustomFields\Contracts\CustomFieldTypeHandler and add
the class to custom-fields.types.handlers.
Conditional visibility
'conditional_visibility' => [ 'operator' => 'and', // 'and' | 'or' 'conditions' => [ ['field' => 'subscribed', 'op' => 'truthy'], ['field' => 'plan', 'op' => 'in', 'value' => ['gold', 'platinum']], ], ],
Supported operators: eq, neq, in, notIn, truthy, falsy. Evaluation
happens in the consuming UI layer (see
byjesper/laravel-custom-fields-filament).
Multi-tenancy and teams
Both are off by default. To enable, flip them in config and run the additive migration generators:
php artisan custom-fields:setup-tenancy php artisan custom-fields:setup-teams php artisan migrate
'tenancy' => [ 'enabled' => true, 'column' => 'tenant_id', 'type' => 'uuid', // 'uuid' | 'bigint' 'model' => \App\Models\Tenant::class, 'create_foreign_keys' => true, 'foreign_key_table' => 'tenants', 'resolver' => null, // optional callable: fn () => Auth::user()?->tenant_id ], 'teams' => [ 'enabled' => true, // ...same shape as tenancy ],
When enabled, the context column(s) are:
- Added to both the
custom_field_definitionsandcustom_field_index_valuestables - Included in unique/composite indexes
- Automatically applied when resolving definitions, reading definitions/index values, and writing index rows
The active context is resolved via ContextResolver. The default
ConfigContextResolver reads from config('custom-fields.tenancy.resolver')
and config('custom-fields.teams.resolver'). Bind your own implementation in
a service provider:
$this->app->singleton(ContextResolver::class, MyContextResolver::class);
When tenant or team support is enabled and the active context resolves a value,
the package models apply a global scope to CustomFieldDefinition and
CustomFieldIndexValue. Administrative or cross-context tooling can opt out
with withoutGlobalScope(\ByJesper\LaravelCustomFields\Scopes\ContextScope::class).
If an enabled tenant or team context cannot be resolved, scoped reads fail closed
and return no records.
Console commands, queued jobs, and administrative tooling that read package
models outside a resolvable request context must set an active context or call
withoutGlobalScope(\ByJesper\LaravelCustomFields\Scopes\ContextScope::class).
Use database-level isolation such as PostgreSQL RLS as defense-in-depth where
available.
REST API
Disabled by default. To enable:
'api' => [ 'enabled' => true, 'prefix' => 'api/custom-fields', 'name_prefix' => 'custom-fields.', 'middleware' => ['api', 'auth:sanctum'], ],
Routes:
| Method | URI | Action |
|---|---|---|
| GET | definitions |
List definitions |
| POST | definitions |
Create definition |
| GET | definitions/{id} |
Show definition |
| PUT | definitions/{id} |
Update definition |
| DELETE | definitions/{id} |
Delete definition |
| PUT | entities/{entityType}/{entityId}/values |
Update an entity's values |
Authorization
Wire policies in config:
'authorization' => [ 'definitions' => \App\Policies\CustomFieldDefinitionPolicy::class, 'values' => \App\Policies\CustomFieldValuePolicy::class, ],
Console commands
| Command | Purpose |
|---|---|
custom-fields:install |
Publish config and migrations. |
custom-fields:setup-tenancy |
Generate an additive migration adding tenant_id context columns. |
custom-fields:setup-teams |
Generate an additive migration adding team_id context columns. |
custom-fields:rebuild-index |
Rebuild the custom_field_index_values table from current JSONB. |
Storage model
┌────────────────────────────┐ ┌──────────────────────────────────┐
│ custom_field_definitions │ │ custom_field_index_values │
│ ────────────────────────── │ │ ──────────────────────────────── │
│ id (uuid) │◄────────┤ definition_id (uuid) │
│ tenant_id / team_id │ │ tenant_id / team_id │
│ entity_type, field_name │ │ entity_type, entity_id (uuid) │
│ field_type │ │ value_string, value_integer, ... │
│ config (jsonb) │ │ valid_from, valid_to │
│ validation_rules (jsonb) │ │ timestamps │
│ conditional_visibility │ └──────────────────────────────────┘
│ default_value, group_* │
│ sort_order, is_active │
└────────────────────────────┘
┌──────────────────────────────────┐
│ <your model> (e.g. contacts) │
│ ──────────────────────────────── │
│ id │
│ custom_field_values (jsonb) │ ← canonical storage
│ ... │
└──────────────────────────────────┘
The JSONB column on the parent model is the source of truth. The index table
is a derived, queryable projection synced via the CustomFieldIndexObserver.
If they drift, run custom-fields:rebuild-index.
Companion package
For a ready-made Filament v5 admin UI (definition CRUD, form/table column
generators, conditional-visibility validation), install
byjesper/laravel-custom-fields-filament.
Development
The following Composer scripts are available for local quality checks:
# Format code automatically composer lint # Run all checks that CI runs composer test # Individual checks composer test:lint # Rector + Pint (dry-run) composer test:type:check # PHPStan Level 8 composer test:unit # Pest unit tests # Additional scripts (enforced by #4/#7) composer test:parallel # Parallel unit tests composer test:integration # Integration tests (PostgreSQL-backed) composer test:type:coverage # Type coverage with Pest composer update:snapshots # Update Pest snapshots
The composer test aggregate runs the full package quality gate: lint,
type-check, type coverage, unit tests, parallel tests, and integration tests.
PostgreSQL-backed integration tests run when the following environment variables are present:
CUSTOM_FIELDS_INTEGRATION_DB_CONNECTION=pgsql CUSTOM_FIELDS_INTEGRATION_DB_HOST=127.0.0.1 CUSTOM_FIELDS_INTEGRATION_DB_PORT=5432 CUSTOM_FIELDS_INTEGRATION_DB_DATABASE=custom_fields_test CUSTOM_FIELDS_INTEGRATION_DB_USERNAME=postgres CUSTOM_FIELDS_INTEGRATION_DB_PASSWORD=postgres
When those variables are absent, PostgreSQL-specific integration tests are skipped locally. CI provides a PostgreSQL service and runs them.
License
MIT — see LICENSE.md.
统计信息
- 总下载量: 0
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 0
- 依赖项目数: 1
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-06-23