PHPackages                             byjesper/laravel-custom-fields - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Database &amp; ORM](/categories/database)
4. /
5. byjesper/laravel-custom-fields

ActiveLibrary[Database &amp; ORM](/categories/database)

byjesper/laravel-custom-fields
==============================

PostgreSQL-backed custom fields for Laravel models.

v2.0.1(1mo ago)010[3 issues](https://github.com/byjesper/laravel-custom-fields/issues)[1 PRs](https://github.com/byjesper/laravel-custom-fields/pulls)1MITPHPPHP ^8.4CI passing

Since May 28Pushed 1mo agoCompare

[ Source](https://github.com/byjesper/laravel-custom-fields)[ Packagist](https://packagist.org/packages/byjesper/laravel-custom-fields)[ RSS](/packages/byjesper-laravel-custom-fields/feed)WikiDiscussions main Synced 2w ago

READMEChangelogDependencies (30)Versions (10)Used By (1)

Laravel Custom Fields
=====================

[](#laravel-custom-fields)

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

[](#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
------------

[](#requirements)

- PHP **8.4+**
- Laravel **13.x** (`illuminate/*` ^13.0)
- PostgreSQL (uses JSONB, GIN indexes, and partial unique indexes via [`tpetry/laravel-postgresql-enhanced`](https://github.com/tpetry/laravel-postgresql-enhanced))

Installation
------------

[](#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

[](#adding-the-jsonb-value-column-to-your-models-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
-----------

[](#quick-start)

### 1. Register the entity

[](#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

[](#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

[](#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

[](#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

[](#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
-----------

[](#field-types)

TypeIndex columnNotes`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-indexedTemporal validation
-------------------

[](#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

[](#custom-types)

Implement `ByJesper\LaravelCustomFields\Contracts\CustomFieldTypeHandler` and add the class to `custom-fields.types.handlers`.

Conditional visibility
----------------------

[](#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`](https://packagist.org/packages/byjesper/laravel-custom-fields-filament)).

Multi-tenancy and teams
-----------------------

[](#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
--------

[](#rest-api)

Disabled by default. To enable:

```
'api' => [
    'enabled'     => true,
    'prefix'      => 'api/custom-fields',
    'name_prefix' => 'custom-fields.',
    'middleware' => ['api', 'auth:sanctum'],
],
```

Routes:

MethodURIActionGET`definitions`List definitionsPOST`definitions`Create definitionGET`definitions/{id}`Show definitionPUT`definitions/{id}`Update definitionDELETE`definitions/{id}`Delete definitionPUT`entities/{entityType}/{entityId}/values`Update an entity's values### Authorization

[](#authorization)

Wire policies in config:

```
'authorization' => [
    'definitions' => \App\Policies\CustomFieldDefinitionPolicy::class,
    'values'      => \App\Policies\CustomFieldValuePolicy::class,
],
```

Console commands
----------------

[](#console-commands)

CommandPurpose`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
-------------

[](#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      │
└────────────────────────────┘

┌──────────────────────────────────┐
│  (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
-----------------

[](#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`](https://packagist.org/packages/byjesper/laravel-custom-fields-filament).

Development
-----------

[](#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
-------

[](#license)

MIT — see [LICENSE.md](LICENSE.md).

###  Health Score

43

—

FairBetter than 89% of packages

Maintenance90

Actively maintained with recent releases

Popularity5

Limited adoption so far

Community10

Small or concentrated contributor base

Maturity57

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~3 days

Total

8

Last Release

45d ago

Major Versions

v1.2.0 → v2.0.02026-06-23

### Community

Maintainers

![](https://www.gravatar.com/avatar/7fe91059b591a5df0a6c3a69bd89e13875e03a2dcc0e046125181b6edd72b92a?d=identicon)[Yezper](/maintainers/Yezper)

---

Top Contributors

[![Yezper](https://avatars.githubusercontent.com/u/9988723?v=4)](https://github.com/Yezper "Yezper (24 commits)")

###  Code Quality

TestsPest

Static AnalysisPHPStan, Rector

Code StyleLaravel Pint

Type Coverage Yes

### Embed Badge

![Health badge](/badges/byjesper-laravel-custom-fields/health.svg)

```
[![Health](https://phpackages.com/badges/byjesper-laravel-custom-fields/health.svg)](https://phpackages.com/packages/byjesper-laravel-custom-fields)
```

###  Alternatives

[psalm/plugin-laravel

Psalm plugin for Laravel

3345.4M353](/packages/psalm-plugin-laravel)[api-platform/laravel

API Platform support for Laravel

58174.6k18](/packages/api-platform-laravel)[laravel/mcp

Rapidly build MCP servers for your Laravel applications.

79227.1M206](/packages/laravel-mcp)[fleetbase/core-api

Core Framework and Resources for Fleetbase API

1239.7k25](/packages/fleetbase-core-api)[pressbooks/pressbooks

Pressbooks is an open source book publishing tool built on a WordPress multisite platform. Pressbooks outputs books in multiple formats, including PDF, EPUB, web, and a variety of XML flavours, using a theming/templating system, driven by CSS.

45844.8k1](/packages/pressbooks-pressbooks)[masterix21/laravel-licensing

Laravel licensing package with polymorphic assignment to any model, activation keys, expirations/renewals, and seat control via LicenseUsage. Supports offline verification with public-key–signed tokens, a CLI to generate/rotate/revoke keys, and an extensible architecture via config and contracts.

1614.1k4](/packages/masterix21-laravel-licensing)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
