Looking to hire Laravel developers? Try LaraJobs

laravel-model-diff maintained by philiprehberger

Description
Track and display structured differences between Eloquent model versions with human-readable labels
Last update
2026/04/21 18:21 (dev-main)
License
Downloads
42

Comments
comments powered by Disqus

Laravel Model Diff

Tests Latest Version on Packagist License

Track and display structured differences between Eloquent model versions with human-readable labels.

Requirements

Dependency Version
PHP ^8.2
Laravel ^11.0 | ^12.0

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

];

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

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

License

MIT