salah / laravel-custom-fields
Professional, flexible, and headless-ready Custom Fields (EAV) package for Laravel.
Package info
github.com/salaheldeen911/laravel-custom-fields
pkg:composer/salah/laravel-custom-fields
Fund package maintenance!
Requires
- php: ^8.2
- ext-intl: *
- illuminate/contracts: ^10.0||^11.0||^12.0
- illuminate/support: ^10.0||^11.0||^12.0
- propaganistas/laravel-phone: ^5.0
- spatie/laravel-package-tools: ^1.92
Requires (Dev)
- larastan/larastan: ^2.9||^3.0
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.1.1||^7.10.0
- orchestra/testbench: ^10.0.0||^9.0.0||^8.22.0
- pestphp/pest: ^2.0||^3.0
- pestphp/pest-plugin-arch: ^2.5||^3.0
- pestphp/pest-plugin-laravel: ^2.0||^3.0
- phpstan/extension-installer: ^1.3
- phpstan/phpstan-deprecation-rules: ^1.1||^2.0
- phpstan/phpstan-phpunit: ^1.3||^2.0
README
The Professional, Sealed-Lifecycle EAV Solution for Modern Laravel Applications.
Tired of messy "extra_attributes" JSON columns that are impossible to validate? Treat user-defined fields as first-class citizens. This package provides high-performance, strictly validated, and extensible custom fields with native support for both Blade (Full-Stack) and Headless (API) architectures.
๐ฅ Why This Package?
- ๐ก Strict Lifecycle: We validate the rules themselves. You can't save a
min > maxor an invalidregex. - ๐ซ Intelligent Conflict Prevention: Automatically prevents assigning conflicting rules (e.g., you can't use
Letters OnlyandAlpha-Numerictogether). - โก๏ธ Built for Speed: Uses database
upsertsand batch operations. Reduces database overhead from N queries to just one per request. - ๐ Refactor-Safe Polymorphism: Uses a
configmap for models. High stability even if you change model namespaces. - ๐งฉ Dual-Nature Architecture:
- Blade: Ready-to-use Tailwind components with error handling and old-input support.
- Headless: Rich metadata API (
models-and-types) explaining rules, labels, and tags to your Frontend.
- ๐ Laravel Octane Ready: The singleton service state is automatically reset after every request via a
terminatinghook โ safe for persistent Octane workers with zero configuration.
๐ฆ Installation
composer require salah/laravel-custom-fields
Install the package (publishes config, migrations, and assets):
php artisan custom-fields:install
โ๏ธ Configuration
-
Map Your Models: In
config/custom-fields.php, define simple aliases for your models. This decouples your database from your class names.'models' => [ 'user' => \App\Models\User::class, 'product' => \App\Models\Product::class, ],
-
Prepare Your Model: Add the
HasCustomFieldstrait.use Salah\LaravelCustomFields\Traits\HasCustomFields; }
-
Advanced Configuration: Tune the package in
config/custom-fields.php.- Cache Strategy: Control
ttlandprefixto balance performance and freshness. - Security: Enable
sanitize_htmlto automatically strip tags from text inputs. - Maintenance: Configure
pruningretention periods for soft-deleted fields.
- Cache Strategy: Control
Important
API Security: If you enable API or Web routes in config, the package will automatically check for authentication middleware. If missing, it will log a warning. Ensure your routes are protected by adding auth middleware in the config.
๐งน Maintenance & Pruning
To keep your database clean, you can permanently remove soft-deleted custom fields that are older than a configured threshold.
-
Configure: Set
'prune_deleted_after_days' => 30in your config file. -
Run Command:
php artisan custom-fields:prune
Tip: Schedule this command in your
App\Console\Kernelto run weekly.
๐ง Architecture & Validation Concepts
This package separates the world into two distinct logical flows to prevent confusion:
1. The Admin Flow (Defining Fields)
- Goal: Define what a field is (e.g., "Age").
- Handled by: The package's internal
CustomFieldBaseRequest(used byStoreCustomFieldRequestandUpdateCustomFieldRequest). - Usage: Automatically applied when creating/editing field definitions via the package routes. It validates that your rules don't conflict (e.g., preventing
alphalogic on anumberfield).
2. The User Flow (Entering Data)
- Goal: Fill in the field (e.g., "25").
- Trait:
ValidatesFieldData - Usage: Used in your Application's forms. It applies the rules defined in Step 1 to the user's input.
๐ Usage: The Laravel Way
1. Rendering the UI (Blade)
Automatically render all custom fields for a specific model using a single tag. It handles errors, old(), and specific input types.
<form action="{{ route('users.store') }}" method="POST"> @csrf <!-- Standard Fields --> <input type="text" name="name" /> <!-- Dynamic Custom Fields Magic --> <x-custom-fields::render :model="$user ?? null" :customFields="\App\Models\User::customFields()" /> <button type="submit">Save</button> </form>
2. Validation (Option A: Form Request - Recommended)
The cleanest way to validate custom fields is by using the ValidatesFieldData trait in your Form Request.
CRITICAL: If
strict_validationis enabled in config (default: true), you MUST use this trait. It not only merges rules but also "marks" the data as safely validated. Failure to use it will result in aValidationIntegrityException.
use Salah\LaravelCustomFields\Traits\ValidatesFieldData; class StoreUserRequest extends FormRequest { use ValidatesFieldData; public function rules(): array { // MERGE custom fields rules into your existing rules return $this->withCustomFieldsRules(User::class, [ 'name' => 'required|string|max:255', ]); } }
3. Validation (Option B: Controller)
If you prefer validating in the controller, use the helper method on the model:
$validated = $request->validate(array_merge([ 'name' => 'required', ], User::getCustomFieldRules())); // Note: getCustomFieldRules() is a helper from the HasCustomFields trait // getCustomFieldModelAlias() is also available for programmatic model resolution
3. Validation (Option C: Manual Service)
For complex scenarios where you need granular control or are validating data outside of a request:
// Validate only custom fields (Throws ValidationException on failure) app(CustomFieldsService::class)->validate(User::class, $data);
4. Storage & Updates
Use optimized batch methods to save or update custom values.
Recommendation: It is highly recommended to wrap the creation/update of your main model and the custom fields in a Database Transaction. This ensures that if the custom field validation fails (or any other error occurs), the main model is not created/updated partially.
use Illuminate\Support\Facades\DB; // Storing DB::transaction(function () use ($request) { $user = User::create($request->validated()); $user->saveCustomFields($request->validated()); }); // Updating (Uses high-performance UPSERT logic) DB::transaction(function () use ($request, $user) { $user->update($request->validated()); $user->updateCustomFields($request->validated()); });
๐ Retrieval & Powerful Querying
Get Single Value
$bio = $user->custom('biography');
Get All Values (Flat Array)
Perfect for API responses or data exports.
return response()->json([ 'user' => $user, 'custom_data' => $user->customFieldsResponse() ]); // Response: {"biography": "...", "age": 30, "city": "Cairo"}
Querying like a Pro
The package provides a powerful scope to filter your models by custom fields values.
// Find users where custom field 'city' is 'Cairo' $users = User::whereCustomField('city', 'Cairo')->get();
โก๏ธ Performance & Eager Loading
To avoid the N+1 query problem when displaying multiple models, always use the withCustomFields scope. This eager loads all values and their field configurations in just two queries.
// Optimized for lists/tables $users = User::withCustomFields()->paginate(20); foreach ($users as $user) { echo $user->custom('biography'); // No extra queries! }
Optimize Show/Edit Pages
When displaying a single model (e.g., in show or edit methods), use the loadCustomFields() helper to ensure all data is loaded efficiently before rendering the view.
public function edit(User $user) { // Eager loads values relationship return view('users.edit')->with('user', $user->loadCustomFields()); }
๐งฉ Built-in Field Types
| Type | Icon | HTML Control | Supported Rules |
|---|---|---|---|
text |
๐ | <input type="text"> |
min, max, regex, alpha, alpha_dash, alpha_num |
textarea |
๐ | <textarea> |
min, max, regex, not_regex |
number |
๐ข | <input type="number"> |
min, max |
decimal |
๐น | <input type="number" step="any"> |
min, max |
date |
๐ | <input type="date"> |
after, before, after_or_equal, date_format |
time |
๐ | <input type="time"> |
required (Standard string validation) |
select |
๐ฝ | <select> |
required (Strictly validated against options) |
checkbox |
โ | <input type="checkbox"> |
required |
phone |
๐ | <input type="tel"> |
phone, mobile, landline (Supports formats or AUTO detection) |
email |
โ๏ธ | <input type="email"> |
min, max, regex (Native email validation) |
url |
๐ | <input type="url"> |
min, max, regex (Native URL validation) |
color |
๐จ | <input type="color"> |
required (Validates hex color format) |
file |
๐ | <input type="file"> |
mimes, max_file_size (Secure storage & URL generation) |
Important
Immutability Notice: To maintain data integrity, the Field Type (type) and Target Model (model) are immutable once a field is created. These cannot be changed during an update to prevent database schema mismatch and validation errors.
๐ก Validation Rule Conflicts
The system is smart enough to prevent logical errors in your field configurations. If you try to apply conflicting rules, the system will throw a validation error during the field creation/update.
Common Conflicts Prevented:
alphavsalpha_numvsalpha_dashaftervsafter_or_equalbeforevsbefore_or_equal
๐ Advanced Customization
Registering New Types
Create a class extending FieldType and register it in your AppServiceProvider.
public function boot() { $this->app->make(FieldTypeRegistry::class)->register(new MyCustomType()); }
Registering Custom Filters
The FilterEngine is registered as a singleton. You can add your own query filters โ for example, filtering by a custom attribute โ from your AppServiceProvider:
use Salah\LaravelCustomFields\Filters\FilterEngine; public function boot() { $this->app->make(FilterEngine::class)->registerFilter('active', MyActiveFilter::class); }
Your filter class must implement a static apply(Builder $query, mixed $value): Builder method:
class MyActiveFilter { public static function apply(Builder $query, mixed $value): Builder { return $query->where('is_active', (bool) $value); } }
Extending Validation Rules
You can add your own validation rules. If your rule conflicts with another, simply override the conflictsWith() method:
class MyPremiumRule extends ValidationRule { public function conflictsWith(): array { return ['basic_rule_name']; } }
๐ Headless & API Reference
This package is a first-class citizen for Headless architectures. It provides a built-in API to manage custom fields and provides the necessary metadata for frontends to render them.
1. The Blueprint (Metadata)
Before rendering any UI, your frontend (React/Vue/Mobile) should fetch the types and rules.
Endpoint: GET /api/custom-fields/models-and-types
Response:
{
"success": true,
"data": {
"models": ["user", "product"],
"types": [
{
"name": "text",
"label": "Text Field",
"tag": "input",
"type": "text",
"has_options": false,
"allowed_rules": [
{ "name": "min", "label": "Min Length", "tag": "input", "type": "number" }
]
}
]
}
}
2. Managing Fields (CRUD API)
If you are building your own Admin Dashboard in a JS framework, use these endpoints:
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/custom-fields |
List all fields (Paginated) |
| GET | /api/custom-fields/{id} |
Get a single field |
| POST | /api/custom-fields |
Create a new field |
| PUT | /api/custom-fields/{id} |
Update field configuration |
| DELETE | /api/custom-fields/{id} |
Soft delete a field |
| POST | /api/custom-fields/{id}/restore |
Restore a soft-deleted field |
| DELETE | /api/custom-fields/{id}/force |
Permanently delete a field |
Example: Creating a Field
Payload (POST /api/custom-fields):
{
"name": "Technical Bio",
"model": "user",
"type": "text",
"required": true,
"validation_rules": {
"min": 10,
"max": 500
}
}
3. Storing Values (Entity Integration)
When your frontend sends data to update a model (like a User profile), send the custom fields as a flat object where the key is the slug.
Payload (PUT /api/users/12):
{
"name": "Salah Eldeen",
"email": "salah@example.com",
"technical-bio": "Full-stack developer with 10 years of experience."
}
Controller Implementation:
public function update(Request $request, User $user) { $user->update($request->all()); $user->updateCustomFields($request->all()); // Scans for slugs and updates values automatically return response()->json(['success' => true]); }
โก๏ธ Laravel Octane Support
This package is fully safe for use with Laravel Octane. The CustomFieldsService is registered as a singleton but its internal $validated state is automatically reset at the end of every request lifecycle via a terminating hook in the service provider. No configuration is needed.
If you run Octane and cache model aliases via config('custom-fields.cache.octane_compatibility', true), the static alias cache in the HasCustomFields trait is disabled by default to avoid cross-request leakage.
๐ Authorization
By default, the package does not enforce authorization on its management routes (it relies on your middleware configuration).
To protect them with a Gate ability, add it in config/custom-fields.php:
'authorization' => [ 'ability' => 'manage-custom-fields', ],
Then define the ability in your AuthServiceProvider:
use Illuminate\Support\Facades\Gate; Gate::define('manage-custom-fields', function ($user) { return $user->isAdmin(); });
When set, all custom field management requests (list, create, update, delete) will check this ability. If the user does not have the ability, a 403 Forbidden response is returned.
๐ Country Service
The package ships with a CountryService that provides a full list of country names using libphonenumber. It is registered as a singleton in the container:
use Salah\LaravelCustomFields\Services\CountryService; class MyController extends Controller { public function __construct(protected CountryService $countryService) {} public function countries() { return response()->json($this->countryService->getAll()); } }
๐จ Management UI
The package comes with a built-in, secure management interface to create and manage fields.
- Route:
/custom-fields(Configurable incustom-fields.php) - Features: List, Search, Create, Edit, and Trash management.
๐จโ๐ณ Cookbook: Advanced Scenarios
Creating a Dependent Dropdown Field Type
Scenario: You want a City field that updates its options based on a Country field.
1. Create the Field Type Class
Create app/CustomFields/Types/DependentSelectField.php. We will use the options array to store the "parent field" slug.
namespace App\CustomFields\Types; use Salah\LaravelCustomFields\FieldTypes\FieldType; class DependentSelectField extends FieldType { public function name(): string { return 'dependent_select'; } public function label(): string { return 'Dependent Select'; } public function htmlTag(): string { return 'select'; } // We expect 'options' to contain the slug of the parent field // e.g., options: ["country"] public function hasOptions(): bool { return true; } public function description(): string { return 'A select menu that depends on another field.'; } public function baseRule(): array { return ['string']; // Basic validation } public function view(): string { return 'components.custom-fields.dependent-select'; } }
2. Register the Type
In AppServiceProvider::boot():
use Salah\LaravelCustomFields\FieldTypeRegistry; use App\CustomFields\Types\DependentSelectField; public function boot() { app(FieldTypeRegistry::class)->register(new DependentSelectField()); }
3. Frontend Implementation
Since the dependency logic is frontend-heavy, your component (resources/views/components/custom-fields/dependent-select.blade.php) should listen to the parent field.
@props(['field', 'value', 'allFields']) @php $parentSlug = $field->options[0] ?? null; @endphp <div x-data="{ parentVal: '', options: [], init() { // Pseudo-code: Listen to the parent field change document.addEventListener('custom-field-changed:{{ $parentSlug }}', (e) => { this.fetchOptions(e.detail.value); }); } }"> <select name="{{ $field->slug }}" x-model="value"> <option value="">Select Option</option> <template x-for="opt in options"> <option :value="opt" x-text="opt"></option> </template> </select> </div>
๐ License
The MIT License (MIT). Please see License File for more information.