theriddleofenigma/laravel-model-validation
Declare validation rules directly on your Eloquent models and have them run automatically on the events you choose, such as saving, creating, or updating. Invalid saves throw Laravel's ValidationException, so no form requests or repeated validator calls are needed.
Install — composer require theriddleofenigma/laravel-model-validation. No service provider registration, migrations, or config publishing is required; Laravel package discovery handles it. Check composer.json first: the package targets PHP 8.2+ and Laravel 12/13 only, so it will not slot into a legacy L9/L10 app.
Opt a model in by adding the trait and declaring rules where the data lives:
namespace App\Models;
use Enigma\ValidatorTrait;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
use ValidatorTrait;
protected $fillable = ['title', 'slug', 'body', 'published_at'];
protected $rules = [
'title' => ['required', 'string', 'max:255'],
'slug' => ['required', 'alpha_dash', 'max:255'],
'body' => ['required', 'string'],
];
// the model event that triggers validation
protected $validationEvent = 'saving';
}
public function store(StorePostRequest $request)
{
return Post::create($request->validated()); // validated twice, by design
}
The payoff isn't the controller; it's that every other write path now gets the same rules for free — an artisan import command, a queued job, a seeder, a Nova/Livewire form, or a console one-liner.
vendor/theriddleofenigma/laravel-model-validation/src/). It is small, and it is the authoritative list of which properties ($rules, $validationEvent, message/attribute overrides) and hooks actually exist, plus which event fires by default. The README's quick start shows the happy path; the trait shows the escape hatches you'll need in week two.Split create vs. update rule sets. The common case is unique rules that must ignore the row being updated:
protected $rules = [
'email' => ['required', 'email'],
];
// in a resolver method / per-event rule set, for updates:
'email' => [Rule::unique('users')->ignore($this->getKey())],
Switch on $this->exists or on the configured event rather than maintaining two diverging arrays.
Custom messages and attribute names. Always set human-readable attributes for snake_case columns; otherwise API consumers get The published at field is required.
Let form requests own authorization and input shaping; let the model own invariants. Requests answer "may this user submit this?" and normalize/filter input. Model rules answer "is this row ever allowed to exist?" Don't duplicate the same message in both layers — pick the model as the source of truth and keep the request thin.
API responses come for free. The package throws Illuminate\Validation\ValidationException, so Laravel's handler already returns 422 with an errors bag for JSON requests and a redirect-with-errors for web. If you use a custom error envelope, map it once in bootstrap/app.php under withExceptions() rather than catching per-controller.
Action/service classes can trust their input. Once validation sits at persistence, a PublishPost action doesn't need defensive re-validation; it can assume the model will reject bad state.
Test the rules, not the call site:
$this->expectException(ValidationException::class);
Post::create(['title' => '']);
This makes rule changes fail loudly across the whole suite, which is the main reason to adopt the package at all.
Multi-tenant / auth-dependent rules should be resolved at runtime (closure rules, Rule::when, or a resolver method reading the current tenant) instead of a static $rules property that can't see request context.
saveQuietly(), updateQuietly(), Model::withoutEvents(), Model::query()->update(), insert(), upsert(), and raw SQL all skip model events and therefore skip validation. The trait is a guardrail for Eloquent writes, not a database constraint — keep your unique indexes and NOT NULLs.firstOrCreate / updateOrCreate are the classic trap. They fire events, so validation runs on the attempted create. If you pass only a subset of attributes, required rules on the rest blow up. Pass the full attribute set, or use firstOrNew() + explicit fill.User::select('id', 'name')->first()->save() has no email attribute loaded, so required|email fails even though the row is valid. Either select the columns your rules touch, or use saveQuietly() for these maintenance writes.Rule::unique() counts trashed rows. Add ->whereNull('deleted_at') if "unique among live records" is the intent.$fillable vs. rules. Rules don't imply fillable. create() silently drops non-fillable keys, which then fails required on a field the user did submit. Keep mass-assignment config and rules in sync.ValidationException inside a job is treated as a normal failure: retries, then a failed job. If the data originates from a user, catch it and route to a dead-letter/notification path.unique, exists, and file rules cost queries. Use sometimes, nullable, and prefer creating-only for expensive checks that can't change on update.$model->saveQuietly() to confirm the validation layer is the culprit, then inspect $e->errors() / $e->validator->errors(). Temporarily removing the trait is also a legitimate bisect.composer.json, read the trait before relying on undocumented properties, and accept that you may need to fork it. For a small app with one or two write paths, form requests are simpler; the package earns its keep in apps with many write paths (API + admin + jobs + imports) that must not drift apart.How can I help you explore Laravel packages today?