ArtisanPack UI
DocsCMS FrameworkCustom Fields Developer Guide

Custom Fields Developer Guide

CMS Framework v2.16.0

Custom fields allow you to add additional data to content types in Digital Shopfront CMS. Unlike traditional meta tables, custom fields are stored as...

Overview

Custom fields allow you to add additional data to content types in Digital Shopfront CMS. Unlike traditional meta tables, custom fields are stored as actual database columns for better performance and queryability.

Table of Contents

Understanding Custom Fields

Column-Based Storage

Digital Shopfront CMS stores custom fields as actual database columns, not in a meta table:

Traditional Approach (Meta Table):

posts table: id, title, content
meta table: post_id, meta_key, meta_value

Digital Shopfront CMS Approach (Columns):

posts table: id, title, content, rating, price, featured

Benefits

  • Better Performance: Direct column access, no joins required
  • Type Safety: Database-level type enforcement
  • Indexable: Can create indexes on custom fields
  • Queryable: Standard SQL queries work
  • Simpler Code: Access fields as model properties

Available Field Types

v1.1.0: Field types and column types are now backed by the FieldType and ColumnType enums for type safety. You can use either string values (for backwards compatibility) or enum instances. See developer/enums for the complete reference.

Text Field

Single-line text input.

use ArtisanPackUI\CMSFramework\Modules\ContentTypes\Enums\FieldType;
use ArtisanPackUI\CMSFramework\Modules\ContentTypes\Enums\ColumnType;

[
    'type' => FieldType::Text,          // or 'text'
    'column_type' => ColumnType::String, // or 'string'
]

Use Cases: Names, titles, short descriptions, URLs

Textarea Field

Multi-line text input.

[
    'type' => 'textarea',
    'column_type' => 'text',
]

Use Cases: Long descriptions, notes, addresses

Number Field

Numeric input.

[
    'type' => 'number',
    'column_type' => 'integer',  // or 'decimal', 'float'
]

Use Cases: Quantities, ratings, prices

Boolean Field

True/false toggle.

[
    'type' => 'boolean',
    'column_type' => 'boolean',
]

Use Cases: Featured flag, published status, enabled/disabled

Date Field

Date picker.

[
    'type' => 'date',
    'column_type' => 'date',  // or 'datetime', 'timestamp'
]

Use Cases: Event dates, deadlines, publish dates

Select Field

Dropdown selection.

[
    'type' => 'select',
    'column_type' => 'string',
    'options' => [
        'choices' => ['Option 1', 'Option 2', 'Option 3'],
    ],
]

Use Cases: Status, priority, type selection

Checkbox Field

Multiple selections.

[
    'type' => 'checkbox',
    'column_type' => 'json',
    'options' => [
        'choices' => ['Feature 1', 'Feature 2', 'Feature 3'],
    ],
]

Use Cases: Multiple options, feature flags

Registering Custom Fields

Via Service Provider

<?php

namespace App\Providers;

use ArtisanPackUI\CMSFramework\Modules\ContentTypes\Managers\CustomFieldManager;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        $customFieldManager = app(CustomFieldManager::class);

        $customFieldManager->registerField([
            'name' => 'Price',
            'key' => 'price',
            'type' => 'number',
            'column_type' => 'decimal',
            'description' => 'Product price in USD',
            'content_types' => ['products'],
            'options' => [
                'precision' => 10,
                'scale' => 2,
            ],
            'order' => 1,
            'required' => true,
            'default_value' => '0.00',
        ]);
    }
}

Via Filter Hook

addFilter('ap.contentTypes.registeredCustomFields', function ($fields) {
    $fields['rating'] = [
        'name' => 'Rating',
        'key' => 'rating',
        'type' => 'number',
        'column_type' => 'integer',
        'content_types' => ['products', 'reviews'],
        'options' => [
            'min' => 1,
            'max' => 5,
        ],
        'required' => false,
        'default_value' => 3,
    ];

    return $fields;
});

Via Database/API

use ArtisanPackUI\CMSFramework\Modules\ContentTypes\Models\CustomField;

$field = CustomField::create([
    'name' => 'SKU',
    'key' => 'sku',
    'type' => 'text',
    'column_type' => 'string',
    'content_types' => ['products'],
    'order' => 1,
    'required' => true,
]);

Column Types

String

For short text (up to 255 characters).

'column_type' => 'string'

Migration:

$table->string('field_name')->nullable();

Text

For long text (up to ~65,000 characters).

'column_type' => 'text'

Migration:

$table->text('field_name')->nullable();

Integer

For whole numbers.

'column_type' => 'integer'

Migration:

$table->integer('field_name')->nullable();

Decimal

For precise decimal numbers.

'column_type' => 'decimal'
'options' => ['precision' => 10, 'scale' => 2]

Migration:

$table->decimal('field_name', 10, 2)->nullable();

Boolean

For true/false values.

'column_type' => 'boolean'

Migration:

$table->boolean('field_name')->default(false);

Date

For dates without time.

'column_type' => 'date'

Migration:

$table->date('field_name')->nullable();

DateTime

For dates with time.

'column_type' => 'datetime'

Migration:

$table->datetime('field_name')->nullable();

JSON

For structured data.

'column_type' => 'json'

Migration:

$table->json('field_name')->nullable();

Migration Generation

Automatic Migration

When you create a custom field, a migration is automatically generated:

$customFieldManager = app(CustomFieldManager::class);

$field = $customFieldManager->createField([
    'name' => 'Price',
    'key' => 'price',
    'type' => 'number',
    'column_type' => 'decimal',
    'content_types' => ['products'],
    'required' => false,
]);

// Migration automatically created in database/migrations/

Rejected keys

Since 2.7.1, createField() throws InvalidArgumentException when the key is unavailable, and CustomFieldRequest surfaces the same conflict as a field-level validation error so the admin UI shows a message rather than a 500. Both call CustomFieldManager::findKeyConflict(), so the API and the UI cannot drift apart. A key is rejected when it:

  • appears in CustomFieldManager::RESERVED_FIELD_KEYS (id, author_id, status, published_at, slug, parent_id, metadata, password, created_at, updated_at, deleted_at, user_id, uuid, remember_token); or
  • already names a column on one of the target content types' tables.

The reason is that addColumnToTable() silently returns when the column already exists. Without the check, creating a field named author_id did not fail — the existing column was adopted as a custom field, and from then on any content editor's custom_fields[author_id] wrote the real column through the DB-persisted-field exemption. That is a permanent, quiet escalation from "manage custom fields" to "reassign authorship of any post", and a footgun even without a malicious actor.

Generated Migration Example

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('products', function (Blueprint $table) {
            $table->decimal('price', 10, 2)->nullable();
        });
    }

    public function down(): void
    {
        Schema::table('products', function (Blueprint $table) {
            $table->dropColumn('price');
        });
    }
};

Manual Migration Generation

Generate a migration manually:

$customFieldManager = app(CustomFieldManager::class);
$field = CustomField::where('key', 'price')->first();

$migrationPath = $customFieldManager->generateMigration($field, 'add');
// Returns: database/migrations/2025_01_13_120000_add_price_to_content_types.php

Running Migrations

# Run all pending migrations
php artisan migrate

# Rollback last migration
php artisan migrate:rollback

# Rollback all migrations
php artisan migrate:reset

Retrieving and Displaying Values

Direct Column Access

Access custom fields as regular model properties:

$product = Product::find(1);

// Get custom field value
$price = $product->price;
$rating = $product->rating;
$featured = $product->featured;

// Set custom field value
$product->price = 29.99;
$product->rating = 5;
$product->featured = true;
$product->save();

In Queries

Use custom fields in queries:

// Where clause
$products = Product::where('price', '>', 100)->get();

// Order by
$products = Product::orderBy('rating', 'desc')->get();

// Select specific fields
$products = Product::select('id', 'title', 'price', 'rating')->get();

// Multiple conditions
$products = Product::where('featured', true)
    ->where('price', '<', 50)
    ->orderBy('rating', 'desc')
    ->get();

In Blade Templates

<div class="product">
    <h2>{{ $product->title }}</h2>
    <p>Price: ${{ number_format($product->price, 2) }}</p>
    <p>Rating: {{ $product->rating }}/5</p>

    @if($product->featured)
        <span class="badge">Featured</span>
    @endif
</div>

With Eloquent Relationships

// Eager load with custom fields
$products = Product::with('categories')
    ->where('price', '<', 100)
    ->get();

// Filter relationships
$category = Category::find(1);
$products = $category->products()
    ->where('featured', true)
    ->orderBy('price', 'asc')
    ->get();

Applying Values From Untrusted Input

A custom-field payload that came from request input must be applied with applyCustomFieldValues(), not by assigning each key directly:

// Correct — drops keys that shadow a real column.
$post->applyCustomFieldValues($request->input('custom_fields', []));

// Unsafe — `custom_fields[author_id]` would overwrite the real column.
foreach ($request->input('custom_fields', []) as $key => $value) {
    $post->{$key} = $value;
}

The magic setter routes a metadata-storage field into the model's metadata JSON column, but a key naming a real column falls through to Eloquent and writes that column — and the custom-field half of a payload gets no fillable filtering.

applyCustomFieldValues() is an allowlist. A key is applied only when it names a custom field actually registered for the model's content type; every other key is dropped and logged. In particular, all of these are dropped:

  • an unknown key, whether or not it names a column;
  • a key naming a real DB column, cast, mutator, accessor, or relation;
  • a case variant of one of those — AUTHOR_ID resolves to author_id on MySQL and SQLite, which treat identifiers case-insensitively;
  • a JSON-path key such as metadata->x or title->x;
  • one of Eloquent's own properties (table, exists, attributes, …).

Values are assigned through setAttribute(), never through a dynamic property write, so a payload can never reach the model's internal state.

DB-registered fields are the exception on the column half: they own the column they name, so their values still write through. Filter-registered fields never qualify for that exemption, even if the registration declares 'storage' => 'column' — only a row in the custom_fields table can authorize a real-column write. Since 2.7.1 such a row can no longer be created with a key that already names a column on the target table, nor with a framework-reserved key (id, author_id, status, published_at, slug, parent_id, metadata, password, …) — see CustomFieldManager::RESERVED_FIELD_KEYS.

Dropped keys emit a Log::warning carrying the key, the reason, and the model, so a plugin author whose field "just doesn't save" has a breadcrumb, and an operator has a signal that someone is probing custom_fields[author_id].

BlogManager::create()/update() and PageManager::create()/update() apply their $customFields argument through this method already, so callers passing custom fields to a manager are covered without doing anything.

Direct assignment from your own code is unaffected — $post->title = 'New' still writes the column even when a plugin has registered title as a custom field. Only the payload path is guarded, so a registration can never block a host model from writing its own columns.

Data Migration

Changing Field Types

When changing a custom field's type, data migration may be needed:

Example: String to Integer

// Before: price stored as string "29.99"
// After: price should be decimal 29.99

// 1. Create new migration
Schema::table('products', function (Blueprint $table) {
    $table->decimal('price_new', 10, 2)->nullable();
});

// 2. Migrate data
Product::chunk(100, function ($products) {
    foreach ($products as $product) {
        $product->price_new = floatval($product->price);
        $product->save();
    }
});

// 3. Drop old column, rename new
Schema::table('products', function (Blueprint $table) {
    $table->dropColumn('price');
    $table->renameColumn('price_new', 'price');
});

Handling Null Values

// Set default for existing records
Product::whereNull('rating')->update(['rating' => 0]);

// Or in migration
Schema::table('products', function (Blueprint $table) {
    $table->integer('rating')->default(0)->change();
});

Converting JSON to Columns

// Before: metadata JSON {'color': 'red', 'size': 'large'}
// After: color and size columns

Schema::table('products', function (Blueprint $table) {
    $table->string('color')->nullable();
    $table->string('size')->nullable();
});

Product::chunk(100, function ($products) {
    foreach ($products as $product) {
        $metadata = $product->metadata;
        $product->color = $metadata['color'] ?? null;
        $product->size = $metadata['size'] ?? null;
        $product->save();
    }
});

Best Practices

1. Use Descriptive Keys

✓ 'key' => 'product_price'
✓ 'key' => 'event_start_date'
✗ 'key' => 'price1'
✗ 'key' => 'field_1'

2. Choose Appropriate Column Types

// For prices
'column_type' => 'decimal'  // Not 'string' or 'float'

// For flags
'column_type' => 'boolean'  // Not 'integer' or 'string'

// For dates
'column_type' => 'date'     // Not 'string'

3. Set Sensible Defaults

'default_value' => 0,        // For numbers
'default_value' => false,    // For booleans
'default_value' => '',       // For strings

4. Use Required Sparingly

'required' => false,  // Most fields should be optional

5. Add Descriptions

'description' => 'Product price in USD. Enter without currency symbol.',

6. Order Fields Logically

'order' => 1,  // Display order in forms
'order' => 2,
'order' => 3,

7. Index Heavy-Use Fields

// In migration
Schema::table('products', function (Blueprint $table) {
    $table->decimal('price', 10, 2)->nullable()->index();
    $table->boolean('featured')->default(false)->index();
});

Examples

Product Price

$customFieldManager->registerField([
    'name' => 'Price',
    'key' => 'price',
    'type' => 'number',
    'column_type' => 'decimal',
    'description' => 'Product price in USD',
    'content_types' => ['products'],
    'options' => ['precision' => 10, 'scale' => 2],
    'order' => 1,
    'required' => true,
    'default_value' => '0.00',
]);

Event Date

$customFieldManager->registerField([
    'name' => 'Event Date',
    'key' => 'event_date',
    'type' => 'date',
    'column_type' => 'datetime',
    'description' => 'When the event starts',
    'content_types' => ['events'],
    'order' => 1,
    'required' => true,
]);
$customFieldManager->registerField([
    'name' => 'Featured',
    'key' => 'featured',
    'type' => 'boolean',
    'column_type' => 'boolean',
    'description' => 'Show on homepage',
    'content_types' => ['products', 'posts'],
    'order' => 10,
    'required' => false,
    'default_value' => false,
]);

Product Rating

$customFieldManager->registerField([
    'name' => 'Rating',
    'key' => 'rating',
    'type' => 'number',
    'column_type' => 'integer',
    'description' => 'Product rating (1-5)',
    'content_types' => ['products'],
    'options' => ['min' => 1, 'max' => 5],
    'order' => 5,
    'required' => false,
    'default_value' => 3,
]);

Deleting Custom Fields

Via Manager

$customFieldManager = app(CustomFieldManager::class);
$customFieldManager->deleteField($fieldId);

// Automatically:
// 1. Removes column from all content type tables
// 2. Generates migration
// 3. Deletes field from database

Warning

Deleting a custom field permanently removes data. Always backup before deleting.

See Also