Supercharge your Laravel Eloquent models with automatic validation, audit trails, external IDs, smart casting, and lifecycle hooks — all with zero boilerplate.
| Feature | Description |
|---|---|
| Automatic Validation | Validate model attributes before save using Laravel's validation rules |
| Audit Trail (Blamable) | Automatically track created_by, updated_by, and deleted_by |
| External ID (UUID) | Public-facing UUIDs while keeping internal integer IDs |
| Smart Auto-Casting | Infer attribute casts from validation rules automatically |
| Date Formatting | Control date output format (string or Carbon instance) |
| Lifecycle Hooks | Execute custom logic at beforeValidate, beforeSave, afterSave, beforeDelete, afterDelete |
| Hidden Attributes | Automatically hide sensitive fields like deleted_at, deleted_by |
| Custom Validators | Built-in CPF/CNPJ (Brazilian documents) and Hex Color validators |
| Custom Casts | OnlyNumbers, RemoveSpecialCharacters, UuidToIdCast |
| Cast Aliases | Register short names for custom casts like Laravel's built-in types |
- PHP ^8.3
- Laravel ^11.0
composer require dev-toolbelt/laravel-eloquent-plusThe service provider is automatically registered via Laravel's package discovery.
Extend your models from ModelBase to unlock all features:
<?php
namespace App\Models;
use DevToolbelt\LaravelEloquentPlus\ModelBase;
class Product extends ModelBase
{
protected $fillable = ['name', 'price', 'sku'];
protected array $rules = [
'name' => ['required', 'string', 'max:255'],
'price' => ['required', 'numeric', 'min:0'],
'sku' => ['required', 'string', 'unique:products,sku'],
];
}That's it! Your model now has:
- Automatic validation before create/update
- Audit trail (
created_by,updated_by,deleted_by) - Soft deletes with tracking
- External UUID for public APIs
- Smart type casting inferred from rules
- Lifecycle hooks ready to use
Use traits individually if you don't want the full ModelBase:
| Trait | Description |
|---|---|
HasValidation |
Automatic validation with rules and auto-population of timestamps/blamable |
HasBlamable |
Track who created, updated, and deleted records |
HasExternalId |
UUID-based public identifiers |
HasAutoCasting |
Infer casts from validation rules |
HasDateFormatting |
Control date attribute output format |
HasLifecycleHooks |
Model lifecycle callbacks |
HasHiddenAttributes |
Auto-hide sensitive fields |
HasCastAliases |
Register custom cast aliases |
use Illuminate\Database\Eloquent\Model;
use DevToolbelt\LaravelEloquentPlus\Concerns\HasValidation;
use DevToolbelt\LaravelEloquentPlus\Concerns\HasBlamable;
class MyModel extends Model
{
use HasValidation;
use HasBlamable;
// ...
}Define rules in your model and validation runs automatically:
class User extends ModelBase
{
protected array $rules = [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'unique:users,email'],
'document' => ['required', 'cpf_cnpj'], // Brazilian CPF/CNPJ
'theme_color' => ['nullable', 'hex_color'],
];
}| Validator | Alias | Description |
|---|---|---|
CpfCnpjValidator |
cpf_cnpj |
Validates Brazilian CPF (11 digits) or CNPJ (14 digits) |
CpfCnpjValidator |
cpf |
Validates only CPF |
CpfCnpjValidator |
cnpj |
Validates only CNPJ |
HexColor |
hex_color |
Validates hex color codes (#FFF or #FFFFFF) |
When validation fails, a ValidationException is thrown with detailed error information:
use DevToolbelt\LaravelEloquentPlus\Exceptions\ValidationException;
try {
$user->save();
} catch (ValidationException $e) {
$e->getErrors(); // All errors as array
$e->getMessages(); // All error messages
$e->hasErrorFor('email'); // Check specific field
$e->getFirstMessageFor('email'); // Get first error message
}Track who performed actions on your records. Blamable is disabled by default and must be explicitly enabled per model:
class Post extends ModelBase
{
// Enable blamable audit tracking
protected bool $usesBlamable = true;
// These columns are automatically populated:
// - created_by: Set on create (authenticated user ID)
// - updated_by: Set on create and update
// - deleted_by: Set on soft delete
}Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->timestamps();
$table->softDeletes();
// Blamable columns
$table->foreignId('created_by')->constrained('users');
$table->foreignId('updated_by')->nullable()->constrained('users');
$table->foreignId('deleted_by')->nullable()->constrained('users');
});If your table doesn't have blamable columns (created_by, updated_by, deleted_by), the trait will silently skip setting those columns. This allows you to enable blamable on models where only some audit columns exist:
class Comment extends ModelBase
{
protected bool $usesBlamable = true;
// Even if the table only has created_by and updated_by (no deleted_by),
// blamable will work for the columns that exist and skip the rest.
}The same graceful behavior applies to timestamps (created_at, updated_at). If a timestamp column doesn't exist on the table, it is silently skipped instead of causing an error.
To enforce that all expected columns exist, enable Strict Mode.
Override the constants in your model:
class Post extends ModelBase
{
public const string CREATED_BY = 'author_id';
public const string UPDATED_BY = 'editor_id';
public const string DELETED_BY = 'remover_id';
}Expose UUIDs publicly while keeping integer primary keys internally:
class Order extends ModelBase
{
// Enable external ID (enabled by default)
public const bool USES_EXTERNAL_ID = true;
public const string EXTERNAL_ID_COLUMN = 'external_id';
}$order = Order::create(['total' => 99.99]);
// Internal ID (hidden from serialization)
$order->id; // 1
// External UUID (exposed in API responses)
$order->getExternalId(); // "550e8400-e29b-41d4-a716-446655440000"
// Find by external ID
$order = Order::findByExternalId('550e8400-e29b-41d4-a716-446655440000');
$order = Order::findByExternalIdOrFail('550e8400-e29b-41d4-a716-446655440000');
// API response automatically uses UUID as "id"
$order->toArray(); // ['id' => '550e8400-e29b-41d4-a716-446655440000', ...]Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->uuid('external_id')->unique();
$table->decimal('total', 10, 2);
$table->timestamps();
});Types are automatically inferred from validation rules:
| Validation Rule | Inferred Cast |
|---|---|
boolean |
boolean |
integer |
integer |
numeric |
float |
array |
array |
date |
datetime |
date_format:Y-m-d |
date:Y-m-d |
date_format:Y-m-d H:i:s |
datetime |
Illuminate\Validation\Rules\Enum |
Enum class |
class Product extends ModelBase
{
protected array $rules = [
'active' => ['boolean'], // Cast to boolean
'quantity' => ['integer'], // Cast to integer
'price' => ['numeric'], // Cast to float
'tags' => ['array'], // Cast to array
'expires_at' => ['date'], // Cast to datetime
];
// No need to define $casts - it's automatic!
}| Cast | Alias | Description |
|---|---|---|
OnlyNumbers |
only_numbers |
Removes non-numeric characters |
RemoveSpecialCharacters |
remove_special_chars |
Removes special characters |
UuidToIdCast |
uuid_to_id |
Converts UUID to internal ID via lookup |
class Customer extends ModelBase
{
protected $casts = [
// Using aliases (short names)
'phone' => 'only_numbers',
'name' => 'remove_special_chars',
'category_id' => 'uuid_to_id:categories,external_id',
// Or using full class names
'document' => \DevToolbelt\LaravelEloquentPlus\Casts\OnlyNumbers::class,
];
}Convert external UUIDs to internal IDs automatically:
// When you receive a UUID from the API
$order->category_id = '550e8400-e29b-41d4-a716-446655440000';
// It's automatically converted to the internal ID
$order->category_id; // 42 (the actual ID from categories table)Execute custom logic at specific points:
class Invoice extends ModelBase
{
protected function beforeValidate(): void
{
// Normalize data before validation
$this->number = strtoupper($this->number);
}
protected function beforeSave(): void
{
// Logic after validation, before database write
$this->total = $this->calculateTotal();
}
protected function afterSave(): void
{
// Logic after persisting to database
event(new InvoiceSaved($this));
}
protected function beforeDelete(): void
{
// Cleanup before deletion
$this->items()->delete();
}
protected function afterDelete(): void
{
// Logic after deletion
Cache::forget("invoice:{$this->id}");
}
}On Create:
autoPopulateFields() → beforeValidate() → validation → beforeSave() → INSERT → afterSave()
On Update:
autoPopulateFields() → beforeValidate() → validation → beforeSave() → UPDATE → afterSave()
On Delete:
beforeDelete() → DELETE → afterDelete()
Control how date attributes are returned:
class Event extends ModelBase
{
// Return dates as formatted strings (default)
protected bool $carbonInstanceInFieldDates = false;
// Or return Carbon instances
protected bool $carbonInstanceInFieldDates = true;
protected array $rules = [
'starts_at' => ['required', 'date_format:Y-m-d H:i:s'],
'ends_at' => ['required', 'date_format:Y-m-d H:i:s'],
];
}$event->starts_at; // "2024-01-15 10:00:00" (string, when $carbonInstanceInFieldDates = false)
$event->starts_at; // Carbon instance (when $carbonInstanceInFieldDates = true)You can publish the configuration file to customize package behavior:
php artisan vendor:publish --tag=eloquent-plus-configThis will create config/devToolbelt/eloquent-plus.php in your application.
| Option | Default | Description |
|---|---|---|
blamable_field_type |
'integer' |
Type of blamable fields (created_by, updated_by, deleted_by) |
blamable_field_value |
null |
Callable to customize user identifier extraction (only for string type) |
blamable_strict_mode |
false |
Throw exception if blamable columns are missing on the model |
timestamps_strict_mode |
false |
Throw exception if timestamp columns are missing on the model |
By default, blamable fields (created_by, updated_by, deleted_by) are validated as integers with an exists rule to ensure the user ID exists in the database.
If your application uses string-based user identifiers (like UUIDs stored as strings), you can change this:
// config/devToolbelt/eloquent-plus.php
return [
'blamable_field_type' => 'string', // Use 'string' for UUID or other string identifiers
];When set to 'integer' (default):
- Validation rules:
['nullable', 'integer', 'exists:users,id'] - Ensures the user ID exists in the users table
When set to 'string':
- Validation rules:
['nullable', 'string'] - User ID is cast to string automatically
- No existence check (useful for external user systems or UUIDs)
When using 'string' type, you can customize how the user identifier is retrieved using a callable:
// config/devToolbelt/eloquent-plus.php
return [
'blamable_field_type' => 'string',
// Use external_id instead of the default user ID
'blamable_field_value' => fn($user) => $user->external_id,
];This is useful when:
- Your users have UUID-based external IDs
- You need to store a different identifier than the primary key
- You're integrating with external authentication systems
Examples:
// Use external UUID
'blamable_field_value' => fn($user) => $user->external_id,
// Use email as identifier
'blamable_field_value' => fn($user) => $user->email,
// Use a formatted string
'blamable_field_value' => fn($user) => "user:{$user->id}",By default, missing blamable and timestamp columns are silently skipped. If you want to enforce that all expected columns exist on your models, enable strict mode:
// config/devToolbelt/eloquent-plus.php
return [
'blamable_strict_mode' => true,
'timestamps_strict_mode' => true,
];When strict mode is enabled, a MissingModelPropertyException is thrown if the model tries to set a column that doesn't exist:
use DevToolbelt\LaravelEloquentPlus\Exceptions\MissingModelPropertyException;
// With blamable_strict_mode = true
// If the table is missing the 'created_by' column:
try {
$post->save();
} catch (MissingModelPropertyException $e) {
// 'The property "created_by" is required in model "App\Models\Post". ...'
}This is useful during development to catch missing migrations early. In production, you may prefer the default behavior (false) to avoid unexpected errors.
| Constant | Default | Description |
|---|---|---|
CREATED_AT |
'created_at' |
Created timestamp column |
UPDATED_AT |
'updated_at' |
Updated timestamp column |
DELETED_AT |
'deleted_at' |
Soft delete timestamp column |
CREATED_BY |
'created_by' |
Created by user column |
UPDATED_BY |
'updated_by' |
Updated by user column |
DELETED_BY |
'deleted_by' |
Deleted by user column |
USES_EXTERNAL_ID |
true |
Enable/disable external UUID |
EXTERNAL_ID_COLUMN |
'external_id' |
External ID column name |
| Property | Default | Description |
|---|---|---|
$timestamps |
true |
Enable timestamps |
$dateFormat |
'Y-m-d H:i:s.u' |
Database date format |
$snakeAttributes |
false |
Snake case in serialization |
$carbonInstanceInFieldDates |
false |
Return Carbon vs string for dates |
$usesBlamable |
false |
Enable audit trail (created_by, updated_by, deleted_by) |
<?php
namespace App\Models;
use DevToolbelt\LaravelEloquentPlus\ModelBase;
use App\Enums\OrderStatus;
class Order extends ModelBase
{
protected $fillable = [
'customer_id',
'status',
'total',
'notes',
'delivered_at',
];
protected array $rules = [
'customer_id' => ['required', 'uuid', 'exists:customers,external_id'],
'status' => ['required', new \Illuminate\Validation\Rules\Enum(OrderStatus::class)],
'total' => ['required', 'numeric', 'min:0'],
'notes' => ['nullable', 'string', 'max:1000'],
'delivered_at' => ['nullable', 'date_format:Y-m-d H:i:s'],
];
protected $casts = [
'customer_id' => 'uuid_to_id:customers,external_id',
];
protected function beforeSave(): void
{
if ($this->isDirty('status') && $this->status === OrderStatus::Delivered) {
$this->delivered_at = now();
}
}
}composer testcomposer test:coveragecomposer phpcs
composer phpcs:fixcomposer phpstanContributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add some amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
- Minimum 85% test coverage
- PSR-12 coding standards
- PHPStan level 6 compliance
This package is open-sourced software licensed under the MIT license.
- Dashboard: Codecov
- HTML Report: GitHub Pages
Made with by Dev Toolbelt