Skip to content

Instantly share code, notes, and snippets.

@pappu687
Created December 10, 2025 09:54
Show Gist options
  • Select an option

  • Save pappu687/d8bdd3f7499066fb88d478bb8009634c to your computer and use it in GitHub Desktop.

Select an option

Save pappu687/d8bdd3f7499066fb88d478bb8009634c to your computer and use it in GitHub Desktop.

In an Accounting SaaS App based on laravel, I need to implement a full featured custom field functionality to be added to different table.

The attached images is from smart office. So they have a list, and clicking the list brings up the custom field designer with some props. And this can be added to the the Create edit screen.

Suggest me the best way to achieve this. I'm using laravel 12, blade, bootstrap, jQuery. Suggest me both from the architechture point of view and also from the UI as well.


Here’s a pattern that works well for “custom fields anywhere in the app” in Laravel + Blade + Bootstrap + jQuery.


1. Data model / architecture

a) What you want to support

  • Different modules: Customer, Invoice, Product, etc.

  • Many field types: text, number, date, dropdown, checkbox, file, etc.

  • Per-record values.

  • Designer UI to create/update fields and order them.

  • Easy inclusion on create/edit forms.

This is essentially a small EAV system, but just for custom fields (your core columns stay normal).


b) Tables

1. custom_field_groups (optional but nice)
Groups fields by module / context.

Schema::create('custom_field_groups', function (Blueprint $table) {
    $table->id();
    $table->string('name');          // "Customers", "Invoices"
    $table->string('model_type');    // e.g. App\Models\Customer
    $table->string('slug')->unique();
    $table->timestamps();
});

2. custom_fields — defines the fields

Schema::create('custom_fields', function (Blueprint $table) {
    $table->id();
    $table->foreignId('group_id')->constrained('custom_field_groups');
    $table->string('name');              // internal key: 'vat_number'
    $table->string('label');             // "VAT Number"
    $table->string('type');              // 'text','number','date','dropdown',...
    $table->boolean('required')->default(false);
    $table->unsignedInteger('position')->default(0);

    $table->json('settings')->nullable();
    // e.g. {"options":["Option 1","Option 2"],"min":0,"max":999}

    $table->string('help_text')->nullable();
    $table->boolean('is_active')->default(true);
    $table->timestamps();
});

3. custom_field_values — stores actual values

Schema::create('custom_field_values', function (Blueprint $table) {
    $table->id();
    $table->foreignId('custom_field_id')->constrained('custom_fields');

    // Link to *any* model using polymorphic relation
    $table->morphs('valuable');     // valuable_type, valuable_id

    $table->text('value')->nullable();  // store as string/JSON, cast later
    $table->timestamps();
});

Storing in a single value text column is easiest; you can cast to appropriate type in PHP when needed.


c) Model layer

Trait for models that can have custom fields

// app/Models/Concerns/HasCustomFields.php
trait HasCustomFields
{
    public function customFieldValues()
    {
        return $this->morphMany(CustomFieldValue::class, 'valuable');
    }

    public function customFields()
    {
        // via group->fields
        return CustomField::whereHas('group', function ($q) {
            $q->where('model_type', static::class);
        })->orderBy('position');
    }

    public function getCustomFieldValue($fieldName, $default = null)
    {
        $field = CustomField::where('name', $fieldName)
            ->whereHas('group', fn ($q) => $q->where('model_type', static::class))
            ->first();

        if (! $field) return $default;

        $value = $this->customFieldValues
            ->firstWhere('custom_field_id', $field->id);

        return $value?->casted_value ?? $default;
    }
}

CustomFieldValue can have an accessor casted_value that converts based on field type.

Service to handle saving/validation

class CustomFieldService
{
    public function getFieldsForModel(string $modelClass)
    {
        return CustomField::whereHas('group', fn($q) => $q->where('model_type', $modelClass))
            ->where('is_active', true)
            ->orderBy('position')
            ->get();
    }

    public function validationRules(Collection $fields): array
    {
        $rules = [];

        foreach ($fields as $field) {
            $name = "custom_fields.{$field->id}";
            $fieldRules = [];

            if ($field->required) {
                $fieldRules[] = 'required';
            } else {
                $fieldRules[] = 'nullable';
            }

            switch ($field->type) {
                case 'number':   $fieldRules[] = 'numeric'; break;
                case 'date':     $fieldRules[] = 'date'; break;
                case 'email':    $fieldRules[] = 'email'; break;
                case 'dropdown': $fieldRules[] = 'in:' . implode(',', $field->settings['options'] ?? []); break;
                // ...
            }

            $rules[$name] = $fieldRules;
        }

        return $rules;
    }

    public function saveValues(Model $model, array $data, Collection $fields)
    {
        foreach ($fields as $field) {
            $key = (string) $field->id;
            $value = $data[$key] ?? null;

            $model->customFieldValues()
                ->updateOrCreate(
                    ['custom_field_id' => $field->id],
                    ['value' => $value]
                );
        }
    }
}

Then in a FormRequest you can merge the dynamic rules from the service.


2. Controller integration

Example for CustomerController@create:

public function create(CustomFieldService $customFieldService)
{
    $fields = $customFieldService->getFieldsForModel(Customer::class);

    return view('customers.create', compact('fields'));
}

public function store(CustomerRequest $request, CustomFieldService $customFieldService)
{
    $customer = Customer::create($request->validatedRegularAttributes());

    $fields = $customFieldService->getFieldsForModel(Customer::class);
    $customFieldService->saveValues(
        $customer,
        $request->input('custom_fields', []),
        $fields
    );

    // redirect...
}

In your CustomerRequest, call $customFieldService->validationRules($fields) and merge into rules.


3. Blade form partial (rendering fields)

Create one partial and reuse it everywhere.

{{-- resources/views/custom_fields/_form.blade.php --}}
@foreach($fields as $field)
    @php
        $fieldName = "custom_fields[{$field->id}]";
        $value = $values[$field->id] ?? null;
    @endphp

    <div class="mb-3" data-custom-field-id="{{ $field->id }}">
        <label class="form-label">
            {{ $field->label }}
            @if($field->required) <span class="text-danger">*</span> @endif
        </label>

        @switch($field->type)
            @case('text')
                <input type="text" name="{{ $fieldName }}" value="{{ old($fieldName, $value) }}"
                       class="form-control">
                @break

            @case('number')
                <input type="number" name="{{ $fieldName }}" value="{{ old($fieldName, $value) }}"
                       class="form-control">
                @break

            @case('date')
                <input type="date" name="{{ $fieldName }}" value="{{ old($fieldName, $value) }}"
                       class="form-control">
                @break

            @case('dropdown')
                <select name="{{ $fieldName }}" class="form-select">
                    <option value="">-- Select --</option>
                    @foreach($field->settings['options'] ?? [] as $option)
                        <option value="{{ $option }}" @selected(old($fieldName, $value) == $option)>
                            {{ $option }}
                        </option>
                    @endforeach
                </select>
                @break

            {{-- add checkbox, radio, textarea, file, etc. --}}
        @endswitch

        @if($field->help_text)
            <small class="form-text text-muted">{{ $field->help_text }}</small>
        @endif

        @error("custom_fields.{$field->id}")
            <div class="text-danger small">{{ $message }}</div>
        @enderror
    </div>
@endforeach

Then in your create/edit views:

@include('custom_fields._form', [
    'fields' => $fields,
    'values' => $customer->customFieldValues->pluck('value','custom_field_id') ?? []
])

4. Admin UI / Designer (like your screenshot)

a) Overview list

Use a Bootstrap offcanvas or side drawer to mimic SmartOffice.

  • Left: list of field types (“Date, Text, Phone, Email, …”) — static list in Blade.

  • Right: form for creating a new field or editing one.

Routes

Route::resource('custom-field-groups', CustomFieldGroupController::class);
Route::resource('custom-fields', CustomFieldController::class);

Or have nested routes under groups.

b) “Add field” flow (UI)

  1. User clicks “Add Field”.

  2. Show offcanvas or modal with form:

    • Field Name (machine name)

    • Label

    • Description / Help Text

    • Type (select)

    • Position (dropdown or numeric)

    • Required (toggle switch)

    • Type-specific settings area.

Example Blade:

<div class="offcanvas offcanvas-end" tabindex="-1" id="fieldDesigner">
  <div class="offcanvas-header">
    <h5>Create Field</h5>
    <button type="button" class="btn-close" data-bs-dismiss="offcanvas"></button>
  </div>

  <div class="offcanvas-body">
    <form id="customFieldForm" method="POST" action="{{ route('custom-fields.store') }}">
      @csrf
      <input type="hidden" name="group_id" value="{{ $group->id }}">

      <div class="mb-3">
        <label class="form-label">Field Name</label>
        <input name="name" class="form-control" required>
      </div>

      <div class="mb-3">
        <label class="form-label">Label</label>
        <input name="label" class="form-control" required>
      </div>

      <div class="mb-3">
        <label class="form-label">Description (Optional)</label>
        <input name="help_text" class="form-control">
      </div>

      <div class="mb-3">
        <label class="form-label">Type</label>
        <select name="type" id="fieldType" class="form-select">
          <option value="text">Text</option>
          <option value="number">Number</option>
          <option value="date">Date</option>
          <option value="dropdown">Dropdown</option>
          <option value="checkbox">Checkbox</option>
          <!-- etc -->
        </select>
      </div>

      <div id="typeSettings"></div>

      <div class="form-check form-switch mb-3">
        <input class="form-check-input" type="checkbox" name="required" value="1" id="requiredSwitch">
        <label class="form-check-label" for="requiredSwitch">Required in form</label>
      </div>

      <button type="submit" class="btn btn-primary w-100">Create</button>
    </form>
  </div>
</div>

c) jQuery for type-specific settings

For example, for dropdown options:

$(function () {
    function renderSettings(type) {
        var $settings = $('#typeSettings');
        $settings.empty();

        if (type === 'dropdown' || type === 'radio') {
            $settings.append(`
                <div class="mb-3">
                    <label class="form-label">Options (one per line)</label>
                    <textarea name="settings[options]" class="form-control" rows="4"></textarea>
                </div>
            `);
        } else if (type === 'number') {
            $settings.append(`
                <div class="row">
                    <div class="col">
                        <label class="form-label">Min</label>
                        <input type="number" name="settings[min]" class="form-control">
                    </div>
                    <div class="col">
                        <label class="form-label">Max</label>
                        <input type="number" name="settings[max]" class="form-control">
                    </div>
                </div>
            `);
        }
        // add others as needed
    }

    $('#fieldType').on('change', function () {
        renderSettings(this.value);
    });

    renderSettings($('#fieldType').val());
});

In your controller, parse settings[options] into an array and store in the settings JSON column.

d) Ordering fields

Use a simple list with data-id attributes and jQuery UI Sortable:

<ul id="fieldList" class="list-group">
  @foreach($fields as $field)
    <li class="list-group-item d-flex align-items-center" data-id="{{ $field->id }}">
      <span class="me-2"><i class="bi bi-grip-vertical"></i></span>
      {{ $field->label }}
    </li>
  @endforeach
</ul>
$('#fieldList').sortable({
    update: function () {
        var order = $(this).children().map(function (i, li) {
            return $(li).data('id');
        }).get();

        $.post('{{ route('custom-fields.reorder') }}', {
            _token: '{{ csrf_token() }}',
            order: order
        });
    }
});

On the backend update position based on that order.


5. UX tips

  • Show preview in the designer: when user changes type/label/options, render a preview field at the bottom using the same Blade partial or a small jQuery template.

  • Validation feedback: reuse Laravel error messages so the custom fields behave exactly like normal ones.

  • Consistency: use Bootstrap’s standard form-control, form-select, form-check to keep everything visually aligned with the rest of your app.

  • Permissions: only admins should access the designer; normal users just see the generated fields.


If you’d like, I can next sketch out concrete migrations + a minimal CustomFieldController so you can just drop them into your Laravel project and tweak.

Awesome, let’s turn the idea into something you can actually drop in.

I’ll give you:

  1. Migrations

  2. Models + trait

  3. Service class

  4. Minimal controllers (groups + fields)

  5. Example: using it in a CustomerController

You can then wire the Blade views using the partial pattern from my previous reply.


1. Migrations

1.1. custom_field_groups

php artisan make:migration create_custom_field_groups_table
// database/migrations/xxxx_xx_xx_xxxxxx_create_custom_field_groups_table.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::create('custom_field_groups', function (Blueprint $table) {
            $table->id();
            $table->string('name');          // "Customers", "Invoices"
            $table->string('slug')->unique();
            $table->string('model_type');    // e.g. App\Models\Customer
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('custom_field_groups');
    }
};

1.2. custom_fields

php artisan make:migration create_custom_fields_table
// database/migrations/xxxx_xx_xx_xxxxxx_create_custom_fields_table.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::create('custom_fields', function (Blueprint $table) {
            $table->id();
            $table->foreignId('group_id')
                ->constrained('custom_field_groups')
                ->cascadeOnDelete();

            $table->string('name');      // machine name: vat_number
            $table->string('label');     // human label
            $table->string('type');      // text, number, date, dropdown, etc
            $table->boolean('required')->default(false);
            $table->unsignedInteger('position')->default(0);
            $table->json('settings')->nullable();  // type-specific config
            $table->string('help_text')->nullable();
            $table->boolean('is_active')->default(true);

            $table->timestamps();

            $table->unique(['group_id', 'name']); // unique per group
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('custom_fields');
    }
};

1.3. custom_field_values

php artisan make:migration create_custom_field_values_table
// database/migrations/xxxx_xx_xx_xxxxxx_create_custom_field_values_table.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::create('custom_field_values', function (Blueprint $table) {
            $table->id();

            $table->foreignId('custom_field_id')
                ->constrained('custom_fields')
                ->cascadeOnDelete();

            $table->morphs('valuable');   // valuable_id, valuable_type
            $table->text('value')->nullable();

            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('custom_field_values');
    }
};

Then:

php artisan migrate

2. Models + Trait

2.1. CustomFieldGroup

// app/Models/CustomFieldGroup.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class CustomFieldGroup extends Model
{
    protected $fillable = [
        'name',
        'slug',
        'model_type',
    ];

    public function fields(): HasMany
    {
        return $this->hasMany(CustomField::class, 'group_id')->orderBy('position');
    }
}

2.2. CustomField

// app/Models/CustomField.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class CustomField extends Model
{
    protected $fillable = [
        'group_id',
        'name',
        'label',
        'type',
        'required',
        'position',
        'settings',
        'help_text',
        'is_active',
    ];

    protected $casts = [
        'required'  => 'bool',
        'is_active' => 'bool',
        'settings'  => 'array',
    ];

    public function group(): BelongsTo
    {
        return $this->belongsTo(CustomFieldGroup::class, 'group_id');
    }

    public function values(): HasMany
    {
        return $this->hasMany(CustomFieldValue::class);
    }
}

2.3. CustomFieldValue

// app/Models/CustomFieldValue.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;

class CustomFieldValue extends Model
{
    protected $fillable = [
        'custom_field_id',
        'valuable_id',
        'valuable_type',
        'value',
    ];

    public function field(): BelongsTo
    {
        return $this->belongsTo(CustomField::class, 'custom_field_id');
    }

    public function valuable(): MorphTo
    {
        return $this->morphTo();
    }

    /**
     * Cast value based on field type.
     */
    public function getCastedValueAttribute()
    {
        $type = $this->field->type ?? 'text';

        return match ($type) {
            'number' => is_numeric($this->value) ? 0 + $this->value : null,
            'date'   => $this->value ? \Carbon\Carbon::parse($this->value) : null,
            'checkbox' => (bool) $this->value,
            default   => $this->value,
        };
    }
}

2.4. Trait for your entities

// app/Models/Concerns/HasCustomFields.php

namespace App\Models\Concerns;

use App\Models\CustomField;
use App\Models\CustomFieldValue;
use Illuminate\Database\Eloquent\Relations\MorphMany;

trait HasCustomFields
{
    public function customFieldValues(): MorphMany
    {
        return $this->morphMany(CustomFieldValue::class, 'valuable');
    }

    public function customFields()
    {
        return CustomField::whereHas('group', function ($q) {
            $q->where('model_type', static::class);
        })->where('is_active', true)
          ->orderBy('position')
          ->get();
    }

    public function getCustomField(string $name, $default = null)
    {
        $field = CustomField::where('name', $name)
            ->whereHas('group', fn ($q) => $q->where('model_type', static::class))
            ->first();

        if (! $field) return $default;

        $value = $this->customFieldValues
            ->firstWhere('custom_field_id', $field->id);

        return $value?->casted_value ?? $default;
    }
}

Use it in any model:

// app/Models/Customer.php

use App\Models\Concerns\HasCustomFields;

class Customer extends Model
{
    use HasCustomFields;

    // ...
}

3. Service class

// app/Services/CustomFieldService.php

namespace App\Services;

use App\Models\CustomField;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;

class CustomFieldService
{
    public function getFieldsForModel(string $modelClass): Collection
    {
        return CustomField::whereHas('group', fn ($q) =>
                $q->where('model_type', $modelClass)
            )
            ->where('is_active', true)
            ->orderBy('position')
            ->get();
    }

    public function validationRules(Collection $fields): array
    {
        $rules = [];

        foreach ($fields as $field) {
            $name = "custom_fields.{$field->id}";
            $fieldRules = $field->required ? ['required'] : ['nullable'];

            switch ($field->type) {
                case 'number':
                    $fieldRules[] = 'numeric';
                    break;
                case 'date':
                    $fieldRules[] = 'date';
                    break;
                case 'email':
                    $fieldRules[] = 'email';
                    break;
                case 'dropdown':
                case 'radio':
                    $options = $field->settings['options'] ?? [];
                    if (! empty($options)) {
                        $fieldRules[] = 'in:' . implode(',', $options);
                    }
                    break;
            }

            $rules[$name] = $fieldRules;
        }

        return $rules;
    }

    public function saveValues(Model $model, array $data, Collection $fields): void
    {
        foreach ($fields as $field) {
            $value = $data[$field->id] ?? null;

            $model->customFieldValues()
                ->updateOrCreate(
                    ['custom_field_id' => $field->id],
                    ['value' => $value]
                );
        }
    }
}

4. Controllers

You can put them in App\Http\Controllers\Admin or wherever fits your structure.

4.1. Routes

// routes/web.php

use App\Http\Controllers\CustomFieldGroupController;
use App\Http\Controllers\CustomFieldController;

Route::prefix('admin')->middleware(['auth'])->group(function () {
    Route::resource('custom-field-groups', CustomFieldGroupController::class);

    Route::get('custom-field-groups/{group}/fields', [CustomFieldController::class, 'index'])
        ->name('custom-fields.index');

    Route::post('custom-field-groups/{group}/fields', [CustomFieldController::class, 'store'])
        ->name('custom-fields.store');

    Route::get('custom-fields/{field}/edit', [CustomFieldController::class, 'edit'])
        ->name('custom-fields.edit');

    Route::put('custom-fields/{field}', [CustomFieldController::class, 'update'])
        ->name('custom-fields.update');

    Route::delete('custom-fields/{field}', [CustomFieldController::class, 'destroy'])
        ->name('custom-fields.destroy');

    Route::post('custom-fields/reorder', [CustomFieldController::class, 'reorder'])
        ->name('custom-fields.reorder');
});

4.2. CustomFieldGroupController (minimal)

// app/Http/Controllers/CustomFieldGroupController.php

namespace App\Http\Controllers;

use App\Models\CustomFieldGroup;
use Illuminate\Http\Request;
use Illuminate\Support\Str;

class CustomFieldGroupController extends Controller
{
    public function index()
    {
        $groups = CustomFieldGroup::orderBy('name')->get();

        return view('admin.custom_fields.groups.index', compact('groups'));
    }

    public function create()
    {
        return view('admin.custom_fields.groups.create');
    }

    public function store(Request $request)
    {
        $data = $request->validate([
            'name'       => 'required|string|max:255',
            'slug'       => 'nullable|string|max:255|unique:custom_field_groups,slug',
            'model_type' => 'required|string|max:255',
        ]);

        if (empty($data['slug'])) {
            $data['slug'] = Str::slug($data['name']);
        }

        CustomFieldGroup::create($data);

        return redirect()->route('custom-field-groups.index')
            ->with('success', 'Group created.');
    }

    public function edit(CustomFieldGroup $customFieldGroup)
    {
        return view('admin.custom_fields.groups.edit', [
            'group' => $customFieldGroup,
        ]);
    }

    public function update(Request $request, CustomFieldGroup $customFieldGroup)
    {
        $data = $request->validate([
            'name'       => 'required|string|max:255',
            'slug'       => 'required|string|max:255|unique:custom_field_groups,slug,' . $customFieldGroup->id,
            'model_type' => 'required|string|max:255',
        ]);

        $customFieldGroup->update($data);

        return redirect()->route('custom-field-groups.index')
            ->with('success', 'Group updated.');
    }

    public function destroy(CustomFieldGroup $customFieldGroup)
    {
        $customFieldGroup->delete();

        return back()->with('success', 'Group deleted.');
    }
}

4.3. CustomFieldController

// app/Http/Controllers/CustomFieldController.php

namespace App\Http\Controllers;

use App\Models\CustomField;
use App\Models\CustomFieldGroup;
use Illuminate\Http\Request;

class CustomFieldController extends Controller
{
    public function index(CustomFieldGroup $group)
    {
        $fields = $group->fields; // ordered by position (from model)

        // Render the designer UI (list + “Create Field” offcanvas)
        return view('admin.custom_fields.fields.index', compact('group', 'fields'));
    }

    public function store(Request $request, CustomFieldGroup $group)
    {
        $data = $request->validate([
            'name'      => 'required|string|max:255',
            'label'     => 'required|string|max:255',
            'type'      => 'required|string|max:50',
            'required'  => 'nullable|boolean',
            'help_text' => 'nullable|string|max:255',
            'settings'  => 'array',
        ]);

        $data['required'] = $request->boolean('required');
        $data['settings'] = $this->normalizeSettings($data['settings'] ?? []);
        $data['position'] = $group->fields()->max('position') + 1;
        $data['group_id'] = $group->id;

        CustomField::create($data);

        return back()->with('success', 'Field created.');
    }

    public function edit(CustomField $field)
    {
        $group = $field->group;

        return view('admin.custom_fields.fields.edit', compact('field', 'group'));
    }

    public function update(Request $request, CustomField $field)
    {
        $data = $request->validate([
            'label'     => 'required|string|max:255',
            'type'      => 'required|string|max:50',
            'required'  => 'nullable|boolean',
            'help_text' => 'nullable|string|max:255',
            'settings'  => 'array',
            'is_active' => 'nullable|boolean',
        ]);

        $data['required']  = $request->boolean('required');
        $data['is_active'] = $request->boolean('is_active');
        $data['settings']  = $this->normalizeSettings($data['settings'] ?? []);

        $field->update($data);

        return redirect()
            ->route('custom-fields.index', $field->group)
            ->with('success', 'Field updated.');
    }

    public function destroy(CustomField $field)
    {
        $field->delete();

        return back()->with('success', 'Field deleted.');
    }

    public function reorder(Request $request)
    {
        $data = $request->validate([
            'order'   => 'required|array',
            'order.*' => 'integer',
        ]);

        foreach ($data['order'] as $position => $id) {
            CustomField::where('id', $id)->update(['position' => $position]);
        }

        return response()->json(['status' => 'ok']);
    }

    protected function normalizeSettings(array $settings): array
    {
        // Example: convert "options" textarea into array if string passed
        if (isset($settings['options']) && is_string($settings['options'])) {
            $lines = preg_split('/\r\n|\r|\n/', $settings['options']);
            $settings['options'] = array_values(array_filter(array_map('trim', $lines)));
        }

        return $settings;
    }
}

5. Example: using custom fields in CustomerController

// app/Http/Controllers/CustomerController.php

namespace App\Http\Controllers;

use App\Models\Customer;
use App\Services\CustomFieldService;
use Illuminate\Http\Request;

class CustomerController extends Controller
{
    public function create(CustomFieldService $customFieldService)
    {
        $fields = $customFieldService->getFieldsForModel(Customer::class);

        return view('customers.create', compact('fields'));
    }

    public function store(Request $request, CustomFieldService $customFieldService)
    {
        $fields = $customFieldService->getFieldsForModel(Customer::class);

        // normal attributes validation
        $rules = [
            'name' => 'required|string|max:255',
            // ...
        ];

        // merge dynamic rules
        $rules = array_merge($rules, $customFieldService->validationRules($fields));

        $validated = $request->validate($rules);

        $customer = Customer::create([
            'name' => $validated['name'],
            // ...
        ]);

        $customFieldService->saveValues(
            $customer,
            $request->input('custom_fields', []),
            $fields
        );

        return redirect()->route('customers.index')
            ->with('success', 'Customer created.');
    }

    public function edit(Customer $customer, CustomFieldService $customFieldService)
    {
        $fields = $customFieldService->getFieldsForModel(Customer::class);
        $values = $customer->customFieldValues->pluck('value', 'custom_field_id');

        return view('customers.edit', compact('customer', 'fields', 'values'));
    }

    public function update(Request $request, Customer $customer, CustomFieldService $customFieldService)
    {
        $fields = $customFieldService->getFieldsForModel(Customer::class);

        $rules = [
            'name' => 'required|string|max:255',
        ];
        $rules = array_merge($rules, $customFieldService->validationRules($fields));

        $validated = $request->validate($rules);

        $customer->update([
            'name' => $validated['name'],
        ]);

        $customFieldService->saveValues(
            $customer,
            $request->input('custom_fields', []),
            $fields
        );

        return redirect()->route('customers.index')
            ->with('success', 'Customer updated.');
    }
}

On the Blade side you just use the partial we discussed earlier:

@include('custom_fields._form', [
    'fields' => $fields,
    'values' => $values ?? [],
])

If you want, next I can help you sketch the actual Bootstrap/jQuery designer view (list + offcanvas) to match the screenshot as closely as possible.


Nice, let’s make it look/behave like the screenshots.

I’ll give you one main view + an included offcanvas partial + jQuery.


1. Main designer view

resources/views/admin/custom_fields/fields/index.blade.php

@extends('layouts.app')

@section('content')
<div class="container-fluid">
    <div class="d-flex justify-content-between align-items-center mb-3">
        <div>
            <h4 class="mb-0">{{ $group->name }} – Custom Fields</h4>
            <small class="text-muted">{{ $group->model_type }}</small>
        </div>

        <button class="btn btn-primary"
                type="button"
                data-bs-toggle="offcanvas"
                data-bs-target="#customFieldOffcanvas">
            + Add Custom Field
        </button>
    </div>

    {{-- Existing fields list (sortable) --}}
    <div class="card">
        <div class="card-header d-flex justify-content-between align-items-center">
            <span>Fields</span>
            <small class="text-muted">Drag to reorder</small>
        </div>

        <ul id="fieldList" class="list-group list-group-flush">
            @forelse($fields as $field)
                <li class="list-group-item d-flex align-items-center" data-id="{{ $field->id }}">
                    <span class="me-2 text-muted" style="cursor:grab;">
                        <i class="bi bi-grip-vertical"></i>
                    </span>

                    <div class="flex-grow-1">
                        <div class="fw-semibold">{{ $field->label }}</div>
                        <div class="small text-muted">
                            {{ $field->type }} · {{ $field->name }}
                            @if(!$field->is_active) · <span class="badge bg-secondary">disabled</span>@endif
                        </div>
                    </div>

                    <a href="{{ route('custom-fields.edit', $field) }}"
                       class="btn btn-sm btn-outline-secondary me-1">Edit</a>

                    <form action="{{ route('custom-fields.destroy', $field) }}"
                          method="POST" onsubmit="return confirm('Delete this field?')">
                        @csrf
                        @method('DELETE')
                        <button class="btn btn-sm btn-outline-danger">Delete</button>
                    </form>
                </li>
            @empty
                <li class="list-group-item text-muted">No custom fields yet.</li>
            @endforelse
        </ul>
    </div>
</div>

@include('admin.custom_fields.fields._offcanvas')

@endsection

@push('scripts')
<script src="https://code.jquery.com/ui/1.13.2/jquery-ui.min.js"></script>
<script>
    // sortable list
    $('#fieldList').sortable({
        handle: 'span',
        update: function () {
            var order = $(this).children().map(function () {
                return $(this).data('id');
            }).get();

            $.post('{{ route('custom-fields.reorder') }}', {
                _token: '{{ csrf_token() }}',
                order: order
            });
        }
    });
</script>
@endpush

2. Offcanvas partial (type picker + “Create Field” screen)

resources/views/admin/custom_fields/fields/_offcanvas.blade.php

<div class="offcanvas offcanvas-end" tabindex="-1" id="customFieldOffcanvas">
    <div class="offcanvas-header border-bottom">
        <h5 class="offcanvas-title" id="offcanvasTitle">Add Field</h5>
        <button type="button" class="btn-close" data-bs-dismiss="offcanvas"></button>
    </div>

    <div class="offcanvas-body p-0 d-flex flex-column" style="height:100%;">
        {{-- STEP 1: type chooser --}}
        <div id="screen-choose-type" class="flex-grow-1 d-flex flex-column">
            <div class="p-3 border-bottom">
                <div class="input-group">
                    <span class="input-group-text"><i class="bi bi-search"></i></span>
                    <input type="text" id="fieldTypeSearch" class="form-control" placeholder="Search">
                </div>
            </div>

            <div class="px-3 pt-2 small text-uppercase text-muted fw-semibold">Popular</div>
            <div class="list-group list-group-flush" id="fieldTypeList">
                {{-- Popular --}}
                <button type="button" class="list-group-item list-group-item-action type-item"
                        data-type="date" data-label="Date">
                    <i class="bi bi-calendar me-2"></i> Date
                </button>
                <button type="button" class="list-group-item list-group-item-action type-item"
                        data-type="text" data-label="Text">
                    <i class="bi bi-fonts me-2"></i> Text
                </button>
                <button type="button" class="list-group-item list-group-item-action type-item"
                        data-type="phone" data-label="Phone">
                    <i class="bi bi-telephone me-2"></i> Phone
                </button>
                <button type="button" class="list-group-item list-group-item-action type-item"
                        data-type="email" data-label="Email">
                    <i class="bi bi-envelope me-2"></i> Email
                </button>
                <button type="button" class="list-group-item list-group-item-action type-item"
                        data-type="number" data-label="Number">
                    <i class="bi bi-hash me-2"></i> Number
                </button>
                <button type="button" class="list-group-item list-group-item-action type-item"
                        data-type="file" data-label="File & Media">
                    <i class="bi bi-folder2-open me-2"></i> File &amp; Media
                </button>
                <button type="button" class="list-group-item list-group-item-action type-item"
                        data-type="url" data-label="URL">
                    <i class="bi bi-link-45deg me-2"></i> URL
                </button>
                <button type="button" class="list-group-item list-group-item-action type-item"
                        data-type="labels" data-label="Labels">
                    <i class="bi bi-shield-check me-2"></i> Labels
                </button>

                {{-- ALL --}}
                <div class="px-3 pt-3 pb-1 small text-uppercase text-muted fw-semibold">All</div>
                <button type="button" class="list-group-item list-group-item-action type-item"
                        data-type="checkbox" data-label="Checkbox">
                    <i class="bi bi-check-square me-2"></i> Checkbox
                </button>
                <button type="button" class="list-group-item list-group-item-action type-item"
                        data-type="dropdown" data-label="Dropdown">
                    <i class="bi bi-caret-down-square me-2"></i> Dropdown
                </button>
                <button type="button" class="list-group-item list-group-item-action type-item"
                        data-type="radio" data-label="Radio Button">
                    <i class="bi bi-record-circle me-2"></i> Radio Button
                </button>
                <button type="button" class="list-group-item list-group-item-action type-item"
                        data-type="textarea" data-label="Text Area">
                    <i class="bi bi-textarea-t me-2"></i> Text Area
                </button>
                <button type="button" class="list-group-item list-group-item-action type-item"
                        data-type="password" data-label="Password">
                    <i class="bi bi-eye-slash me-2"></i> Password
                </button>
                <button type="button" class="list-group-item list-group-item-action type-item"
                        data-type="color" data-label="Color">
                    <i class="bi bi-palette me-2"></i> Color
                </button>

                {{-- Pre-defined --}}
                <div class="px-3 pt-3 pb-1 small text-uppercase text-muted fw-semibold">Pre defined inputs</div>
                <button type="button" class="list-group-item list-group-item-action type-item"
                        data-type="employee" data-label="All Employees">
                    <i class="bi bi-people me-2"></i> All Employees
                </button>
            </div>

            <div class="p-3 border-top mt-auto">
                <button class="btn btn-outline-secondary w-100" type="button">
                    Add Existing
                </button>
            </div>
        </div>

        {{-- STEP 2: create/configure field --}}
        <div id="screen-create-field" class="flex-grow-1 d-none">
            <form id="customFieldForm" method="POST"
                  action="{{ route('custom-fields.store', $group) }}"
                  class="h-100 d-flex flex-column">
                @csrf

                <div class="p-3 border-bottom d-flex align-items-center">
                    <button class="btn btn-link p-0 me-2" type="button" id="btnBackToTypes">
                        <i class="bi bi-chevron-left"></i>
                    </button>
                    <div>
                        <div class="fw-semibold">Create Field</div>
                        <small id="selectedTypeLabel" class="text-muted"></small>
                    </div>
                </div>

                <div class="p-3 flex-grow-1 overflow-auto">
                    <input type="hidden" name="type" id="fieldTypeInput">

                    <div class="mb-3">
                        <label class="form-label">Field Name</label>
                        <input type="text" name="name" class="form-control" required>
                        <div class="form-text">Used internally (no spaces).</div>
                    </div>

                    <div class="mb-3">
                        <label class="form-label">Label</label>
                        <input type="text" name="label" class="form-control" required>
                    </div>

                    <div class="mb-3">
                        <label class="form-label">Description (Optional)</label>
                        <input type="text" name="help_text" class="form-control">
                        <div class="form-text">
                            View descriptions when hovering over fields in tasks or views.
                        </div>
                    </div>

                    <div class="mb-3">
                        <label class="form-label">Position</label>
                        <select name="position" class="form-select">
                            @for ($i = 1; $i <= max(1, $fields->count()+1); $i++)
                                <option value="{{ $i }}" @selected($i === $fields->count()+1)>
                                    {{ $i }}
                                </option>
                            @endfor
                        </select>
                    </div>

                    {{-- type-specific settings area --}}
                    <div id="typeSettings" class="mb-3"></div>

                    <div class="form-check form-switch">
                        <input class="form-check-input" type="checkbox" name="required" value="1" id="requiredSwitch">
                        <label class="form-check-label" for="requiredSwitch">Required in form</label>
                    </div>
                </div>

                <div class="p-3 border-top">
                    <div class="d-flex gap-2">
                        <button type="button" class="btn btn-outline-secondary flex-grow-1"
                                data-bs-dismiss="offcanvas">
                            Cancel
                        </button>
                        <button type="submit" class="btn btn-primary flex-grow-1">
                            Create
                        </button>
                    </div>
                </div>
            </form>
        </div>
    </div>
</div>

@push('scripts')
<script>
    // search filter in type list
    $('#fieldTypeSearch').on('keyup', function () {
        let q = $(this).val().toLowerCase();
        $('#fieldTypeList .type-item').each(function () {
            let text = $(this).text().toLowerCase();
            $(this).toggle(text.indexOf(q) !== -1);
        });
    });

    // click on type -> go to create screen
    $('.type-item').on('click', function () {
        let type  = $(this).data('type');
        let label = $(this).data('label');

        $('#fieldTypeInput').val(type);
        $('#selectedTypeLabel').text(label + ' field');

        renderTypeSettings(type);

        $('#screen-choose-type').addClass('d-none');
        $('#screen-create-field').removeClass('d-none');
    });

    // back to type list
    $('#btnBackToTypes').on('click', function () {
        $('#screen-create-field').addClass('d-none');
        $('#screen-choose-type').removeClass('d-none');
    });

    // render extra settings depending on field type
    function renderTypeSettings(type) {
        let $settings = $('#typeSettings');
        $settings.empty();

        if (type === 'dropdown' || type === 'radio' || type === 'labels') {
            $settings.append(`
                <label class="form-label">Options</label>
                <textarea name="settings[options]" class="form-control" rows="4"
                    placeholder="One option per line"></textarea>
            `);
        } else if (type === 'number') {
            $settings.append(`
                <div class="row">
                    <div class="col">
                        <label class="form-label">Min</label>
                        <input type="number" name="settings[min]" class="form-control">
                    </div>
                    <div class="col">
                        <label class="form-label">Max</label>
                        <input type="number" name="settings[max]" class="form-control">
                    </div>
                </div>
            `);
        } else if (type === 'color') {
            $settings.append(`
                <label class="form-label d-block">Default Color (optional)</label>
                <input type="color" name="settings[default]" class="form-control form-control-color">
            `);
        } else if (type === 'date') {
            $settings.append(`
                <label class="form-label">Default</label>
                <select name="settings[default]" class="form-select">
                    <option value="">None</option>
                    <option value="today">Today</option>
                    <option value="tomorrow">Tomorrow</option>
                </select>
            `);
        }
        // others can be added as needed
    }
</script>
@endpush

Note: this uses Bootstrap Icons (bi ...). If you’re not already including them, add their CDN <link> in your main layout <head>.


With these pieces you’ll have:

  • A designer page listing existing fields (sortable).

  • An offcanvas drawer that:

    • First shows the searchable list of field types (like your screenshot).

    • On click, flips to a Create Field screen with name/description/type/position/required + type-specific options.

  • The controller we wrote earlier will receive the form and create the field, normalizing settings.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment