alariva/modelmerge
Laravel package for merging duplicate Eloquent models. Move attributes and relationships from a source model into a target, then delete the obsolete record. Configurable merge behavior, events, transactions, and support for custom models.
Install the package and pull in its configuration before you write any merge logic:
composer require alariva/modelmerge
php artisan vendor:publish --provider="Alariva\ModelMerge\ModelMergeServiceProvider"
This drops a config/modelmerge.php file containing the handler map — the mapping between attribute types and the classes responsible for reconciling two values. Read this file before anything else; it tells you which "side" wins for strings, dates, etc., and it is the file you will edit most often.
Attach the trait to a model you intend to de-duplicate:
use Alariva\ModelMerge\Traits\Mergeable;
class Customer extends Model
{
use Mergeable;
}
The canonical first use case is record de-duplication: you have a "keeper" and a "loser", and you want to fold the loser's non-empty attributes into the keeper.
$keeper = Customer::findOrFail($keepId);
$loser = Customer::findOrFail($dropId);
$keeper->merge($loser);
Where to look first, in order:
README.md — it is short; skim it for the merge direction semantics.src/Traits/Mergeable.php — the actual merge() entry point and how it iterates attributes.config/modelmerge.php — the handler map, including any ignore/except keys.Do not assume the direction of the merge. Confirm from source whether $a->merge($b) fills nulls in $a from $b, or overwrites $a with non-null values from $b. Both are common designs and the result is very different.
Wrap merges in a transaction and soft-delete the loser. A merge is a multi-step write; if the second save() throws you have a half-merged record.
DB::transaction(function () use ($keeper, $loser) {
$keeper->merge($loser);
$loser->delete(); // or: $loser->merged_into_id = $keeper->id; $loser->save();
});
Pin the semantics with a test before you trust it. A two-record merge test with one field set on the keeper and a conflicting value on the loser is the fastest way to discover the precedence rule. Add one test per handler type you rely on (string, date, JSON).
Configure handlers per attribute type, not per attribute. The config maps a type to a handler class. If your email column and your notes column are both strings, they use the same handler. If you need different behaviour, either implement a custom handler or keep that attribute out of the merge and set it explicitly afterwards.
Write a custom handler for anything non-trivial. Arrays, JSON blobs, and money fields almost never merge well with a generic handler. Implement the package's handler contract in your own namespace and register it in config/modelmerge.php:
'handlers' => [
'array' => App\Merging\UnionArrayHandler::class,
],
This is the package's primary extension point — prefer it over forking.
Handle relations yourself. Most field-merger packages iterate columns only. For has-many relations (orders, addresses, attached files) you must reassign foreign keys explicitly after the merge:
$loser->orders()->update(['customer_id' => $keeper->id]);
$loser->tags()->syncWithoutDetaching($keeper->tags()->pluck('id'));
Add a merge command for operators. A small Artisan command that takes two IDs, prints the resulting values, and asks for confirmation is worth far more than a UI given how rarely this runs. Fire an application event (e.g. ModelMerged) at the end so search indexes, caches, and audit logs can react.
Abstract behind your own service. Because the package is small (17 stars, zero dependents) and single-maintainer, wrap it:
class CustomerMerger
{
public function merge(Customer $keeper, Customer $loser): Customer { /* ... */ }
}
If the package goes unmaintained you swap the internals without touching call sites.
The package is a single-maintainer, low-traffic project. Pin the exact version in composer.json ("alariva/modelmerge": "1.0.*" or a commit hash), scan the source once, and do not assume it handles edge cases you have not tested. There is no ecosystem of dependents to catch regressions for you.
Events and observers may not fire. If the merge writes through query-builder style updates or saveQuietly, your updated/saved observers, model events, and broadcasting will be skipped. Confirm this in Mergeable and fire anything you need manually.
Timestamps and primary keys are the usual casualties. Explicitly exclude id, created_at, updated_at, and any deleted_at from merging — either through the config's ignore list or by overriding the mergeable attribute set on the model. Otherwise you get a keeper with the loser's creation date.
Mass-assignment and $fillable/$guarded can silently drop attributes. Depending on implementation, a merge may go through fill(). If so, guarded attributes (like email_verified_at, is_admin) will not be merged even though the handler computed a value. Verify with a fillable attribute and a guarded one.
Null vs empty string vs zero. Handlers typically treat null as "absent" but '' and 0 as real values. If your schema stores empty strings instead of nulls, the merge will not backfill them. Normalise to null at the model or cast layer first.
Unique constraints will abort mid-transaction. Merging a loser whose email is already on the keeper, or that collides with a third record, throws on the DB level. Decide the precedence rule for unique columns explicitly — usually "keeper wins, always" — and exclude those columns from the merge.
Casts must match handlers. A datetime cast attribute merged by a string handler produces garbage. Cross-check $casts against your handler map.
Merging is not idempotent if you soft-delete. Running the same merge twice should not double-assign relations. Guard with an early return if the loser is already soft-deleted or already points at the keeper.
Debug by echoing the handler decision. When a field comes out wrong, temporarily log inside the relevant handler (logger()->info([$attribute, $current, $incoming])) rather than stepping through the whole merge() loop. It is usually two lines of handler logic, not the orchestration, that surprises you.
Extension points to prefer: the handler map in config/modelmerge.php, and per-model overrides of which attributes are mergeable. Fork only if the merge direction or the transaction boundary is wrong for you.
How can I help you explore Laravel packages today?