philiprehberger / laravel-model-diff
Track and display structured differences between Eloquent model versions with human-readable labels
Package info
github.com/philiprehberger/laravel-model-diff
pkg:composer/philiprehberger/laravel-model-diff
Fund package maintenance!
Requires
- php: ^8.2
- illuminate/database: ^11.0|^12.0
- illuminate/support: ^11.0|^12.0
Requires (Dev)
- laravel/pint: ^1.0
- nunomaduro/larastan: ^2.0|^3.0
- orchestra/testbench: ^9.0|^10.0
- phpstan/extension-installer: ^1.3
- phpstan/phpstan: ^1.12|^2.0
- phpunit/phpunit: ^11.0
README
Track and display structured differences between Eloquent model versions with human-readable labels.
Requirements
- PHP 8.2+
- Laravel 11 or 12
Installation
Install via Composer:
composer require philiprehberger/laravel-model-diff
The service provider and facade are registered automatically via Laravel package auto-discovery.
Publishing the config
php artisan vendor:publish --tag=model-diff-config
This creates config/model-diff.php in your application.
Configuration
// config/model-diff.php return [ /* | Attributes excluded from every diff comparison. */ 'ignored_attributes' => [ 'created_at', 'updated_at', 'id', ], /* | Format string used when rendering date/datetime values in | DiffResult::toHumanReadable(). */ 'date_format' => 'M j, Y g:i A', ];
Usage
Comparing two model instances
Pass two instances of the same model — a "before" snapshot and an "after" snapshot — to ModelDiff::compare():
use PhilipRehberger\ModelDiff\Facades\ModelDiff; $before = User::find(42); // ... some time passes, the record is updated ... $after = User::find(42); $result = ModelDiff::compare($before, $after); if ($result->hasChanges()) { // ['name', 'email'] $result->changedAttributes(); // Array of AttributeChange objects $result->getChanges(); // Plain arrays $result->toArray(); // Keyed by human-readable label $result->toHumanReadable(); }
Comparing an unsaved dirty model
Use ModelDiff::fromDirty() to inspect changes on a model that has not yet been saved:
$user = User::find(42); $user->name = 'New Name'; $user->email = 'new@example.com'; // Do NOT call save() — inspect the dirty state $result = ModelDiff::fromDirty($user); $result->changedAttributes(); // ['name', 'email']
Excluding extra attributes at call-site
$result = ModelDiff::ignoring(['internal_notes', 'cache_key']) ->compare($before, $after);
Filtering results
Use only() and except() to narrow a DiffResult after comparison:
$result = ModelDiff::compare($before, $after); // Keep only specific attributes $nameAndEmail = $result->only(['name', 'email']); // Exclude specific attributes $withoutMetadata = $result->except(['metadata', 'internal_notes']);
Accessing individual values
Retrieve the old or new value of a single attribute without iterating:
$result = ModelDiff::compare($before, $after); $result->getBefore('name'); // 'Alice' $result->getAfter('name'); // 'Bob' // Returns null when the attribute is not in the diff $result->getBefore('unchanged_field'); // null
Human-Readable Labels
Using the HasDiffLabels trait
Add the HasDiffLabels trait to any model and define a $diffLabels map:
use PhilipRehberger\ModelDiff\Concerns\HasDiffLabels; class Client extends Model { use HasDiffLabels; protected array $diffLabels = [ 'company_name' => 'Company Name', 'is_active' => 'Active Status', 'arr_monthly' => 'Monthly ARR', ]; }
Attributes without an explicit entry are automatically humanized:
billing_address becomes Billing Address.
Retrieving a label directly
$client = new Client(); $client->getDiffLabel('company_name'); // "Company Name" $client->getDiffLabel('phone_number'); // "Phone Number"
API
DiffResult
| Method | Return type | Description |
|---|---|---|
hasChanges() |
bool |
true when at least one attribute changed |
changedAttributes() |
string[] |
Names of changed attributes |
getChanges() |
AttributeChange[] |
All change objects |
only(string[] $attributes) |
DiffResult |
New result with only the specified attributes |
except(string[] $attributes) |
DiffResult |
New result excluding the specified attributes |
getBefore(string $attribute) |
mixed |
Old value for an attribute (null if not in diff) |
getAfter(string $attribute) |
mixed |
New value for an attribute (null if not in diff) |
toArray() |
array |
Plain array — one entry per change |
toHumanReadable() |
array |
Keyed by label; values formatted for display |
toArray() output
[
[
'attribute' => 'name',
'old' => 'Alice',
'new' => 'Bob',
'label' => 'Full Name',
],
// ...
]
toHumanReadable() output
[
'Full Name' => [
'old' => 'Alice',
'new' => 'Bob',
],
'Published At' => [
'old' => 'Jan 1, 2024 9:00 AM',
'new' => 'Jun 20, 2025 2:30 PM',
],
// ...
]
AttributeChange
| Property | Type | Description |
|---|---|---|
$attribute |
string |
Raw attribute name |
$old |
mixed |
Normalized old value |
$new |
mixed |
Normalized new value |
$label |
string |
Human-readable label |
foreach ($result->getChanges() as $change) { echo "{$change->label}: {$change->old} → {$change->new}"; }
Cast-Aware Comparison
The package normalizes values before comparing them, so you never get false positives from type mismatches:
| Cast type | Normalization |
|---|---|
date, datetime, immutable_date/datetime |
Parsed to Carbon and formatted with date_format config |
timestamp |
Parsed to Carbon and formatted with date_format config |
array, json, object, collection |
Decoded and compared by content, not by serialized string |
boolean, bool |
Strict (bool) cast before comparison |
integer, int |
Strict (int) cast |
float, double, real |
Strict (float) cast |
decimal:N |
Strict (float) cast |
Backed enum (SomeEnum::class) |
Compared by ->value; stored as scalar in AttributeChange |
Unit enum (SomeEnum::class without backing) |
Compared by ->name; stored as string in AttributeChange |
Note: Associative arrays are compared order-insensitively —
['a' => 1, 'b' => 2]equals['b' => 2, 'a' => 1]. Sequential (list) arrays are compared in order.
Using the Facade
The ModelDiff facade is registered automatically:
use PhilipRehberger\ModelDiff\Facades\ModelDiff; $result = ModelDiff::compare($before, $after); $result = ModelDiff::fromDirty($model); $result = ModelDiff::ignoring(['token'])->compare($before, $after);
Using the Class Directly
If you prefer not to use the facade, resolve the class from the container or instantiate it directly:
use PhilipRehberger\ModelDiff\ModelDiff; // Via DI public function __construct(private ModelDiff $diff) {} // Directly $diff = new ModelDiff(); $result = $diff->compare($before, $after);
Development
composer install vendor/bin/phpunit vendor/bin/pint --test vendor/bin/phpstan analyse
Support
If you find this project useful: