byjesper/laravel-custom-fields

PostgreSQL-backed custom fields for Laravel models.

Maintainers

Package info

github.com/byjesper/laravel-custom-fields

pkg:composer/byjesper/laravel-custom-fields

Statistics

Installs: 10

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 4

v2.0.1 2026-06-23 19:31 UTC

This package is auto-updated.

Last update: 2026-06-23 19:33:59 UTC


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_values column. No N+1 joins to read.
  • Typed index table — every change is mirrored to a custom_field_index_values table 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_to so 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/or rule tree.
  • Grouping — two-level groups (group_level_1, group_level_2) and sort_order drive form layout.
  • Multi-tenant aware — opt-in tenant_id and/or team_id context 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

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_definitions and custom_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:0006: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_definitions and custom_field_index_values tables
  • 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.