watheqalshowaiter/model-fields
Easily add custom fields to Laravel Eloquent models with watheqalshowaiter/model-fields. A simple trait stores dynamic, validated attributes as JSON, letting you extend models without extra migrations or schema changes.
model-fields is a small, single-purpose package: it lets a model declare the attributes that must always have a value, and throws an exception when a save() would leave any of them empty. It is a last-line-of-defence guard, not a form validator.
Install it, then add the trait and declare the fields on the model:
composer require watheqalshowaiter/model-fields
use Illuminate\Database\Eloquent\Model;
use WatheqAlshowaiter\ModelFields\HasModelFields;
class Post extends Model
{
use HasModelFields;
protected function fields(): array
{
return ['title', 'body', 'author_id'];
}
}
Now an incomplete write fails loudly instead of silently persisting a half-built row:
Post::create(['title' => 'Hello']); // throws — body and author_id missing
Where to look first: the trait source (it is short) to confirm the exact method/signature your installed tag expects, then the exception class it throws so you can catch it deliberately. First use case: a model that is assembled from several places — an API controller, an import command, a queued job — where a missing field is otherwise only discovered when a view renders null days later.
If you are on a fresh install, note that 3.2.2 is a compatibility-only release: it widens the framework constraint to allow Laravel 13. No API changed, so anything written against 3.2.1 applies unchanged.
Centralise the declaration. Put the trait on a base model (or a shared abstract) and let children extend the list rather than redeclare it. Field lists drift when they are copy-pasted; a array_merge(parent::fields(), [...]) keeps a shared minimum plus per-model additions.
Conditional requirements. Because the list is produced by a method, it can depend on model state — only require published_at and slug once status is published:
protected function fields(): array
{
$fields = ['title', 'body'];
if ($this->status === 'published') {
$fields[] = 'published_at';
$fields[] = 'slug';
}
return $fields;
}
Keep these predicates cheap and side-effect free; they run on every save.
Layer it under validation, not instead of it. A FormRequest (or $request->validate()) is what produces a friendly 422 for the user. Model fields catch the paths that never touch a request: console commands, imports, jobs, factories, tinker, Horizon retries. The two are complementary — do not delete request rules because the model "already checks".
Handle the exception per context. In HTTP, catch it in the exception handler and render a 422 or a 500 with logging — a missing field there is a bug, not user error. In queued jobs, catch it and release() or mark failed rather than letting it retry forever. In artisan commands, let it fail the command with a clear message naming the offending model and field.
Factories and seeders. Every attribute in the list needs a default in the factory definition, otherwise the test suite starts throwing where it used to pass. Treat this as a feature: the factory becomes the canonical description of a valid model. For deliberately partial fixtures use saveQuietly() or Model::withoutEvents() — see the gotchas.
Tests. Assert the contract explicitly so the list cannot silently shrink:
$this->expectException(ModelFieldsException::class);
Post::create(['title' => 'Only a title']);
Combine with a pest/phpunit dataset over the field list to get one failing case per field for free.
Query-builder writes bypass everything. Post::where(...)->update([...]), insert(), upsert(), increment() and friends never instantiate a model, so no saving event fires and no fields are checked. The same applies to raw DB statements. This is the biggest false sense of security with the package: it guards model writes, not table writes. If a field really must exist, back it with a NOT NULL constraint in the migration and let the package handle the friendlier, earlier error.
saveQuietly() and withoutEvents() disarm it. Quiet saves skip model events entirely, which is exactly what you want in seeders and fixtures, and exactly what you must not sprinkle into application code. Grep for both before shipping; treat any non-test usage as a red flag in review.
Event ordering. The check runs inside the model lifecycle, so mutators and creating/saving listeners that populate required attributes must run first. If you generate a slug in a creating observer and also list slug as required, verify the order rather than assuming it — the throw happens before the observer if the listener registers later.
Null versus empty string. Attribute values pass through casts and accessors, so '', '0', 0, false and null may not all be treated the way you expect. Decide explicitly whether "filled" means "not null" or "not blank", and test the boundary values (0 and '0' are the classic false negatives) against your installed version.
Soft deletes and restores. A restore() writes to the model and can trip the check on a row that has since acquired a null in a newly-required column — common after you add a field to the list. Audit existing rows before widening the declaration, and consider a backfill migration in the same deploy.
Performance is negligible but not zero. It is an in-memory array check per save. Bulk operations that build thousands of models (Model::create() in a loop) will pay it thousands of times; use chunked insert() where the guard is not meaningful anyway.
Version pinning for Laravel 13. 3.2.2 only relaxes the framework constraint. If you are upgrading an app to Laravel 13, bump the package and run the suite — nothing else in your code should change. If you are staying on an older Laravel, there is no reason to move off 3.2.1, and there is no reason to hold back either. Add Laravel 13 to the CI matrix at the same time so the constraint is actually exercised.
Confirm the API against your tag. The surface is tiny (trait, field declaration, exception), so read the installed trait source once rather than trusting a README from main; it takes two minutes and removes any ambiguity about the method name and exception class in your version.
How can I help you explore Laravel packages today?