padosoft/laravel-validable
A Laravel package that adds model-level validation via a Validable trait. Define rules and messages directly on Eloquent models and validate automatically before save or update, keeping persistence logic cleaner and data safer.
Install and inspect before you trust it.
composer require padosoft/laravel-validable
There is essentially no documentation beyond the README, and the package is a single trait of a few dozen lines. Your first step is not configuration — it is reading the source:
ls vendor/padosoft/laravel-validable/src
cat vendor/padosoft/laravel-validable/src/*.php
Confirm three things before writing rules: (1) the exact trait namespace, (2) the event it hooks (saving vs creating/updating), and (3) how it signals failure (throws Illuminate\Validation\ValidationException vs a package-specific exception vs returning false from save()). Everything else follows from those three facts. If a config file ships with it, publish it:
php artisan vendor:publish
and look for a validable entry in the list.
Minimal integration. Add the trait to one model and declare rules alongside the schema they describe:
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Padosoft\Validable\ValidableTrait; // verify namespace in vendor/
class Invoice extends Model
{
use ValidableTrait;
protected $fillable = ['customer_id', 'number', 'total_cents', 'due_at'];
protected $rules = [
'customer_id' => 'required|exists:customers,id',
'number' => 'required|string|max:40',
'total_cents' => 'required|integer|min:0',
'due_at' => 'required|date|after:today',
];
protected $messages = [
'total_cents.min' => 'An invoice total cannot be negative.',
];
protected $attributeNames = [
'customer_id' => 'customer',
'due_at' => 'due date',
];
}
First use case. Pick a model where a bad row is expensive — money, state machines, anything with a status column — not your Post or Page model. Prove the loop end to end in Tinker before touching a controller:
$invoice = new App\Models\Invoice(['total_cents' => -5]);
$invoice->save(); // should now fail loudly
If that silently succeeds, your trait hook or property names are wrong and the package is doing nothing at all. Verify failure first, then verify success. A self-validating model that fails open is worse than no validation.
Put the trait on a base model, rules on the leaves. If you have more than two validated models, create App\Models\ValidatedModel extends Model that uses the trait, and let domain models extend it. This gives you one place to override failure behaviour, one place to add shared rules (e.g. a tenant_id rule), and one place to swap the package out later.
Branch rules between create and update by overriding the getter. The package exposes rule/message/attribute accessors you can override; that is where update-specific rules belong:
protected function getValidationRules(): array
{
$rules = $this->rules;
if ($this->exists) {
$rules['number'] = [
'required', 'string', 'max:40',
Rule::unique('invoices', 'number')->ignore($this->getKey()),
];
$rules['due_at'] = 'required|date'; // 'after:today' only makes sense at creation
}
return $rules;
}
Doing this in $rules directly is impossible; a static property cannot express "ignore my own row". Every package like this eventually forces this override, so plan for it up front.
Treat model rules as invariants, FormRequests as UX. Do not try to make one rule set serve both. A FormRequest exists to produce friendly, field-level errors for a specific form before the model is touched. Model rules exist to guarantee that no code path — artisan command, queued job, importer, observer — can persist an invalid row. Keep them in sync by extracting shared fragments:
class InvoiceRules
{
public static function base(): array { /* ... */ }
public static function forCreate(): array { /* ... */ }
public static function forUpdate(Model $m): array { /* ... */ }
}
Then the FormRequest and the model both compose from the same static methods. Duplicated rule strings drift within a month.
Wrap saves at the service/action layer. Automatic validation turns save() into a partial function, which means callers must be prepared for it to fail. Centralise that:
final class CreateInvoice
{
public function __invoke(array $data): Invoice
{
$invoice = new Invoice($data);
try {
$invoice->save();
} catch (ValidationException $e) {
throw new InvoiceRejected($e->errors(), previous: $e);
}
return $invoice;
}
}
Now your domain exception is a meaningful type, and controllers decide whether to flash errors or return a 422. If the package throws ValidationException, Laravel's handler already converts it to a redirect-with-errors (web) or a 422 JSON body (API), so you may not need this at all — but be explicit about which contract you rely on.
Use it to protect state transitions. The strongest day-to-day use case for self-validation is conditional rules tied to model state, which is exactly the logic that leaks out of controllers:
protected function getValidationRules(): array
{
$rules = InvoiceRules::base();
if ($this->status === InvoiceStatus::Paid) {
$rules['paid_at'] = 'required|date';
$rules['total_cents'] = 'required|integer|min:1';
}
return $rules;
}
Every code path that marks an invoice paid — admin panel, webhook, artisan fix-up command — now enforces the same invariant. That is the real value proposition of this package, and it is worth the trait.
Bypass deliberately in seeders, tests, and data migrations. When you need to write a known-good row without the validation dance, use saveQuietly(), insert(), or withoutEvents(). Do it explicitly and rarely; if you find yourself reaching for saveQuietly() in application code, that is a signal your rules are wrong, not that you need an escape hatch.
It fails open by default if you get the trait namespace wrong. PHP will happily let you use a class from a missing namespace only if Composer's autoloader resolves it — but a typo in a property name ($validationRules vs $rules) produces no error at all; the model just silently saves anything. Write one test that asserts a model rejects bad data for every validated model. Without it you will never notice a regression after a package bump.
Event bypasses are the number one surprise. saveQuietly(), Model::insert(), Model::upsert(), DB::table()->update(), and ->update() on a query builder all skip Eloquent events entirely. The trait validates nothing on those paths. Audit bulk writes and report/analytics code paths before assuming coverage. Relationship writes ($invoice->lines()->create([...])) do fire events, but only on the related model — the parent is not re-validated, so parent-level invariants over children must be checked at a higher layer.
getAttributes() is not what your users see. The validator sees raw attributes: no accessors, no $appends, no casts applied (a Carbon cast attribute is still a string in getAttributes()), and no JSON columns flattened. Rules referencing computed values, or required_if on an accessor name, will fail in confusing ways. Validate raw columns only; validate derived values in a service.
Unique rules need the model's own key. unique:invoices,number will fail every time you update a model without changing its number. You must use Rule::unique(...)->ignore($this->getKey()). Since static $rules cannot do this, the override from the previous section is mandatory, not optional.
Watch for re-entrancy and infinite loops. If an observer on saving mutates a validated attribute, or if a model saves another model inside its own saving handler, you can get nested validation failures that surface with misleading stack traces. Keep saving observers free of writes.
Error messages will be raw keys unless you set $attributeNames. With automatic validation the exception surfaces far from the form, so validation.required on customer_id is what ends up in your UI. Fill in $attributeNames (or override the getter to run them through __()) on day one, not after QA complains.
Exceptions may not render as 422 in your API. Laravel's handler special-cases Illuminate\Validation\ValidationException. If the package wraps it in its own exception type, add a render() method to that exception or register a handler — otherwise clients get a 500 for what is really a client error.
Check Laravel and PHP compatibility before committing. Last release January 2023, seven stars, zero dependents. Verify the illuminate/* constraint in vendor/padosoft/laravel-validable/composer.json against your installed framework. On Laravel 11/12 you may need to fork or pin. Because the surface is one trait, the exit cost is low — the mitigation is simply: keep rules in one getter so you can drop the trait into a hand-rolled saving hook if it breaks:
protected static function booted(): void
{
static::saving(function (self $model) {
Validator::make($model->getAttributes(), $model->getValidationRules())->validate();
});
}
That is roughly the whole package. Knowing this means the abandonment risk is genuinely near zero — but also that adopting it is a convenience choice, not an architectural one.
Test at the model level. Feature tests through HTTP will not exercise commands, jobs, or importer paths. A small tests/Models/ValidationTest.php that loops over your validated models with a table of invalid payloads gives you coverage that the package itself does not provide. Use expectException(ValidationException::class) and assert on the specific field so a broken rule fails loudly rather than passing by accident.
Don't validate everything. Applying this to every model makes factories, seeders, and legacy import scripts fight you, and validation of pure-presentation models (CMS pages, settings blobs) buys nothing. Restrict it to entities with real invariants and document the list. A short allowlist in a README is more maintainable than a base class that quietly applies to fifty tables.
How can I help you explore Laravel packages today?