An open API service indexing awesome lists of open source software.

https://github.com/dev-toolbelt/laravel-eloquent-plus

An opinionated package that extends Eloquent with a reusable base model, custom casts, validation rules, and query scopes to solve common, real-world problems in Laravel applications.
https://github.com/dev-toolbelt/laravel-eloquent-plus

audit blamable casting laravel laravel-framework lifecycle-hooks model soft-deletes uuid validation

Last synced: 4 months ago
JSON representation

An opinionated package that extends Eloquent with a reusable base model, custom casts, validation rules, and query scopes to solve common, real-world problems in Laravel applications.

Awesome Lists containing this project

README

          

# Laravel Eloquent Plus

[![CI](https://github.com/Dev-Toolbelt/laravel-eloquent-plus/actions/workflows/ci.yml/badge.svg)](https://github.com/Dev-Toolbelt/laravel-eloquent-plus/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/Dev-Toolbelt/laravel-eloquent-plus/branch/main/graph/badge.svg)](https://codecov.io/gh/Dev-Toolbelt/laravel-eloquent-plus)
[![Latest Stable Version](https://poser.pugx.org/dev-toolbelt/laravel-eloquent-plus/v/stable)](https://packagist.org/packages/dev-toolbelt/laravel-eloquent-plus)
[![Total Downloads](https://poser.pugx.org/dev-toolbelt/laravel-eloquent-plus/downloads)](https://packagist.org/packages/dev-toolbelt/laravel-eloquent-plus)
[![License](https://poser.pugx.org/dev-toolbelt/laravel-eloquent-plus/license)](https://packagist.org/packages/dev-toolbelt/laravel-eloquent-plus)
[![PHP Version](https://img.shields.io/badge/php-%5E8.3-blue)](https://php.net)

**Supercharge your Laravel Eloquent models** with automatic validation, audit trails, external IDs, smart casting, and lifecycle hooks — all with zero boilerplate.

---

## Features

| 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 |

---

## Requirements

- PHP ^8.3
- Laravel ^11.0

---

## Installation

```bash
composer require dev-toolbelt/laravel-eloquent-plus
```

The service provider is automatically registered via Laravel's package discovery.

---

## Quick Start

Extend your models from `ModelBase` to unlock all features:

```php
['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

---

## Available Traits

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 |

```php
use Illuminate\Database\Eloquent\Model;
use DevToolbelt\LaravelEloquentPlus\Concerns\HasValidation;
use DevToolbelt\LaravelEloquentPlus\Concerns\HasBlamable;

class MyModel extends Model
{
use HasValidation;
use HasBlamable;

// ...
}
```

---

## Validation

Define rules in your model and validation runs automatically:

```php
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'],
];
}
```

### Built-in Validators

| 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) |

### Validation Exception

When validation fails, a `ValidationException` is thrown with detailed error information:

```php
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
}
```

---

## Audit Trail (Blamable)

Track who performed actions on your records. Blamable is **disabled by default** and must be explicitly enabled per model:

```php
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
}
```

### Database Migration

```php
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');
});
```

### Graceful Column Handling

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:

```php
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](#strict-mode).

### Customizing Column Names

Override the constants in your model:

```php
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';
}
```

---

## External ID (UUID)

Expose UUIDs publicly while keeping integer primary keys internally:

```php
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';
}
```

### Usage

```php
$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', ...]
```

### Database Migration

```php
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->uuid('external_id')->unique();
$table->decimal('total', 10, 2);
$table->timestamps();
});
```

---

## Auto-Casting

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 |

```php
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!
}
```

---

## Custom Casts

### Built-in Casts

| 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 |

### Using Casts

```php
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,
];
}
```

### UuidToIdCast

Convert external UUIDs to internal IDs automatically:

```php
// 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)
```

---

## Lifecycle Hooks

Execute custom logic at specific points:

```php
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}");
}
}
```

### Hook Execution Order

**On Create:**
```
autoPopulateFields() → beforeValidate() → validation → beforeSave() → INSERT → afterSave()
```

**On Update:**
```
autoPopulateFields() → beforeValidate() → validation → beforeSave() → UPDATE → afterSave()
```

**On Delete:**
```
beforeDelete() → DELETE → afterDelete()
```

---

## Date Formatting

Control how date attributes are returned:

```php
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'],
];
}
```

```php
$event->starts_at; // "2024-01-15 10:00:00" (string, when $carbonInstanceInFieldDates = false)
$event->starts_at; // Carbon instance (when $carbonInstanceInFieldDates = true)
```

---

## Configuration

### Publishing Configuration

You can publish the configuration file to customize package behavior:

```bash
php artisan vendor:publish --tag=eloquent-plus-config
```

This will create `config/devToolbelt/eloquent-plus.php` in your application.

### Configuration Options

| 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 |

#### Blamable Field Type

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:

```php
// 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)

#### Blamable Field Value (Custom User Identifier)

When using `'string'` type, you can customize how the user identifier is retrieved using a callable:

```php
// 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:**

```php
// 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}",
```

#### Strict Mode

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:

```php
// 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:

```php
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.

### ModelBase Constants

| 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 |

### ModelBase Properties

| 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) |

---

## Full Example

```php
['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();
}
}
}
```

---

## Development

### Running Tests

```bash
composer test
```

### Running Tests with Coverage

```bash
composer test:coverage
```

### Code Style (PSR-12)

```bash
composer phpcs
composer phpcs:fix
```

### Static Analysis (PHPStan)

```bash
composer phpstan
```

---

## Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

1. Fork the repository
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request

### Development Standards

- Minimum **85% test coverage**
- PSR-12 coding standards
- PHPStan level 6 compliance

---

## License

This package is open-sourced software licensed under the [MIT license](LICENSE).

---

### Coverage Report

- **Dashboard:** [Codecov](https://codecov.io/gh/dev-toolbelt/laravel-eloquent-plus)
- **HTML Report:** [GitHub Pages](https://dev-toolbelt.github.io/laravel-eloquent-plus/)

## Credits

- [Kilderson Sena](https://github.com/dersonsena)
- [All Contributors](../../contributors)

---

Made with by [Dev Toolbelt](https://github.com/Dev-Toolbelt)